diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
index 839a135aed..65b85d5971 100644
--- a/api/app/clients/BaseClient.js
+++ b/api/app/clients/BaseClient.js
@@ -490,6 +490,9 @@ class BaseClient {
sender: 'User',
text,
isCreatedByUser: true,
+ ...(this.options?.req?._agentEventTriggerProjection != null && {
+ subagentTriggerProjection: this.options.req._agentEventTriggerProjection,
+ }),
};
}
diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js
index 3f4baa8528..139e9e9518 100644
--- a/api/app/clients/specs/BaseClient.test.js
+++ b/api/app/clients/specs/BaseClient.test.js
@@ -101,6 +101,26 @@ describe('BaseClient', () => {
});
});
+ test('persists only the host-authored external event display projection on the user turn', () => {
+ const projection = {
+ version: 1,
+ eventType: 'chess.turn.ready',
+ sourceType: 'speed-chess',
+ occurredAt: new Date('2026-08-21T12:00:00.000Z'),
+ expectedActionToolName: 'submit_move',
+ };
+ TestClient.options.req = { _agentEventTriggerProjection: projection };
+
+ expect(
+ TestClient.createUserMessage({
+ messageId: 'event:user',
+ parentMessageId: 'parent',
+ conversationId: 'event-thread',
+ text: 'Private event payload',
+ }),
+ ).toEqual(expect.objectContaining({ subagentTriggerProjection: projection }));
+ });
+
test('returns the input messages without instructions when addInstructions() is called with empty instructions', () => {
const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
const instructions = '';
diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
index 4c9b309263..f29d0427f9 100644
--- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -4021,9 +4021,9 @@ describe('ResumableAgentController resume metadata', () => {
});
const event = {
id: 'game-1:ply-8',
- type: 'chess.turn',
+ type: 'chess.\u202Eturn',
occurredAt: Date.now(),
- source: { id: 'speed-chess', type: 'mcp' },
+ source: { id: 'speed-chess', type: 'm\u2066cp' },
payload: { gameId: 'game-1', expectedPly: 8 },
};
const req = {
@@ -4037,7 +4037,7 @@ describe('ResumableAgentController resume metadata', () => {
deliveryKey: 'req-event-fork',
target: { bindingId: 'binding-1' },
event,
- expectedAction: { toolName: 'submit_move', argumentSubset: { expectedPly: 8 } },
+ expectedAction: { toolName: 'submit_\u200Fmove', argumentSubset: { expectedPly: 8 } },
},
},
config: {
@@ -4064,7 +4064,7 @@ describe('ResumableAgentController resume metadata', () => {
conversationId: 'child-conversation',
invocationId: 'req-event-fork',
event,
- expectedAction: { toolName: 'submit_move', argumentSubset: { expectedPly: 8 } },
+ expectedAction: { toolName: 'submit_\u200Fmove', argumentSubset: { expectedPly: 8 } },
}),
{
getSnapshot: expect.any(Function),
@@ -4079,6 +4079,13 @@ describe('ResumableAgentController resume metadata', () => {
},
);
expect(mockExecuteAgentEventActor.mock.calls[0][0]).not.toHaveProperty('tenantId');
+ expect(req._agentEventTriggerProjection).toEqual({
+ version: 1,
+ eventType: 'chess. turn',
+ sourceType: 'm cp',
+ occurredAt: new Date(event.occurredAt),
+ expectedActionToolName: 'submit_ move',
+ });
expect(client).toMatchObject({
checkpointNamespace: 'event-actor/fork',
eventActorCheckpointId: 'checkpoint-base',
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
index e20087d6ea..f612ef57a6 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -140,6 +140,7 @@ function getPreliminaryResponseMessageId({ messageId, responseMessageId }) {
function getPreliminaryUserMessage(
{ messageId, parentMessageId, text, quotes, files, manualSkills, alwaysAppliedSkills },
conversationId,
+ subagentTriggerProjection,
) {
if (typeof messageId !== 'string' || messageId.length === 0) {
return null;
@@ -170,6 +171,36 @@ function getPreliminaryUserMessage(
...(Array.isArray(manualSkills) && manualSkills.length > 0 && { manualSkills }),
...(Array.isArray(alwaysAppliedSkills) &&
alwaysAppliedSkills.length > 0 && { alwaysAppliedSkills }),
+ ...(subagentTriggerProjection != null && { subagentTriggerProjection }),
+ };
+}
+
+const DISPLAY_IDENTITY_CONTROLS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu;
+
+function sanitizeEventDisplayIdentity(value) {
+ if (typeof value !== 'string') return undefined;
+ const bounded = Array.from(value).slice(0, 512).join('');
+ const sanitized = bounded.normalize('NFC').replace(DISPLAY_IDENTITY_CONTROLS, ' ').trim();
+ return sanitized.length === 0 ? undefined : Array.from(sanitized).slice(0, 256).join('');
+}
+
+function getAgentEventTriggerProjection(agentEventDelivery) {
+ const event = agentEventDelivery?.event;
+ const occurredAt = new Date(event?.occurredAt);
+ const eventType = sanitizeEventDisplayIdentity(event?.type);
+ const sourceType = sanitizeEventDisplayIdentity(event?.source?.type);
+ if (eventType == null || sourceType == null || Number.isNaN(occurredAt.getTime())) {
+ return undefined;
+ }
+ const expectedActionToolName = sanitizeEventDisplayIdentity(
+ agentEventDelivery?.expectedAction?.toolName,
+ );
+ return {
+ version: 1,
+ eventType,
+ sourceType,
+ occurredAt,
+ ...(expectedActionToolName == null ? {} : { expectedActionToolName }),
};
}
@@ -271,7 +302,7 @@ async function saveErrorTurn(
alwaysAppliedSkills: req.body.alwaysAppliedSkills,
}),
}
- : getPreliminaryUserMessage(req.body, conversationId);
+ : getPreliminaryUserMessage(req.body, conversationId, req._agentEventTriggerProjection);
if (!userMessage) {
return;
}
@@ -1271,6 +1302,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
rawAgentEventDelivery.deliveryKey === clientRequestId
? rawAgentEventDelivery
: undefined;
+ req._agentEventTriggerProjection = getAgentEventTriggerProjection(agentEventDelivery);
try {
logger.debug(`[ResumableAgentController] Creating job`, {
@@ -1285,6 +1317,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const preliminaryUserMessage = getPreliminaryUserMessage(
{ ...req.body, messageId: preallocatedUserMessageId },
conversationId,
+ req._agentEventTriggerProjection,
);
const job = await GenerationJobManager.createJob(streamId, userId, conversationId, {
startupTelemetry,
diff --git a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx
index 98e0af9671..7ab4a52815 100644
--- a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx
+++ b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx
@@ -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();
@@ -216,6 +216,12 @@ describe('SubagentActivity', () => {
},
);
+ it('does not repeat the completed lifecycle in the conversation body', () => {
+ render();
+
+ expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
+ });
+
it('reports when the durable control history is bounded', () => {
render();
diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx
index 832565fffd..350eda3536 100644
--- a/client/src/components/Chat/Subagents/SubagentActivity.tsx
+++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx
@@ -357,11 +357,6 @@ export function SubagentActivityContent({
{localize('com_ui_subagent_control_history_truncated')}
)}
- {activity.activityTruncated === true && (
-
- {localize('com_ui_subagent_thread_history_truncated')}
-
- )}
{showDetailTruncationNotice && activityDetailsTruncated && (
{localize('com_ui_subagent_activity_details_truncated')}
@@ -406,11 +401,12 @@ export default function SubagentActivity({
showPrompt?: boolean;
onCancelControl?: (controlId: string) => void;
}) {
- const statusHeader = (
-
-
-
- );
+ const statusHeader =
+ activity.status === 'completed' ? null : (
+
+
+
+ );
const content = (
({
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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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();
});
});
diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx
index ae2e61914e..cc841d4c66 100644
--- a/client/src/components/Chat/Subagents/SubagentConversation.tsx
+++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx
@@ -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 (
+
+
+ {label}
+
+ );
+ }
+ return (
+
+
+
+
+
+
+ - {localize('com_ui_subagent_event_type')}
+ - {details.eventType}
+ - {localize('com_ui_subagent_event_source')}
+ - {details.sourceType}
+ - {localize('com_ui_subagent_event_received')}
+ -
+ {new Date(details.occurredAt).toLocaleString()}
+
+ {details.expectedActionToolName != null && (
+ <>
+ - {localize('com_ui_subagent_event_expected_action')}
+ - {details.expectedActionToolName}
+ >
+ )}
+
+
+
+ );
+}
+
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 ;
+ }
return (
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' && (
+
+ {turn.activity.activityTruncated === true &&
+ onLoadDetails != null &&
+ detailState !== 'unavailable' ? (
+
+ ) : (
+ localize('com_ui_subagent_activity_details_unavailable')
+ )}
+
+ )}
+ {detailState === 'loading' && (
+
+ {localize('com_ui_loading')}
+
+ )}
);
}
@@ -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;
controllableTaskId?: string;
onCancelControl?: (taskId: string, controlId: string) => void;
+ detailStateByTask?: ReadonlyMap;
+ onLoadTurnDetails?: (taskId: string) => void;
}) {
- const localize = useLocalize();
const fullWidth = useRecoilValue(store.maximizeChatSpace);
- const hasShortenedDetails = turns.some((turn) => hasTruncatedActivityDetails(turn.activity));
return (
- {hasShortenedDetails && (
-
- {localize('com_ui_subagent_activity_details_truncated')}
-
- )}
{turns.map((turn) => (
onCancelControl(turn.taskId, controlId)
}
+ detailState={detailStateByTask?.get(turn.taskId)}
+ onLoadDetails={
+ turn.activity.activityTruncated !== true || onLoadTurnDetails == null
+ ? undefined
+ : () => onLoadTurnDetails(turn.taskId)
+ }
/>
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
index b2a4459fc3..ba4177aaf6 100644
--- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
@@ -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();
let mockParentChildrenByThread = new Map();
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;
+ detailStateByTask?: ReadonlyMap;
+ onLoadTurnDetails?: (taskId: string) => void;
}) => (
({
{turn.activity.items.map((item, index) => (
{item.text}
))}
+ {turn.activity.activityTruncated === true && onLoadTurnDetails != null && (
+
+ )}
+ {detailStateByTask?.get(turn.taskId)}
))}
@@ -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', () => {
,
);
- 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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((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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+ expect(
+ screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
+ ).toBeInTheDocument();
+
+ latestView = {
+ ...latestView,
+ historyTruncated: false,
+ nextCursor: 'new-boundary:assistant',
+ };
+ rerender(
+
+
+ ,
+ );
+
+ 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((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(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'load-task-a' }));
+ rerender(
+
+
+ ,
+ );
+ rerender(
+
+
+ ,
+ );
+ 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((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(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_load_earlier_activity' }));
+ rerender(
+
+
+ ,
+ );
+ rerender(
+
+
+ ,
+ );
+ 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((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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+ expect(screen.getByText('Old result')).toBeInTheDocument();
+
+ latestView = {
+ ...latestView,
+ nextCursor: 'task:assistant',
+ turns: [makeTurn('task', 'Current result'), makeTurn('new', 'New result')],
+ };
+ rerender(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ expect(screen.getAllByTestId('shared-activity')).toHaveLength(1);
+ expect(
+ screen.getByRole('status', { name: 'com_ui_subagent_thread_history_truncated' }),
+ ).toHaveTextContent('•••');
});
});
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
index ae46f2c321..fcd0b732af 100644
--- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
@@ -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>(),
+ );
+ const [turnDetailStates, setTurnDetailStates] = useState(
+ () => new Map(),
+ );
+ const [olderTurns, setOlderTurns] = useState>(
+ [],
+ );
+ const [movingWindowTurns, setMovingWindowTurns] = useState<
+ ReturnType
+ >([]);
+ const [rebaseTurns, setRebaseTurns] = useState>(
+ [],
+ );
+ const [postRebaseTurns, setPostRebaseTurns] = useState<
+ ReturnType
+ >([]);
+ const postRebaseTurnsRef = useRef(postRebaseTurns);
+ const [historyRebaseActive, setHistoryRebaseActive] = useState(false);
+ const historyRebaseActiveRef = useRef(historyRebaseActive);
+ const [historyCursor, setHistoryCursor] = useState(undefined);
+ const [historyCursorGeneration, setHistoryCursorGeneration] = useState();
+ 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());
+ const historyRequestRef = useRef(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
);
- } else if (eventSummary?.tasksTruncated) {
+ } else if (eventSummary?.tasksTruncated === true) {
timelinePrefix = (
- {localize('com_ui_subagent_thread_history_truncated')}
+ •••
);
}
@@ -645,12 +957,40 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
if (hasConversationProjection) {
activityPanel = (
- {data?.historyTruncated === true && (
+ {showUnavailableHistoryBoundary && (
- {localize('com_ui_subagent_thread_history_truncated')}
+ •••
+
+ )}
+ {effectiveHistoryCursor != null && historyState !== 'error' && (
+
+
+
+ )}
+ {historyState === 'error' && (
+
+
)}
submitControl('cancel_message', controlId)
}
+ detailStateByTask={effectiveTurnDetailStates}
+ onLoadTurnDetails={loadTurnDetails}
/>
);
- } else if (selection.event != null && (eventSummary?.tasks.length ?? 0) > 1) {
+ } else if (
+ selection.event != null &&
+ ((eventSummary?.tasks.length ?? 0) > 1 || eventSummary?.tasksTruncated === true)
+ ) {
activityPanel = (
@@ -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"
>
-