🪟 feat: Unify Subagent Activity Panel (#15106)

* 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
This commit is contained in:
Danny Avila 2026-08-21 19:51:11 -04:00 committed by GitHub
parent 21b7f78d56
commit 749eed0d60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2668 additions and 1393 deletions

View file

@ -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.

View file

@ -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<T>(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
* `<Part />` 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<TMessageContentParts[]>(() => {
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<SubagentTickerLine[]>(() => {
@ -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 `<Part />` 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 `<Part />` 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 (
<SubagentDialogPart
key={`${toolCallId}-part-${idx}`}
part={part}
isSubmitting={running}
showCursor={running && isLastPart}
isLast={isLastPart}
onToolExpand={onToolExpand}
/>
);
},
[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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
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 (
<MessageContext.Provider value={dialogMessageContext}>
{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 (
<ToolCallGroup
key={`${toolCallId}-group-${group.parts[0].idx}`}
parts={group.parts}
isSubmitting={running}
isLast={group.parts.some((p) => p.idx === lastPartIndex)}
renderPart={renderDialogPart}
lastContentIdx={lastPartIndex}
/>
);
})}
</MessageContext.Provider>
);
}
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 (
<MessageContext.Provider value={dialogMessageContext}>
<SubagentDialogPart
part={
{
type: ContentTypes.TEXT,
text: output,
} as unknown as TMessageContentParts
}
isSubmitting={false}
showCursor={false}
isLast
/>
</MessageContext.Provider>
);
}
return (
<div className="text-sm italic text-text-secondary">
{running
? localize('com_ui_subagent_no_result_yet')
: localize('com_ui_subagent_empty_result')}
</div>
);
};
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({
</ul>
</button>
{!canOpenDurablePanel && (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogContent
className={cn(
'flex h-[min(85vh,56rem)] flex-col overflow-hidden p-0',
/** Tighter inter-row gap than the dialog default (`gap-4`)
* title + description + scroll area read as one block
* rather than three separated panels. */
'gap-0',
/** Responsive width: narrow on phones, scales up to ~80rem on
* widescreens. Viewport-relative max keeps margin on the
* edges while still using real estate on laptops / large
* displays noticeably wider than the default dialog. */
'w-[min(96vw,80rem)] max-w-[min(96vw,80rem)]',
)}
>
<div className="shrink-0 px-6 pb-3 pr-14 pt-6">
<div className="flex min-w-0 items-center justify-between gap-3">
<OGDialogTitle>
{isSelfSpawn
? localize('com_ui_subagent_dialog_title_self')
: localize('com_ui_subagent_dialog_title', { 0: subagentType })}
</OGDialogTitle>
</div>
<OGDialogDescription className="sr-only">
{localize('com_ui_subagent_dialog_description')}
</OGDialogDescription>
</div>
<div className="relative min-h-0 flex-1 border-t border-border-light bg-surface-primary">
{!isAtBottom && (
<Button
variant="ghost"
size="icon"
onClick={scrollDialogToBottom}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="absolute bottom-3 right-4 z-10 h-8 w-8 rounded-full border border-border-light bg-surface-secondary text-text-secondary shadow-md transition hover:bg-surface-tertiary hover:text-text-primary"
>
<ArrowDown size={16} aria-hidden="true" />
</Button>
)}
<div
ref={scrollRef}
onScroll={handleScroll}
/** The prompt and activity trace share one scroller so expanded
* prompt content participates in the same reading flow as the
* subagent output instead of reserving fixed dialog space.
* Part-specific wrappers (`Container`, `Reasoning`,
* `ToolCallGroup`) handle their own widths and spacing. */
className="h-full overflow-y-auto px-3 py-3"
>
<div ref={contentRef} className="flex max-w-full flex-grow flex-col gap-0">
{prompt ? (
<SubagentPrompt
prompt={prompt}
expanded={promptExpanded}
onToggle={() => setPromptExpanded((expanded) => !expanded)}
/>
) : null}
{renderDialogBody()}
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
)}
{!hideAttachments && attachments && attachments.length > 0 && (
<AttachmentGroup attachments={attachments} />
)}
@ -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 (
<section
aria-labelledby={headingId}
className="mb-3 shrink-0 overflow-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary"
>
<div className="flex min-h-[2.75rem] items-center justify-between gap-3 border-b border-border-light px-3 py-2">
<h3 id={headingId} className="text-sm font-medium text-text-primary">
{localize('com_ui_prompt')}
</h3>
<Button
variant="ghost"
size="sm"
onClick={onToggle}
aria-controls={contentId}
aria-expanded={expanded}
aria-label={toggleLabel}
title={toggleLabel}
className="h-8 gap-1.5 rounded-md px-2 text-xs font-medium text-text-secondary transition hover:bg-surface-tertiary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-text-primary"
>
{expanded ? (
<Minimize2 size={14} aria-hidden="true" />
) : (
<Maximize2 size={14} aria-hidden="true" />
)}
<span className="hidden sm:inline">{toggleLabel}</span>
</Button>
</div>
<div
id={contentId}
className={cn(
'relative min-w-0 px-4 py-3',
expanded ? 'overflow-visible' : 'max-h-32 overflow-hidden',
)}
>
<div className="markdown prose prose-sm message-content light dark:prose-invert w-full max-w-none break-words text-text-primary">
<MarkdownLite content={prompt} codeExecution={false} />
</div>
{!expanded && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-surface-secondary to-transparent"
aria-hidden="true"
/>
)}
</div>
</section>
);
}
/** 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 {
</li>
);
}
/**
* Per-part renderer for the dialog. Mirrors the wrapper choices `<Part>`
* 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 `<Part>`) 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 (
<Container>
<Text text={text} showCursor={showCursor} isCreatedByUser={false} />
</Container>
);
}
if (part.type === ContentTypes.THINK) {
const think = (part as { think: string }).think;
return <Reasoning reasoning={think} isLast={isLast} />;
}
if (part.type === ContentTypes.TOOL_CALL) {
const tc = (
part as {
[ContentTypes.TOOL_CALL]?: {
id?: string;
args?: string | Record<string, unknown>;
output?: string;
name?: string;
progress?: number;
approval?: Agents.ToolCall['approval'];
};
}
)[ContentTypes.TOOL_CALL];
if (!tc) return null;
const toolCall = (
<ToolCall
args={tc.args ?? ''}
output={tc.output ?? ''}
initialProgress={tc.progress ?? 0.1}
isSubmitting={isSubmitting}
isLast={isLast}
toolCallId={tc.id}
name={tc.name ?? ''}
onExpand={onToolExpand}
/>
);
// 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}
<ToolApproval approval={tc.approval} toolCallId={tc.id ?? ''} args={tc.args} />
</>
);
}
return toolCall;
}
return null;
}

View file

@ -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 (

View file

@ -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 }) => <div>{text}</div>,
}));
jest.mock('~/components/Chat/Messages/Content/Parts/Reasoning', () => ({
__esModule: true,
default: ({ reasoning }: { reasoning: string }) => <div>{reasoning}</div>,
}));
jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div>{content}</div>,
}));
jest.mock('~/components/Chat/Messages/Content/Container', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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<typeof import('react')>('react');
const [open, setOpen] = useState(false);
return (
<div>
<button
type="button"
aria-expanded={open}
data-run-step-status={runStepStatus}
onClick={() => setOpen((value) => !value)}
>
{name}
</button>
{open && (
<div>
{JSON.stringify(args)} {output}
</div>
)}
</div>
);
},
}));
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;
}) => (
<div>
{/* eslint-disable-next-line i18next/no-literal-string */}
<div>Used {parts.length} tools</div>
{parts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
</div>
),
}));
jest.mock('~/components/Chat/Messages/Content/ToolApproval', () => ({
__esModule: true,
// eslint-disable-next-line i18next/no-literal-string
default: () => <div data-testid="tool-approval">approval</div>,
}));
jest.mock('@librechat/client', () => ({
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
<button {...props}>{children}</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(<SubagentActivity activity={{ ...base, status }} />);
expect(screen.getByText(`com_ui_subagent_thread_status_${status}`)).toBeInTheDocument();
},
);
it('renders approval controls when the provider persists an empty output', () => {
render(
<SubagentActivity
activity={{
...base,
status: 'running',
items: [
{
type: 'tool',
toolCallId: 'tool',
name: 'protected_tool',
output: '',
status: 'running',
approval: {} as Agents.ToolCall['approval'],
},
],
}}
/>,
);
expect(screen.getByTestId('tool-approval')).toBeInTheDocument();
});
it('marks bounded tool details as shortened without expanding them', () => {
render(
<SubagentActivity
activity={{
...base,
items: [
{
type: 'tool',
toolCallId: 'tool',
name: 'search',
input: 'bounded input',
status: 'completed',
inputTruncated: true,
},
],
}}
/>,
);
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(<SubagentActivity activity={base} />);
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(
<SubagentActivity
activity={{
...base,
items: [{ type: 'tool', toolCallId: 'tool', name: 'search', status }],
}}
/>,
);
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(<SubagentActivity activity={{ ...base, items: [] }} state={state} />);
expect(screen.getByText(label)).toBeInTheDocument();
});
});

View file

@ -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 (
<Container>
<div className="mb-1 text-xs font-medium text-text-secondary">
{localize('com_ui_subagent_ticker_writing')}
</div>
<Text text={item.text} showCursor={showCursor} isCreatedByUser={false} />
{item.textTruncated === true && (
<div className="mt-2 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_message_truncated')}
</div>
)}
</Container>
);
}
if (item.type === 'reasoning') {
if (item.text == null || item.text === '') {
return (
<div className="my-2 text-sm text-text-secondary" role="status">
{localize('com_ui_subagent_ticker_reasoning')}
</div>
);
}
return <Reasoning reasoning={item.text} isLast={isLast} />;
}
const tool = (
part as {
[ContentTypes.TOOL_CALL]: {
id: string;
args: string | Record<string, unknown>;
output: string;
name: string;
progress: number;
};
}
)[ContentTypes.TOOL_CALL];
const toolCall = (
<ToolCall
args={tool.args}
output={tool.output}
initialProgress={tool.progress}
isSubmitting={isSubmitting && item.status === 'running'}
isLast={isLast}
toolCallId={tool.id}
name={tool.name}
onExpand={onToolExpand}
runStepStatus={item.status === 'running' ? undefined : item.status}
/>
);
const truncationNotice =
item.inputTruncated === true || item.outputTruncated === true ? (
<div className="mb-2 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_message_truncated')}
</div>
) : null;
if (item.approval != null && (item.output?.length ?? 0) === 0) {
return (
<>
{toolCall}
{truncationNotice}
<ToolApproval approval={item.approval} toolCallId={item.toolCallId} args={item.input} />
</>
);
}
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 (
<section
aria-labelledby={headingId}
className="mb-3 shrink-0 overflow-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary"
>
<div className="flex min-h-[2.75rem] items-center justify-between gap-3 border-b border-border-light px-3 py-2">
<h3 id={headingId} className="text-sm font-medium text-text-primary">
{localize('com_ui_prompt')}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((value) => !value)}
aria-controls={contentId}
aria-expanded={expanded}
aria-label={toggleLabel}
title={toggleLabel}
className="h-8 gap-1.5 rounded-md px-2 text-xs font-medium text-text-secondary transition hover:bg-surface-tertiary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-text-primary"
>
{expanded ? <Minimize2 size={14} aria-hidden /> : <Maximize2 size={14} aria-hidden />}
<span className="hidden sm:inline">{toggleLabel}</span>
</Button>
</div>
<div
id={contentId}
className={cn(
'relative min-w-0 px-4 py-3',
expanded ? 'overflow-visible' : 'max-h-32 overflow-hidden',
)}
>
<div className="markdown prose prose-sm message-content light dark:prose-invert w-full max-w-none break-words text-text-primary">
<MarkdownLite content={prompt} codeExecution={false} />
</div>
{!expanded && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-surface-secondary to-transparent"
aria-hidden
/>
)}
</div>
</section>
);
}
export default function SubagentActivity({
activity,
state = 'ready',
}: {
activity: ChildActivity;
state?: 'ready' | 'loading' | 'error';
}) {
const localize = useLocalize();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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) => (
<ActivityPart
key={`activity-${idx}`}
item={activity.items[idx]}
part={part}
isSubmitting={isSubmitting}
showCursor={isSubmitting && isLast}
isLast={isLast}
onToolExpand={onToolExpand}
/>
),
[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<HTMLDivElement>) => {
const element = event.currentTarget;
setIsAtBottom(
element.scrollHeight - element.scrollTop - element.clientHeight <= AT_BOTTOM_THRESHOLD_PX,
);
}, []);
let body: React.ReactNode;
if (state === 'loading') {
body = (
<div className="py-8 text-center text-sm text-text-secondary" role="status">
{localize('com_ui_subagent_waiting')}
</div>
);
} else if (state === 'error') {
body = (
<div className="rounded-lg border border-status-error-border bg-status-error-subtle p-3 text-sm text-status-error">
{localize('com_ui_subagent_thread_load_error')}
</div>
);
} else if (activity.items.length === 0) {
body = (
<div className="rounded-lg border border-border-light bg-surface-secondary p-3 text-sm text-text-secondary">
{isSubmitting
? localize('com_ui_subagent_no_result_yet')
: localize('com_ui_subagent_empty_result')}
</div>
);
} else {
const last = parts.length - 1;
body = (
<MessageContext.Provider value={context}>
{groupedParts.map((group) =>
group.type === 'single' ? (
renderPart(group.part.part, group.part.idx, group.part.idx === last)
) : (
<ToolCallGroup
key={`activity-group-${group.parts[0].idx}`}
parts={group.parts}
isSubmitting={isSubmitting}
isLast={group.parts.some((part) => part.idx === last)}
renderPart={renderPart}
lastContentIdx={last}
/>
),
)}
</MessageContext.Provider>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border-light px-4 py-2">
<div
className={cn(
'flex items-center gap-1 text-xs text-text-secondary',
activity.status === 'failed' || activity.status === 'interrupted'
? 'text-status-error'
: '',
)}
aria-live="polite"
>
<StatusIcon size={13} aria-hidden />
<span>{localize(statusLabels[activity.status])}</span>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative min-h-0 flex-1 overflow-y-auto px-4 py-4"
>
{!isAtBottom && (
<Button
variant="ghost"
size="icon"
onClick={() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
});
setIsAtBottom(true);
}}
aria-label={localize('com_ui_subagent_scroll_to_bottom')}
className="sticky top-[calc(100%-2.75rem)] z-10 ml-auto h-8 w-8 rounded-full border border-border-light bg-surface-secondary text-text-secondary shadow-md"
>
<ArrowDown size={16} aria-hidden />
</Button>
)}
<div ref={contentRef} className="flex max-w-full flex-col gap-0">
{activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
{activity.activityTruncated === true && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_history_truncated')}
</div>
)}
{body}
</div>
</div>
</div>
);
}

View file

@ -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<typeof import('react')>('react');
mockReact.useEffect(() => {
mockApprovalProviderMounted();
return () => mockApprovalProviderUnmounted();
}, []);
return children;
},
}));
jest.mock('~/components/Chat/Messages/Content/MarkdownLite', () => ({
__esModule: true,
default: ({ content }: { content: string }) => <div>{content}</div>,
}));
jest.mock('./SubagentActivity', () => ({
__esModule: true,
default: ({
activity,
state,
}: {
activity: { status: string; prompt?: string; items: Array<{ type: string; text?: string }> };
state: string;
}) => (
<div data-testid="shared-activity" data-state={state} data-status={activity.status}>
{activity.prompt}
{activity.items.map((item, index) => (
<span key={index}>{item.text ?? item.type}</span>
))}
</div>
),
}));
jest.mock('@librechat/client', () => ({
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
<button {...props}>{children}</button>
),
Spinner: () => <span>{mockSpinnerLabel}</span>,
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(
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
<button
type="button"
data-subagent-tool-call="tool-call"
data-subagent-parent-message="parent-message"
data-subagent-part-index="2"
/>
<Observer />
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
@ -107,14 +156,79 @@ describe('SubagentThreadPanel', () => {
'task',
);
expect(screen.getByText('Research child')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_status_completed')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_history_truncated')).toBeInTheDocument();
expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_message_truncated')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'completed');
fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
expect(active).toBeNull();
await waitFor(() =>
expect(
container.querySelector(
'[data-subagent-tool-call="tool-call"][data-subagent-parent-message="parent-message"][data-subagent-part-index="2"]',
),
).toHaveFocus(),
);
});
it('renders foreground persisted activity through the same shared panel without a durable read', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
isReadinessPending: false,
});
const foreground: ActiveSubagentPanel = {
parentConversationId: 'parent-conversation',
parentMessageId: 'parent-message',
toolCallId: 'foreground-call',
partIndex: 3,
subagentType: 'researcher',
prompt: 'Review this change.',
persistedContent: [
{ type: 'text', text: 'Review complete.' },
] as unknown as TMessageContentParts[],
initialProgress: 1,
isSubmitting: false,
};
render(
<RecoilRoot>
<SubagentThreadPanel selection={foreground} />
</RecoilRoot>,
);
expect(screen.getByText('Review this change.')).toBeInTheDocument();
expect(screen.getByText('Review complete.')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'ready');
});
it('resets invocation-scoped approval state when the selected card changes', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
isReadinessPending: false,
});
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={{ ...selection, durable: undefined }} />
</RecoilRoot>,
);
expect(mockApprovalProviderMounted).toHaveBeenCalledTimes(1);
expect(mockApprovalProviderUnmounted).not.toHaveBeenCalled();
rerender(
<RecoilRoot>
<SubagentThreadPanel
selection={{ ...selection, partIndex: selection.partIndex + 1, durable: undefined }}
/>
</RecoilRoot>,
);
expect(mockApprovalProviderUnmounted).toHaveBeenCalledTimes(1);
expect(mockApprovalProviderMounted).toHaveBeenCalledTimes(2);
});
it('keeps an expected pre-reservation 404 in the readiness state', () => {
@ -131,8 +245,64 @@ describe('SubagentThreadPanel', () => {
</RecoilRoot>,
);
expect(screen.getByText(mockSpinnerLabel)).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_thread_load_error')).not.toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'loading');
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'dispatched');
});
it('surfaces a durable read failure after the readiness window', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'error');
});
it('shows live detached activity while its durable view is still becoming ready', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot
initializeState={({ set }) =>
set(
subagentProgressByToolCallId(
subagentProgressKey(
selection.parentMessageId,
selection.toolCallId,
selection.partIndex,
),
),
{
subagentRunId: 'run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: [{ type: ContentTypes.TEXT, text: 'Live child update.' }],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
},
)
}
>
<SubagentThreadPanel selection={{ ...selection, runStepStatus: 'completed' }} />
</RecoilRoot>,
);
expect(screen.getByText('Live child update.')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'ready');
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'running');
});
it('exposes the focus-trapped mobile overlay as a modal dialog', () => {

View file

@ -1,53 +1,60 @@
import { useCallback, useEffect, useRef } from 'react';
import { useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery } from '@librechat/client';
import { AlertCircle, Bot, CheckCircle2, Clock3, X, XCircle } from 'lucide-react';
import type { SubagentThreadStatus } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Bot, X } from 'lucide-react';
import { Button, useMediaQuery } from '@librechat/client';
import { useRecoilValue, useResetRecoilState } from 'recoil';
import type { ActiveSubagentPanel } from '~/store/subagents';
import type { TranslationKeys } from '~/hooks';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import {
activeSubagentPanel,
subagentProgressByToolCallId,
subagentProgressKey,
} from '~/store/subagents';
import { adaptDurableThreadActivity, adaptLivePersistedActivity } from './adapters';
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
import { useSubagentThreadQuery } from '~/data-provider';
import { activeSubagentPanel } from '~/store/subagents';
import { useFocusTrap, useLocalize } from '~/hooks';
import { cn } from '~/utils';
const statusIcon = (status: SubagentThreadStatus) => {
if (status === 'completed') return CheckCircle2;
if (status === 'failed' || status === 'interrupted') return AlertCircle;
if (status === 'cancelled') return XCircle;
return Clock3;
};
const statusLabels: Record<SubagentThreadStatus, TranslationKeys> = {
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',
};
import SubagentActivity from './SubagentActivity';
export default function SubagentThreadPanel({ selection }: { selection: ActiveSubagentPanel }) {
const localize = useLocalize();
const panelRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery('(max-width: 767px)');
const resetSelection = useResetRecoilState(activeSubagentPanel);
const progress = useRecoilValue(
subagentProgressByToolCallId(
subagentProgressKey(selection.parentMessageId, selection.toolCallId, selection.partIndex),
),
);
const foregroundTitle =
selection.subagentType === 'self'
? localize('com_ui_subagent_dialog_title_self')
: localize('com_ui_subagent_dialog_title', { 0: selection.subagentType });
const threadId = selection.durable?.threadId ?? '';
const taskId = selection.durable?.taskId ?? '';
const { data, isLoading, isError, isReadinessPending } = useSubagentThreadQuery(
selection.parentConversationId,
selection.threadId,
selection.taskId,
threadId,
taskId,
);
const detachedLiveSubmitting =
selection.durable != null &&
progress != null &&
progress.status !== 'stop' &&
progress.status !== 'error';
const close = useCallback(() => {
resetSelection();
requestAnimationFrame(() => {
const trigger = Array.from(
document.querySelectorAll<HTMLElement>('[data-subagent-tool-call]'),
).find((element) => element.dataset.subagentToolCall === selection.toolCallId);
).find(
(element) =>
element.dataset.subagentToolCall === selection.toolCallId &&
element.dataset.subagentParentMessage === selection.parentMessageId &&
element.dataset.subagentPartIndex === String(selection.partIndex),
);
trigger?.focus();
});
}, [resetSelection, selection.toolCallId]);
}, [resetSelection, selection.parentMessageId, selection.partIndex, selection.toolCallId]);
useFocusTrap(panelRef, isMobile, close);
@ -59,65 +66,54 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
};
}, [isMobile]);
const status = data?.status ?? 'dispatched';
const StatusIcon = statusIcon(status);
const title = data?.title ?? selection.subagentType;
let panelBody: ReactNode;
if (isLoading || isReadinessPending) {
panelBody = (
<div className="flex h-full items-center justify-center" role="status">
<Spinner className="text-text-secondary" />
</div>
);
} else if (isError) {
panelBody = (
<div className="rounded-lg border border-status-error-border bg-status-error-subtle p-3 text-sm text-status-error">
{localize('com_ui_subagent_thread_load_error')}
</div>
);
} else if (data?.messages.length === 0) {
panelBody = (
<div className="rounded-lg border border-border-light bg-surface-secondary p-3 text-sm text-text-secondary">
{localize('com_ui_subagent_thread_empty')}
</div>
);
} else {
panelBody = (
<ol className="relative space-y-4 before:absolute before:bottom-3 before:left-[0.4375rem] before:top-3 before:w-px before:bg-border-medium">
{data?.historyTruncated === true && (
<li className="relative pl-7 text-xs text-text-secondary">
<span className="absolute left-1 top-1 h-2 w-2 rounded-full bg-border-heavy" />
{localize('com_ui_subagent_thread_history_truncated')}
</li>
)}
{data?.messages.map((message) => (
<li key={message.messageId} className="relative pl-7">
<span
className={cn(
'absolute left-0 top-1.5 flex h-3.5 w-3.5 items-center justify-center rounded-full ring-4 ring-surface-primary',
message.role === 'user' ? 'bg-status-info' : 'bg-status-success',
)}
aria-hidden="true"
/>
<article className="rounded-lg border border-border-light bg-surface-secondary px-3 py-2.5">
<div className="mb-1 text-xs font-medium text-text-secondary">
{message.role === 'user'
? localize('com_ui_subagent_thread_task')
: localize('com_ui_subagent_thread_response')}
</div>
<div className="prose-sm max-w-none break-words text-sm text-text-primary">
<MarkdownLite content={message.text} codeExecution={false} />
</div>
{message.textTruncated === true && (
<div className="mt-2 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_message_truncated')}
</div>
)}
</article>
</li>
))}
</ol>
);
const liveActivity = useMemo(
() =>
adaptLivePersistedActivity({
title: foregroundTitle,
prompt: selection.prompt,
progress,
persistedContent: selection.persistedContent,
legacyOutput: selection.legacyOutput,
// A detached parent tool step closes as soon as dispatch succeeds;
// its terminal status does not describe the still-running child.
initialProgress: selection.durable == null ? selection.initialProgress : 0,
isSubmitting: selection.durable == null ? selection.isSubmitting : detachedLiveSubmitting,
runStepStatus: selection.durable == null ? selection.runStepStatus : undefined,
reasoningVisibility: selection.durable == null ? 'visible' : 'marker',
}),
[detachedLiveSubmitting, foregroundTitle, progress, selection],
);
const activity = useMemo(() => {
if (selection.durable == null) return liveActivity;
if (data == null) {
return progress == null ? { ...liveActivity, status: 'dispatched' as const } : liveActivity;
}
const durable = adaptDurableThreadActivity(data, selection.durable.taskId);
if (
(durable.status === 'running' || durable.status === 'dispatched') &&
liveActivity.items.length > 0
) {
return {
...durable,
prompt: durable.prompt ?? liveActivity.prompt,
items: liveActivity.items,
};
}
return {
...durable,
prompt: durable.prompt ?? liveActivity.prompt,
items: durable.items.length > 0 ? durable.items : liveActivity.items,
};
}, [data, liveActivity, progress, selection.durable]);
let panelState: 'ready' | 'loading' | 'error' = 'ready';
if (
selection.durable != null &&
liveActivity.items.length === 0 &&
(isLoading || isReadinessPending)
) {
panelState = 'loading';
} else if (selection.durable != null && liveActivity.items.length === 0 && isError) {
panelState = 'error';
}
return (
@ -133,19 +129,9 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
<Bot size={17} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold" title={title}>
{title}
<h2 className="truncate text-sm font-semibold" title={activity.title}>
{activity.title}
</h2>
<div
className={cn(
'mt-0.5 flex items-center gap-1 text-xs text-text-secondary',
status === 'failed' || status === 'interrupted' ? 'text-status-error' : '',
)}
aria-live="polite"
>
<StatusIcon size={13} aria-hidden="true" />
<span>{localize(statusLabels[status])}</span>
</div>
</div>
<Button
type="button"
@ -159,7 +145,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">{panelBody}</div>
{/* Keep the foreground panel's existing nested-tool approval controls
coordinated within this invocation. Detached activity projections
never include approval payloads. */}
<ApprovalProvider
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
>
<SubagentActivity
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activity={activity}
state={panelState}
/>
</ApprovalProvider>
</aside>
);
}

View file

@ -0,0 +1,173 @@
import { ContentTypes } from 'librechat-data-provider';
import type { SubagentThreadView, TMessageContentParts } from 'librechat-data-provider';
import { initSubagentAggregatorState, initSubagentTickerState } from '~/utils/subagentContent';
import { adaptDurableThreadActivity, adaptLivePersistedActivity } from './adapters';
describe('child activity adapters', () => {
it('prefers authoritative parent persistence over a partial live foreground trace', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: {
subagentRunId: 'run',
subagentType: 'researcher',
status: 'message_delta',
contentParts: [{ type: ContentTypes.TEXT, text: 'Partial live text.' }],
aggregatorState: initSubagentAggregatorState(),
tickerState: initSubagentTickerState(),
},
persistedContent: [
{ type: 'think', think: 'Visible reasoning.' },
{
type: 'tool_call',
tool_call: {
id: 'tool-1',
name: 'search',
args: '{"query":"release"}',
output: 'Found it.',
progress: 1,
},
},
{ type: 'text', text: 'Persisted answer.' },
] as unknown as TMessageContentParts[],
initialProgress: 1,
isSubmitting: false,
});
expect(activity.items).toEqual([
{ type: 'reasoning', text: 'Visible reasoning.' },
expect.objectContaining({
type: 'tool',
toolCallId: 'tool-1',
name: 'search',
status: 'completed',
}),
{ type: 'writing', text: 'Persisted answer.' },
]);
});
it('rehydrates the selected detached task from its sanitized durable activity', () => {
const view: SubagentThreadView = {
threadId: 'thread',
parentConversationId: 'parent',
parentMessageId: 'parent-message',
parentToolCallId: 'parent-tool',
subagentType: 'researcher',
subagentKind: 'agent',
title: 'Research child',
status: 'completed',
activity: [
{
type: 'tool',
toolCallId: 'tool-1',
name: 'search',
input: '{"query":"release"}',
output: 'Found it.',
status: 'completed',
},
{ type: 'writing', text: 'Durable answer.' },
],
activityTruncated: false,
messages: [
{
messageId: 'task:user',
parentMessageId: null,
role: 'user',
text: 'Investigate the release.',
},
{
messageId: 'task:assistant',
parentMessageId: 'task:user',
role: 'assistant',
text: 'Durable answer.',
},
],
historyTruncated: false,
};
expect(adaptDurableThreadActivity(view, 'task')).toEqual(
expect.objectContaining({
title: 'Research child',
prompt: 'Investigate the release.',
status: 'completed',
items: view.activity,
}),
);
});
it('redacts detached live reasoning while retaining its activity marker', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: null,
persistedContent: [
{ type: ContentTypes.THINK, think: 'private live reasoning' },
{ type: ContentTypes.TEXT, text: 'Visible answer.' },
] as TMessageContentParts[],
initialProgress: 0,
isSubmitting: true,
reasoningVisibility: 'marker',
});
expect(activity.items).toEqual([
{ type: 'reasoning' },
{ type: 'writing', text: 'Visible answer.' },
]);
expect(JSON.stringify(activity)).not.toContain('private live reasoning');
});
it('keeps an empty-output approval pending', () => {
const activity = adaptLivePersistedActivity({
title: 'researcher',
progress: null,
persistedContent: [
{
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: {
id: 'tool',
name: 'protected_tool',
args: '{}',
output: '',
progress: 0.1,
approval: { expires_at: 123 },
},
},
] as unknown as TMessageContentParts[],
initialProgress: 0,
isSubmitting: true,
});
expect(activity.items[0]).toEqual(
expect.objectContaining({
type: 'tool',
status: 'running',
output: '',
approval: expect.any(Object),
}),
);
});
it('uses the exact assistant row as terminal authority for an older API response', () => {
const oldView = {
threadId: 'thread',
parentConversationId: 'parent',
parentMessageId: 'parent-message',
parentToolCallId: 'parent-tool',
subagentType: 'researcher',
subagentKind: 'agent',
title: 'Research child',
status: 'running',
messages: [
{
messageId: 'task:assistant',
parentMessageId: 'task:user',
role: 'assistant',
text: 'Done.',
},
],
historyTruncated: false,
} as SubagentThreadView;
expect(adaptDurableThreadActivity(oldView, 'task')).toEqual(
expect.objectContaining({ status: 'completed', items: [{ type: 'writing', text: 'Done.' }] }),
);
});
});

View file

@ -0,0 +1,177 @@
import { ContentTypes } from 'librechat-data-provider';
import type {
Agents,
PartMetadata,
SubagentActivityItem,
SubagentThreadStatus,
SubagentThreadView,
TMessageContentParts,
} from 'librechat-data-provider';
import type { SubagentProgress } from '~/store/subagents';
export type ChildActivityItem =
| {
type: 'writing';
text: string;
textTruncated?: boolean;
}
| {
type: 'reasoning';
text?: string;
}
| {
type: 'tool';
toolCallId: string;
name: string;
input?: string | Record<string, unknown>;
output?: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
approval?: Agents.ToolCall['approval'];
inputTruncated?: boolean;
outputTruncated?: boolean;
};
export type ChildActivity = {
title: string;
prompt?: string;
status: SubagentThreadStatus;
items: ChildActivityItem[];
activityTruncated?: boolean;
};
type ContentToolCall = {
id?: string;
args?: string | Record<string, unknown>;
output?: string;
name?: string;
progress?: number;
runStepStatus?: PartMetadata['runStepStatus'];
approval?: Agents.ToolCall['approval'];
};
const contentPartsToActivity = (
parts: TMessageContentParts[],
reasoningVisibility: 'visible' | 'marker',
): ChildActivityItem[] =>
parts.flatMap((part, index): ChildActivityItem[] => {
if (part.type === ContentTypes.TEXT) {
return [{ type: 'writing', text: (part as { text: string }).text }];
}
if (part.type === ContentTypes.THINK) {
return [
{
type: 'reasoning',
...(reasoningVisibility === 'visible' ? { text: (part as { think: string }).think } : {}),
},
];
}
if (part.type !== ContentTypes.TOOL_CALL) return [];
const tool = (part as { [ContentTypes.TOOL_CALL]?: ContentToolCall })[ContentTypes.TOOL_CALL];
if (tool == null) return [];
const runStepStatus =
(part as { runStepStatus?: PartMetadata['runStepStatus'] }).runStepStatus ??
tool.runStepStatus;
const waitingForApproval =
tool.approval != null &&
(tool.output?.length ?? 0) === 0 &&
(tool.progress ?? 0) < 1 &&
runStepStatus == null;
const completed = !waitingForApproval && ((tool.progress ?? 0) >= 1 || tool.output != null);
return [
{
type: 'tool',
toolCallId: tool.id ?? `tool-${index}`,
name: tool.name ?? '',
...(tool.args == null ? {} : { input: tool.args }),
...(tool.output == null ? {} : { output: tool.output }),
status: runStepStatus ?? (completed ? 'completed' : 'running'),
...(tool.approval == null ? {} : { approval: tool.approval }),
},
];
});
const publicActivityToChildActivity = (items: SubagentActivityItem[]): ChildActivityItem[] =>
items.map((item) => {
if (item.type !== 'tool') return item;
return {
...item,
...(item.input == null ? {} : { input: item.input }),
};
});
const liveStatus = ({
progress,
initialProgress,
isSubmitting,
runStepStatus,
}: {
progress: SubagentProgress | null;
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
}): SubagentThreadStatus => {
if (runStepStatus === 'cancelled') return 'cancelled';
if (runStepStatus === 'failed' || progress?.status === 'error') return 'failed';
if (runStepStatus != null || initialProgress >= 1 || progress?.status === 'stop') {
return 'completed';
}
return isSubmitting ? 'running' : 'cancelled';
};
/** Adapts live SSE state, parent persistence, and legacy output at one seam. */
export function adaptLivePersistedActivity(input: {
title: string;
prompt?: string;
progress: SubagentProgress | null;
persistedContent?: TMessageContentParts[];
legacyOutput?: string | null;
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
reasoningVisibility?: 'visible' | 'marker';
}): ChildActivity {
const persisted = input.persistedContent ?? [];
const live = (input.progress?.contentParts ?? []) as TMessageContentParts[];
const parts = persisted.length > 0 ? persisted : live;
const items = contentPartsToActivity(parts, input.reasoningVisibility ?? 'visible');
if (items.length === 0 && input.legacyOutput != null && input.legacyOutput !== '') {
items.push({ type: 'writing', text: input.legacyOutput });
}
return {
title: input.title,
...(input.prompt == null ? {} : { prompt: input.prompt }),
status: liveStatus(input),
items,
};
}
/** Adapts the bounded parent-authorized child view without exposing its storage shape. */
export function adaptDurableThreadActivity(
view: SubagentThreadView,
taskId: string,
): ChildActivity {
const prompt = view.messages.find((message) => message.messageId === `${taskId}:user`)?.text;
const response = view.messages.find((message) => message.messageId === `${taskId}:assistant`);
// Tolerate a briefly mixed-version deployment where an older API replica
// returns the pre-projection view shape.
const hasProjectedActivity = Array.isArray(view.activity);
const items = publicActivityToChildActivity(view.activity ?? []);
if (items.length === 0 && response?.text != null && response.text !== '') {
items.push({
type: 'writing',
text: response.text,
...(response.textTruncated === true ? { textTruncated: true } : {}),
});
}
let status = view.status;
if (!hasProjectedActivity && response != null) {
status = response.error === true ? 'failed' : 'completed';
}
return {
title: view.title,
...(prompt == null ? {} : { prompt }),
status,
items,
activityTruncated: view.activityTruncated || view.historyTruncated,
};
}

View file

@ -46,13 +46,22 @@ describe('subagent thread refresh policy', () => {
expect(subagentThreadRefetchInterval(prior, 1_000, 1_000, 'new-task')).toBe(false);
});
it('stops polling an older API view once the exact task response exists', () => {
const rollingDeployView = {
...view('running'),
messages: [{ messageId: 'selected:assistant' }],
} as SubagentThreadView;
expect(subagentThreadRefetchInterval(rollingDeployView, 1_000, 500, 'selected')).toBe(false);
});
it('treats only readiness-window 404s as pending', () => {
expect(isSubagentReadinessPending({ response: { status: 404 } }, 1_000, 500)).toBe(true);
expect(isSubagentReadinessPending({ response: { status: 404 } }, 1_000, 1_000)).toBe(false);
expect(isSubagentReadinessPending({ response: { status: 500 } }, 1_000, 500)).toBe(false);
});
it('refetches a terminal thread when a new invocation continues it', () => {
it('keys the bounded activity projection by the selected invocation', () => {
const refetch = jest.fn();
mockUseQuery.mockReturnValue({
data: view('completed'),
@ -64,8 +73,19 @@ describe('subagent thread refresh policy', () => {
{ initialProps: { taskId: 'task-1' } },
);
expect(refetch).not.toHaveBeenCalled();
expect(mockUseQuery.mock.calls.at(-1)?.[0]).toEqual([
'subagentThread',
'parent-conversation',
'child-thread',
'task-1',
]);
rerender({ taskId: 'task-2' });
expect(refetch).toHaveBeenCalledTimes(1);
expect(mockUseQuery.mock.calls.at(-1)?.[0]).toEqual([
'subagentThread',
'parent-conversation',
'child-thread',
'task-2',
]);
expect(refetch).not.toHaveBeenCalled();
});
});

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
@ -29,6 +29,16 @@ export const subagentThreadRefetchInterval = (
) {
return now < readinessDeadline ? ACTIVE_THREAD_REFRESH_MS : false;
}
// During a rolling deploy, an older replica can return a thread-wide status
// without the task-scoped activity projection. The exact assistant row is
// nevertheless authoritative evidence that this selected invocation ended.
if (
expectedTaskId != null &&
view?.activity == null &&
view?.messages.some((message) => message.messageId === `${expectedTaskId}:assistant`)
) {
return false;
}
if (view == null || view.status === 'dispatched') {
return now < readinessDeadline ? ACTIVE_THREAD_REFRESH_MS : false;
}
@ -62,10 +72,9 @@ export const useSubagentThreadQuery = (
() => ({ key: readinessKey, deadline: Date.now() + CHILD_READY_POLL_WINDOW_MS }),
[readinessKey],
);
const previousTaskId = useRef(taskId);
const query = useQuery<SubagentThreadView>(
[QueryKeys.subagentThread, parentConversationId, threadId],
() => dataService.getSubagentThread(parentConversationId, threadId),
[QueryKeys.subagentThread, parentConversationId, threadId, taskId],
() => dataService.getSubagentThread(parentConversationId, threadId, taskId),
{
enabled: parentConversationId !== '' && threadId !== '',
retry: false,
@ -75,12 +84,6 @@ export const useSubagentThreadQuery = (
...config,
},
);
const { refetch } = query;
useEffect(() => {
if (previousTaskId.current === taskId) return;
previousTaskId.current = taskId;
void refetch();
}, [taskId, refetch]);
return {
...query,

View file

@ -17,7 +17,7 @@ import type {
SubagentUpdateEvent,
Agents,
} from 'librechat-data-provider';
import { subagentProgressByToolCallId } from '~/store/subagents';
import { subagentProgressByToolCallId, subagentProgressKey } from '~/store/subagents';
import { resolveAskUserQuestionPart } from '~/utils/approval';
import useStepHandler from '~/hooks/SSE/useStepHandler';
@ -2990,7 +2990,7 @@ describe('useStepHandler', () => {
*/
const renderStepHandlerWithReader = (): {
result: ReturnType<typeof renderHook>['result'];
getProgress: (toolCallId: string) => unknown;
getProgress: (toolCallId: string, parentMessageId?: string, partIndex?: number) => unknown;
} => {
/** Composite hook: the step handler under test + a `useRecoilCallback`
* reader that shares the same `RecoilRoot` store. Reading via a
@ -3001,8 +3001,18 @@ describe('useStepHandler', () => {
const stepHandler = useStepHandler(createHookParams());
const read = useRecoilCallback(
({ snapshot }) =>
(toolCallId: string): unknown =>
snapshot.getLoadable(subagentProgressByToolCallId(toolCallId)).valueOrThrow(),
(
toolCallId: string,
parentMessageId: string = 'response-msg-1',
partIndex: number = 0,
): unknown =>
snapshot
.getLoadable(
subagentProgressByToolCallId(
subagentProgressKey(parentMessageId, toolCallId, partIndex),
),
)
.valueOrThrow(),
[],
);
return { ...stepHandler, read };
@ -3010,8 +3020,11 @@ describe('useStepHandler', () => {
{ wrapper: RecoilRoot },
);
const getProgress = (toolCallId: string): unknown =>
(hookResult.result.current as any).read(toolCallId);
const getProgress = (
toolCallId: string,
parentMessageId?: string,
partIndex?: number,
): unknown => (hookResult.result.current as any).read(toolCallId, parentMessageId, partIndex);
return { result: hookResult.result, getProgress };
};
@ -3054,7 +3067,7 @@ describe('useStepHandler', () => {
};
const makeUpdate = (overrides: Partial<SubagentUpdateEvent> = {}): SubagentUpdateEvent => ({
runId: 'parent-run',
runId: 'response-msg-1',
subagentRunId: 'child-run-1',
subagentType: 'self',
subagentAgentId: 'child-1',
@ -3143,7 +3156,7 @@ describe('useStepHandler', () => {
});
const first = getProgress('call_old') as { latestLabel?: string };
const second = getProgress('call_new') as { latestLabel?: string };
const second = getProgress('call_new', undefined, 1) as { latestLabel?: string };
expect(first.latestLabel).toBe('first');
expect(second.latestLabel).toBe('second');
});
@ -3336,7 +3349,7 @@ describe('useStepHandler', () => {
status: string;
latestLabel?: string;
};
const bucketB = getProgress('call_b') as {
const bucketB = getProgress('call_b', undefined, 1) as {
subagentRunId: string;
status: string;
latestLabel?: string;
@ -3355,10 +3368,140 @@ describe('useStepHandler', () => {
expect(bucketB.status).toBe('run_step');
});
it('clearStepMaps preserves subagent atoms so the dialog can be re-opened for auditability', () => {
it('keeps reused provider tool-call IDs isolated across parent messages', () => {
const { result, getProgress } = renderStepHandlerWithReader();
const firstResponse: TMessage = {
...createResponseMessage({ messageId: 'response-one' }),
content: [buildSubagentToolCallPart('call_shared')],
};
const secondResponse: TMessage = {
...createResponseMessage({ messageId: 'response-two' }),
content: [buildSubagentToolCallPart('call_shared')],
};
act(() => {
(result.current as any).syncStepMessage(firstResponse);
(result.current as any).syncStepMessage(secondResponse);
(result.current as any).stepHandler(
{
event: StepEvents.ON_SUBAGENT_UPDATE,
data: makeUpdate({
runId: 'response-one',
subagentRunId: 'child-one',
parentToolCallId: 'call_shared',
label: 'first parent',
}),
},
createSubmission(),
);
(result.current as any).stepHandler(
{
event: StepEvents.ON_SUBAGENT_UPDATE,
data: makeUpdate({
runId: 'response-two',
subagentRunId: 'child-two',
parentToolCallId: 'call_shared',
label: 'second parent',
}),
},
createSubmission(),
);
});
expect(getProgress('call_shared', 'response-one')).toEqual(
expect.objectContaining({ subagentRunId: 'child-one', latestLabel: 'first parent' }),
);
expect(getProgress('call_shared', 'response-two')).toEqual(
expect.objectContaining({ subagentRunId: 'child-two', latestLabel: 'second parent' }),
);
});
it('buffers an update for its expected parent instead of claiming another same-ID call', () => {
const { result, getProgress } = renderStepHandlerWithReader();
const firstResponse: TMessage = {
...createResponseMessage({ messageId: 'response-one' }),
content: [buildSubagentToolCallPart('call_shared')],
};
act(() => {
(result.current as any).syncStepMessage(firstResponse);
(result.current as any).stepHandler(
{
event: StepEvents.ON_SUBAGENT_UPDATE,
data: makeUpdate({
runId: 'response-two',
subagentRunId: 'child-two',
parentToolCallId: 'call_shared',
phase: 'stop',
label: 'finished before parent two arrived',
}),
},
createSubmission(),
);
});
expect(getProgress('call_shared', 'response-one')).toBeNull();
const secondResponse: TMessage = {
...createResponseMessage({ messageId: 'response-two' }),
content: [buildSubagentToolCallPart('call_shared')],
};
act(() => {
(result.current as any).syncStepMessage(secondResponse);
});
expect(getProgress('call_shared', 'response-one')).toBeNull();
expect(getProgress('call_shared', 'response-two')).toEqual(
expect.objectContaining({
subagentRunId: 'child-two',
status: 'stop',
latestLabel: 'finished before parent two arrived',
}),
);
});
it('keeps repeated provider tool-call IDs isolated by content-part occurrence', () => {
const { result, getProgress } = renderStepHandlerWithReader();
const { submission } = seedResponseWithSubagentToolCalls(result, [
'call_shared',
'call_shared',
]);
act(() => {
(result.current as any).stepHandler(
{
event: StepEvents.ON_SUBAGENT_UPDATE,
data: makeUpdate({
subagentRunId: 'child-one',
parentToolCallId: 'call_shared',
label: 'first occurrence',
}),
},
submission,
);
(result.current as any).stepHandler(
{
event: StepEvents.ON_SUBAGENT_UPDATE,
data: makeUpdate({
subagentRunId: 'child-two',
parentToolCallId: 'call_shared',
label: 'second occurrence',
}),
},
submission,
);
});
expect(getProgress('call_shared', 'response-msg-1', 0)).toEqual(
expect.objectContaining({ subagentRunId: 'child-one', latestLabel: 'first occurrence' }),
);
expect(getProgress('call_shared', 'response-msg-1', 1)).toEqual(
expect.objectContaining({ subagentRunId: 'child-two', latestLabel: 'second occurrence' }),
);
});
it('clearStepMaps preserves subagent atoms so the panel can be re-opened for auditability', () => {
/**
* Intentionally the inverse of the earlier behavior: the collapsed
* `SubagentCall` ticker and its dialog must stay readable after the
* `SubagentCall` ticker and its panel must stay readable after the
* stream ends. Wiping the atoms on `clearStepMaps` would leave a
* completed subagent tool call with no content to display, forcing
* the fallback "raw tool output" branch and losing interleaved tool

View file

@ -28,8 +28,12 @@ import {
initSubagentAggregatorState,
initSubagentTickerState,
} from '~/utils/subagentContent';
import {
subagentProgressByToolCallId,
subagentProgressKey,
sandboxStartingByToolCallId,
} from '~/store';
import { isAskUserQuestionPart, isAnsweredAskUserQuestionPart } from '~/utils/approval';
import { subagentProgressByToolCallId, sandboxStartingByToolCallId } from '~/store';
import { MESSAGE_UPDATE_INTERVAL } from '~/common';
type TUseStepHandler = {
@ -163,22 +167,22 @@ export default function useStepHandler({
const pendingDeltaFlushIds = useRef(new Set<string>());
const pendingDeltaFlushRef = useRef<(() => void) | null>(null);
/**
* Maps `SubagentUpdateEvent.subagentRunId` parent `tool_call_id`.
* Preferred source is `payload.parentToolCallId` (threaded through by the
* SDK from `ToolRunnableConfig.toolCall.id`, deterministic). If a host
* runs an older SDK that doesn't emit it, we fall back to a temporal
* claim: the OLDEST unclaimed `subagent` tool call in the active message.
* Forward (oldest-first) iteration matches the order tool calls are
* created in, so concurrent spawns map in creation order.
* Maps `SubagentUpdateEvent.subagentRunId` one concrete parent content-part
* occurrence. `payload.parentToolCallId` narrows the candidates when present,
* but provider IDs are not unique enough to be the atom identity: they may be
* reused across messages or even within one message. Forward (oldest-first)
* claiming preserves creation order for both modern and legacy envelopes.
*/
const subagentRunToToolCallId = useRef(new Map<string, string>());
const claimedSubagentToolCallIds = useRef(new Set<string>());
const subagentRunToInvocationKey = useRef(new Map<string, string>());
const claimedSubagentInvocationKeys = useRef(new Set<string>());
/**
* Buffers for envelopes that arrive before their `subagent` tool call is
* reflected in `messageMap`. Keyed by `subagentRunId`. Once a tool call is
* claimed we drain the buffer into the Recoil atom in arrival order.
*/
const pendingSubagentBuffer = useRef(new Map<string, SubagentUpdateEvent[]>());
const pendingSubagentBuffer = useRef(
new Map<string, { parentMessageId: string; events: SubagentUpdateEvent[] }>(),
);
/**
* Tracked atom keys so `clearStepMaps` can reset them. Without this, each
* subagent invocation leaks an `events: SubagentUpdateEvent[]` array in the
@ -201,23 +205,23 @@ export default function useStepHandler({
* memory past what the structural output requires. */
/**
* Attempts to resolve the parent `tool_call_id` for a subagent run, using
* the SDK-provided `parentToolCallId` first and falling back to an
* oldest-unclaimed temporal claim.
* Resolves a subagent run to an occurrence-scoped parent invocation key.
*/
const resolveSubagentToolCallId = useCallback(
(payload: SubagentUpdateEvent): string | undefined => {
const cached = subagentRunToToolCallId.current.get(payload.subagentRunId);
const resolveSubagentInvocationKey = useCallback(
(payload: SubagentUpdateEvent, parentMessageId: string): string | undefined => {
const cached = subagentRunToInvocationKey.current.get(payload.subagentRunId);
if (cached != null) return cached;
if (parentMessageId === '') return undefined;
if (payload.parentToolCallId) {
subagentRunToToolCallId.current.set(payload.subagentRunId, payload.parentToolCallId);
claimedSubagentToolCallIds.current.add(payload.parentToolCallId);
return payload.parentToolCallId;
}
// Fallback — oldest unclaimed subagent tool call wins.
for (const message of messageMap.current.values()) {
// Claim one concrete content-part occurrence. Providers can repeat a
// tool_call ID even within one assistant message, so raw IDs alone are
// not sufficient identity for either the card or its live progress.
const preferred = messageMap.current.get(parentMessageId);
// `runId` gives us the expected parent message. If that message has not
// arrived yet, buffer instead of claiming a same-ID call from another
// parallel response; the mapping is permanent once claimed.
if (preferred == null) return undefined;
for (const [messageId, message] of [[parentMessageId, preferred] as const]) {
const content = message.content;
if (!Array.isArray(content)) continue;
for (let i = 0; i < content.length; i++) {
@ -229,11 +233,13 @@ export default function useStepHandler({
if (
tc?.name === Constants.SUBAGENT &&
tc.id &&
!claimedSubagentToolCallIds.current.has(tc.id)
(payload.parentToolCallId == null || tc.id === payload.parentToolCallId) &&
!claimedSubagentInvocationKeys.current.has(subagentProgressKey(messageId, tc.id, i))
) {
subagentRunToToolCallId.current.set(payload.subagentRunId, tc.id);
claimedSubagentToolCallIds.current.add(tc.id);
return tc.id;
const invocationKey = subagentProgressKey(messageId, tc.id, i);
subagentRunToInvocationKey.current.set(payload.subagentRunId, invocationKey);
claimedSubagentInvocationKeys.current.add(invocationKey);
return invocationKey;
}
}
}
@ -251,24 +257,27 @@ export default function useStepHandler({
*/
const applySubagentUpdate = useRecoilCallback(
({ set }) =>
(payload: SubagentUpdateEvent): void => {
const toolCallId = resolveSubagentToolCallId(payload);
(payload: SubagentUpdateEvent, parentMessageId: string): void => {
const invocationKey = resolveSubagentInvocationKey(payload, parentMessageId);
if (!toolCallId) {
const queue = pendingSubagentBuffer.current.get(payload.subagentRunId) ?? [];
queue.push(payload);
pendingSubagentBuffer.current.set(payload.subagentRunId, queue);
if (!invocationKey) {
const pending = pendingSubagentBuffer.current.get(payload.subagentRunId) ?? {
parentMessageId,
events: [],
};
pending.events.push(payload);
pendingSubagentBuffer.current.set(payload.subagentRunId, pending);
return;
}
const buffered = pendingSubagentBuffer.current.get(payload.subagentRunId);
if (buffered && buffered.length > 0) {
const pending = pendingSubagentBuffer.current.get(payload.subagentRunId);
if (pending && pending.events.length > 0) {
pendingSubagentBuffer.current.delete(payload.subagentRunId);
}
const toApply = buffered ? [...buffered, payload] : [payload];
const toApply = pending ? [...pending.events, payload] : [payload];
knownSubagentAtomKeys.current.add(toolCallId);
set(subagentProgressByToolCallId(toolCallId), (prev) => {
knownSubagentAtomKeys.current.add(invocationKey);
set(subagentProgressByToolCallId(invocationKey), (prev) => {
/** Fold the batch into both aggregators. Pure functions they
* return a new reference only when something actually changed,
* so React bails out of unnecessary re-renders downstream. */
@ -297,25 +306,25 @@ export default function useStepHandler({
};
});
},
[resolveSubagentToolCallId],
[resolveSubagentInvocationKey],
);
/**
* Resets all accumulated subagent Recoil state. Kept for conversation-
* switch cleanup (see top-level hook usage) but NOT called from
* `clearStepMaps` the collapsed SubagentCall ticker and its dialog
* `clearStepMaps` the collapsed SubagentCall ticker and its panel
* read from these atoms to render the child's content parts, and we
* want that history to remain visible after the stream ends so the
* user can reopen the dialog for auditability. The atoms are bounded
* per-call (200-event cap) and per-conversation (one atom per
* user can reopen the panel for auditability. The atoms are bounded
* by aggregated structure and per-conversation (one atom per
* subagent spawn), so growth is proportional to messages the same
* growth profile as the rest of the conversation state.
*/
const resetSubagentAtoms = useRecoilCallback(
({ reset }) =>
(): void => {
for (const toolCallId of knownSubagentAtomKeys.current) {
reset(subagentProgressByToolCallId(toolCallId));
for (const invocationKey of knownSubagentAtomKeys.current) {
reset(subagentProgressByToolCallId(invocationKey));
}
knownSubagentAtomKeys.current.clear();
},
@ -1270,7 +1279,11 @@ export default function useStepHandler({
} else if (stepEvent.event === StepEvents.ON_SANDBOX_STARTING) {
setSandboxStarting(stepEvent.data.tool_call_id);
} else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) {
applySubagentUpdate(stepEvent.data);
let responseMessageId = stepEvent.data.runId;
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
}
applySubagentUpdate(stepEvent.data, responseMessageId);
} else if (stepEvent.event === StepEvents.ON_SUMMARIZE_START) {
announcePolite({ message: 'summarize_started', isStatus: true });
} else if (stepEvent.event === StepEvents.ON_SUMMARIZE_DELTA) {
@ -1421,8 +1434,8 @@ export default function useStepHandler({
messageMap.current.clear();
stepMap.current.clear();
pendingDeltaBuffer.current.clear();
subagentRunToToolCallId.current.clear();
claimedSubagentToolCallIds.current.clear();
subagentRunToInvocationKey.current.clear();
claimedSubagentInvocationKeys.current.clear();
pendingSubagentBuffer.current.clear();
/** Unlike subagent atoms below, sandbox-starting flags are transient
* status with no audit value reset them at this boundary so an
@ -1444,11 +1457,22 @@ export default function useStepHandler({
* Call this after receiving sync event to ensure subsequent deltas
* build on the synced content, not stale content.
*/
const syncStepMessage = useCallback((message: TMessage) => {
if (message?.messageId) {
const syncStepMessage = useCallback(
(message: TMessage) => {
if (!message?.messageId) return;
messageMap.current.set(message.messageId, { ...message });
}
}, []);
const ready = [...pendingSubagentBuffer.current.entries()].filter(
([, pending]) => pending.parentMessageId === message.messageId,
);
for (const [subagentRunId, pending] of ready) {
pendingSubagentBuffer.current.delete(subagentRunId);
for (const event of pending.events) {
applySubagentUpdate(event, message.messageId);
}
}
},
[applySubagentUpdate],
);
return {
stepHandler,

View file

@ -2156,19 +2156,15 @@
"com_ui_subagent_activity": "Agent activity",
"com_ui_subagent_cancelled": "Cancelled agent",
"com_ui_subagent_complete": "Ran agent",
"com_ui_subagent_dialog_description": "Isolated-context child run. Activity and final result below.",
"com_ui_subagent_dialog_title": "\"{{0}}\" agent",
"com_ui_subagent_dialog_title_self": "Agent",
"com_ui_subagent_empty_result": "No text returned.",
"com_ui_subagent_errored": "Agent errored",
"com_ui_subagent_no_result_yet": "Still running — no final result yet.",
"com_ui_subagent_open_thread": "Open child chat",
"com_ui_subagent_thread_empty": "This agent has not recorded any activity yet.",
"com_ui_subagent_thread_history_truncated": "Earlier activity is not shown.",
"com_ui_subagent_thread_load_error": "The agent activity could not be loaded.",
"com_ui_subagent_thread_message_truncated": "This entry was shortened for display.",
"com_ui_subagent_thread_panel": "Child agent activity",
"com_ui_subagent_thread_response": "Agent response",
"com_ui_subagent_thread_read_only": "This child thread is view-only here. Its parent agent owns this execution and can continue it with the saved thread history.",
"com_ui_subagent_thread_status_cancelled": "Cancelled",
"com_ui_subagent_thread_status_completed": "Completed",
@ -2176,7 +2172,6 @@
"com_ui_subagent_thread_status_failed": "Failed",
"com_ui_subagent_thread_status_interrupted": "Interrupted",
"com_ui_subagent_thread_status_running": "Running",
"com_ui_subagent_thread_task": "Assigned task",
"com_ui_subagent_running": "Running agent",
"com_ui_subagent_scroll_to_bottom": "Scroll to latest",
"com_ui_subagent_ticker_error": "Error",

View file

@ -1,5 +1,9 @@
import { atom, atomFamily } from 'recoil';
import type { SubagentUpdatePhase } from 'librechat-data-provider';
import type {
PartMetadata,
SubagentUpdatePhase,
TMessageContentParts,
} from 'librechat-data-provider';
import type {
SubagentAggregatorState,
SubagentContentPart,
@ -9,10 +13,10 @@ import type {
/**
* Progress bucket captured per subagent tool call. Populated as
* `ON_SUBAGENT_UPDATE` SSE events stream in from the backend. Keyed by the
* parent's `tool_call_id` so the `SubagentCall` renderer can look the bucket
* up from the tool call it's rendering.
* parent invocation so provider-local tool call IDs cannot collide across
* separate assistant messages.
*
* Both the dialog content and the ticker are aggregated *incrementally*
* Both the panel content and the ticker are aggregated *incrementally*
* into the atom as each envelope arrives the atom never keeps the raw
* event array. A long-running subagent can emit thousands of deltas
* without the state growing past what its structural output (N text
@ -40,13 +44,23 @@ export interface SubagentProgress {
latestLabel?: string;
}
/** One parent-owned durable child selected for the read-only activity panel. */
/** One child invocation selected for the shared read-only activity panel. */
export type ActiveSubagentPanel = {
parentConversationId: string;
threadId: string;
taskId: string;
parentMessageId: string;
toolCallId: string;
partIndex: number;
subagentType: string;
prompt?: string;
legacyOutput?: string | null;
persistedContent?: TMessageContentParts[];
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
durable?: {
threadId: string;
taskId: string;
};
};
export const activeSubagentPanel = atom<ActiveSubagentPanel | null>({
@ -54,7 +68,14 @@ export const activeSubagentPanel = atom<ActiveSubagentPanel | null>({
default: null,
});
/** Progress state keyed by parent tool_call_id. */
/** Stable identity for one subagent invocation in the parent conversation. */
export const subagentProgressKey = (
parentMessageId: string,
toolCallId: string,
partIndex: number,
) => `${parentMessageId}\u0000${toolCallId}\u0000${partIndex}`;
/** Progress state keyed by one concrete tool-call content-part occurrence. */
export const subagentProgressByToolCallId = atomFamily<SubagentProgress | null, string>({
key: 'subagentProgressByToolCallId',
default: null,

View file

@ -0,0 +1,262 @@
import { projectSubagentActivity, SUBAGENT_ACTIVITY_LIMITS } from './activity';
describe('durable subagent activity projection', () => {
it('keeps visible text and tool lifecycle while dropping private metadata and reasoning text', () => {
const projection = projectSubagentActivity(
JSON.stringify([
{
type: 'ai',
data: {
content: [
{ type: 'reasoning', reasoning: 'private chain of thought' },
{ type: 'text', text: 'I will check.' },
],
tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'release' } }],
response_metadata: { providerRequestId: 'private-request' },
},
},
{
type: 'tool',
data: {
tool_call_id: 'call-1',
name: 'search',
content: 'Found it.',
status: 'success',
artifact: { secret: 'never expose' },
},
},
]),
);
expect(projection).toEqual({
activity: [
{ type: 'reasoning' },
{ type: 'writing', text: 'I will check.' },
{
type: 'tool',
toolCallId: 'call-1',
name: 'search',
input: '{"query":"release"}',
output: 'Found it.',
status: 'completed',
},
],
truncated: false,
});
expect(JSON.stringify(projection)).not.toContain('private chain of thought');
expect(JSON.stringify(projection)).not.toContain('private-request');
expect(JSON.stringify(projection)).not.toContain('never expose');
});
it('fails closed on invalid input and bounds adversarial activity', () => {
expect(projectSubagentActivity('{')).toEqual({ activity: [], truncated: true });
const projection = projectSubagentActivity(
JSON.stringify(
Array.from({ length: 500 }, (_, index) => ({
type: 'ai',
data: {
content: '🧵'.repeat(SUBAGENT_ACTIVITY_LIMITS.textBytes),
tool_calls: [
{
id: `call-${index}`,
name: 'tool',
args: { value: 'x'.repeat(SUBAGENT_ACTIVITY_LIMITS.toolInputBytes * 2) },
},
],
},
})),
),
);
expect(projection.truncated).toBe(true);
expect(projection.activity.length).toBeLessThanOrEqual(SUBAGENT_ACTIVITY_LIMITS.items);
expect(Buffer.byteLength(JSON.stringify(projection.activity), 'utf8')).toBeLessThanOrEqual(
SUBAGENT_ACTIVITY_LIMITS.bytes,
);
expect(projection.activity[projection.activity.length - 1]).toEqual(
expect.objectContaining({ type: 'tool', toolCallId: 'call-499' }),
);
});
it.each([
['error', 'failed'],
['failed', 'failed'],
['cancelled', 'cancelled'],
['success', 'completed'],
] as const)('maps a %s tool result to the %s public lifecycle', (stored, expected) => {
const projection = projectSubagentActivity(
JSON.stringify([
{ type: 'ai', data: { tool_calls: [{ id: 'call', name: 'search', args: {} }] } },
{ type: 'tool', data: { tool_call_id: 'call', name: 'search', status: stored } },
]),
);
expect(projection.activity[0]).toEqual(expect.objectContaining({ status: expected }));
});
it('shows only the selected invocation segment from a replacement transcript', () => {
const projection = projectSubagentActivity(
JSON.stringify([
{ type: 'human', data: { content: 'Earlier request.' } },
{ type: 'ai', data: { content: 'Earlier private activity.' } },
{ type: 'human', data: { content: 'Selected request.' } },
{ type: 'ai', data: { content: 'Selected activity.' } },
]),
'replace',
'Selected request.',
);
expect(projection.activity).toEqual([{ type: 'writing', text: 'Selected activity.' }]);
expect(JSON.stringify(projection)).not.toContain('Earlier private activity.');
expect(
projectSubagentActivity('[{"type":"ai","data":{"content":"old"}}]', 'replace', 'new'),
).toEqual({ activity: [], truncated: true });
expect(
projectSubagentActivity(
'[{"type":"human","data":{"content":"different"}}]',
'replace',
'selected',
),
).toEqual({ activity: [], truncated: true });
expect(
projectSubagentActivity('[{"type":"human","data":{"content":"selected"}}]', 'replace'),
).toEqual({ activity: [], truncated: true });
});
it('correlates repeated provider tool IDs by occurrence without merging their results', () => {
const projection = projectSubagentActivity(
JSON.stringify([
{ type: 'ai', data: { tool_calls: [{ id: 'call', name: 'first', args: {} }] } },
{ type: 'ai', data: { tool_calls: [{ id: 'call', name: 'second', args: {} }] } },
{ type: 'tool', data: { tool_call_id: 'call', content: 'first result' } },
{ type: 'tool', data: { tool_call_id: 'call', content: 'second result' } },
]),
);
expect(projection.activity).toEqual([
expect.objectContaining({
toolCallId: 'call',
name: 'first',
output: 'first result',
status: 'completed',
}),
expect.objectContaining({
toolCallId: 'call#2',
name: 'second',
output: 'second result',
status: 'completed',
}),
]);
});
it('correlates a large repeated-ID queue in FIFO order within the item cap', () => {
const count = SUBAGENT_ACTIVITY_LIMITS.items * 3;
const projection = projectSubagentActivity(
JSON.stringify([
...Array.from({ length: count }, (_, index) => ({
type: 'ai',
data: { tool_calls: [{ id: 'call', name: `tool-${index}`, args: {} }] },
})),
...Array.from({ length: count }, (_, index) => ({
type: 'tool',
data: { tool_call_id: 'call', content: `result-${index}` },
})),
]),
);
expect(projection.activity).toHaveLength(SUBAGENT_ACTIVITY_LIMITS.items);
expect(projection.activity[0]).toEqual(
expect.objectContaining({
toolCallId: 'call#201',
name: 'tool-200',
output: 'result-200',
}),
);
expect(projection.activity[projection.activity.length - 1]).toEqual(
expect.objectContaining({
toolCallId: 'call#300',
name: 'tool-299',
output: 'result-299',
}),
);
});
it('allocates suffixes globally when long provider IDs share a truncated namespace', () => {
const prefix = 'x'.repeat(510);
const first = `${prefix}aa`;
const second = `${prefix}bb`;
const projection = projectSubagentActivity(
JSON.stringify([
{
type: 'ai',
data: {
tool_calls: [
{ id: first, name: 'first-a' },
{ id: first, name: 'first-b' },
{ id: second, name: 'second-a' },
{ id: second, name: 'second-b' },
],
},
},
]),
);
const ids = projection.activity.flatMap((item) =>
item.type === 'tool' ? [item.toolCallId] : [],
);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual([first, `${prefix}#2`, second, `${prefix}#3`]);
});
it('retains a late tool completion even when its declaration predates the item tail', () => {
const projection = projectSubagentActivity(
JSON.stringify([
{ type: 'ai', data: { tool_calls: [{ id: 'early', name: 'search', args: {} }] } },
...Array.from({ length: SUBAGENT_ACTIVITY_LIMITS.items + 10 }, (_, index) => ({
type: 'ai',
data: { content: `update-${index}` },
})),
{ type: 'tool', data: { tool_call_id: 'early', content: 'late result' } },
]),
);
expect(projection.truncated).toBe(true);
expect(projection.activity[projection.activity.length - 1]).toEqual(
expect.objectContaining({
type: 'tool',
toolCallId: 'early',
output: 'late result',
status: 'completed',
}),
);
});
it('keeps an escape-heavy terminal result within the serialized byte cap', () => {
const projection = projectSubagentActivity(
JSON.stringify([
{ type: 'ai', data: { tool_calls: [{ id: 'terminal', name: 'compute', args: {} }] } },
{
type: 'tool',
data: {
tool_call_id: 'terminal',
content: '\u0000'.repeat(SUBAGENT_ACTIVITY_LIMITS.toolOutputBytes),
},
},
]),
);
expect(projection.truncated).toBe(true);
expect(Buffer.byteLength(JSON.stringify(projection.activity), 'utf8')).toBeLessThanOrEqual(
SUBAGENT_ACTIVITY_LIMITS.bytes,
);
expect(projection.activity).toEqual([
expect.objectContaining({
type: 'tool',
toolCallId: 'terminal',
status: 'completed',
outputTruncated: true,
}),
]);
});
});

View file

@ -0,0 +1,317 @@
import type { SubagentActivityItem } from 'librechat-data-provider';
const MAX_ACTIVITY_ITEMS = 100;
const MAX_ACTIVITY_BYTES = 64 * 1024;
const MAX_ACTIVITY_TEXT_BYTES = 32 * 1024;
const MAX_TOOL_INPUT_BYTES = 8 * 1024;
const MAX_TOOL_OUTPUT_BYTES = 16 * 1024;
const MAX_TOOL_NAME_BYTES = 512;
const MAX_TOOL_CALL_ID_BYTES = 512;
type Projection = {
activity: SubagentActivityItem[];
truncated: boolean;
};
type MutableToolActivity = Extract<SubagentActivityItem, { type: 'tool' }>;
type ProjectedActivityEntry = { item: SubagentActivityItem; active: boolean };
type MutableToolProjection = {
item: MutableToolActivity;
entry: ProjectedActivityEntry;
};
type MutableToolQueue = {
items: MutableToolProjection[];
nextPending: number;
};
const toolResultStatus = (value: unknown): MutableToolActivity['status'] => {
if (value === 'error' || value === 'failed') return 'failed';
if (value === 'cancelled') return 'cancelled';
return 'completed';
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
value != null && typeof value === 'object' && !Array.isArray(value);
const truncateUtf8 = (input: string, byteLimit: number) => {
if (Buffer.byteLength(input, 'utf8') <= byteLimit) {
return { value: input, truncated: false };
}
let low = 0;
let high = input.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (Buffer.byteLength(input.slice(0, middle), 'utf8') <= byteLimit) {
low = middle;
} else {
high = middle - 1;
}
}
let end = low;
if (end > 0 && /[\uD800-\uDBFF]/.test(input[end - 1])) end -= 1;
return { value: input.slice(0, end), truncated: true };
};
const safeJson = (value: unknown): string => {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value) ?? '';
} catch {
return '';
}
};
const serializedBytes = (value: unknown): number =>
Buffer.byteLength(JSON.stringify(value), 'utf8');
const shrinkStringField = <T extends SubagentActivityItem>(
item: T,
field: keyof T,
truncatedField?: keyof T,
): T => {
const current = item[field];
if (typeof current !== 'string') return item;
const base = {
...item,
[field]: '',
...(truncatedField == null ? {} : { [truncatedField]: true }),
} as T;
if (serializedBytes([base]) > MAX_ACTIVITY_BYTES) return base;
let low = 0;
let high = current.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const candidate = { ...base, [field]: current.slice(0, middle) } as T;
if (serializedBytes([candidate]) <= MAX_ACTIVITY_BYTES) {
low = middle;
} else {
high = middle - 1;
}
}
return { ...base, [field]: current.slice(0, low) } as T;
};
const fitNewestItemToSerializedBudget = (item: SubagentActivityItem): SubagentActivityItem => {
if (serializedBytes([item]) <= MAX_ACTIVITY_BYTES) return item;
if (item.type === 'writing') return shrinkStringField(item, 'text', 'textTruncated');
if (item.type === 'reasoning') return item;
// Preserve the terminal output as long as possible: discard oversized input
// first, then trim output and finally public identity fields if a provider
// supplied escape-heavy strings.
let tool = shrinkStringField(item, 'input', 'inputTruncated');
if (serializedBytes([tool]) <= MAX_ACTIVITY_BYTES) return tool;
tool = shrinkStringField(tool, 'output', 'outputTruncated');
if (serializedBytes([tool]) <= MAX_ACTIVITY_BYTES) return tool;
tool = shrinkStringField(tool, 'name');
if (serializedBytes([tool]) <= MAX_ACTIVITY_BYTES) return tool;
return shrinkStringField(tool, 'toolCallId');
};
const visibleContent = (value: unknown): { text: string; hasReasoning: boolean } => {
if (typeof value === 'string') return { text: value, hasReasoning: false };
if (!Array.isArray(value)) return { text: '', hasReasoning: false };
const text: string[] = [];
let hasReasoning = false;
for (const block of value) {
if (!isRecord(block) || typeof block.type !== 'string') continue;
if ((block.type === 'text' || block.type === 'text-plain') && typeof block.text === 'string') {
text.push(block.text);
} else if (block.type === 'reasoning' || block.type === 'thinking') {
// Preserve the user-visible lifecycle marker, never the model's hidden reasoning payload.
hasReasoning = true;
}
}
return { text: text.join(''), hasReasoning };
};
const readToolCalls = (data: Record<string, unknown>): unknown[] => {
if (Array.isArray(data.tool_calls)) return data.tool_calls;
const additional = isRecord(data.additional_kwargs) ? data.additional_kwargs : undefined;
return Array.isArray(additional?.tool_calls) ? additional.tool_calls : [];
};
const normalizeToolCall = (
value: unknown,
index: number,
): { rawId: string; item: MutableToolActivity } | undefined => {
if (!isRecord(value)) return undefined;
const fn = isRecord(value.function) ? value.function : undefined;
const rawName = typeof value.name === 'string' ? value.name : fn?.name;
if (typeof rawName !== 'string' || rawName.trim() === '') return undefined;
const rawId = typeof value.id === 'string' && value.id !== '' ? value.id : `tool-${index}`;
const rawInput = value.args ?? fn?.arguments;
const input = truncateUtf8(safeJson(rawInput), MAX_TOOL_INPUT_BYTES);
return {
rawId,
item: {
type: 'tool',
toolCallId: truncateUtf8(rawId, MAX_TOOL_CALL_ID_BYTES).value,
name: truncateUtf8(rawName, MAX_TOOL_NAME_BYTES).value,
...(input.value === '' ? {} : { input: input.value }),
...(input.truncated ? { inputTruncated: true } : {}),
status: 'running',
},
};
};
const uniqueToolActivityId = (
rawId: string,
used: Set<string>,
nextGeneratedOccurrence: { value: number },
): string => {
const base = truncateUtf8(rawId, MAX_TOOL_CALL_ID_BYTES).value || 'tool';
let candidate = base;
while (used.has(candidate)) {
const suffix = `#${nextGeneratedOccurrence.value}`;
nextGeneratedOccurrence.value += 1;
const prefix = truncateUtf8(base, MAX_TOOL_CALL_ID_BYTES - Buffer.byteLength(suffix)).value;
candidate = `${prefix}${suffix}`;
}
used.add(candidate);
return candidate;
};
/**
* Converts one server-private LangChain transcript into a bounded public
* activity projection. Only visible text and declared tool calls/results are
* retained; response metadata, artifacts, runtime fields, and reasoning text
* are intentionally ignored.
*/
export function projectSubagentActivity(
messagesJson: string | undefined,
mode: 'append' | 'replace' = 'append',
expectedTaskInput?: string,
): Projection {
if (messagesJson == null) return { activity: [], truncated: false };
let parsed: unknown;
try {
parsed = JSON.parse(messagesJson) as unknown;
} catch {
return { activity: [], truncated: true };
}
if (!Array.isArray(parsed)) return { activity: [], truncated: true };
let relevantMessages = parsed;
if (mode === 'replace') {
if (expectedTaskInput == null) return { activity: [], truncated: true };
let latestInputIndex = -1;
for (let index = parsed.length - 1; index >= 0; index -= 1) {
const stored = parsed[index];
if (isRecord(stored) && (stored.type === 'human' || stored.type === 'user')) {
latestInputIndex = index;
break;
}
}
// A replacement transcript can contain the complete child history. If
// its current input boundary is missing, fail closed instead of exposing
// activity from earlier invocations on the selected parent card.
if (
latestInputIndex < 0 ||
!isRecord(parsed[latestInputIndex]) ||
!isRecord(parsed[latestInputIndex].data) ||
visibleContent(parsed[latestInputIndex].data.content).text !== expectedTaskInput
) {
return { activity: [], truncated: true };
}
relevantMessages = parsed.slice(latestInputIndex + 1);
}
const activity: ProjectedActivityEntry[] = [];
const toolsByRawId = new Map<string, MutableToolQueue>();
const usedToolActivityIds = new Set<string>();
// A global cursor makes collision probing amortized linear even when many
// maximum-length provider IDs collapse to the same suffixed prefix.
const nextGeneratedToolOccurrence = { value: 2 };
let truncated = false;
const append = (item: SubagentActivityItem) => {
const entry = { item, active: true };
activity.push(entry);
return entry;
};
for (const stored of relevantMessages) {
if (!isRecord(stored) || !isRecord(stored.data) || typeof stored.type !== 'string') {
truncated = true;
continue;
}
const { data } = stored;
if (stored.type === 'ai' || stored.type === 'assistant') {
const content = visibleContent(data.content);
if (content.hasReasoning) append({ type: 'reasoning' });
if (content.text !== '') {
const text = truncateUtf8(content.text, MAX_ACTIVITY_TEXT_BYTES);
append({
type: 'writing',
text: text.value,
...(text.truncated ? { textTruncated: true } : {}),
});
}
readToolCalls(data).forEach((call, index) => {
const normalized = normalizeToolCall(call, index);
if (normalized == null) return;
normalized.item.toolCallId = uniqueToolActivityId(
normalized.item.toolCallId,
usedToolActivityIds,
nextGeneratedToolOccurrence,
);
const entry = append(normalized.item);
const queue = toolsByRawId.get(normalized.rawId) ?? { items: [], nextPending: 0 };
queue.items.push({ item: normalized.item, entry });
toolsByRawId.set(normalized.rawId, queue);
});
continue;
}
if (stored.type !== 'tool') continue;
const toolCallId = typeof data.tool_call_id === 'string' ? data.tool_call_id : '';
const output = truncateUtf8(visibleContent(data.content).text, MAX_TOOL_OUTPUT_BYTES);
const queue = toolsByRawId.get(toolCallId);
const existing = queue?.items[queue.nextPending];
if (queue != null && existing != null) {
queue.nextPending += 1;
existing.item.status = toolResultStatus(data.status);
if (output.value !== '') existing.item.output = output.value;
if (output.truncated) existing.item.outputTruncated = true;
existing.entry.active = false;
existing.entry = append(existing.item);
continue;
}
const name = typeof data.name === 'string' && data.name !== '' ? data.name : 'tool';
const projectedToolCallId = uniqueToolActivityId(
toolCallId || `tool-result-${activity.length}`,
usedToolActivityIds,
nextGeneratedToolOccurrence,
);
const orphan: MutableToolActivity = {
type: 'tool',
toolCallId: projectedToolCallId,
name: truncateUtf8(name, MAX_TOOL_NAME_BYTES).value,
...(output.value === '' ? {} : { output: output.value }),
...(output.truncated ? { outputTruncated: true } : {}),
status: toolResultStatus(data.status),
};
append(orphan);
}
let boundedActivity = activity.filter((entry) => entry.active).map((entry) => entry.item);
if (boundedActivity.length > MAX_ACTIVITY_ITEMS) {
boundedActivity = boundedActivity.slice(-MAX_ACTIVITY_ITEMS);
truncated = true;
}
while (boundedActivity.length > 1 && serializedBytes(boundedActivity) > MAX_ACTIVITY_BYTES) {
boundedActivity.shift();
truncated = true;
}
if (boundedActivity.length === 1 && serializedBytes(boundedActivity) > MAX_ACTIVITY_BYTES) {
boundedActivity[0] = fitNewestItemToSerializedBudget(boundedActivity[0]);
truncated = true;
}
return { activity: boundedActivity, truncated };
}
export const SUBAGENT_ACTIVITY_LIMITS = {
items: MAX_ACTIVITY_ITEMS,
bytes: MAX_ACTIVITY_BYTES,
textBytes: MAX_ACTIVITY_TEXT_BYTES,
toolInputBytes: MAX_TOOL_INPUT_BYTES,
toolOutputBytes: MAX_TOOL_OUTPUT_BYTES,
} as const;

View file

@ -70,9 +70,13 @@ const createResponse = () => {
};
};
const createRequest = (params: Record<string, string> = {}): ServerRequest =>
const createRequest = (
params: Record<string, string> = {},
query: Record<string, string> = {},
): ServerRequest =>
({
params: { parentConversationId, threadId, ...params },
query,
user: { id: 'user-1', tenantId: 'tenant-1' },
}) as ServerRequest;
@ -115,6 +119,8 @@ describe('subagent thread parent-scoped view', () => {
agentId: 'agent-1',
title: 'Research child',
status: 'completed',
activity: [],
activityTruncated: false,
messages: [
expect.objectContaining({ messageId: 'task-1:user', role: 'user' }),
expect.objectContaining({
@ -135,6 +141,124 @@ describe('subagent thread parent-scoped view', () => {
expect(json.mock.calls[0][0].messages[1]).not.toHaveProperty('subagentTask');
});
it("returns only the selected task's sanitized bounded activity", async () => {
const selected = {
...message('task-1:assistant', 'completed'),
subagentTranscript: {
taskId: 'task-1',
mode: 'append' as const,
messagesJson: JSON.stringify([
{
type: 'ai',
data: {
content: [{ type: 'reasoning', reasoning: 'private thought' }],
tool_calls: [{ id: 'inner-1', name: 'search', args: { query: 'release' } }],
response_metadata: { private: true },
},
},
{
type: 'tool',
data: {
tool_call_id: 'inner-1',
name: 'search',
content: 'Found it.',
},
},
{ type: 'ai', data: { content: 'Final answer.' } },
]),
},
} as IMessage;
const getMessages = jest.fn().mockResolvedValue([selected]);
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: getMessages,
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'task-1' }), response);
expect(getMessages).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'task-1' }));
const view = json.mock.calls[0][0];
expect(view.activity).toEqual([
{ type: 'reasoning' },
expect.objectContaining({
type: 'tool',
toolCallId: 'inner-1',
status: 'completed',
output: 'Found it.',
}),
{ type: 'writing', text: 'Final answer.' },
]);
expect(JSON.stringify(view)).not.toContain('private thought');
expect(JSON.stringify(view)).not.toContain('response_metadata');
expect(view.messages[0]).not.toHaveProperty('subagentTranscript');
});
it('fences replacement activity to the exact selected task input', async () => {
const selected = {
...message('task-1:assistant', 'completed'),
subagentTranscript: {
taskId: 'task-1',
mode: 'replace' as const,
messagesJson: JSON.stringify([
{ type: 'human', data: { content: 'Earlier request.' } },
{ type: 'ai', data: { content: 'Earlier activity.' } },
{ type: 'human', data: { content: 'Investigate this.' } },
{ type: 'ai', data: { content: 'Selected activity.' } },
]),
},
} as IMessage;
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: jest
.fn()
.mockResolvedValue([selected, message('task-1:user', 'running', true)]),
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'task-1' }), response);
expect(json.mock.calls[0][0]).toEqual(
expect.objectContaining({
activity: [{ type: 'writing', text: 'Selected activity.' }],
activityTruncated: false,
}),
);
expect(JSON.stringify(json.mock.calls[0][0])).not.toContain('Earlier activity.');
});
it('fails closed when the selected row carries a mismatched transcript identity', async () => {
const selected = {
...message('task-1:assistant', 'completed'),
subagentTranscript: {
taskId: 'task-other',
mode: 'append' as const,
messagesJson: JSON.stringify([{ type: 'ai', data: { content: 'Wrong task.' } }]),
},
} as IMessage;
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([selected]),
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'task-1' }), response);
expect(json.mock.calls[0][0]).toEqual(
expect.objectContaining({ activity: [], activityTruncated: true }),
);
expect(JSON.stringify(json.mock.calls[0][0])).not.toContain('Wrong task.');
});
it('bounds the complete UTF-8 response while retaining the newest history', async () => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const messages = Array.from(
@ -363,6 +487,25 @@ describe('subagent thread parent-scoped view', () => {
expect(getMessages).not.toHaveBeenCalled();
});
it('rejects an oversized task selector before reading storage', async () => {
const getConvoOwnership = jest.fn();
const getSubagentThreadForParent = jest.fn();
const getMessages = jest.fn();
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent,
getMessagesForSubagentThreadView: getMessages,
});
const { response, status } = createResponse();
await handler(createRequest({}, { taskId: 'x'.repeat(513) }), response);
expect(status).toHaveBeenCalledWith(404);
expect(getConvoOwnership).not.toHaveBeenCalled();
expect(getSubagentThreadForParent).not.toHaveBeenCalled();
expect(getMessages).not.toHaveBeenCalled();
});
it('marks an unleased running seed as interrupted', async () => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const handler = createSubagentThreadViewHandler({

View file

@ -11,6 +11,7 @@ import type {
} from 'librechat-data-provider';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
import { projectSubagentActivity, SUBAGENT_ACTIVITY_LIMITS } from './activity';
const MAX_THREAD_MESSAGES = 50;
const MAX_MESSAGE_TEXT_BYTES = 32 * 1024;
@ -35,6 +36,11 @@ type SubagentThreadViewParams = {
const validConversationId = (value: string | undefined): value is string =>
value != null && value.trim() !== '' && value.length <= 256;
const validTaskId = (value: unknown): value is string =>
typeof value === 'string' &&
value.trim() !== '' &&
Buffer.byteLength(value, 'utf8') <= MAX_PUBLIC_ID_BYTES;
const tenantMatches = (recordTenantId: string | undefined, requestTenantId: string | undefined) =>
recordTenantId === requestTenantId;
@ -99,8 +105,12 @@ const publicMessage = (
const publicStatus = (
messages: SubagentThreadViewMessageRecord[],
activeLeaseTaskId: string | undefined,
requestedTaskId?: string,
): SubagentThreadStatus => {
if (activeLeaseTaskId != null) {
if (
activeLeaseTaskId != null &&
(requestedTaskId == null || requestedTaskId === activeLeaseTaskId)
) {
const activeTaskMessage = messages.find(
(message) =>
message.messageId === `${activeLeaseTaskId}:user` ||
@ -114,7 +124,11 @@ const publicStatus = (
}
return publicStatus([activeTaskMessage], undefined);
}
const message = messages.find((candidate) => candidate.subagentTask != null);
const message = messages.find(
(candidate) =>
candidate.subagentTask != null &&
(requestedTaskId == null || candidate.messageId.startsWith(`${requestedTaskId}:`)),
);
switch (message?.subagentTask?.status) {
case 'running':
return 'interrupted';
@ -139,11 +153,13 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
const userId = req.user?.id;
const tenantId = req.user?.tenantId || undefined;
const { parentConversationId, threadId } = req.params as SubagentThreadViewParams;
const requestedTaskId = req.query?.taskId;
if (
!userId ||
!validConversationId(parentConversationId) ||
!validConversationId(threadId) ||
parentConversationId === threadId
parentConversationId === threadId ||
(requestedTaskId != null && !validTaskId(requestedTaskId))
) {
notFound(res);
return;
@ -179,6 +195,7 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
...(tenantId == null ? {} : { tenantId }),
limit: MAX_THREAD_MESSAGES + 1,
textCodePointLimit: MAX_MESSAGE_TEXT_PROJECTION_CODE_POINTS,
...(requestedTaskId == null ? {} : { taskId: requestedTaskId }),
});
const historyTruncated = messages.length > MAX_THREAD_MESSAGES;
@ -187,6 +204,23 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
child.subagentThreadLease != null && child.subagentThreadLease.expiresAt > now
? child.subagentThreadLease.taskId
: undefined;
const selectedTranscript =
requestedTaskId == null
? undefined
: newestFirst.find((message) => message.messageId === `${requestedTaskId}:assistant`)
?.subagentTranscript;
const selectedInput =
requestedTaskId == null
? undefined
: newestFirst.find((message) => message.messageId === `${requestedTaskId}:user`);
const projectedActivity =
selectedTranscript != null && selectedTranscript.taskId === requestedTaskId
? projectSubagentActivity(
selectedTranscript.messagesJson,
selectedTranscript.mode,
selectedInput?.textProjectionTruncated === true ? undefined : selectedInput?.text,
)
: { activity: [], truncated: selectedTranscript != null };
const projectedNewestFirst: SubagentThreadMessage[] = [];
let remainingTextBytes = MAX_RESPONSE_TEXT_BYTES;
for (const message of newestFirst) {
@ -209,7 +243,9 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
: { agentId: truncateUtf8(child.agent_id, MAX_PUBLIC_ID_BYTES).text }),
title: truncateUtf8(child.title ?? `Subagent: ${lineage.subagentType}`, MAX_TITLE_BYTES)
.text,
status: publicStatus(newestFirst, activeLeaseTaskId),
status: publicStatus(newestFirst, activeLeaseTaskId, requestedTaskId),
activity: projectedActivity.activity,
activityTruncated: projectedActivity.truncated,
messages: projectedNewestFirst.reverse(),
historyTruncated: historyTruncated || projectedNewestFirst.length < newestFirst.length,
...(isoDate(child.updatedAt) == null ? {} : { updatedAt: isoDate(child.updatedAt) }),
@ -234,9 +270,13 @@ export const SUBAGENT_THREAD_VIEW_LIMITS: Readonly<{
messageTextBytes: number;
responseTextBytes: number;
responseBytes: number;
activityItems: number;
activityBytes: number;
}> = {
messages: MAX_THREAD_MESSAGES,
messageTextBytes: MAX_MESSAGE_TEXT_BYTES,
responseTextBytes: MAX_RESPONSE_TEXT_BYTES,
responseBytes: MAX_RESPONSE_BYTES,
activityItems: SUBAGENT_ACTIVITY_LIMITS.items,
activityBytes: SUBAGENT_ACTIVITY_LIMITS.bytes,
};

View file

@ -116,8 +116,10 @@ export const conversations = (params: q.ConversationListParams) => {
export const conversationById = (id: string) => `${conversationsRoot}/${id}`;
export const subagentThread = (parentConversationId: string, threadId: string) =>
`${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents/${encodeURIComponent(threadId)}`;
export const subagentThread = (parentConversationId: string, threadId: string, taskId?: string) => {
const endpoint = `${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents/${encodeURIComponent(threadId)}`;
return taskId == null ? endpoint : `${endpoint}?taskId=${encodeURIComponent(taskId)}`;
};
export const genTitle = (conversationId: string) =>
`${conversationsRoot}/gen_title/${encodeURIComponent(conversationId)}`;

View file

@ -1005,8 +1005,9 @@ export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage
export function getSubagentThread(
parentConversationId: string,
threadId: string,
taskId?: string,
): Promise<t.SubagentThreadView> {
return request.get(endpoints.subagentThread(parentConversationId, threadId));
return request.get(endpoints.subagentThread(parentConversationId, threadId, taskId));
}
export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> {

View file

@ -6,6 +6,31 @@ export type SubagentThreadStatus =
| 'interrupted'
| 'cancelled';
/**
* A bounded, presentation-safe description of child work. This deliberately
* models user-visible activity instead of the LangChain messages, SSE events,
* or durable records that produced it.
*/
export type SubagentActivityItem =
| {
type: 'writing';
text: string;
textTruncated?: boolean;
}
| {
type: 'reasoning';
}
| {
type: 'tool';
toolCallId: string;
name: string;
input?: string;
output?: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
inputTruncated?: boolean;
outputTruncated?: boolean;
};
export type SubagentThreadMessage = {
messageId: string;
parentMessageId: string | null;
@ -26,6 +51,9 @@ export type SubagentThreadView = {
agentId?: string;
title: string;
status: SubagentThreadStatus;
/** Activity for the exact task requested by the parent card, when retained. */
activity: SubagentActivityItem[];
activityTruncated: boolean;
messages: SubagentThreadMessage[];
historyTruncated: boolean;
updatedAt?: string;

View file

@ -712,6 +712,44 @@ describe('Message Operations', () => {
expect(messages[0]).not.toHaveProperty('user');
expect(messages[0]).not.toHaveProperty('conversationId');
});
it('projects the private transcript only for the explicitly selected task', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'task-a:assistant',
conversationId,
text: 'A',
user: 'user123',
subagentTranscript: {
taskId: 'task-a',
mode: 'append',
messagesJson: '[{"type":"ai","data":{"content":"A"}}]',
},
});
await saveMessage(mockCtx, {
messageId: 'task-b:assistant',
conversationId,
text: 'B',
user: 'user123',
subagentTranscript: {
taskId: 'task-b',
mode: 'append',
messagesJson: '[{"type":"ai","data":{"content":"B"}}]',
},
});
const messages = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
limit: 10,
textCodePointLimit: 8_192,
taskId: 'task-a',
});
expect(messages).toHaveLength(1);
expect(messages[0]).toHaveProperty('messageId', 'task-a:assistant');
expect(messages[0]).toHaveProperty('subagentTranscript.taskId', 'task-a');
});
});
describe('deleteMessages', () => {

View file

@ -61,6 +61,7 @@ export type SubagentThreadViewMessageRecord = Pick<
| 'text'
| 'createdAt'
| 'error'
| 'subagentTranscript'
| 'subagentTask'
> & { textProjectionTruncated?: boolean };
@ -127,6 +128,7 @@ export interface MessageMethods {
tenantId?: string;
limit: number;
textCodePointLimit: number;
taskId?: string;
}): Promise<SubagentThreadViewMessageRecord[]>;
getMessage(params: { user: string; messageId: string }): Promise<IMessage | null>;
getMessagesByCursor(
@ -745,6 +747,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
tenantId?: string;
limit: number;
textCodePointLimit: number;
taskId?: string;
}): Promise<SubagentThreadViewMessageRecord[]> {
try {
const Message = mongoose.models.Message as Model<IMessage>;
@ -756,6 +759,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
...(input.tenantId == null
? { tenantId: { $exists: false } }
: { tenantId: input.tenantId }),
...(input.taskId == null
? {}
: {
messageId: {
$in: [`${input.taskId}:user`, `${input.taskId}:assistant`],
},
}),
},
},
{ $sort: { createdAt: -1, _id: -1 } },
@ -774,6 +784,17 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
},
createdAt: 1,
error: 1,
...(input.taskId == null
? {}
: {
subagentTranscript: {
$cond: [
{ $eq: ['$messageId', `${input.taskId}:assistant`] },
'$subagentTranscript',
'$$REMOVE',
],
},
}),
subagentTask: 1,
},
},