🎛️ 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:
Danny Avila 2026-08-24 20:37:49 -04:00 committed by GitHub
parent d641c398d5
commit 69e7c73614
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2460 additions and 27 deletions

View file

@ -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" />);

View file

@ -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')}

View file

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

View file

@ -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],
);

View file

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

View file

@ -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);

View file

@ -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),
},
);
};

View file

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

View file

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