mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-29 05:20:49 +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
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue