mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🎛️ feat: Expose Authoritative Subagent Controls (#15169)
* feat: expose authoritative subagent controls * fix: reconcile subagent control races * fix: reconcile durable control conflicts * fix: preserve authoritative subagent control outcomes * fix: fence subagent controls to child thread * fix: validate subagent control targets before routing * fix: close subagent control boundary gaps * fix: keep control reservations private * fix: close subagent control admission gaps * fix: preserve authoritative control history * style: sort subagent control imports * fix: preserve authoritative subagent control retries * style: sort control state imports
This commit is contained in:
parent
d641c398d5
commit
69e7c73614
20 changed files with 2460 additions and 27 deletions
|
|
@ -216,6 +216,12 @@ describe('SubagentActivity', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('reports when the durable control history is bounded', () => {
|
||||
render(<SubagentActivity activity={{ ...base, controlsTruncated: true }} />);
|
||||
|
||||
expect(screen.getByText('com_ui_subagent_control_history_truncated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves question input-validation failure for the regular renderer', () => {
|
||||
render(
|
||||
<SubagentActivity
|
||||
|
|
@ -326,6 +332,100 @@ describe('SubagentActivity', () => {
|
|||
expect(screen.getByText('Prepared the release')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders command receipts separately from child status and allows an accepted withdrawal', () => {
|
||||
const onCancelControl = jest.fn();
|
||||
render(
|
||||
<SubagentActivity
|
||||
activity={{
|
||||
...base,
|
||||
status: 'running',
|
||||
controls: [
|
||||
{
|
||||
invocationId: 'submitted',
|
||||
action: 'steer',
|
||||
status: 'submitted',
|
||||
createdAt: '2026-08-24T12:00:00.000Z',
|
||||
updatedAt: '2026-08-24T12:00:00.000Z',
|
||||
message: 'Check the source.',
|
||||
},
|
||||
{
|
||||
invocationId: 'accepted',
|
||||
controlId: 'control-1',
|
||||
action: 'queue',
|
||||
status: 'accepted',
|
||||
createdAt: '2026-08-24T12:00:01.000Z',
|
||||
updatedAt: '2026-08-24T12:00:01.000Z',
|
||||
message: 'Add a citation.',
|
||||
messageTruncated: true,
|
||||
},
|
||||
{
|
||||
invocationId: 'applied',
|
||||
action: 'interrupt',
|
||||
status: 'applied',
|
||||
createdAt: '2026-08-24T12:00:02.000Z',
|
||||
updatedAt: '2026-08-24T12:00:03.000Z',
|
||||
boundary: 'preempt',
|
||||
},
|
||||
{
|
||||
invocationId: 'rejected',
|
||||
action: 'steer',
|
||||
status: 'rejected',
|
||||
createdAt: '2026-08-24T12:00:04.000Z',
|
||||
updatedAt: '2026-08-24T12:00:05.000Z',
|
||||
reason: 'task_completed',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCancelControl={onCancelControl}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('com_ui_subagent_thread_status_running')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_status_submitted')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_status_accepted')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_status_applied')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_status_rejected')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_message_truncated')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_reason_task_completed')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_control_withdraw' }));
|
||||
expect(onCancelControl).toHaveBeenCalledWith('control-1');
|
||||
});
|
||||
|
||||
it('renders storage-prioritized control receipts in chronological order', () => {
|
||||
render(
|
||||
<SubagentActivity
|
||||
activity={{
|
||||
...base,
|
||||
controls: [
|
||||
{
|
||||
invocationId: 'new-accepted',
|
||||
controlId: 'control-2',
|
||||
action: 'queue',
|
||||
status: 'accepted',
|
||||
createdAt: '2026-08-24T12:00:02.000Z',
|
||||
updatedAt: '2026-08-24T12:00:02.000Z',
|
||||
},
|
||||
{
|
||||
invocationId: 'old-applied',
|
||||
controlId: 'control-1',
|
||||
action: 'steer',
|
||||
status: 'applied',
|
||||
createdAt: '2026-08-24T12:00:01.000Z',
|
||||
updatedAt: '2026-08-24T12:00:03.000Z',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const applied = screen.getByText('com_ui_subagent_control_status_applied');
|
||||
const accepted = screen.getByText('com_ui_subagent_control_status_accepted');
|
||||
expect(
|
||||
applied.compareDocumentPosition(accepted) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('scopes regular-chat renderer state to the selected child activity', () => {
|
||||
render(<SubagentActivity activity={base} activityId="parent:tool:child" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@librechat/client';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { ArrowDown, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { ArrowDown, CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-react';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { ChildActivity, ChildActivityItem } from './adapters';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import { subagentStatusIcon, subagentStatusLabelKey } from './status';
|
||||
|
|
@ -13,6 +14,106 @@ import { useLocalize } from '~/hooks';
|
|||
import { cn } from '~/utils';
|
||||
|
||||
const AT_BOTTOM_THRESHOLD_PX = 120;
|
||||
const CONTROL_ACTION_LABELS = {
|
||||
steer: 'com_ui_subagent_control_steer',
|
||||
queue: 'com_ui_subagent_control_queue',
|
||||
interrupt: 'com_ui_subagent_control_interrupt',
|
||||
cancel: 'com_ui_subagent_control_cancel',
|
||||
cancel_message: 'com_ui_subagent_control_cancel_message',
|
||||
} as const satisfies Record<string, TranslationKeys>;
|
||||
const CONTROL_STATUS_LABELS = {
|
||||
submitted: 'com_ui_subagent_control_status_submitted',
|
||||
accepted: 'com_ui_subagent_control_status_accepted',
|
||||
applied: 'com_ui_subagent_control_status_applied',
|
||||
rejected: 'com_ui_subagent_control_status_rejected',
|
||||
failed: 'com_ui_subagent_control_status_failed',
|
||||
} as const satisfies Record<string, TranslationKeys>;
|
||||
const CONTROL_REASON_LABELS: Record<string, TranslationKeys> = {
|
||||
control_not_found: 'com_ui_subagent_control_reason_control_not_found',
|
||||
invalid_command: 'com_ui_subagent_control_reason_invalid_command',
|
||||
owner_unavailable: 'com_ui_subagent_control_reason_owner_unavailable',
|
||||
task_inaccessible: 'com_ui_subagent_control_reason_task_inaccessible',
|
||||
task_cancelled: 'com_ui_subagent_control_reason_task_cancelled',
|
||||
task_completed: 'com_ui_subagent_control_reason_task_completed',
|
||||
task_failed: 'com_ui_subagent_control_reason_task_failed',
|
||||
task_not_running: 'com_ui_subagent_control_reason_task_not_running',
|
||||
withdrawn: 'com_ui_subagent_control_reason_withdrawn',
|
||||
};
|
||||
|
||||
function SubagentControlHistory({
|
||||
controls,
|
||||
onCancelControl,
|
||||
}: {
|
||||
controls: NonNullable<ChildActivity['controls']>;
|
||||
onCancelControl?: (controlId: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
if (controls.length === 0) return null;
|
||||
/** Storage keeps actionable accepted receipts ahead of bounded terminal history.
|
||||
* Presentation restores chronology without changing that retention priority. */
|
||||
const chronologicalControls = controls
|
||||
.map((control, index) => ({ control, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.control.createdAt.localeCompare(right.control.createdAt) || left.index - right.index,
|
||||
)
|
||||
.map(({ control }) => control);
|
||||
return (
|
||||
<section aria-label={localize('com_ui_subagent_control_history')} className="mb-3 space-y-2">
|
||||
{chronologicalControls.map((control) => {
|
||||
const pending = control.status === 'submitted' || control.status === 'accepted';
|
||||
let StatusIcon = XCircle;
|
||||
if (pending) StatusIcon = Clock3;
|
||||
if (control.status === 'applied') StatusIcon = CheckCircle2;
|
||||
return (
|
||||
<div
|
||||
key={control.invocationId}
|
||||
className="rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon size={14} aria-hidden className="shrink-0 text-text-secondary" />
|
||||
<span className="font-medium">{localize(CONTROL_ACTION_LABELS[control.action])}</span>
|
||||
<span className="ml-auto text-xs text-text-secondary" aria-live="polite">
|
||||
{localize(CONTROL_STATUS_LABELS[control.status])}
|
||||
</span>
|
||||
</div>
|
||||
{control.message != null && control.message !== '' && (
|
||||
<div className="mt-1 break-words text-text-secondary">
|
||||
{control.message}
|
||||
{control.messageTruncated === true && (
|
||||
<span className="ml-1 text-xs italic">
|
||||
{localize('com_ui_subagent_control_message_truncated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{control.reason != null && (
|
||||
<div className="mt-1 text-xs text-status-error">
|
||||
{localize(
|
||||
CONTROL_REASON_LABELS[control.reason] ??
|
||||
'com_ui_subagent_control_reason_invalid_command',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{control.status === 'accepted' &&
|
||||
control.controlId != null &&
|
||||
onCancelControl != null && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-7 px-2 text-xs"
|
||||
onClick={() => onCancelControl(control.controlId as string)}
|
||||
>
|
||||
{localize('com_ui_subagent_control_withdraw')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubagentActivityScrollSurface({
|
||||
children,
|
||||
|
|
@ -182,11 +283,13 @@ export default function SubagentActivity({
|
|||
activityId,
|
||||
state = 'ready',
|
||||
embedded = false,
|
||||
onCancelControl,
|
||||
}: {
|
||||
activity: ChildActivity;
|
||||
activityId?: string;
|
||||
state?: 'ready' | 'loading' | 'error';
|
||||
embedded?: boolean;
|
||||
onCancelControl?: (controlId: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
|
||||
|
|
@ -256,6 +359,15 @@ export default function SubagentActivity({
|
|||
const content = (
|
||||
<div className="flex max-w-full flex-col gap-0">
|
||||
{activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
|
||||
<SubagentControlHistory
|
||||
controls={activity.controls ?? []}
|
||||
onCancelControl={onCancelControl}
|
||||
/>
|
||||
{activity.controlsTruncated === true && (
|
||||
<div className="mb-3 text-xs italic text-text-secondary">
|
||||
{localize('com_ui_subagent_control_history_truncated')}
|
||||
</div>
|
||||
)}
|
||||
{activityTruncated && (
|
||||
<div className="mb-3 text-xs italic text-text-secondary">
|
||||
{localize('com_ui_subagent_thread_history_truncated')}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot, useRecoilValue } from 'recoil';
|
||||
import { ContentTypes, ForkOptions } from 'librechat-data-provider';
|
||||
import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type {
|
||||
ParentSubagentSummary,
|
||||
|
|
@ -19,6 +19,7 @@ import SubagentThreadPanel from './SubagentThreadPanel';
|
|||
const mockUseSubagentThreadQuery = jest.fn();
|
||||
const mockUseSubagentActivityStream = jest.fn();
|
||||
const mockForkMutate = jest.fn();
|
||||
const mockControlMutate = jest.fn();
|
||||
const mockNavigateToConvo = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockApprovalProviderMounted = jest.fn();
|
||||
|
|
@ -43,6 +44,17 @@ jest.mock('~/data-provider', () => ({
|
|||
mutate: (payload: unknown) => mockForkMutate(payload, options),
|
||||
isLoading: false,
|
||||
}),
|
||||
useSubagentControlMutation: (options: {
|
||||
onSuccess: (result: unknown, variables: unknown) => void;
|
||||
onError: (error: unknown, variables: unknown) => void;
|
||||
}) => ({
|
||||
mutate: (variables: unknown) =>
|
||||
mockControlMutate(variables, {
|
||||
onSuccess: (result: unknown) => options.onSuccess(result, variables),
|
||||
onError: (error: unknown) => options.onError(error, variables),
|
||||
}),
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider/Subagents/useSubagentActivityStream', () => ({
|
||||
|
|
@ -97,21 +109,39 @@ jest.mock('./SubagentActivity', () => ({
|
|||
activity,
|
||||
activityId,
|
||||
state,
|
||||
onCancelControl,
|
||||
}: {
|
||||
activity: { status: string; prompt?: string; items: Array<{ type: string; text?: string }> };
|
||||
activity: {
|
||||
status: string;
|
||||
prompt?: string;
|
||||
items: Array<{ type: string; text?: string }>;
|
||||
controls?: Array<{ invocationId: string; status: string }>;
|
||||
};
|
||||
activityId?: string;
|
||||
state: string;
|
||||
onCancelControl?: (controlId: string) => void;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="shared-activity"
|
||||
data-activity-id={activityId}
|
||||
data-state={state}
|
||||
data-status={activity.status}
|
||||
data-can-withdraw={onCancelControl != null ? 'true' : 'false'}
|
||||
>
|
||||
{activity.prompt}
|
||||
{activity.items.map((item, index) => (
|
||||
<span key={index}>{item.text ?? item.type}</span>
|
||||
))}
|
||||
{activity.controls?.map((control) => (
|
||||
<span key={control.invocationId}>{control.status}</span>
|
||||
))}
|
||||
{onCancelControl != null && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="withdraw-control"
|
||||
onClick={() => onCancelControl('control-1')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
|
@ -120,6 +150,11 @@ jest.mock('@librechat/client', () => {
|
|||
const mockReact = jest.requireActual<typeof import('react')>('react');
|
||||
const MockSelectContext = mockReact.createContext((_value: string): void => {});
|
||||
return {
|
||||
Alert: ({ children, ...props }: React.ComponentProps<'div'>) => (
|
||||
<div role="alert" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
|
|
@ -151,6 +186,7 @@ jest.mock('@librechat/client', () => {
|
|||
</button>
|
||||
);
|
||||
},
|
||||
Textarea: (props: React.ComponentProps<'textarea'>) => <textarea {...props} />,
|
||||
useMediaQuery: () => mockIsMobile,
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
};
|
||||
|
|
@ -159,11 +195,15 @@ jest.mock('@librechat/client', () => {
|
|||
jest.mock('lucide-react', () => ({
|
||||
AlertCircle: () => null,
|
||||
Bot: () => null,
|
||||
CornerDownRight: () => null,
|
||||
CheckCircle2: () => null,
|
||||
Clock3: () => null,
|
||||
ListEnd: () => null,
|
||||
MessagesSquare: () => null,
|
||||
OctagonX: () => null,
|
||||
X: () => null,
|
||||
XCircle: () => null,
|
||||
Zap: () => null,
|
||||
}));
|
||||
|
||||
const selection: ActiveSubagentPanel = {
|
||||
|
|
@ -210,10 +250,12 @@ const completedView: SubagentThreadView = {
|
|||
|
||||
describe('SubagentThreadPanel', () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
mockIsMobile = false;
|
||||
mockApprovalProviderMounted.mockClear();
|
||||
mockApprovalProviderUnmounted.mockClear();
|
||||
mockForkMutate.mockClear();
|
||||
mockControlMutate.mockClear();
|
||||
mockNavigateToConvo.mockClear();
|
||||
mockShowToast.mockClear();
|
||||
mockRefreshParentChildren.mockClear();
|
||||
|
|
@ -270,6 +312,499 @@ describe('SubagentThreadPanel', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('submits one command invocation, blocks duplicate clicks, and shows its receipt', async () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Check the primary source.' },
|
||||
});
|
||||
const queue = screen.getByRole('button', { name: 'com_ui_queue' });
|
||||
fireEvent.click(queue);
|
||||
fireEvent.click(queue);
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(1);
|
||||
const [variables, callbacks] = mockControlMutate.mock.calls[0] as [
|
||||
{
|
||||
parentConversationId: string;
|
||||
threadId: string;
|
||||
command: { taskId: string; invocationId: string; action: string; message: string };
|
||||
},
|
||||
{ onSuccess: (value: unknown) => void },
|
||||
];
|
||||
expect(variables).toEqual({
|
||||
parentConversationId: 'parent-conversation',
|
||||
threadId: 'child-thread',
|
||||
submittedAt: expect.any(String),
|
||||
command: {
|
||||
taskId: 'task',
|
||||
invocationId: expect.any(String),
|
||||
action: 'queue',
|
||||
message: 'Check the primary source.',
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
callbacks.onSuccess({
|
||||
receipt: {
|
||||
invocationId: variables.command.invocationId,
|
||||
controlId: 'control-1',
|
||||
action: 'queue',
|
||||
status: 'accepted',
|
||||
createdAt: '2026-08-24T12:00:00.000Z',
|
||||
updatedAt: '2026-08-24T12:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('accepted')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'true');
|
||||
});
|
||||
|
||||
it('retries an unavailable owner with the same authoritative invocation id', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('com_ui_subagent_control_message')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' })).toBeDisabled();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
|
||||
|
||||
expect(mockControlMutate).toHaveBeenCalledTimes(2);
|
||||
expect(mockControlMutate.mock.calls[1][0].command).toEqual(firstCommand);
|
||||
});
|
||||
|
||||
it('releases the composer after a definitive policy rejection', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Blocked guidance.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 400 } });
|
||||
});
|
||||
|
||||
expect(screen.getByText('com_ui_subagent_control_reason_invalid_command')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_retry' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_subagent_control_message')).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('retains an ambiguous invocation across closing and reopening the panel', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
const PanelHost = () => {
|
||||
const current = useRecoilValue(activeSubagentPanel);
|
||||
const setCurrent = useSetRecoilState(activeSubagentPanel);
|
||||
return current == null ? (
|
||||
<button type="button" onClick={() => setCurrent(selection)}>
|
||||
{selection.subagentType}
|
||||
</button>
|
||||
) : (
|
||||
<SubagentThreadPanel selection={current} />
|
||||
);
|
||||
};
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<PanelHost />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: selection.subagentType }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'com_ui_retry' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
|
||||
expect(mockControlMutate.mock.calls[1][0].command).toEqual(firstCommand);
|
||||
});
|
||||
|
||||
it('retains an ambiguous invocation across a full page-state reload', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
const first = render(
|
||||
<RecoilRoot>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Keep the same invocation.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
first.unmount();
|
||||
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'com_ui_retry' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
|
||||
expect(mockControlMutate.mock.calls[1][0].command).toEqual(firstCommand);
|
||||
});
|
||||
|
||||
it('records an ambiguous result after the panel closes before the mutation settles', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
const PanelHost = () => {
|
||||
const current = useRecoilValue(activeSubagentPanel);
|
||||
const setCurrent = useSetRecoilState(activeSubagentPanel);
|
||||
return current == null ? (
|
||||
<button type="button" onClick={() => setCurrent(selection)}>
|
||||
{selection.subagentType}
|
||||
</button>
|
||||
) : (
|
||||
<SubagentThreadPanel selection={current} />
|
||||
);
|
||||
};
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<PanelHost />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Retry after closing.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_queue' }));
|
||||
const firstCommand = mockControlMutate.mock.calls[0][0].command;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_close' }));
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: selection.subagentType }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'com_ui_retry' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
|
||||
expect(mockControlMutate.mock.calls[1][0].command).toEqual(firstCommand);
|
||||
});
|
||||
|
||||
it('keeps an unavailable-owner retry visible if the child settles before the receipt appears', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
const { rerender } = render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
rerender(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('com_ui_subagent_control_reason_owner_unavailable'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'com_ui_retry' })).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves drafted guidance when withdrawing an accepted control', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: {
|
||||
...completedView,
|
||||
status: 'running',
|
||||
controlReceipts: [
|
||||
{
|
||||
invocationId: 'accepted-control',
|
||||
controlId: 'control-1',
|
||||
action: 'queue',
|
||||
status: 'accepted',
|
||||
createdAt: '2026-08-24T12:00:00.000Z',
|
||||
updatedAt: '2026-08-24T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
const composer = screen.getByLabelText('com_ui_subagent_control_message');
|
||||
fireEvent.change(composer, { target: { value: 'Keep this draft.' } });
|
||||
fireEvent.click(screen.getByTestId('withdraw-control'));
|
||||
const command = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onSuccess({
|
||||
receipt: {
|
||||
invocationId: command.invocationId,
|
||||
controlId: 'control-1',
|
||||
action: 'cancel_message',
|
||||
status: 'applied',
|
||||
createdAt: '2026-08-24T12:00:01.000Z',
|
||||
updatedAt: '2026-08-24T12:00:01.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(composer).toHaveValue('Keep this draft.');
|
||||
});
|
||||
|
||||
it('clears transient retry state when refresh returns the same durable invocation', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
const { rerender } = render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('com_ui_subagent_control_message'), {
|
||||
target: { value: 'Use the primary source.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_steer' }));
|
||||
const command = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 503 } });
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'com_ui_retry' })).toBeInTheDocument();
|
||||
expect(window.sessionStorage.length).toBe(1);
|
||||
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: {
|
||||
...completedView,
|
||||
status: 'running',
|
||||
controlReceipts: [
|
||||
{
|
||||
invocationId: command.invocationId,
|
||||
action: 'steer',
|
||||
status: 'applied',
|
||||
createdAt: '2026-08-24T12:00:00.000Z',
|
||||
updatedAt: '2026-08-24T12:00:01.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
rerender(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('applied')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_retry' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('com_ui_subagent_control_message')).toHaveValue('');
|
||||
expect(
|
||||
screen.queryByText('com_ui_subagent_control_reason_owner_unavailable'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(window.sessionStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('closes stale running controls after task cancellation is applied', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' }));
|
||||
const command = mockControlMutate.mock.calls[0][0].command;
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onSuccess({
|
||||
receipt: {
|
||||
invocationId: command.invocationId,
|
||||
action: 'cancel',
|
||||
status: 'applied',
|
||||
createdAt: '2026-08-24T12:00:00.000Z',
|
||||
updatedAt: '2026-08-24T12:00:01.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
});
|
||||
|
||||
it('reports an inaccessible task without offering a misleading retry', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: { ...completedView, status: 'running', controlReceipts: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_cancel_task' }));
|
||||
act(() => {
|
||||
mockControlMutate.mock.calls[0][1].onError({ response: { status: 404 } });
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('com_ui_subagent_control_reason_task_inaccessible'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_retry' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'com_ui_subagent_cancel_task' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
});
|
||||
|
||||
it('renders rejected and refreshed applied receipts independently from child status', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: {
|
||||
...completedView,
|
||||
status: 'running',
|
||||
controlReceipts: [
|
||||
{
|
||||
invocationId: 'persisted',
|
||||
action: 'interrupt',
|
||||
status: 'applied',
|
||||
createdAt: '2026-08-24T12:00:00.000Z',
|
||||
updatedAt: '2026-08-24T12:00:01.000Z',
|
||||
},
|
||||
{
|
||||
invocationId: 'terminal-race',
|
||||
action: 'steer',
|
||||
status: 'rejected',
|
||||
createdAt: '2026-08-24T12:00:02.000Z',
|
||||
updatedAt: '2026-08-24T12:00:03.000Z',
|
||||
reason: 'task_completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('applied')).toBeInTheDocument();
|
||||
expect(screen.getByText('rejected')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'running');
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
});
|
||||
|
||||
it('does not expose task controls after the selected child is terminal', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: completedView,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
|
||||
<SubagentThreadPanel selection={selection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('com_ui_subagent_control_message')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-can-withdraw', 'false');
|
||||
});
|
||||
|
||||
it('renders foreground persisted activity through the same shared panel without a durable read', () => {
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,45 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Bot, MessagesSquare, X } from 'lucide-react';
|
||||
import { v4 } from 'uuid';
|
||||
import { ForkOptions } from 'librechat-data-provider';
|
||||
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { Bot, CornerDownRight, ListEnd, MessagesSquare, OctagonX, X, Zap } from 'lucide-react';
|
||||
import {
|
||||
useRecoilCallback,
|
||||
useRecoilState,
|
||||
useRecoilValue,
|
||||
useResetRecoilState,
|
||||
useSetRecoilState,
|
||||
} from 'recoil';
|
||||
import {
|
||||
Button,
|
||||
Alert,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
useMediaQuery,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { ParentSubagentTaskSummary } from 'librechat-data-provider';
|
||||
import type {
|
||||
ParentSubagentTaskSummary,
|
||||
SubagentControlAction,
|
||||
SubagentControlReceipt,
|
||||
SubagentControlRequest,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ActiveSubagentPanel } from '~/store/subagents';
|
||||
import type { ActiveSubagentPanel, SubagentControlUiState } from '~/store/subagents';
|
||||
import {
|
||||
ACTIVE_THREAD_REFRESH_MS,
|
||||
subagentThreadHasTaskEvidence,
|
||||
useForkConvoMutation,
|
||||
useSubagentControlMutation,
|
||||
useSubagentThreadQuery,
|
||||
} from '~/data-provider';
|
||||
import {
|
||||
activeSubagentPanel,
|
||||
subagentControlStateByTask,
|
||||
subagentControlStateKey,
|
||||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/store/subagents';
|
||||
|
|
@ -36,6 +53,45 @@ import { eventSubagentSelection } from './eventSelection';
|
|||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
||||
const EVENT_TASK_PAGE_SIZE = 3;
|
||||
const TERMINAL_CONTROL_REASONS = new Set([
|
||||
'task_not_running',
|
||||
'task_completed',
|
||||
'task_cancelled',
|
||||
'task_failed',
|
||||
]);
|
||||
|
||||
const isTerminalControlReason = (reason?: string): boolean =>
|
||||
reason != null && TERMINAL_CONTROL_REASONS.has(reason);
|
||||
|
||||
const closesTaskControls = (receipt: SubagentControlReceipt): boolean =>
|
||||
isTerminalControlReason(receipt.reason) ||
|
||||
(receipt.action === 'cancel' && receipt.status === 'applied');
|
||||
|
||||
const responseStatus = (error: unknown): number | undefined =>
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
'response' in error &&
|
||||
typeof error.response === 'object' &&
|
||||
error.response != null &&
|
||||
'status' in error.response &&
|
||||
typeof error.response.status === 'number'
|
||||
? error.response.status
|
||||
: undefined;
|
||||
|
||||
const failedControlReason = (
|
||||
inaccessible: boolean,
|
||||
retryable: boolean,
|
||||
): 'task_inaccessible' | 'owner_unavailable' | 'invalid_command' => {
|
||||
if (inaccessible) return 'task_inaccessible';
|
||||
if (retryable) return 'owner_unavailable';
|
||||
return 'invalid_command';
|
||||
};
|
||||
|
||||
const failedControlLocaleKey = (reason?: string) => {
|
||||
if (reason === 'task_inaccessible') return 'com_ui_subagent_control_reason_task_inaccessible';
|
||||
if (reason === 'owner_unavailable') return 'com_ui_subagent_control_reason_owner_unavailable';
|
||||
return 'com_ui_subagent_control_reason_invalid_command';
|
||||
};
|
||||
|
||||
export default function SubagentThreadPanel({ selection }: { selection: ActiveSubagentPanel }) {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -62,6 +118,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
: localize('com_ui_subagent_dialog_title', { 0: selection.subagentType });
|
||||
const threadId = selection.durable?.threadId ?? '';
|
||||
const taskId = selection.durable?.taskId ?? '';
|
||||
const controlIdentity = subagentControlStateKey(selection.parentConversationId, threadId, taskId);
|
||||
const [controlState, setControlState] = useRecoilState(
|
||||
subagentControlStateByTask(controlIdentity),
|
||||
);
|
||||
const setControlStateForIdentity = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(identity: string, state: SubagentControlUiState | null) =>
|
||||
set(subagentControlStateByTask(identity), state),
|
||||
[],
|
||||
);
|
||||
const transientControl = controlState?.receipt ?? null;
|
||||
const retryControl = controlState?.retry ?? null;
|
||||
const eventSummary = selection.event == null ? undefined : byThreadId.get(threadId);
|
||||
const eventTaskCount = eventSummary?.tasks.length ?? 0;
|
||||
const [eventTaskWindow, setEventTaskWindow] = useState(() => ({
|
||||
|
|
@ -168,6 +236,170 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
showToast({ message: localize('com_ui_continue_chat_error'), status: 'error' });
|
||||
},
|
||||
});
|
||||
const [controlMessage, setControlMessage] = useState('');
|
||||
const [controlInaccessible, setControlInaccessible] = useState(false);
|
||||
const [controlsClosed, setControlsClosed] = useState(false);
|
||||
const controlInFlightRef = useRef(false);
|
||||
const controlSelectionRef = useRef(controlIdentity);
|
||||
useEffect(() => {
|
||||
controlSelectionRef.current = controlIdentity;
|
||||
setControlMessage('');
|
||||
setControlInaccessible(false);
|
||||
setControlsClosed(false);
|
||||
controlInFlightRef.current = false;
|
||||
return () => {
|
||||
controlSelectionRef.current = '';
|
||||
};
|
||||
}, [controlIdentity]);
|
||||
|
||||
const controlTask = useSubagentControlMutation({
|
||||
onSuccess: ({ receipt }, variables) => {
|
||||
const submittedSelection = subagentControlStateKey(
|
||||
variables.parentConversationId,
|
||||
variables.threadId,
|
||||
variables.command.taskId,
|
||||
);
|
||||
setControlStateForIdentity(submittedSelection, { receipt });
|
||||
if (controlSelectionRef.current !== submittedSelection) return;
|
||||
controlInFlightRef.current = false;
|
||||
if (closesTaskControls(receipt)) setControlsClosed(true);
|
||||
if (
|
||||
variables.command.action !== 'cancel_message' &&
|
||||
(receipt.status === 'accepted' || receipt.status === 'applied')
|
||||
) {
|
||||
setControlMessage('');
|
||||
}
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
const status = responseStatus(error);
|
||||
const inaccessible = status === 404;
|
||||
const retryable = status == null || status >= 500;
|
||||
const command = variables.command;
|
||||
const submittedSelection = subagentControlStateKey(
|
||||
variables.parentConversationId,
|
||||
variables.threadId,
|
||||
command.taskId,
|
||||
);
|
||||
setControlStateForIdentity(submittedSelection, {
|
||||
receipt: {
|
||||
invocationId: command.invocationId,
|
||||
...(command.controlId == null ? {} : { controlId: command.controlId }),
|
||||
action: command.action,
|
||||
status: 'failed',
|
||||
createdAt: variables.submittedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(command.message == null ? {} : { message: command.message }),
|
||||
reason: failedControlReason(inaccessible, retryable),
|
||||
},
|
||||
...(retryable ? { retry: command } : {}),
|
||||
});
|
||||
if (controlSelectionRef.current !== submittedSelection) return;
|
||||
controlInFlightRef.current = false;
|
||||
if (inaccessible) {
|
||||
setControlInaccessible(true);
|
||||
setControlsClosed(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (transientControl == null) return;
|
||||
const durableReceipt = data?.controlReceipts?.find(
|
||||
(receipt) => receipt.invocationId === transientControl.invocationId,
|
||||
);
|
||||
if (durableReceipt == null) return;
|
||||
/** The durable view is authoritative after refresh. Drop mutation-only state
|
||||
* once the same invocation appears there so stale failure/retry UI cannot
|
||||
* outlive a successfully persisted receipt. */
|
||||
if (
|
||||
retryControl != null &&
|
||||
retryControl.action !== 'cancel' &&
|
||||
retryControl.action !== 'cancel_message' &&
|
||||
(durableReceipt.status === 'accepted' || durableReceipt.status === 'applied')
|
||||
) {
|
||||
setControlMessage((current) => (current === retryControl.message ? '' : current));
|
||||
}
|
||||
if (closesTaskControls(durableReceipt)) setControlsClosed(true);
|
||||
setControlState(null);
|
||||
}, [data?.controlReceipts, retryControl, setControlState, transientControl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.controlReceipts?.some(closesTaskControls)) {
|
||||
setControlsClosed(true);
|
||||
}
|
||||
}, [data?.controlReceipts]);
|
||||
|
||||
const submitControl = useCallback(
|
||||
(action: SubagentControlAction, controlId?: string, retry?: SubagentControlRequest) => {
|
||||
if (
|
||||
selection.durable == null ||
|
||||
controlTask.isLoading ||
|
||||
controlInFlightRef.current ||
|
||||
(retryControl != null && retry == null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let command: SubagentControlRequest;
|
||||
if (retry != null) {
|
||||
command = retry;
|
||||
} else if (action === 'cancel_message') {
|
||||
command = {
|
||||
taskId: selection.durable.taskId,
|
||||
invocationId: v4(),
|
||||
action,
|
||||
controlId,
|
||||
};
|
||||
} else if (action === 'cancel') {
|
||||
command = {
|
||||
taskId: selection.durable.taskId,
|
||||
invocationId: v4(),
|
||||
action,
|
||||
};
|
||||
} else {
|
||||
command = {
|
||||
taskId: selection.durable.taskId,
|
||||
invocationId: v4(),
|
||||
action,
|
||||
message: controlMessage.trim(),
|
||||
};
|
||||
}
|
||||
if (
|
||||
action !== 'cancel' &&
|
||||
action !== 'cancel_message' &&
|
||||
(command.message == null || command.message === '')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
setControlState({
|
||||
receipt: {
|
||||
invocationId: command.invocationId,
|
||||
...(command.controlId == null ? {} : { controlId: command.controlId }),
|
||||
action: command.action,
|
||||
status: 'submitted',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(command.message == null ? {} : { message: command.message }),
|
||||
},
|
||||
retry: command,
|
||||
});
|
||||
controlInFlightRef.current = true;
|
||||
controlTask.mutate({
|
||||
parentConversationId: selection.parentConversationId,
|
||||
threadId: selection.durable.threadId,
|
||||
command,
|
||||
submittedAt: now,
|
||||
});
|
||||
},
|
||||
[
|
||||
controlMessage,
|
||||
controlTask,
|
||||
retryControl,
|
||||
selection.durable,
|
||||
selection.parentConversationId,
|
||||
setControlState,
|
||||
],
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
resetSelection();
|
||||
|
|
@ -215,25 +447,40 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
const activity = useMemo(() => {
|
||||
if (selection.durable == null) return liveActivity;
|
||||
if (data == null) {
|
||||
return progress == null ? { ...liveActivity, status: 'dispatched' as const } : liveActivity;
|
||||
const activityWithoutData =
|
||||
progress == null ? { ...liveActivity, status: 'dispatched' as const } : liveActivity;
|
||||
return transientControl == null
|
||||
? activityWithoutData
|
||||
: { ...activityWithoutData, controls: [transientControl] };
|
||||
}
|
||||
const durable = adaptDurableThreadActivity(data, selection.durable.taskId);
|
||||
if (
|
||||
const useLiveItems =
|
||||
(durable.status === 'running' || durable.status === 'dispatched') &&
|
||||
liveActivity.items.length > 0
|
||||
) {
|
||||
return {
|
||||
...durable,
|
||||
prompt: durable.prompt ?? liveActivity.prompt,
|
||||
items: liveActivity.items,
|
||||
};
|
||||
}
|
||||
return {
|
||||
liveActivity.items.length > 0;
|
||||
const mergedItems =
|
||||
!useLiveItems && durable.items.length > 0 ? durable.items : liveActivity.items;
|
||||
const merged = {
|
||||
...durable,
|
||||
prompt: durable.prompt ?? liveActivity.prompt,
|
||||
items: durable.items.length > 0 ? durable.items : liveActivity.items,
|
||||
items: mergedItems,
|
||||
};
|
||||
}, [data, liveActivity, progress, selection.durable]);
|
||||
if (
|
||||
transientControl == null ||
|
||||
(merged.controls ?? []).some(
|
||||
(receipt) => receipt.invocationId === transientControl.invocationId,
|
||||
)
|
||||
) {
|
||||
return merged;
|
||||
}
|
||||
return { ...merged, controls: [...(merged.controls ?? []), transientControl] };
|
||||
}, [data, liveActivity, progress, selection.durable, transientControl]);
|
||||
const taskInaccessible = controlInaccessible || transientControl?.reason === 'task_inaccessible';
|
||||
const controlAvailable =
|
||||
selection.durable != null && data?.status === 'running' && !taskInaccessible && !controlsClosed;
|
||||
const controlPending =
|
||||
controlTask.isLoading || transientControl?.status === 'submitted' || retryControl != null;
|
||||
const showControlFooter =
|
||||
controlAvailable || retryControl != null || transientControl?.reason === 'task_inaccessible';
|
||||
const canContinueAsChat =
|
||||
selection.host === 'conversation' &&
|
||||
selection.durable != null &&
|
||||
|
|
@ -288,6 +535,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
activity={activity}
|
||||
state={panelState}
|
||||
embedded
|
||||
onCancelControl={
|
||||
controlAvailable && !controlPending
|
||||
? (controlId) => submitControl('cancel_message', controlId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -417,9 +669,94 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
|
||||
activity={activity}
|
||||
state={panelState}
|
||||
onCancelControl={
|
||||
controlAvailable && !controlPending
|
||||
? (controlId) => submitControl('cancel_message', controlId)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ApprovalProvider>
|
||||
{showControlFooter && (
|
||||
<div className="shrink-0 border-t border-border-light p-3">
|
||||
{transientControl?.status === 'failed' && (
|
||||
<Alert variant="error" className="mb-2 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
{localize(failedControlLocaleKey(transientControl.reason))}
|
||||
</span>
|
||||
{retryControl != null && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlTask.isLoading}
|
||||
onClick={() =>
|
||||
submitControl(retryControl.action, retryControl.controlId, retryControl)
|
||||
}
|
||||
>
|
||||
{localize('com_ui_retry')}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{controlAvailable && (
|
||||
<>
|
||||
<Textarea
|
||||
value={controlMessage}
|
||||
onChange={(event) => setControlMessage(event.target.value)}
|
||||
placeholder={localize('com_ui_subagent_control_placeholder')}
|
||||
aria-label={localize('com_ui_subagent_control_message')}
|
||||
maxLength={4 * 1024}
|
||||
rows={2}
|
||||
disabled={controlPending}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('steer')}
|
||||
>
|
||||
<CornerDownRight size={14} aria-hidden />
|
||||
{localize('com_ui_steer')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('queue')}
|
||||
>
|
||||
<ListEnd size={14} aria-hidden />
|
||||
{localize('com_ui_queue')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={controlPending || controlMessage.trim() === ''}
|
||||
onClick={() => submitControl('interrupt')}
|
||||
>
|
||||
<Zap size={14} aria-hidden />
|
||||
{localize('com_ui_subagent_interrupt')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={controlPending}
|
||||
onClick={() => submitControl('cancel')}
|
||||
className="ml-auto text-status-error"
|
||||
>
|
||||
<OctagonX size={14} aria-hidden />
|
||||
{localize('com_ui_subagent_cancel_task')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -442,7 +779,7 @@ function HistoricalEventTaskActivity({
|
|||
const activity = useMemo(
|
||||
() =>
|
||||
data == null
|
||||
? { title, status: task.status, items: [] }
|
||||
? { title, status: task.status, items: [], controls: [] }
|
||||
: adaptDurableThreadActivity(data, task.taskId),
|
||||
[data, task.status, task.taskId, title],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
Agents,
|
||||
PartMetadata,
|
||||
SubagentActivityItem,
|
||||
SubagentControlReceipt,
|
||||
SubagentThreadStatus,
|
||||
SubagentThreadView,
|
||||
TMessageContentParts,
|
||||
|
|
@ -51,7 +52,13 @@ export type ChildActivity = {
|
|||
prompt?: string;
|
||||
status: SubagentThreadStatus;
|
||||
items: ChildActivityItem[];
|
||||
controls?: Array<
|
||||
Omit<SubagentControlReceipt, 'status'> & {
|
||||
status: SubagentControlReceipt['status'] | 'submitted';
|
||||
}
|
||||
>;
|
||||
activityTruncated?: boolean;
|
||||
controlsTruncated?: boolean;
|
||||
};
|
||||
|
||||
type ContentToolCall = {
|
||||
|
|
@ -283,6 +290,7 @@ export function adaptLivePersistedActivity(input: {
|
|||
...(input.prompt == null ? {} : { prompt: input.prompt }),
|
||||
status: liveStatus(input),
|
||||
items,
|
||||
controls: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -313,6 +321,8 @@ export function adaptDurableThreadActivity(
|
|||
...(prompt == null ? {} : { prompt }),
|
||||
status,
|
||||
items,
|
||||
controls: view.controlReceipts ?? [],
|
||||
controlsTruncated: view.controlReceiptsTruncated === true,
|
||||
activityTruncated:
|
||||
view.activityTruncated ||
|
||||
view.historyTruncated ||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ParentSubagentIndex, SubagentThreadView } from 'librechat-data-pro
|
|||
import {
|
||||
isSubagentReadinessPending,
|
||||
parentSubagentsRefetchInterval,
|
||||
reconcileSubagentControlReceipts,
|
||||
subagentThreadHasTaskEvidence,
|
||||
subagentThreadRefetchInterval,
|
||||
useParentSubagentsQuery,
|
||||
|
|
@ -19,6 +20,23 @@ const view = (status: SubagentThreadView['status']): SubagentThreadView =>
|
|||
({ status }) as SubagentThreadView;
|
||||
|
||||
describe('subagent thread refresh policy', () => {
|
||||
it('does not downgrade a refreshed terminal control receipt to accepted', () => {
|
||||
const applied = {
|
||||
invocationId: 'invocation-1',
|
||||
action: 'queue',
|
||||
status: 'applied',
|
||||
createdAt: '2026-08-24T12:00:00.000Z',
|
||||
updatedAt: '2026-08-24T12:00:02.000Z',
|
||||
} as const;
|
||||
const accepted = {
|
||||
...applied,
|
||||
status: 'accepted',
|
||||
updatedAt: '2026-08-24T12:00:01.000Z',
|
||||
} as const;
|
||||
|
||||
expect(reconcileSubagentControlReceipts([applied], accepted)).toEqual([applied]);
|
||||
});
|
||||
|
||||
it('bounds child-readiness retries and keeps active work fresh', () => {
|
||||
expect(subagentThreadRefetchInterval(undefined, 1_000, 500)).toBe(2_000);
|
||||
expect(subagentThreadRefetchInterval(view('dispatched'), 1_000, 500)).toBe(2_000);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import type { ParentSubagentIndex, SubagentThreadView } from 'librechat-data-provider';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Constants, MutationKeys, QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import type {
|
||||
ParentSubagentIndex,
|
||||
SubagentControlReceipt,
|
||||
SubagentControlRequest,
|
||||
SubagentControlResponse,
|
||||
SubagentThreadView,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
|
||||
|
||||
export const ACTIVE_THREAD_REFRESH_MS = 2_000;
|
||||
|
|
@ -118,3 +124,70 @@ export const useSubagentThreadQuery = (
|
|||
isReadinessPending: isSubagentReadinessPending(query.error, readiness.deadline),
|
||||
};
|
||||
};
|
||||
|
||||
export type SubagentControlVariables = {
|
||||
parentConversationId: string;
|
||||
threadId: string;
|
||||
command: SubagentControlRequest;
|
||||
/** Client-only timestamp retained across retries; never sent to the API. */
|
||||
submittedAt: string;
|
||||
};
|
||||
|
||||
type SubagentControlMutationOptions = {
|
||||
onSuccess?: (data: SubagentControlResponse, variables: SubagentControlVariables) => void;
|
||||
onError?: (error: Error, variables: SubagentControlVariables) => void;
|
||||
};
|
||||
|
||||
const isTerminalControlReceipt = (receipt: SubagentControlReceipt): boolean =>
|
||||
receipt.status === 'applied' || receipt.status === 'rejected' || receipt.status === 'failed';
|
||||
|
||||
/** Reconciles a mutation response without allowing an older accepted projection
|
||||
* to replace a terminal receipt delivered by the concurrent activity refresh. */
|
||||
export const reconcileSubagentControlReceipts = (
|
||||
receipts: SubagentControlReceipt[],
|
||||
incoming: SubagentControlReceipt,
|
||||
): SubagentControlReceipt[] => {
|
||||
const index = receipts.findIndex((candidate) => candidate.invocationId === incoming.invocationId);
|
||||
if (index === -1) return [...receipts, incoming];
|
||||
const current = receipts[index];
|
||||
const currentUpdatedAt = Date.parse(current.updatedAt);
|
||||
const incomingUpdatedAt = Date.parse(incoming.updatedAt);
|
||||
if (
|
||||
(isTerminalControlReceipt(current) && !isTerminalControlReceipt(incoming)) ||
|
||||
(Number.isFinite(currentUpdatedAt) &&
|
||||
Number.isFinite(incomingUpdatedAt) &&
|
||||
currentUpdatedAt > incomingUpdatedAt)
|
||||
) {
|
||||
return receipts;
|
||||
}
|
||||
return receipts.map((candidate, candidateIndex) =>
|
||||
candidateIndex === index ? incoming : candidate,
|
||||
);
|
||||
};
|
||||
|
||||
export const useSubagentControlMutation = (options: SubagentControlMutationOptions = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<SubagentControlResponse, Error, SubagentControlVariables>(
|
||||
({ parentConversationId, threadId, command }) =>
|
||||
dataService.controlSubagentTask(parentConversationId, threadId, command),
|
||||
{
|
||||
mutationKey: [MutationKeys.subagentControl],
|
||||
onSuccess: ({ receipt }, { parentConversationId, threadId, command, submittedAt }) => {
|
||||
const key = [QueryKeys.subagentThread, parentConversationId, threadId, command.taskId];
|
||||
queryClient.setQueryData<SubagentThreadView | undefined>(key, (current) => {
|
||||
if (current == null) return current;
|
||||
return {
|
||||
...current,
|
||||
controlReceipts: reconcileSubagentControlReceipts(
|
||||
current.controlReceipts ?? [],
|
||||
receipt,
|
||||
),
|
||||
};
|
||||
});
|
||||
void queryClient.invalidateQueries(key);
|
||||
options.onSuccess?.({ receipt }, { parentConversationId, threadId, command, submittedAt });
|
||||
},
|
||||
onError: (error, variables) => options.onError?.(error, variables),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2202,6 +2202,33 @@
|
|||
"com_ui_subagent_complete": "Ran agent",
|
||||
"com_ui_subagent_dialog_title": "\"{{0}}\" agent",
|
||||
"com_ui_subagent_dialog_title_self": "Agent",
|
||||
"com_ui_subagent_cancel_task": "Cancel task",
|
||||
"com_ui_subagent_control_cancel": "Cancel task",
|
||||
"com_ui_subagent_control_cancel_message": "Withdraw message",
|
||||
"com_ui_subagent_control_history": "Control history",
|
||||
"com_ui_subagent_control_history_truncated": "Earlier control activity is not shown.",
|
||||
"com_ui_subagent_control_interrupt": "Interrupt",
|
||||
"com_ui_subagent_control_message": "Message to subagent",
|
||||
"com_ui_subagent_control_message_truncated": "Message shortened for display.",
|
||||
"com_ui_subagent_control_placeholder": "Add guidance for this subagent",
|
||||
"com_ui_subagent_control_queue": "Queued guidance",
|
||||
"com_ui_subagent_control_reason_control_not_found": "That queued message is no longer available.",
|
||||
"com_ui_subagent_control_reason_invalid_command": "The command was not accepted.",
|
||||
"com_ui_subagent_control_reason_owner_unavailable": "The running subagent is temporarily unavailable. Retry with the same command.",
|
||||
"com_ui_subagent_control_reason_task_inaccessible": "This subagent task is no longer accessible.",
|
||||
"com_ui_subagent_control_reason_task_cancelled": "The task was cancelled before this command applied.",
|
||||
"com_ui_subagent_control_reason_task_completed": "The task completed before this command applied.",
|
||||
"com_ui_subagent_control_reason_task_failed": "The task failed before this command applied.",
|
||||
"com_ui_subagent_control_reason_task_not_running": "This task is no longer running.",
|
||||
"com_ui_subagent_control_reason_withdrawn": "This queued message was withdrawn.",
|
||||
"com_ui_subagent_control_status_accepted": "Waiting",
|
||||
"com_ui_subagent_control_status_applied": "Applied",
|
||||
"com_ui_subagent_control_status_failed": "Failed",
|
||||
"com_ui_subagent_control_status_rejected": "Not applied",
|
||||
"com_ui_subagent_control_status_submitted": "Sending",
|
||||
"com_ui_subagent_control_steer": "Steering guidance",
|
||||
"com_ui_subagent_control_withdraw": "Withdraw",
|
||||
"com_ui_subagent_interrupt": "Interrupt",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ import { atom, atomFamily } from 'recoil';
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
PartMetadata,
|
||||
SubagentControlReceipt,
|
||||
SubagentControlRequest,
|
||||
SubagentUpdatePhase,
|
||||
TMessageContentParts,
|
||||
SubagentUpdateEvent,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AtomEffect } from 'recoil';
|
||||
import type {
|
||||
SubagentAggregatorState,
|
||||
SubagentContentPart,
|
||||
|
|
@ -281,6 +284,122 @@ export const activeSubagentPanel = atom<ActiveSubagentPanel | null>({
|
|||
default: null,
|
||||
});
|
||||
|
||||
export type SubagentControlUiReceipt = Omit<SubagentControlReceipt, 'status'> & {
|
||||
status: SubagentControlReceipt['status'] | 'submitted';
|
||||
};
|
||||
|
||||
export type SubagentControlUiState = {
|
||||
receipt: SubagentControlUiReceipt;
|
||||
/** Present only while the same invocation must be retried to resolve an
|
||||
* ambiguous delivery. It is never replaced with a fresh invocation id. */
|
||||
retry?: SubagentControlRequest;
|
||||
};
|
||||
|
||||
export const subagentControlStateKey = (
|
||||
parentConversationId: string,
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
): string => `${parentConversationId}\u0000${threadId}\u0000${taskId}`;
|
||||
|
||||
const SUBAGENT_CONTROL_STORAGE_PREFIX = 'librechat.subagent-control:';
|
||||
const CONTROL_ACTIONS = new Set(['steer', 'queue', 'interrupt', 'cancel', 'cancel_message']);
|
||||
const storedControlState = (value: unknown): SubagentControlUiState | null => {
|
||||
if (value == null || typeof value !== 'object') return null;
|
||||
const candidate = value as Partial<SubagentControlUiState>;
|
||||
const receipt = candidate.receipt as Partial<SubagentControlUiReceipt> | undefined;
|
||||
const retry = candidate.retry as Partial<SubagentControlRequest> | undefined;
|
||||
if (
|
||||
receipt == null ||
|
||||
typeof receipt.invocationId !== 'string' ||
|
||||
!CONTROL_ACTIONS.has(receipt.action ?? '') ||
|
||||
(receipt.status !== 'submitted' && receipt.status !== 'failed') ||
|
||||
typeof receipt.createdAt !== 'string' ||
|
||||
typeof receipt.updatedAt !== 'string' ||
|
||||
retry == null ||
|
||||
typeof retry.taskId !== 'string' ||
|
||||
retry.taskId === '' ||
|
||||
retry.invocationId !== receipt.invocationId ||
|
||||
retry.action !== receipt.action ||
|
||||
!CONTROL_ACTIONS.has(retry.action ?? '')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const action = retry.action as SubagentControlRequest['action'];
|
||||
if (
|
||||
(action === 'cancel' && (retry.message != null || retry.controlId != null)) ||
|
||||
(action === 'cancel_message' &&
|
||||
(typeof retry.controlId !== 'string' || retry.controlId === '' || retry.message != null)) ||
|
||||
(action !== 'cancel' &&
|
||||
action !== 'cancel_message' &&
|
||||
(typeof retry.message !== 'string' || retry.message.trim() === '' || retry.controlId != null))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const sanitizedRetry = {
|
||||
taskId: retry.taskId,
|
||||
invocationId: retry.invocationId,
|
||||
action,
|
||||
...(action === 'cancel_message' ? { controlId: retry.controlId as string } : {}),
|
||||
...(action !== 'cancel' && action !== 'cancel_message'
|
||||
? { message: retry.message as string }
|
||||
: {}),
|
||||
} as SubagentControlRequest;
|
||||
return {
|
||||
receipt: {
|
||||
invocationId: receipt.invocationId,
|
||||
action,
|
||||
status: 'failed',
|
||||
createdAt: receipt.createdAt,
|
||||
updatedAt: now,
|
||||
...(action === 'cancel_message' ? { controlId: retry.controlId as string } : {}),
|
||||
...(action !== 'cancel' && action !== 'cancel_message'
|
||||
? { message: retry.message as string }
|
||||
: {}),
|
||||
reason: 'owner_unavailable',
|
||||
},
|
||||
retry: sanitizedRetry,
|
||||
};
|
||||
};
|
||||
|
||||
const subagentControlStorageEffect =
|
||||
(identity: string): AtomEffect<SubagentControlUiState | null> =>
|
||||
({ setSelf, onSet }) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const storageKey = `${SUBAGENT_CONTROL_STORAGE_PREFIX}${encodeURIComponent(identity)}`;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey);
|
||||
if (raw != null) {
|
||||
const restored = storedControlState(JSON.parse(raw));
|
||||
if (restored == null) window.sessionStorage.removeItem(storageKey);
|
||||
else setSelf(restored);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Some privacy modes deny session storage entirely.
|
||||
}
|
||||
}
|
||||
onSet((next, _previous, isReset) => {
|
||||
try {
|
||||
if (isReset || next?.retry == null) window.sessionStorage.removeItem(storageKey);
|
||||
else window.sessionStorage.setItem(storageKey, JSON.stringify(next));
|
||||
} catch {
|
||||
// Storage is best-effort; the in-memory receipt still protects this mounted session.
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Parent-owned control state survives closing the activity panel or selecting
|
||||
* another child. Ambiguous retries also survive a full page reload in this tab;
|
||||
* durable receipts clear both copies after authoritative reconciliation. */
|
||||
export const subagentControlStateByTask = atomFamily<SubagentControlUiState | null, string>({
|
||||
key: 'subagentControlStateByTask',
|
||||
default: null,
|
||||
effects_UNSTABLE: (identity) => [subagentControlStorageEffect(identity)],
|
||||
});
|
||||
|
||||
/** Stable identity for one subagent invocation in the parent conversation. */
|
||||
export const subagentProgressKey = (
|
||||
parentMessageId: string,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue