🧅 feat: Reveal Subagent Thread History and Event Detail on Demand (#15283)

*  feat: Refine Subagent Thread Activity UX

* test: align subagent completion lifecycle

* chore: satisfy subagent panel static checks

* fix: harden subagent activity pagination

* fix: preserve paged subagent history

* fix: surface unrecoverable activity boundaries

* fix: rebase paged activity after live advances

* fix: bound child activity history state

* fix: preserve rebased child history order

* fix: retain unavailable child history boundary

* fix: fence child history cursor generations

* fix: preserve subagent history continuity

* fix: retain live turns during history rebase

* fix: close subagent history edge cases

* fix: harden subagent history phase transitions
This commit is contained in:
Danny Avila 2026-08-27 08:56:06 -04:00 committed by GitHub
parent 151dc9e03e
commit 41ba808e6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2025 additions and 134 deletions

View file

@ -208,7 +208,7 @@ const base: ChildActivity = {
};
describe('SubagentActivity', () => {
it.each(['running', 'completed', 'failed', 'cancelled'] as const)(
it.each(['running', 'failed', 'cancelled'] as const)(
'renders the %s lifecycle through the shared view',
(status) => {
render(<SubagentActivity activity={{ ...base, status }} />);
@ -216,6 +216,12 @@ describe('SubagentActivity', () => {
},
);
it('does not repeat the completed lifecycle in the conversation body', () => {
render(<SubagentActivity activity={base} />);
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
});
it('reports when the durable control history is bounded', () => {
render(<SubagentActivity activity={{ ...base, controlsTruncated: true }} />);

View file

@ -357,11 +357,6 @@ export function SubagentActivityContent({
{localize('com_ui_subagent_control_history_truncated')}
</div>
)}
{activity.activityTruncated === true && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_history_truncated')}
</div>
)}
{showDetailTruncationNotice && activityDetailsTruncated && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_activity_details_truncated')}
@ -406,11 +401,12 @@ export default function SubagentActivity({
showPrompt?: boolean;
onCancelControl?: (controlId: string) => void;
}) {
const statusHeader = (
<div className="shrink-0 border-b border-border-light px-4 py-2">
<SubagentStatus activity={activity} />
</div>
);
const statusHeader =
activity.status === 'completed' ? null : (
<div className="shrink-0 border-b border-border-light px-4 py-2">
<SubagentStatus activity={activity} />
</div>
);
const content = (
<SubagentActivityContent
activity={activity}

View file

@ -1,6 +1,6 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { ChildConversationTurn } from './adapters';
import SubagentConversation from './SubagentConversation';
@ -48,6 +48,7 @@ jest.mock('lucide-react', () => ({
Bot: () => null,
CheckCircle2: () => null,
Clock3: () => null,
ChevronDown: () => null,
CornerDownRight: () => null,
Radio: () => null,
XCircle: () => null,
@ -81,7 +82,13 @@ const turns: ChildConversationTurn[] = [
taskId: 'task-2',
trigger: {
kind: 'external_event',
summary: 'A deployment event arrived.',
summary: '',
externalEvent: {
eventType: 'chess.turn.ready',
sourceType: 'speed-chess',
occurredAt: '2026-08-25T12:01:00.000Z',
expectedActionToolName: 'submit_move',
},
},
activity: {
title: 'Research child',
@ -100,7 +107,7 @@ describe('SubagentConversation', () => {
);
expect(screen.getAllByText('com_ui_subagent_trigger_parent_dispatch')).toHaveLength(2);
expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(2);
expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(1);
expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
expect(screen.getByText('Checked the constraints.')).toBeInTheDocument();
expect(screen.getByText('search')).toBeInTheDocument();
@ -108,11 +115,71 @@ describe('SubagentConversation', () => {
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_status_running')).toBeInTheDocument();
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
expect(container.querySelectorAll('.message-render')).toHaveLength(4);
expect(container.querySelectorAll('.user-turn')).toHaveLength(2);
expect(container.querySelectorAll('.message-render')).toHaveLength(3);
expect(container.querySelectorAll('.user-turn')).toHaveLength(1);
expect(container.querySelectorAll('.agent-turn')).toHaveLength(2);
expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument();
expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument();
expect(screen.getAllByText('com_ui_subagent_activity_details_truncated')).toHaveLength(1);
expect(
screen.queryByText('com_ui_subagent_activity_details_truncated'),
).not.toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_activity_details_unavailable')).toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', {
name: /com_ui_subagent_trigger_external_event.*chess\.turn\.ready.*speed-chess/,
}),
);
expect(screen.getByText('chess.turn.ready')).toBeInTheDocument();
expect(screen.getByText('speed-chess')).toBeInTheDocument();
expect(screen.getByText('submit_move')).toBeInTheDocument();
});
it('requests an exact bounded projection only when shortened turn activity is opened', () => {
const loadDetails = jest.fn();
const shortened = [
{ ...turns[0], activity: { ...turns[0].activity, activityTruncated: true } },
];
render(
<RecoilRoot>
<SubagentConversation turns={shortened} onLoadTurnDetails={loadDetails} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_show_full_activity' }));
expect(loadDetails).toHaveBeenCalledWith('task-1');
});
it('gives repeated external-event disclosures distinguishable accessible names', () => {
const secondEvent: ChildConversationTurn = {
...turns[1],
taskId: 'task-3',
trigger: {
kind: 'external_event',
summary: '',
externalEvent: {
eventType: 'chess.turn.ready',
sourceType: 'speed-chess',
occurredAt: '2026-08-25T12:02:00.000Z',
},
},
};
render(
<RecoilRoot>
<SubagentConversation turns={[turns[1], secondEvent]} />
</RecoilRoot>,
);
expect(
screen.getByRole('button', {
name: /com_ui_subagent_trigger_external_event.*chess\.turn\.ready.*speed-chess.*2026-08-25T12:01:00.000Z/,
}),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /com_ui_subagent_trigger_external_event.*chess\.turn\.ready.*speed-chess.*2026-08-25T12:02:00.000Z/,
}),
).toBeInTheDocument();
});
});

View file

@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Bot, CornerDownRight, Radio } from 'lucide-react';
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
import { Bot, ChevronDown, CornerDownRight, Radio } from 'lucide-react';
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@librechat/client';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { ChildConversationTurn } from './adapters';
import type { TranslationKeys } from '~/hooks';
@ -32,6 +33,62 @@ function TriggerIcon({ kind }: { kind: ChildConversationTurn['trigger']['kind']
);
}
function ExternalEventTrigger({ turn }: { turn: ChildConversationTurn }) {
const localize = useLocalize();
const [expanded, setExpanded] = useState(false);
const details = turn.trigger.externalEvent;
const label = localize('com_ui_subagent_trigger_external_event');
if (details == null) {
return (
<div className="flex min-h-9 items-center gap-2 text-sm text-text-secondary">
<TriggerIcon kind="external_event" />
<span className="font-medium">{label}</span>
</div>
);
}
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto min-h-9 w-full justify-start gap-2 px-0 text-left text-sm text-text-secondary hover:bg-transparent hover:text-text-primary"
>
<TriggerIcon kind="external_event" />
<span className="font-medium">{label}</span>
<span className="min-w-0 truncate text-xs">
{details.eventType} · {details.sourceType}
</span>
<span className="sr-only">{details.occurredAt}</span>
<ChevronDown
size={15}
aria-hidden
className={`ml-auto shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-8 border-l border-border-light py-1 pl-3 text-xs text-text-secondary">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>{localize('com_ui_subagent_event_type')}</dt>
<dd className="break-words text-text-primary">{details.eventType}</dd>
<dt>{localize('com_ui_subagent_event_source')}</dt>
<dd className="break-words text-text-primary">{details.sourceType}</dd>
<dt>{localize('com_ui_subagent_event_received')}</dt>
<dd className="break-words text-text-primary">
{new Date(details.occurredAt).toLocaleString()}
</dd>
{details.expectedActionToolName != null && (
<>
<dt>{localize('com_ui_subagent_event_expected_action')}</dt>
<dd className="break-words text-text-primary">{details.expectedActionToolName}</dd>
</>
)}
</dl>
</CollapsibleContent>
</Collapsible>
);
}
function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; fullWidth: boolean }) {
const localize = useLocalize();
const label = localize(TRIGGER_LABELS[turn.trigger.kind]);
@ -47,6 +104,9 @@ function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; full
],
[turn.trigger.summary],
);
if (turn.trigger.kind === 'external_event') {
return <ExternalEventTrigger turn={turn} />;
}
return (
<MessageRow
id={`${turn.taskId}:trigger`}
@ -90,6 +150,8 @@ function ChildMessage({
conversationId,
fullWidth,
onCancelControl,
detailState,
onLoadDetails,
}: {
turn: ChildConversationTurn;
state: 'ready' | 'loading' | 'error';
@ -97,10 +159,15 @@ function ChildMessage({
conversationId?: string | null;
fullWidth: boolean;
onCancelControl?: (controlId: string) => void;
detailState?: 'idle' | 'loading' | 'unavailable' | 'error';
onLoadDetails?: () => void;
}) {
const localize = useLocalize();
const agentsMap = useAgentsMapContext();
const agent = agentId == null ? undefined : agentsMap?.[agentId];
const label = agent?.name ?? turn.activity.title;
const detailsLimited =
turn.activity.activityTruncated === true || hasTruncatedActivityDetails(turn.activity);
const iconData = {
endpoint: EModelEndpoint.agents,
modelLabel: label,
@ -136,6 +203,26 @@ function ChildMessage({
conversationId={conversationId}
onCancelControl={onCancelControl}
/>
{detailsLimited && detailState !== 'loading' && (
<div className="mt-2 text-xs text-text-secondary">
{turn.activity.activityTruncated === true &&
onLoadDetails != null &&
detailState !== 'unavailable' ? (
<Button type="button" variant="ghost" size="sm" onClick={onLoadDetails}>
{detailState === 'error'
? localize('com_ui_retry')
: localize('com_ui_subagent_show_full_activity')}
</Button>
) : (
localize('com_ui_subagent_activity_details_unavailable')
)}
</div>
)}
{detailState === 'loading' && (
<div className="mt-2 text-xs text-text-secondary" aria-live="polite">
{localize('com_ui_loading')}
</div>
)}
</MessageRow>
);
}
@ -147,6 +234,8 @@ export default function SubagentConversation({
stateByTask,
controllableTaskId,
onCancelControl,
detailStateByTask,
onLoadTurnDetails,
}: {
turns: ChildConversationTurn[];
agentId?: string;
@ -154,17 +243,12 @@ export default function SubagentConversation({
stateByTask?: ReadonlyMap<string, 'ready' | 'loading' | 'error'>;
controllableTaskId?: string;
onCancelControl?: (taskId: string, controlId: string) => void;
detailStateByTask?: ReadonlyMap<string, 'idle' | 'loading' | 'unavailable' | 'error'>;
onLoadTurnDetails?: (taskId: string) => void;
}) {
const localize = useLocalize();
const fullWidth = useRecoilValue(store.maximizeChatSpace);
const hasShortenedDetails = turns.some((turn) => hasTruncatedActivityDetails(turn.activity));
return (
<div className="flex flex-col gap-6 py-4" data-subagent-conversation>
{hasShortenedDetails && (
<div className="px-4 text-xs italic text-text-secondary">
{localize('com_ui_subagent_activity_details_truncated')}
</div>
)}
{turns.map((turn) => (
<section
key={turn.taskId}
@ -186,6 +270,12 @@ export default function SubagentConversation({
? undefined
: (controlId) => onCancelControl(turn.taskId, controlId)
}
detailState={detailStateByTask?.get(turn.taskId)}
onLoadDetails={
turn.activity.activityTruncated !== true || onLoadTurnDetails == null
? undefined
: () => onLoadTurnDetails(turn.taskId)
}
/>
</div>
</section>

View file

@ -22,6 +22,7 @@ const mockForkMutate = jest.fn();
const mockControlMutate = jest.fn();
const mockNavigateToConvo = jest.fn();
const mockShowToast = jest.fn();
const mockGetSubagentThread = jest.fn();
const mockApprovalProviderMounted = jest.fn();
const mockApprovalProviderUnmounted = jest.fn();
let mockIsMobile = false;
@ -29,6 +30,17 @@ let mockParentChildrenByMessage = new Map<string, ParentSubagentSummary[]>();
let mockParentChildrenByThread = new Map<string, ParentSubagentSummary>();
const mockRefreshParentChildren = jest.fn().mockResolvedValue(undefined);
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
dataService: {
...actual.dataService,
getSubagentThread: (...args: unknown[]) => mockGetSubagentThread(...args),
},
};
});
jest.mock('~/data-provider', () => ({
ACTIVE_THREAD_REFRESH_MS: 2000,
useSubagentThreadQuery: (...args: unknown[]) => mockUseSubagentThreadQuery(...args),
@ -151,13 +163,17 @@ jest.mock('./SubagentConversation', () => ({
default: ({
turns,
stateByTask,
detailStateByTask,
onLoadTurnDetails,
}: {
turns: Array<{
taskId: string;
trigger: { summary: string };
activity: { items: Array<{ text?: string }> };
activity: { items: Array<{ text?: string }>; activityTruncated?: boolean };
}>;
stateByTask?: ReadonlyMap<string, string>;
detailStateByTask?: ReadonlyMap<string, string>;
onLoadTurnDetails?: (taskId: string) => void;
}) => (
<div
data-testid="subagent-conversation"
@ -169,6 +185,12 @@ jest.mock('./SubagentConversation', () => ({
{turn.activity.items.map((item, index) => (
<span key={index}>{item.text}</span>
))}
{turn.activity.activityTruncated === true && onLoadTurnDetails != null && (
<button type="button" onClick={() => onLoadTurnDetails(turn.taskId)}>
{`load-${turn.taskId}`}
</button>
)}
{detailStateByTask?.get(turn.taskId)}
</div>
))}
</div>
@ -289,11 +311,16 @@ describe('SubagentThreadPanel', () => {
mockControlMutate.mockClear();
mockNavigateToConvo.mockClear();
mockShowToast.mockClear();
mockGetSubagentThread.mockReset();
mockRefreshParentChildren.mockClear();
mockParentChildrenByMessage = new Map();
mockParentChildrenByThread = new Map();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders a bounded read-only activity timeline and closes its selection', async () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
@ -328,7 +355,7 @@ describe('SubagentThreadPanel', () => {
);
expect(mockUseSubagentActivityStream).toHaveBeenCalledWith(selection, false);
expect(screen.getByText('Research child')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_depth')).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_depth')).not.toBeInTheDocument();
expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'completed');
@ -369,7 +396,7 @@ describe('SubagentThreadPanel', () => {
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_view_in_parent' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
expect(active).toBeNull();
await waitFor(() =>
@ -1406,6 +1433,844 @@ describe('SubagentThreadPanel', () => {
expect(mockRefreshParentChildren).toHaveBeenCalled();
});
it('keeps a single event actor in the compact header without a duplicate selector', () => {
const eventChild: ParentSubagentSummary = {
threadId: 'child-thread',
parentMessageId: 'parent-message',
subagentType: 'agent-1',
subagentKind: 'agent',
agentId: 'agent-1',
title: 'First actor',
origin: 'event',
actorId: 'actor-1',
status: 'completed',
latestTaskId: 'task',
tasks: [{ taskId: 'task', status: 'completed' }],
tasksTruncated: false,
};
mockParentChildrenByMessage = new Map([['parent-message', [eventChild]]]);
mockParentChildrenByThread = new Map([[eventChild.threadId, eventChild]]);
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel
selection={{
...selection,
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task' },
}}
/>
</RecoilRoot>,
);
expect(screen.getByRole('heading', { name: 'Analyst One' })).toBeInTheDocument();
expect(
screen.queryByRole('combobox', { name: 'com_ui_subagent_actor' }),
).not.toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_depth')).not.toBeInTheDocument();
});
it('loads an exact older turn projection only after the local disclosure is opened', async () => {
const truncatedView: SubagentThreadView = {
...completedView,
turns: [
{
taskId: 'task-old',
trigger: { kind: 'parent_dispatch', summary: 'Old prompt' },
status: 'completed',
activity: [],
activityTruncated: true,
messages: [],
},
{
taskId: 'task',
trigger: { kind: 'parent_continuation', summary: 'Current prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Current result' }],
activityTruncated: false,
messages: [],
},
],
};
mockUseSubagentThreadQuery.mockReturnValue({
data: truncatedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
mockGetSubagentThread.mockResolvedValue({
...completedView,
activity: [{ type: 'writing', text: 'Loaded exact activity' }],
activityTruncated: false,
messages: [
{
messageId: 'task-old:assistant',
parentMessageId: 'task-old:user',
role: 'assistant',
text: 'Loaded exact activity',
},
],
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(mockGetSubagentThread).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'load-task-old' }));
await waitFor(() => expect(screen.getByText('Loaded exact activity')).toBeInTheDocument());
expect(mockGetSubagentThread).toHaveBeenCalledTimes(1);
expect(mockGetSubagentThread).toHaveBeenCalledWith(
'parent-conversation',
'child-thread',
'task-old',
);
});
it('retains partial activity when an exact historical task has vanished', async () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
turns: [
{
taskId: 'task-old',
trigger: { kind: 'parent_dispatch', summary: 'Retained prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Retained partial result' }],
activityTruncated: true,
messages: [],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
mockGetSubagentThread.mockResolvedValue({
...completedView,
activity: [],
activityTruncated: false,
messages: [],
turns: [],
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'load-task-old' }));
await waitFor(() =>
expect(screen.getAllByTestId('conversation-turn')[1]).toHaveTextContent('unavailable'),
);
expect(screen.getByText('Retained partial result')).toBeInTheDocument();
});
it('prepends an older bounded page without adding it to live polling', async () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
nextCursor: 'older:assistant',
turns: [
{
taskId: 'task',
trigger: { kind: 'parent_dispatch', summary: 'Current prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Current result' }],
activityTruncated: false,
messages: [],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
mockGetSubagentThread.mockResolvedValue({
...completedView,
activity: [],
messages: [],
historyTruncated: false,
turns: [
{
taskId: 'older',
trigger: { kind: 'parent_dispatch', summary: 'Older prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Older result' }],
activityTruncated: false,
messages: [],
},
],
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() => expect(screen.getAllByTestId('conversation-turn')).toHaveLength(2));
expect(mockGetSubagentThread).toHaveBeenCalledWith(
'parent-conversation',
'child-thread',
undefined,
'older:assistant',
);
expect(mockUseSubagentThreadQuery.mock.calls.every((call) => call[2] === 'task')).toBe(true);
});
it('discards an older-page response when the latest cursor generation advances', async () => {
let resolveOlderPage: (view: SubagentThreadView) => void = () => undefined;
const olderPage = new Promise<SubagentThreadView>((resolve) => {
resolveOlderPage = resolve;
});
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
nextCursor: 'older-a:assistant',
turns: [
{
taskId: 'task',
trigger: { kind: 'parent_dispatch', summary: 'Current prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Current result' }],
activityTruncated: false,
messages: [],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
mockGetSubagentThread.mockReturnValueOnce(olderPage);
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
nextCursor: 'older-b:assistant',
turns: [
{
taskId: 'task-new',
trigger: { kind: 'parent_continuation', summary: 'New prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'New result' }],
activityTruncated: false,
messages: [],
},
{
taskId: 'task',
trigger: { kind: 'parent_dispatch', summary: 'Current prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Current result' }],
activityTruncated: false,
messages: [],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
await act(async () => {
resolveOlderPage({
...completedView,
activity: [],
messages: [],
turns: [
{
taskId: 'older-a',
trigger: { kind: 'parent_dispatch', summary: 'Displaced prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Displaced result' }],
activityTruncated: false,
messages: [],
},
],
});
await olderPage;
});
expect(screen.queryByText('Displaced result')).not.toBeInTheDocument();
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }),
).toBeEnabled(),
);
});
it('shows only the retry control after an earlier-history request fails', async () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
nextCursor: 'older:assistant',
turns: [
{
taskId: 'task',
trigger: { kind: 'parent_dispatch', summary: 'Current prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Current result' }],
activityTruncated: false,
messages: [],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
mockGetSubagentThread.mockRejectedValueOnce(new Error('history unavailable'));
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'com_ui_retry' })).toBeEnabled());
expect(
screen.queryByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }),
).not.toBeInTheDocument();
});
it('shows a compact inaccessible-history boundary when no recovery cursor exists', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
historyTruncated: true,
turns: [
{
taskId: 'task',
trigger: { kind: 'parent_dispatch', summary: 'Current prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Current result' }],
activityTruncated: false,
messages: [],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(
screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
).toHaveTextContent('•••');
expect(
screen.queryByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }),
).not.toBeInTheDocument();
});
it('preserves an unrecoverable boundary while loading later cursor pages', async () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
nextCursor: 'older:assistant',
turns: [],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
mockGetSubagentThread
.mockResolvedValueOnce({
...completedView,
nextCursor: 'oldest:assistant',
historyTruncated: true,
historyUnavailable: true,
activity: [],
messages: [],
turns: [],
})
.mockResolvedValueOnce({
...completedView,
historyTruncated: false,
activity: [],
messages: [],
turns: [],
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() =>
expect(mockGetSubagentThread).toHaveBeenLastCalledWith(
'parent-conversation',
'child-thread',
undefined,
'older:assistant',
),
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() =>
expect(mockGetSubagentThread).toHaveBeenLastCalledWith(
'parent-conversation',
'child-thread',
undefined,
'oldest:assistant',
),
);
expect(
screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
).toHaveTextContent('•••');
});
it('preserves a cursorless truncated boundary after the latest polling window moves', async () => {
let latestView: SubagentThreadView = {
...completedView,
historyTruncated: true,
historyUnavailable: undefined,
nextCursor: undefined,
turns: [],
};
mockUseSubagentThreadQuery.mockImplementation(() => ({
data: latestView,
isLoading: false,
isError: false,
isReadinessPending: false,
}));
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(
screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
).toBeInTheDocument();
latestView = {
...latestView,
historyTruncated: false,
nextCursor: 'new-boundary:assistant',
};
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
await waitFor(() =>
expect(
screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
).toBeInTheDocument(),
);
});
it('rejects stale exact-detail responses across an actor A-B-A switch', async () => {
let resolveStaleDetail: (view: SubagentThreadView) => void = () => undefined;
const staleDetail = new Promise<SubagentThreadView>((resolve) => {
resolveStaleDetail = resolve;
});
const viewFor = (threadId: string, taskId: string): SubagentThreadView => ({
...completedView,
threadId,
activity: [],
activityTruncated: true,
messages: [],
turns: [
{
taskId,
trigger: { kind: 'parent_dispatch', summary: `${threadId} prompt` },
status: 'completed',
activity: [],
activityTruncated: true,
messages: [],
},
],
});
mockUseSubagentThreadQuery.mockImplementation(
(_parentConversationId: string, requestedThreadId: string, requestedTaskId: string) => ({
data: viewFor(requestedThreadId, requestedTaskId),
isLoading: false,
isError: false,
isReadinessPending: false,
}),
);
mockGetSubagentThread.mockReturnValueOnce(staleDetail).mockResolvedValueOnce({
...completedView,
threadId: 'thread-a',
activity: [{ type: 'writing', text: 'Fresh detail' }],
activityTruncated: false,
messages: [
{
messageId: 'task-a:assistant',
parentMessageId: 'task-a:user',
role: 'assistant',
text: 'Fresh detail',
},
],
});
const selectionA: ActiveSubagentPanel = {
...selection,
durable: { threadId: 'thread-a', taskId: 'task-a' },
};
const selectionB: ActiveSubagentPanel = {
...selection,
durable: { threadId: 'thread-b', taskId: 'task-b' },
};
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={selectionA} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'load-task-a' }));
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selectionB} />
</RecoilRoot>,
);
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selectionA} />
</RecoilRoot>,
);
await waitFor(() => expect(screen.getByRole('button', { name: 'load-task-a' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: 'load-task-a' }));
await waitFor(() => expect(screen.getByText('Fresh detail')).toBeInTheDocument());
await act(async () => {
resolveStaleDetail({
...completedView,
threadId: 'thread-a',
activity: [{ type: 'writing', text: 'Stale detail' }],
activityTruncated: false,
messages: [],
});
await staleDetail;
});
expect(screen.getByText('Fresh detail')).toBeInTheDocument();
expect(screen.queryByText('Stale detail')).not.toBeInTheDocument();
});
it('rejects stale history responses across an actor A-B-A switch', async () => {
let resolveStaleHistory: (view: SubagentThreadView) => void = () => undefined;
const staleHistory = new Promise<SubagentThreadView>((resolve) => {
resolveStaleHistory = resolve;
});
const viewFor = (threadId: string, taskId: string): SubagentThreadView => ({
...completedView,
threadId,
nextCursor: `${taskId}-older:assistant`,
activity: [{ type: 'writing', text: `${threadId} current` }],
turns: [
{
taskId,
trigger: { kind: 'parent_dispatch', summary: `${threadId} prompt` },
status: 'completed',
activity: [{ type: 'writing', text: `${threadId} current` }],
activityTruncated: false,
messages: [],
},
],
});
mockUseSubagentThreadQuery.mockImplementation(
(_parentConversationId: string, requestedThreadId: string, requestedTaskId: string) => ({
data: viewFor(requestedThreadId, requestedTaskId),
isLoading: false,
isError: false,
isReadinessPending: false,
}),
);
mockGetSubagentThread.mockReturnValueOnce(staleHistory).mockResolvedValueOnce({
...completedView,
threadId: 'thread-a',
activity: [],
messages: [],
turns: [
{
taskId: 'fresh-older',
trigger: { kind: 'parent_dispatch', summary: 'Fresh older prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Fresh older result' }],
activityTruncated: false,
messages: [],
},
],
});
const selectionA: ActiveSubagentPanel = {
...selection,
durable: { threadId: 'thread-a', taskId: 'task-a' },
};
const selectionB: ActiveSubagentPanel = {
...selection,
durable: { threadId: 'thread-b', taskId: 'task-b' },
};
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={selectionA} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selectionB} />
</RecoilRoot>,
);
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selectionA} />
</RecoilRoot>,
);
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }),
).toBeEnabled(),
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() => expect(screen.getByText('Fresh older result')).toBeInTheDocument());
await act(async () => {
resolveStaleHistory({
...completedView,
threadId: 'thread-a',
activity: [],
messages: [],
turns: [
{
taskId: 'stale-older',
trigger: { kind: 'parent_dispatch', summary: 'Stale older prompt' },
status: 'completed',
activity: [{ type: 'writing', text: 'Stale older result' }],
activityTruncated: false,
messages: [],
},
],
});
await staleHistory;
});
expect(screen.getByText('Fresh older result')).toBeInTheDocument();
expect(screen.queryByText('Stale older result')).not.toBeInTheDocument();
});
it('retains a latest-window turn displaced after older history has loaded', async () => {
const makeTurn = (taskId: string, text: string) => ({
taskId,
trigger: { kind: 'parent_dispatch' as const, summary: `${text} prompt` },
status: 'completed' as const,
activity: [{ type: 'writing' as const, text }],
activityTruncated: false,
messages: [],
});
let latestView: SubagentThreadView = {
...completedView,
nextCursor: 'boundary:assistant',
activity: [{ type: 'writing', text: 'Current result' }],
turns: [makeTurn('boundary', 'Boundary result'), makeTurn('task', 'Current result')],
};
let resolveReconnect: (view: SubagentThreadView) => void = () => undefined;
const reconnectPage = new Promise<SubagentThreadView>((resolve) => {
resolveReconnect = resolve;
});
mockUseSubagentThreadQuery.mockImplementation(() => ({
data: latestView,
isLoading: false,
isError: false,
isReadinessPending: false,
}));
mockGetSubagentThread
.mockResolvedValueOnce({
...completedView,
nextCursor: 'very-old:assistant',
activity: [],
messages: [],
turns: [makeTurn('very-old', 'Very old result')],
})
.mockResolvedValueOnce({
...completedView,
nextCursor: 'middle-older:assistant',
activity: [],
messages: [],
historyTruncated: true,
turns: [makeTurn('middle-newer', 'Newer middle result')],
})
.mockReturnValueOnce(reconnectPage);
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() => expect(screen.getAllByTestId('conversation-turn')).toHaveLength(3));
latestView = {
...latestView,
nextCursor: 'middle:assistant',
turns: [makeTurn('task-new', 'New result'), makeTurn('task-newer', 'Newest result')],
};
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
await waitFor(() => expect(screen.getAllByTestId('conversation-turn')).toHaveLength(5));
expect(screen.getByText('Very old result')).toBeInTheDocument();
expect(screen.getByText('Boundary result')).toBeInTheDocument();
expect(screen.getByText('Current result')).toBeInTheDocument();
expect(screen.getByText('New result')).toBeInTheDocument();
expect(screen.getByText('Newest result')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() =>
expect(mockGetSubagentThread).toHaveBeenLastCalledWith(
'parent-conversation',
'child-thread',
undefined,
'middle:assistant',
),
);
await waitFor(() => expect(screen.getAllByTestId('conversation-turn')).toHaveLength(6));
latestView = {
...latestView,
nextCursor: 'new-live-boundary:assistant',
turns: [makeTurn('task-final', 'Final live result')],
};
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
await waitFor(() => expect(screen.getAllByTestId('conversation-turn')).toHaveLength(7));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
await waitFor(() =>
expect(mockGetSubagentThread).toHaveBeenLastCalledWith(
'parent-conversation',
'child-thread',
undefined,
'middle-older:assistant',
),
);
latestView = {
...latestView,
nextCursor: 'ultimate-live-boundary:assistant',
turns: [makeTurn('task-ultimate', 'Ultimate live result')],
};
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
await waitFor(() => expect(screen.getByText('Ultimate live result')).toBeInTheDocument());
await act(async () => {
resolveReconnect({
...completedView,
activity: [],
messages: [],
historyTruncated: false,
turns: [
makeTurn('boundary', 'Boundary result'),
makeTurn('middle-older', 'Older middle result'),
],
});
await reconnectPage;
});
await waitFor(() => expect(screen.getAllByTestId('conversation-turn')).toHaveLength(9));
expect(screen.getAllByTestId('conversation-turn').map((turn) => turn.textContent)).toEqual([
expect.stringContaining('Very old result'),
expect.stringContaining('Boundary result'),
expect.stringContaining('Current result'),
expect.stringContaining('Older middle result'),
expect.stringContaining('Newer middle result'),
expect.stringContaining('New result'),
expect.stringContaining('Newest result'),
expect.stringContaining('Final live result'),
expect.stringContaining('Ultimate live result'),
]);
expect(
screen.queryByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }),
).not.toBeInTheDocument();
});
it('does not accumulate displaced latest-window turns before history is requested', async () => {
const makeTurn = (taskId: string, text: string) => ({
taskId,
trigger: { kind: 'parent_dispatch' as const, summary: `${text} prompt` },
status: 'completed' as const,
activity: [{ type: 'writing' as const, text }],
activityTruncated: false,
messages: [],
});
let latestView: SubagentThreadView = {
...completedView,
nextCursor: 'old:assistant',
turns: [makeTurn('old', 'Old result'), makeTurn('task', 'Current result')],
};
mockUseSubagentThreadQuery.mockImplementation(() => ({
data: latestView,
isLoading: false,
isError: false,
isReadinessPending: false,
}));
const { rerender } = render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(screen.getByText('Old result')).toBeInTheDocument();
latestView = {
...latestView,
nextCursor: 'task:assistant',
turns: [makeTurn('task', 'Current result'), makeTurn('new', 'New result')],
};
rerender(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
await waitFor(() => expect(screen.queryByText('Old result')).not.toBeInTheDocument());
expect(screen.getByText('Current result prompt')).toBeInTheDocument();
expect(screen.getByText('New result')).toBeInTheDocument();
});
it('follows a newly appended latest turn while preserving the continuous history', async () => {
const eventChild: ParentSubagentSummary = {
threadId: 'child-thread',
@ -1507,6 +2372,49 @@ describe('SubagentThreadPanel', () => {
expect(new Set(mockUseSubagentThreadQuery.mock.calls.map((call) => call[2]))).toEqual(
new Set(['task-5', 'task-4', 'task-3', 'task-2', 'task-1']),
);
expect(screen.getByText('com_ui_subagent_thread_history_truncated')).toBeInTheDocument();
expect(
screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
).toHaveTextContent('•••');
});
it('marks a truncated one-task legacy event window as incomplete', () => {
const eventChild: ParentSubagentSummary = {
threadId: 'child-thread',
parentMessageId: 'parent-message',
subagentType: 'agent-1',
subagentKind: 'agent',
agentId: 'agent-1',
title: 'Actor',
origin: 'event',
actorId: 'actor-1',
status: 'completed',
latestTaskId: 'task',
tasks: [{ taskId: 'task', status: 'completed' }],
tasksTruncated: true,
};
mockParentChildrenByMessage = new Map([['parent-message', [eventChild]]]);
mockParentChildrenByThread = new Map([[eventChild.threadId, eventChild]]);
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel
selection={{
...selection,
event: { actorId: 'actor-1', progressKey: 'event-task:child-thread:task' },
}}
/>
</RecoilRoot>,
);
expect(screen.getAllByTestId('shared-activity')).toHaveLength(1);
expect(
screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
).toHaveTextContent('•••');
});
});

View file

@ -1,16 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { v4 } from 'uuid';
import { ForkOptions } from 'librechat-data-provider';
import {
Bot,
CornerDownRight,
CornerUpLeft,
ListEnd,
MessagesSquare,
OctagonX,
X,
Zap,
} from 'lucide-react';
import { dataService, ForkOptions } from 'librechat-data-provider';
import { Bot, CornerDownRight, ListEnd, MessagesSquare, OctagonX, X, Zap } from 'lucide-react';
import {
useRecoilCallback,
useRecoilState,
@ -38,6 +29,13 @@ import type {
} from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { ActiveSubagentPanel, SubagentControlUiState } from '~/store/subagents';
import {
adaptDurableThreadActivity,
adaptDurableThreadConversation,
adaptLivePersistedActivity,
mergeChildConversationTurns,
retainBoundedMovingWindowTurns,
} from './adapters';
import {
ACTIVE_THREAD_REFRESH_MS,
subagentThreadHasTaskEvidence,
@ -52,11 +50,6 @@ import {
subagentProgressByToolCallId,
subagentProgressKey,
} from '~/store/subagents';
import {
adaptDurableThreadActivity,
adaptDurableThreadConversation,
adaptLivePersistedActivity,
} from './adapters';
import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActivityStream';
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
@ -186,12 +179,26 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
return true;
});
}, [byMessageId, selection.event, selection.parentMessageId]);
const selectedEventActor = eventSiblings.find((child) => child.threadId === threadId);
const selectedEventActorName =
(selectedEventActor?.agentId == null
? undefined
: agentsMap?.[selectedEventActor.agentId]?.name) ??
selectedEventActor?.actorId ??
eventSummary?.actorId ??
foregroundTitle;
const { data, isLoading, isError, isReadinessPending, refetch } = useSubagentThreadQuery(
selection.parentConversationId,
threadId,
taskId,
eventTaskRunning ? { refetchInterval: ACTIVE_THREAD_REFRESH_MS } : undefined,
);
const latestHistoryGeneration = JSON.stringify([
data?.nextCursor ?? null,
...(data?.turns?.map((turn) => turn.taskId) ?? []),
]);
const latestHistoryGenerationRef = useRef(latestHistoryGeneration);
latestHistoryGenerationRef.current = latestHistoryGeneration;
const durableTerminal =
subagentThreadHasTaskEvidence(data, taskId) &&
(data?.status === 'completed' ||
@ -251,6 +258,42 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
},
});
const [controlMessage, setControlMessage] = useState('');
const [turnDetailOverrides, setTurnDetailOverrides] = useState(
() => new Map<string, ReturnType<typeof adaptDurableThreadActivity>>(),
);
const [turnDetailStates, setTurnDetailStates] = useState(
() => new Map<string, 'idle' | 'loading' | 'unavailable' | 'error'>(),
);
const [olderTurns, setOlderTurns] = useState<ReturnType<typeof adaptDurableThreadConversation>>(
[],
);
const [movingWindowTurns, setMovingWindowTurns] = useState<
ReturnType<typeof adaptDurableThreadConversation>
>([]);
const [rebaseTurns, setRebaseTurns] = useState<ReturnType<typeof adaptDurableThreadConversation>>(
[],
);
const [postRebaseTurns, setPostRebaseTurns] = useState<
ReturnType<typeof adaptDurableThreadConversation>
>([]);
const postRebaseTurnsRef = useRef(postRebaseTurns);
const [historyRebaseActive, setHistoryRebaseActive] = useState(false);
const historyRebaseActiveRef = useRef(historyRebaseActive);
const [historyCursor, setHistoryCursor] = useState<string | null | undefined>(undefined);
const [historyCursorGeneration, setHistoryCursorGeneration] = useState<string>();
const [historyState, setHistoryState] = useState<'idle' | 'loading' | 'error'>('idle');
const [historyBoundaryUnavailable, setHistoryBoundaryUnavailable] = useState(false);
const activeThreadRef = useRef(threadId);
const selectionThreadRef = useRef(threadId);
const selectionGenerationRef = useRef(0);
const turnDetailRequestsRef = useRef(new Set<string>());
const historyRequestRef = useRef<string | null>(null);
const historyHasLoadedRef = useRef(false);
if (selectionThreadRef.current !== threadId) {
selectionThreadRef.current = threadId;
selectionGenerationRef.current += 1;
}
activeThreadRef.current = threadId;
const [controlInaccessible, setControlInaccessible] = useState(false);
const [controlsClosed, setControlsClosed] = useState(false);
const controlInFlightRef = useRef(false);
@ -266,6 +309,207 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
};
}, [controlIdentity]);
useEffect(() => {
setTurnDetailOverrides(new Map());
setTurnDetailStates(new Map());
setOlderTurns([]);
setMovingWindowTurns([]);
setRebaseTurns([]);
setPostRebaseTurns([]);
postRebaseTurnsRef.current = [];
setHistoryRebaseActive(false);
historyRebaseActiveRef.current = false;
setHistoryCursor(undefined);
setHistoryCursorGeneration(undefined);
setHistoryState('idle');
setHistoryBoundaryUnavailable(false);
turnDetailRequestsRef.current.clear();
historyRequestRef.current = null;
historyHasLoadedRef.current = false;
}, [threadId]);
useEffect(() => {
if (
data?.threadId === threadId &&
(data.historyUnavailable === true ||
(data.historyTruncated === true && data.nextCursor == null))
) {
setHistoryBoundaryUnavailable(true);
}
}, [
data?.historyTruncated,
data?.historyUnavailable,
data?.nextCursor,
data?.threadId,
threadId,
]);
const loadTurnDetails = useCallback(
async (detailTaskId: string) => {
const requestedThreadId = threadId;
const requestedGeneration = selectionGenerationRef.current;
const requestKey = `${requestedGeneration}\u0000${requestedThreadId}\u0000${detailTaskId}`;
if (
turnDetailStates.get(detailTaskId) === 'loading' ||
turnDetailRequestsRef.current.has(requestKey)
) {
return;
}
turnDetailRequestsRef.current.add(requestKey);
setTurnDetailStates((current) => new Map(current).set(detailTaskId, 'loading'));
try {
const exact = await dataService.getSubagentThread(
selection.parentConversationId,
requestedThreadId,
detailTaskId,
);
if (
activeThreadRef.current !== requestedThreadId ||
selectionGenerationRef.current !== requestedGeneration
) {
return;
}
if (!subagentThreadHasTaskEvidence(exact, detailTaskId)) {
setTurnDetailStates((current) => new Map(current).set(detailTaskId, 'unavailable'));
return;
}
const detail = adaptDurableThreadActivity(exact, detailTaskId);
setTurnDetailOverrides((current) => new Map(current).set(detailTaskId, detail));
setTurnDetailStates((current) =>
new Map(current).set(
detailTaskId,
detail.activityTruncated === true ? 'unavailable' : 'idle',
),
);
} catch {
if (
activeThreadRef.current !== requestedThreadId ||
selectionGenerationRef.current !== requestedGeneration
) {
return;
}
setTurnDetailStates((current) => new Map(current).set(detailTaskId, 'error'));
} finally {
turnDetailRequestsRef.current.delete(requestKey);
}
},
[selection.parentConversationId, threadId, turnDetailStates],
);
const loadEarlierHistory = useCallback(async () => {
const requestedThreadId = threadId;
const requestedSelectionGeneration = selectionGenerationRef.current;
const requestedGeneration = historyRebaseActive
? (historyCursorGeneration ?? latestHistoryGeneration)
: latestHistoryGeneration;
const startsRebase =
!historyRebaseActive &&
historyCursor !== undefined &&
historyCursorGeneration !== requestedGeneration;
const recoveringRebase = startsRebase || historyRebaseActive;
const cursor = historyCursor === undefined || startsRebase ? data?.nextCursor : historyCursor;
const requestKey = `${requestedSelectionGeneration}\u0000${requestedThreadId}\u0000${cursor ?? ''}\u0000${requestedGeneration}`;
if (cursor == null || historyState === 'loading' || historyRequestRef.current != null) {
return;
}
historyRequestRef.current = requestKey;
setHistoryState('loading');
try {
const page = await dataService.getSubagentThread(
selection.parentConversationId,
requestedThreadId,
undefined,
cursor,
);
if (
activeThreadRef.current !== requestedThreadId ||
selectionGenerationRef.current !== requestedSelectionGeneration
) {
return;
}
if (!historyRebaseActive && latestHistoryGenerationRef.current !== requestedGeneration) {
setHistoryState('idle');
return;
}
const pageTurns = adaptDurableThreadConversation(page);
let recoveryCompleted = false;
if (recoveringRebase) {
const bridgeTurns = startsRebase
? retainBoundedMovingWindowTurns(movingWindowTurns, rebaseTurns)
: movingWindowTurns;
const nextRebaseTurns = mergeChildConversationTurns(
pageTurns,
startsRebase ? [] : rebaseTurns,
);
const retainedTaskIds = new Set(
mergeChildConversationTurns(olderTurns, bridgeTurns, postRebaseTurnsRef.current).map(
(turn) => turn.taskId,
),
);
const reconnected = pageTurns.some((turn) => retainedTaskIds.has(turn.taskId));
const recoveryComplete = reconnected || page.nextCursor == null;
if (recoveryComplete) {
recoveryCompleted = true;
const retainedPostRebaseTurns = postRebaseTurnsRef.current;
postRebaseTurnsRef.current = [];
setOlderTurns((current) =>
mergeChildConversationTurns(
current,
bridgeTurns,
nextRebaseTurns,
retainedPostRebaseTurns,
),
);
setMovingWindowTurns([]);
setRebaseTurns([]);
setPostRebaseTurns([]);
setHistoryRebaseActive(false);
historyRebaseActiveRef.current = false;
} else {
if (startsRebase) setMovingWindowTurns(bridgeTurns);
setRebaseTurns(nextRebaseTurns);
setHistoryRebaseActive(true);
historyRebaseActiveRef.current = true;
}
} else {
setOlderTurns((current) => mergeChildConversationTurns(pageTurns, current));
}
historyHasLoadedRef.current = true;
setHistoryCursor(page.nextCursor ?? null);
setHistoryCursorGeneration(
recoveryCompleted ? latestHistoryGenerationRef.current : requestedGeneration,
);
setHistoryBoundaryUnavailable(
(current) =>
current ||
page.historyUnavailable === true ||
(page.historyTruncated && page.nextCursor == null),
);
setHistoryState('idle');
} catch {
if (
activeThreadRef.current !== requestedThreadId ||
selectionGenerationRef.current !== requestedSelectionGeneration
) {
return;
}
setHistoryState('error');
} finally {
if (historyRequestRef.current === requestKey) historyRequestRef.current = null;
}
}, [
data?.nextCursor,
historyCursor,
historyCursorGeneration,
historyRebaseActive,
historyState,
latestHistoryGeneration,
movingWindowTurns,
olderTurns,
rebaseTurns,
selection.parentConversationId,
threadId,
]);
const controlTask = useSubagentControlMutation({
onSuccess: ({ receipt }, variables) => {
const submittedSelection = subagentControlStateKey(
@ -488,19 +732,59 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
}
return { ...merged, controls: [...(merged.controls ?? []), transientControl] };
}, [data, liveActivity, progress, selection.durable, transientControl]);
const panelTitle = selection.event == null ? activity.title : selectedEventActorName;
const latestConversationTurns = useMemo(
() => (data == null ? [] : adaptDurableThreadConversation(data)),
[data],
);
const previousLatestTurnsRef = useRef({
threadId,
generation: latestHistoryGeneration,
turns: latestConversationTurns,
});
useEffect(() => {
if (data == null) return;
const previous = previousLatestTurnsRef.current;
if (previous.threadId === threadId) {
const latestTaskIds = new Set(latestConversationTurns.map((turn) => turn.taskId));
const displaced = previous.turns.filter((turn) => !latestTaskIds.has(turn.taskId));
if (displaced.length > 0 && historyHasLoadedRef.current) {
if (historyRebaseActiveRef.current) {
const retained = retainBoundedMovingWindowTurns(postRebaseTurnsRef.current, displaced);
postRebaseTurnsRef.current = retained;
setPostRebaseTurns(retained);
} else {
setMovingWindowTurns((current) => retainBoundedMovingWindowTurns(current, displaced));
}
}
}
previousLatestTurnsRef.current = {
threadId,
generation: latestHistoryGeneration,
turns: latestConversationTurns,
};
}, [data, historyRebaseActive, latestConversationTurns, latestHistoryGeneration, threadId]);
const conversationTurns = useMemo(() => {
const durableTurns = data == null ? [] : adaptDurableThreadConversation(data);
const durableTurns = mergeChildConversationTurns(
olderTurns,
movingWindowTurns,
rebaseTurns,
postRebaseTurns,
latestConversationTurns,
);
if (durableTurns.length > 0) {
const selectedTurnIndex = durableTurns.findIndex((turn) => turn.taskId === taskId);
if (selectedTurnIndex >= 0) {
return durableTurns.map((turn, index) =>
index === selectedTurnIndex ? { ...turn, activity } : turn,
);
return durableTurns.map((turn, index) => {
const selected = index === selectedTurnIndex ? { ...turn, activity } : turn;
const override = turnDetailOverrides.get(turn.taskId);
return override == null ? selected : { ...selected, activity: override };
});
}
// The API keeps the exact selected activity even when its bounded
// chronological turn is the first item removed from the response.
// Preserve that selection ahead of the retained newer continuation.
return [
const retained = [
{
taskId: taskId || `${selection.parentMessageId}:${selection.toolCallId}`,
trigger: {
@ -514,6 +798,10 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
},
...durableTurns,
];
return retained.map((turn) => {
const override = turnDetailOverrides.get(turn.taskId);
return override == null ? turn : { ...turn, activity: override };
});
}
return [
{
@ -526,7 +814,30 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
activity,
},
];
}, [activity, data, selection, taskId]);
}, [
activity,
latestConversationTurns,
movingWindowTurns,
olderTurns,
postRebaseTurns,
rebaseTurns,
selection,
taskId,
turnDetailOverrides,
]);
const effectiveTurnDetailStates = useMemo(() => {
const states = new Map(turnDetailStates);
if (activity.activityTruncated === true && taskId !== '') states.set(taskId, 'unavailable');
return states;
}, [activity.activityTruncated, taskId, turnDetailStates]);
const historyCursorUsesLatest =
!historyRebaseActive &&
(historyCursor === undefined || historyCursorGeneration !== latestHistoryGeneration);
const effectiveHistoryCursor = historyCursorUsesLatest ? data?.nextCursor : historyCursor;
const showUnavailableHistoryBoundary =
historyBoundaryUnavailable ||
data?.historyUnavailable === true ||
(historyCursorUsesLatest && data?.historyTruncated === true && data.nextCursor == null);
/** During a rolling deployment an older API replica can omit `turns`. Keep
* that response readable through the same deep activity renderer; every
* current host otherwise enters the conversation-native rendering seam. */
@ -626,13 +937,14 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
</Button>
</div>
);
} else if (eventSummary?.tasksTruncated) {
} else if (eventSummary?.tasksTruncated === true) {
timelinePrefix = (
<div
role="note"
className="border-b border-border-light px-4 py-3 text-sm text-text-secondary"
role="status"
aria-label={localize('com_ui_subagent_thread_history_truncated')}
className="flex h-7 items-center justify-center border-b border-border-light text-text-tertiary"
>
{localize('com_ui_subagent_thread_history_truncated')}
<span aria-hidden></span>
</div>
);
}
@ -645,12 +957,40 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
if (hasConversationProjection) {
activityPanel = (
<SubagentActivityScrollSurface padded={false}>
{data?.historyTruncated === true && (
{showUnavailableHistoryBoundary && (
<div
role="note"
className="border-b border-border-light px-4 py-3 text-sm text-text-secondary"
role="status"
aria-label={localize('com_ui_subagent_thread_history_truncated')}
className="flex h-7 items-center justify-center border-b border-border-light text-text-tertiary"
>
{localize('com_ui_subagent_thread_history_truncated')}
<span aria-hidden></span>
</div>
)}
{effectiveHistoryCursor != null && historyState !== 'error' && (
<div className="flex justify-center border-b border-border-light px-4 py-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={historyState === 'loading'}
onClick={() => void loadEarlierHistory()}
>
{historyState === 'loading'
? localize('com_ui_loading')
: localize('com_ui_subagent_load_earlier_activity')}
</Button>
</div>
)}
{historyState === 'error' && (
<div className="flex justify-center border-b border-border-light px-4 py-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void loadEarlierHistory()}
>
{localize('com_ui_retry')}
</Button>
</div>
)}
<SubagentConversation
@ -664,10 +1004,15 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
onCancelControl={(_controlledTaskId, controlId) =>
submitControl('cancel_message', controlId)
}
detailStateByTask={effectiveTurnDetailStates}
onLoadTurnDetails={loadTurnDetails}
/>
</SubagentActivityScrollSurface>
);
} else if (selection.event != null && (eventSummary?.tasks.length ?? 0) > 1) {
} else if (
selection.event != null &&
((eventSummary?.tasks.length ?? 0) > 1 || eventSummary?.tasksTruncated === true)
) {
activityPanel = (
<SubagentActivityScrollSurface padded={false}>
<div data-subagent-thread-timeline>
@ -701,33 +1046,42 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
aria-label={localize('com_ui_subagent_thread_panel')}
className="flex h-full w-full flex-col overflow-hidden bg-surface-primary-alt text-text-primary"
>
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b border-border-light px-4 py-3">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-border-light px-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-tertiary">
<Bot size={17} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold" title={activity.title}>
{activity.title}
</h2>
{data?.depth != null && (
<div className="truncate text-xs text-text-secondary">
{localize('com_ui_subagent_depth', { 0: String(data.depth) })}
</div>
{selection.event != null && eventSiblings.length > 1 ? (
<Select value={threadId} onValueChange={selectActor}>
<SelectTrigger
className="h-8 max-w-sm border-0 bg-transparent px-1 font-semibold shadow-none"
aria-label={localize('com_ui_subagent_actor')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{eventSiblings.map((child) => (
<SelectItem
key={child.threadId}
value={child.threadId}
disabled={!child.latestTaskId}
>
{child.agentId != null && agentsMap?.[child.agentId]?.name
? agentsMap[child.agentId]?.name
: child.actorId || child.title}
{child.actorId != null && agentsMap?.[child.agentId ?? '']?.name
? ` · ${child.actorId}`
: ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<h2 className="truncate text-sm font-semibold" title={panelTitle}>
{panelTitle}
</h2>
)}
</div>
{selection.host === 'conversation' && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={close}
aria-label={localize('com_ui_subagent_view_in_parent')}
className="h-8 shrink-0 gap-1.5"
>
<CornerUpLeft size={15} aria-hidden="true" />
<span className="hidden lg:inline">{localize('com_ui_subagent_view_in_parent')}</span>
</Button>
)}
{canContinueAsChat && (
<Button
type="button"
@ -754,34 +1108,6 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
</Button>
</header>
{selection.event != null && (
<div className="flex shrink-0 items-center gap-2 border-b border-border-light px-4 py-2">
<div className="min-w-0 flex-1">
<Select value={threadId} onValueChange={selectActor}>
<SelectTrigger className="h-8" aria-label={localize('com_ui_subagent_actor')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{eventSiblings.map((child) => (
<SelectItem
key={child.threadId}
value={child.threadId}
disabled={!child.latestTaskId}
>
{child.agentId != null && agentsMap?.[child.agentId]?.name
? agentsMap[child.agentId]?.name
: child.actorId || child.title}
{child.actorId != null && agentsMap?.[child.agentId ?? '']?.name
? ` · ${child.actorId}`
: ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Keep the foreground panel's existing nested-tool approval controls
coordinated within this invocation. Detached activity projections
never include approval payloads. */}

View file

@ -4,12 +4,19 @@ import type {
SubagentUpdateEvent,
TMessageContentParts,
} from 'librechat-data-provider';
import type { ChildConversationTurn } from './adapters';
import {
adaptDurableThreadActivity,
adaptLivePersistedActivity,
MAX_RETAINED_MOVING_WINDOW_TURNS,
mergeChildConversationTurns,
retainBoundedMovingWindowTurns,
} from './adapters';
import {
aggregateSubagentContent,
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', () => {
@ -535,4 +542,56 @@ describe('child activity adapters', () => {
expect.objectContaining({ status: 'completed', items: [{ type: 'writing', text: 'Done.' }] }),
);
});
it('merges a page-split task trigger with its newer assistant activity', () => {
const triggerHalf: ChildConversationTurn = {
taskId: 'task',
trigger: {
kind: 'external_event',
summary: 'Play the next move.',
createdAt: '2026-08-27T12:00:00.000Z',
externalEvent: {
eventType: 'chess.turn.ready',
sourceType: 'speed-chess',
occurredAt: '2026-08-27T12:00:00.000Z',
},
},
activity: { title: 'Player', status: 'running', items: [] },
};
const assistantHalf: ChildConversationTurn = {
taskId: 'task',
trigger: { kind: 'parent_continuation', summary: '' },
activity: {
title: 'Player',
status: 'completed',
items: [{ type: 'writing', text: 'Played e4.' }],
},
};
expect(mergeChildConversationTurns([triggerHalf], [assistantHalf])).toEqual([
{
taskId: 'task',
trigger: triggerHalf.trigger,
activity: expect.objectContaining({
status: 'completed',
items: [{ type: 'writing', text: 'Played e4.' }],
}),
},
]);
});
it('bounds automatically retained moving-window turns', () => {
const turns = Array.from(
{ length: MAX_RETAINED_MOVING_WINDOW_TURNS + 3 },
(_, index): ChildConversationTurn => ({
taskId: `task-${index}`,
trigger: { kind: 'parent_continuation', summary: '' },
activity: { title: 'Player', status: 'completed', items: [] },
}),
);
expect(retainBoundedMovingWindowTurns([], turns).map((turn) => turn.taskId)).toEqual(
turns.slice(-MAX_RETAINED_MOVING_WINDOW_TURNS).map((turn) => turn.taskId),
);
});
});

View file

@ -4,7 +4,6 @@ import type {
PartMetadata,
SubagentActivityItem,
SubagentControlReceipt,
SubagentThreadTriggerKind,
SubagentThreadTurn,
SubagentThreadStatus,
SubagentThreadView,
@ -66,12 +65,7 @@ export type ChildActivity = {
export type ChildConversationTurn = {
taskId: string;
trigger: {
kind: SubagentThreadTriggerKind;
summary: string;
createdAt?: string;
summaryTruncated?: boolean;
};
trigger: SubagentThreadTurn['trigger'];
activity: ChildActivity;
};
@ -370,3 +364,66 @@ const adaptDurableTurn = (turn: SubagentThreadTurn, title: string): ChildConvers
export function adaptDurableThreadConversation(view: SubagentThreadView): ChildConversationTurn[] {
return (view.turns ?? []).map((turn) => adaptDurableTurn(turn, view.title));
}
const triggerDetailScore = (turn: ChildConversationTurn): number =>
(turn.trigger.summary.trim().length > 0 ? 1 : 0) +
(turn.trigger.createdAt == null ? 0 : 1) +
(turn.trigger.externalEvent == null ? 0 : 2);
const mergeTurnControls = (
older: ChildActivity['controls'],
newer: ChildActivity['controls'],
): ChildActivity['controls'] => {
if (older == null) return newer;
if (newer == null) return older;
const receipts = new Map(older.map((receipt) => [receipt.invocationId, receipt]));
for (const receipt of newer) receipts.set(receipt.invocationId, receipt);
return [...receipts.values()];
};
/** Merge chronological page projections whose bounded Mongo windows can split
* one task between its user trigger and assistant activity records. */
export function mergeChildConversationTurns(
...pages: ChildConversationTurn[][]
): ChildConversationTurn[] {
const merged: ChildConversationTurn[] = [];
const indexByTaskId = new Map<string, number>();
for (const turn of pages.flat()) {
const existingIndex = indexByTaskId.get(turn.taskId);
if (existingIndex == null) {
indexByTaskId.set(turn.taskId, merged.length);
merged.push(turn);
continue;
}
const older = merged[existingIndex];
const trigger =
triggerDetailScore(turn) > triggerDetailScore(older) ? turn.trigger : older.trigger;
const controls = mergeTurnControls(older.activity.controls, turn.activity.controls);
merged[existingIndex] = {
taskId: turn.taskId,
trigger,
activity: {
...older.activity,
...turn.activity,
items: turn.activity.items.length > 0 ? turn.activity.items : older.activity.items,
...(controls == null ? {} : { controls }),
activityTruncated:
older.activity.activityTruncated === true || turn.activity.activityTruncated === true,
controlsTruncated:
older.activity.controlsTruncated === true || turn.activity.controlsTruncated === true,
},
};
}
return merged;
}
/** Polling may displace turns from the API's latest window. Retain only one
* bounded bridge window; older pages remain an explicit user action. */
export const MAX_RETAINED_MOVING_WINDOW_TURNS = 50;
export function retainBoundedMovingWindowTurns(
current: ChildConversationTurn[],
displaced: ChildConversationTurn[],
): ChildConversationTurn[] {
return mergeChildConversationTurns(current, displaced).slice(-MAX_RETAINED_MOVING_WINDOW_TURNS);
}

View file

@ -2218,6 +2218,13 @@
"com_ui_subagent_control_history": "Control history",
"com_ui_subagent_control_history_truncated": "Earlier control activity is not shown.",
"com_ui_subagent_activity_details_truncated": "Some activity details were shortened.",
"com_ui_subagent_activity_details_unavailable": "Additional activity details are unavailable.",
"com_ui_subagent_show_full_activity": "Show full activity",
"com_ui_subagent_load_earlier_activity": "Load earlier activity",
"com_ui_subagent_event_type": "Event",
"com_ui_subagent_event_source": "Source",
"com_ui_subagent_event_received": "Occurred",
"com_ui_subagent_event_expected_action": "Expected action",
"com_ui_subagent_control_interrupt": "Interrupt",
"com_ui_subagent_control_message": "Message to subagent",
"com_ui_subagent_control_message_truncated": "Message shortened for display.",
@ -2243,11 +2250,10 @@
"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_thread_history_truncated": "Earlier activity is not shown.",
"com_ui_subagent_thread_history_truncated": "Some earlier activity is unavailable.",
"com_ui_subagent_thread_load_error": "The agent activity could not be loaded.",
"com_ui_subagent_thread_panel": "Child agent activity",
"com_ui_subagent_depth": "Level {{0}} child",
"com_ui_subagent_view_in_parent": "View in parent",
"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",