mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🪟 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:
parent
21b7f78d56
commit
749eed0d60
25 changed files with 2668 additions and 1393 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 (
|
||||
|
|
|
|||
229
client/src/components/Chat/Subagents/SubagentActivity.test.tsx
Normal file
229
client/src/components/Chat/Subagents/SubagentActivity.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
357
client/src/components/Chat/Subagents/SubagentActivity.tsx
Normal file
357
client/src/components/Chat/Subagents/SubagentActivity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
173
client/src/components/Chat/Subagents/adapters.test.ts
Normal file
173
client/src/components/Chat/Subagents/adapters.test.ts
Normal 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.' }] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
177
client/src/components/Chat/Subagents/adapters.ts
Normal file
177
client/src/components/Chat/Subagents/adapters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
262
packages/api/src/agents/activity.spec.ts
Normal file
262
packages/api/src/agents/activity.spec.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
317
packages/api/src/agents/activity.ts
Normal file
317
packages/api/src/agents/activity.ts
Normal 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;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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 }> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue