🧅 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

@ -490,6 +490,9 @@ class BaseClient {
sender: 'User',
text,
isCreatedByUser: true,
...(this.options?.req?._agentEventTriggerProjection != null && {
subagentTriggerProjection: this.options.req._agentEventTriggerProjection,
}),
};
}

View file

@ -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 = '';

View file

@ -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',

View file

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

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",

View file

@ -243,6 +243,7 @@ describe('subagent thread parent-scoped view', () => {
]);
expect(JSON.stringify(view)).not.toContain('abandoned');
expect(view.historyTruncated).toBe(true);
expect(view.historyUnavailable).toBe(true);
});
it('labels a retained continuation honestly when its task ancestor was truncated', async () => {
@ -601,6 +602,8 @@ describe('subagent thread parent-scoped view', () => {
SUBAGENT_THREAD_VIEW_LIMITS.responseBytes,
);
expect(view.historyTruncated).toBe(true);
const firstRetainedTask = Number(view.turns[0].taskId.replace('task-', ''));
expect(view.nextCursor).toBe(`task-${firstRetainedTask - 1}:assistant`);
});
it('requires tenantless messages when the authenticated request has no tenant', async () => {
@ -1091,6 +1094,13 @@ describe('parent child-thread index', () => {
text: 'Safe instruction. {"privateRoutingKey":"must-not-leak"}',
textProjectionTruncated: true,
createdAt: new Date('2026-08-21T11:00:00.000Z'),
subagentTriggerProjection: {
version: 1,
eventType: 'chess.turn.ready',
sourceType: 'speed-chess',
occurredAt: new Date('2026-08-21T10:59:00.000Z'),
expectedActionToolName: 'submit_move',
},
},
]);
const handler = createSubagentThreadViewHandler({
@ -1108,7 +1118,16 @@ describe('parent child-thread index', () => {
turns: [
expect.objectContaining({
taskId: 'delivery-1',
trigger: expect.objectContaining({ kind: 'external_event', summary: '' }),
trigger: expect.objectContaining({
kind: 'external_event',
summary: '',
externalEvent: {
eventType: 'chess.turn.ready',
sourceType: 'speed-chess',
occurredAt: '2026-08-21T10:59:00.000Z',
expectedActionToolName: 'submit_move',
},
}),
activity: [
expect.objectContaining({
type: 'tool',
@ -1134,6 +1153,77 @@ describe('parent child-thread index', () => {
);
});
it('anchors an older page through an exact scoped task-message cursor', async () => {
const olderInput = {
...message('older:user', 'running', true),
parentMessageId: null,
} as IMessage;
const olderAssistant = {
...message('older:assistant', 'completed'),
parentMessageId: 'older:user',
} as IMessage;
const getMessagesForSubagentThreadView = jest
.fn()
.mockResolvedValue([olderAssistant, olderInput]);
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest.fn().mockResolvedValue(child),
getMessagesForSubagentThreadView,
});
const { response, json } = createResponse();
await handler(createRequest({}, { cursor: 'newer:user' }), response);
expect(getMessagesForSubagentThreadView).toHaveBeenCalledWith(
expect.objectContaining({ beforeMessageId: 'newer:user' }),
);
expect(json.mock.calls[0][0].turns).toEqual([expect.objectContaining({ taskId: 'older' })]);
});
it('marks a vanished inclusive history cursor as unavailable', async () => {
const getMessagesForSubagentThreadView = jest.fn().mockResolvedValue([]);
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest.fn().mockResolvedValue(child),
getMessagesForSubagentThreadView,
});
const { response, json } = createResponse();
await handler(createRequest({}, { cursor: 'vanished:assistant' }), response);
expect(getMessagesForSubagentThreadView).toHaveBeenCalledWith(
expect.objectContaining({ beforeMessageId: 'vanished:assistant' }),
);
expect(json.mock.calls[0][0]).toEqual(
expect.objectContaining({
historyTruncated: true,
historyUnavailable: true,
}),
);
expect(json.mock.calls[0][0]).not.toHaveProperty('nextCursor');
});
it('rejects malformed or combined history cursors before storage access', async () => {
const getMessagesForSubagentThreadView = jest.fn();
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn(),
getSubagentThreadForParent: jest.fn(),
getMessagesForSubagentThreadView,
});
const malformed = createResponse();
const combined = createResponse();
await handler(createRequest({}, { cursor: 'private-routing-id' }), malformed.response);
await handler(
createRequest({}, { cursor: 'older:user', taskId: 'selected-task' }),
combined.response,
);
expect(malformed.status).toHaveBeenCalledWith(404);
expect(combined.status).toHaveBeenCalledWith(404);
expect(getMessagesForSubagentThreadView).not.toHaveBeenCalled();
});
it('projects ordinary and event children together without exposing event delivery identity', async () => {
const handler = createParentSubagentIndexHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),

View file

@ -56,6 +56,13 @@ type SubagentThreadViewParams = {
threadId?: string;
};
const validTaskMessageId = (value: unknown): value is string => {
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_PUBLIC_ID_BYTES) {
return false;
}
return taskIdFromMessageId(value) != null;
};
const validConversationId = (value: string | undefined): value is string =>
value != null && value.trim() !== '' && value.length <= 256;
@ -337,6 +344,31 @@ const publicThreadTurns = (
summary: eventThread ? '' : (input?.text ?? ''),
...(input?.createdAt == null ? {} : { createdAt: input.createdAt }),
...(!eventThread && input?.textTruncated === true ? { summaryTruncated: true } : {}),
...(eventThread &&
record.input?.subagentTriggerProjection?.version === 1 &&
isoDate(record.input.subagentTriggerProjection.occurredAt) != null
? {
externalEvent: {
eventType: truncateUtf8(
record.input.subagentTriggerProjection.eventType,
MAX_PUBLIC_ID_BYTES,
).text,
sourceType: truncateUtf8(
record.input.subagentTriggerProjection.sourceType,
MAX_PUBLIC_ID_BYTES,
).text,
occurredAt: isoDate(record.input.subagentTriggerProjection.occurredAt)!,
...(record.input.subagentTriggerProjection.expectedActionToolName == null
? {}
: {
expectedActionToolName: truncateUtf8(
record.input.subagentTriggerProjection.expectedActionToolName,
MAX_PUBLIC_ID_BYTES,
).text,
}),
},
}
: {}),
},
status: publicStatus(
[record.assistant, record.input].filter(
@ -537,12 +569,15 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
const tenantId = req.user?.tenantId || undefined;
const { parentConversationId, threadId } = req.params as SubagentThreadViewParams;
const requestedTaskId = req.query?.taskId;
const historyCursor = req.query?.cursor;
if (
!userId ||
!validConversationId(parentConversationId) ||
!validConversationId(threadId) ||
parentConversationId === threadId ||
(requestedTaskId != null && !validTaskId(requestedTaskId))
(requestedTaskId != null && !validTaskId(requestedTaskId)) ||
(historyCursor != null && !validTaskMessageId(historyCursor)) ||
(requestedTaskId != null && historyCursor != null)
) {
notFound(res);
return;
@ -577,6 +612,7 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
user: userId,
...(tenantId == null ? {} : { tenantId }),
...(requestedTaskId == null ? {} : { selectedTaskId: requestedTaskId }),
...(historyCursor == null ? {} : { beforeMessageId: historyCursor }),
limit: MAX_THREAD_MESSAGES + 1,
textCodePointLimit: MAX_MESSAGE_TEXT_PROJECTION_CODE_POINTS,
});
@ -584,11 +620,21 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
let historyTruncated = messages.length > MAX_THREAD_MESSAGES;
const newestFirst = messages.slice(0, MAX_THREAD_MESSAGES);
const branch = canonicalThreadBranch(newestFirst);
if (branch.length < newestFirst.length) historyTruncated = true;
/** A valid inclusive cursor returns at least its anchor. An empty cursor
* page means that the retained chain vanished between requests, so the
* public projection must expose the discontinuity instead of presenting
* the latest page as complete history. */
let historyUnavailable = historyCursor != null && messages.length === 0;
historyUnavailable ||= branch.length < newestFirst.length;
if (historyUnavailable) historyTruncated = true;
const branchRootParentId = branch[0]?.parentMessageId;
if (branchRootParentId != null && taskIdFromMessageId(branchRootParentId) != null) {
historyTruncated = true;
}
const nextCursor =
branchRootParentId != null && validTaskMessageId(branchRootParentId)
? branchRootParentId
: undefined;
const activeLeaseTaskId =
child.subagentThreadLease != null && child.subagentThreadLease.expiresAt > now
? child.subagentThreadLease.taskId
@ -655,6 +701,7 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
const projected = projectedById.get(message.messageId);
return projected == null ? [] : [projected];
});
if (projectedMessages.length < publicSource.length) historyUnavailable = true;
const projectedMessagesById = new Map(
projectedMessages.map((message) => [message.messageId, message]),
);
@ -690,13 +737,23 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
turns,
messages: projectedMessages,
historyTruncated: historyTruncated || projectedMessages.length < publicSource.length,
...(historyUnavailable ? { historyUnavailable: true } : {}),
...(nextCursor == null ? {} : { nextCursor }),
...(isoDate(child.updatedAt) == null ? {} : { updatedAt: isoDate(child.updatedAt) }),
};
const selectedAssistantId =
requestedTaskId == null ? undefined : `${requestedTaskId}:assistant`;
while (Buffer.byteLength(JSON.stringify(view), 'utf8') > MAX_RESPONSE_BYTES) {
if ((view.turns?.length ?? 0) > 1) {
view.turns?.shift();
const removedTurn = view.turns?.shift();
const removedTurnAnchor = [...branch]
.reverse()
.find(
(message) =>
removedTurn != null &&
taskIdFromMessageId(message.messageId) === removedTurn.taskId,
)?.messageId;
if (validTaskMessageId(removedTurnAnchor)) view.nextCursor = removedTurnAnchor;
view.historyTruncated = true;
continue;
}

View file

@ -119,9 +119,15 @@ export const conversationById = (id: string) => `${conversationsRoot}/${id}`;
export const parentSubagents = (parentConversationId: string) =>
`${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents`;
export const subagentThread = (parentConversationId: string, threadId: string, taskId?: string) => {
export const subagentThread = (
parentConversationId: string,
threadId: string,
taskId?: string,
cursor?: string,
) => {
const endpoint = `${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents/${encodeURIComponent(threadId)}`;
return taskId == null ? endpoint : `${endpoint}?taskId=${encodeURIComponent(taskId)}`;
if (taskId != null) return `${endpoint}?taskId=${encodeURIComponent(taskId)}`;
return cursor == null ? endpoint : `${endpoint}?cursor=${encodeURIComponent(cursor)}`;
};
export const subagentControl = (parentConversationId: string, threadId: string) =>

View file

@ -1010,8 +1010,9 @@ export function getSubagentThread(
parentConversationId: string,
threadId: string,
taskId?: string,
cursor?: string,
): Promise<t.SubagentThreadView> {
return request.get(endpoints.subagentThread(parentConversationId, threadId, taskId));
return request.get(endpoints.subagentThread(parentConversationId, threadId, taskId, cursor));
}
export function controlSubagentTask(

View file

@ -122,6 +122,13 @@ export type SubagentThreadTriggerKind =
| 'parent_continuation'
| 'external_event';
export type SubagentExternalEventDetails = {
eventType: string;
sourceType: string;
occurredAt: string;
expectedActionToolName?: string;
};
/**
* One chronological child execution boundary. The trigger is host-authored,
* while activity and messages are bounded public projections of the child run.
@ -133,6 +140,7 @@ export type SubagentThreadTurn = {
summary: string;
createdAt?: string;
summaryTruncated?: boolean;
externalEvent?: SubagentExternalEventDetails;
};
status: SubagentThreadStatus;
activity: SubagentActivityItem[];
@ -165,5 +173,9 @@ export type SubagentThreadView = {
turns?: SubagentThreadTurn[];
messages: SubagentThreadMessage[];
historyTruncated: boolean;
/** True when some branch rows were omitted and cannot be recovered with `nextCursor`. */
historyUnavailable?: boolean;
/** Opaque task-message cursor for the next older bounded branch page. */
nextCursor?: string;
updatedAt?: string;
};

View file

@ -1092,6 +1092,81 @@ describe('Message Operations', () => {
expect(messages[0]).not.toHaveProperty('conversationId');
});
it('anchors older pages by an exact scoped message without exposing Mongo ids', async () => {
const conversationId = uuidv4();
for (let index = 0; index < 4; index += 1) {
await saveMessage(mockCtx, {
messageId: `task-${index}:assistant`,
conversationId,
text: `Answer ${index}`,
user: 'user123',
createdAt: new Date(Date.UTC(2026, 7, 21, 12, index)),
});
}
await saveMessage(mockCtx, {
messageId: 'foreign:user',
conversationId: uuidv4(),
text: 'Foreign anchor',
user: 'user123',
});
const page = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
beforeMessageId: 'task-2:assistant',
limit: 2,
textCodePointLimit: 8_192,
});
const foreign = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
beforeMessageId: 'foreign:user',
limit: 2,
textCodePointLimit: 8_192,
});
expect(page.map((message) => message.messageId)).toEqual([
'task-2:assistant',
'task-1:assistant',
]);
expect(page[0]).not.toHaveProperty('_id');
expect(foreign).toEqual([]);
});
it('projects only the bounded display-safe external event identity', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'event:user',
conversationId,
text: 'Private event payload',
user: 'user123',
subagentTriggerProjection: {
version: 1,
eventType: 'chess.turn.ready',
sourceType: 'speed-chess',
occurredAt: new Date('2026-08-21T12:00:00.000Z'),
expectedActionToolName: 'submit_move',
},
});
const [projected] = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
limit: 1,
textCodePointLimit: 8_192,
});
expect(projected.subagentTriggerProjection).toEqual({
version: 1,
eventType: 'chess.turn.ready',
sourceType: 'speed-chess',
occurredAt: new Date('2026-08-21T12:00:00.000Z'),
expectedActionToolName: 'submit_move',
});
expect(projected.subagentTriggerProjection).not.toHaveProperty('deliveryId');
expect(projected.subagentTriggerProjection).not.toHaveProperty('sourceId');
});
it('projects bounded private transcripts for the retained linear history', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {

View file

@ -366,6 +366,7 @@ export type SubagentThreadViewMessageRecord = Pick<
| 'error'
| 'unfinished'
| 'subagentTranscript'
| 'subagentTriggerProjection'
> & {
textProjectionTruncated?: boolean;
subagentTranscriptProjectionTruncated?: boolean;
@ -499,6 +500,7 @@ export interface MessageMethods {
conversationId: string;
tenantId?: string;
selectedTaskId?: string;
beforeMessageId?: string;
limit: number;
textCodePointLimit: number;
}): Promise<SubagentThreadViewMessageRecord[]>;
@ -1462,6 +1464,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
conversationId: string;
tenantId?: string;
selectedTaskId?: string;
beforeMessageId?: string;
limit: number;
textCodePointLimit: number;
}): Promise<SubagentThreadViewMessageRecord[]> {
@ -1855,6 +1858,36 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
],
},
subagentTask: boundedSubagentTask,
subagentTriggerProjection: {
$cond: [
{ $eq: ['$subagentTriggerProjection.version', 1] },
{
version: 1,
eventType: boundedString(
'$subagentTriggerProjection.eventType',
SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT,
),
sourceType: boundedString(
'$subagentTriggerProjection.sourceType',
SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT,
),
occurredAt: '$subagentTriggerProjection.occurredAt',
expectedActionToolName: {
$cond: [
{
$eq: [{ $type: '$subagentTriggerProjection.expectedActionToolName' }, 'string'],
},
boundedString(
'$subagentTriggerProjection.expectedActionToolName',
SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT,
),
'$$REMOVE',
],
},
},
'$$REMOVE',
],
},
};
const sourceMetadataProjection = {
_subagentTranscriptSourceBytes: transcriptJsonBytes,
@ -1894,17 +1927,34 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
? { tenantId: { $exists: false } }
: { tenantId: input.tenantId }),
};
const anchor =
input.beforeMessageId == null
? null
: await Message.findOne({ ...baseMatch, messageId: input.beforeMessageId })
.select('_id createdAt')
.lean<Pick<IMessage, '_id' | 'createdAt'>>();
if (input.beforeMessageId != null && anchor == null) return [];
const pageMatch =
anchor == null
? baseMatch
: {
...baseMatch,
$or: [
{ createdAt: { $lt: anchor.createdAt } },
{ createdAt: anchor.createdAt, _id: { $lte: anchor._id } },
],
};
/** Keep rows as independent MongoDB results. A `$facet` would combine the
* complete page into one BSON document and could exceed MongoDB's 16 MiB
* document limit before the API applies its smaller public byte budget. */
const messagesPromise = Message.aggregate<SubagentThreadViewMessageRecord>([
{ $match: baseMatch },
{ $match: pageMatch },
{ $sort: { createdAt: -1, _id: -1 } },
{ $limit: input.limit },
{ $project: boundedMessageProjection },
]);
const recentSourcesPromise = Message.aggregate<ActivitySourceProjection>([
{ $match: baseMatch },
{ $match: pageMatch },
{ $sort: { createdAt: -1, _id: -1 } },
{ $limit: SUBAGENT_ACTIVITY_SOURCE_CANDIDATE_LIMIT },
{

View file

@ -166,6 +166,19 @@ const messageSchema: Schema<IMessage> = new Schema(
select: false,
default: undefined,
},
/** Bounded, display-safe identity for an event-authored child turn. */
subagentTriggerProjection: {
type: {
version: { type: Number, enum: [1], required: true },
eventType: { type: String, required: true },
sourceType: { type: String, required: true },
occurredAt: { type: Date, required: true },
expectedActionToolName: { type: String },
},
_id: false,
select: false,
default: undefined,
},
/** Durable, server-only marker used to make detached retries at-most-once. */
subagentTask: {
type: {

View file

@ -19,6 +19,14 @@ export type SubagentTaskControlReceiptStatus =
| 'rejected'
| 'failed';
export type SubagentTriggerProjection = {
version: 1;
eventType: string;
sourceType: string;
occurredAt: Date;
expectedActionToolName?: string;
};
/** Server-private durable receipt for one parent-to-child control invocation. */
export interface ISubagentTaskControlReceipt {
invocationId: string;
@ -108,6 +116,7 @@ export interface IMessage extends Document {
};
controlReceipts?: ISubagentTaskControlReceipt[];
};
subagentTriggerProjection?: SubagentTriggerProjection;
contextMeta?: {
calibrationRatio?: number;
encoding?: string;