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

- {activity.title} -

- {data?.depth != null && ( -
- {localize('com_ui_subagent_depth', { 0: String(data.depth) })} -
+ {selection.event != null && eventSiblings.length > 1 ? ( + + ) : ( +

+ {panelTitle} +

)}
- {selection.host === 'conversation' && ( - - )} {canContinueAsChat && (
- {selection.event != null && ( -
-
- -
-
- )} - {/* Keep the foreground panel's existing nested-tool approval controls coordinated within this invocation. Detached activity projections never include approval payloads. */} diff --git a/client/src/components/Chat/Subagents/adapters.test.ts b/client/src/components/Chat/Subagents/adapters.test.ts index 8c0c162bea..fb7a0b77a1 100644 --- a/client/src/components/Chat/Subagents/adapters.test.ts +++ b/client/src/components/Chat/Subagents/adapters.test.ts @@ -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), + ); + }); }); diff --git a/client/src/components/Chat/Subagents/adapters.ts b/client/src/components/Chat/Subagents/adapters.ts index 5b323adcb0..ba45420448 100644 --- a/client/src/components/Chat/Subagents/adapters.ts +++ b/client/src/components/Chat/Subagents/adapters.ts @@ -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(); + 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); +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b581c965b9..0563fd6dee 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/packages/api/src/agents/view.spec.ts b/packages/api/src/agents/view.spec.ts index a4570d4b22..c306c6ac05 100644 --- a/packages/api/src/agents/view.spec.ts +++ b/packages/api/src/agents/view.spec.ts @@ -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), diff --git a/packages/api/src/agents/view.ts b/packages/api/src/agents/view.ts index 7fe00a70a7..2037825a02 100644 --- a/packages/api/src/agents/view.ts +++ b/packages/api/src/agents/view.ts @@ -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; } diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 49c80fdced..156b6403f2 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -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) => diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 4192c66900..2b7262f3ec 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -1010,8 +1010,9 @@ export function getSubagentThread( parentConversationId: string, threadId: string, taskId?: string, + cursor?: string, ): Promise { - return request.get(endpoints.subagentThread(parentConversationId, threadId, taskId)); + return request.get(endpoints.subagentThread(parentConversationId, threadId, taskId, cursor)); } export function controlSubagentTask( diff --git a/packages/data-provider/src/types/subagents.ts b/packages/data-provider/src/types/subagents.ts index 2aa70fab00..2adc929b04 100644 --- a/packages/data-provider/src/types/subagents.ts +++ b/packages/data-provider/src/types/subagents.ts @@ -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; }; diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 5ca6d060cf..6732b3eb98 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -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, { diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index e03b40b21c..0c9a38755e 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -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; @@ -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 { @@ -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>(); + 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([ - { $match: baseMatch }, + { $match: pageMatch }, { $sort: { createdAt: -1, _id: -1 } }, { $limit: input.limit }, { $project: boundedMessageProjection }, ]); const recentSourcesPromise = Message.aggregate([ - { $match: baseMatch }, + { $match: pageMatch }, { $sort: { createdAt: -1, _id: -1 } }, { $limit: SUBAGENT_ACTIVITY_SOURCE_CANDIDATE_LIMIT }, { diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index 83775b7de4..b1ad4458eb 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -166,6 +166,19 @@ const messageSchema: Schema = 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: { diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index f8a963911b..69fd580630 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -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;