mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-23 02:26:20 +00:00
🧅 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:
parent
151dc9e03e
commit
41ba808e6f
22 changed files with 2025 additions and 134 deletions
|
|
@ -490,6 +490,9 @@ class BaseClient {
|
|||
sender: 'User',
|
||||
text,
|
||||
isCreatedByUser: true,
|
||||
...(this.options?.req?._agentEventTriggerProjection != null && {
|
||||
subagentTriggerProjection: this.options.req._agentEventTriggerProjection,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('•••');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue