{localize('com_ui_subagent_thread_history_truncated')}
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
index 98aad64b86..4e429803fc 100644
--- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx
@@ -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;
}) => (
{activity.prompt}
{activity.items.map((item, index) => (
{item.text ?? item.type}
))}
+ {activity.controls?.map((control) => (
+ {control.status}
+ ))}
+ {onCancelControl != null && (
+
),
}));
@@ -120,6 +150,11 @@ jest.mock('@librechat/client', () => {
const mockReact = jest.requireActual
('react');
const MockSelectContext = mockReact.createContext((_value: string): void => {});
return {
+ Alert: ({ children, ...props }: React.ComponentProps<'div'>) => (
+
+ {children}
+
+ ),
Button: ({ children, ...props }: React.ComponentProps<'button'>) => (
),
@@ -151,6 +186,7 @@ jest.mock('@librechat/client', () => {
);
},
+ Textarea: (props: React.ComponentProps<'textarea'>) => ,
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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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 ? (
+
+ ) : (
+
+ );
+ };
+ render(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+ 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 ? (
+
+ ) : (
+
+ );
+ };
+ render(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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(
+ set(activeSubagentPanel, selection)}>
+
+ ,
+ );
+
+ 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,
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
index 714c5dbe36..59b1ec8755 100644
--- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
@@ -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
+ }
/>
)}
+ {showControlFooter && (
+
+ {transientControl?.status === 'failed' && (
+
+
+ {localize(failedControlLocaleKey(transientControl.reason))}
+
+ {retryControl != null && (
+
+ )}
+
+ )}
+ {controlAvailable && (
+ <>
+
+ )}
);
}
@@ -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],
);
diff --git a/client/src/components/Chat/Subagents/adapters.ts b/client/src/components/Chat/Subagents/adapters.ts
index 3f1bec25d3..846006359b 100644
--- a/client/src/components/Chat/Subagents/adapters.ts
+++ b/client/src/components/Chat/Subagents/adapters.ts
@@ -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 & {
+ 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 ||
diff --git a/client/src/data-provider/Subagents/queries.test.ts b/client/src/data-provider/Subagents/queries.test.ts
index d1683d7c5a..ddfc1ba556 100644
--- a/client/src/data-provider/Subagents/queries.test.ts
+++ b/client/src/data-provider/Subagents/queries.test.ts
@@ -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);
diff --git a/client/src/data-provider/Subagents/queries.ts b/client/src/data-provider/Subagents/queries.ts
index bac18486d4..c74b080fe3 100644
--- a/client/src/data-provider/Subagents/queries.ts
+++ b/client/src/data-provider/Subagents/queries.ts
@@ -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(
+ ({ 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(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),
+ },
+ );
+};
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index b66e186052..8350bc740a 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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.",
diff --git a/client/src/store/subagents.ts b/client/src/store/subagents.ts
index 7b40ce4188..fd1e417bb0 100644
--- a/client/src/store/subagents.ts
+++ b/client/src/store/subagents.ts
@@ -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({
default: null,
});
+export type SubagentControlUiReceipt = Omit & {
+ 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;
+ const receipt = candidate.receipt as Partial | undefined;
+ const retry = candidate.retry as Partial | 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 =>
+ ({ 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({
+ key: 'subagentControlStateByTask',
+ default: null,
+ effects_UNSTABLE: (identity) => [subagentControlStorageEffect(identity)],
+});
+
/** Stable identity for one subagent invocation in the parent conversation. */
export const subagentProgressKey = (
parentMessageId: string,
diff --git a/packages/api/src/agents/control.spec.ts b/packages/api/src/agents/control.spec.ts
new file mode 100644
index 0000000000..c9e4ec626b
--- /dev/null
+++ b/packages/api/src/agents/control.spec.ts
@@ -0,0 +1,577 @@
+import type { IConversation } from '@librechat/data-schemas';
+import type { Response } from 'express';
+import type { ServerRequest } from '~/types';
+import { controlFingerprint, SubagentTaskOwnerUnavailableError } from './subagentTaskRouting';
+import { createSubagentControlHandler, isValidSubagentControlRequest } from './control';
+
+const parentConversationId = 'parent-conversation';
+const threadId = 'child-thread';
+const taskId = 'task-1';
+const parent = {
+ conversationId: parentConversationId,
+ user: 'user-1',
+ tenantId: 'tenant-1',
+} as IConversation;
+const child = {
+ conversationId: threadId,
+ user: 'user-1',
+ tenantId: 'tenant-1',
+ subagentThread: {
+ rootConversationId: parentConversationId,
+ parentConversationId,
+ parentMessageId: 'parent-message',
+ parentToolCallId: 'parent-tool-call',
+ parentAgentId: 'parent-agent',
+ subagentType: 'researcher',
+ subagentKind: 'agent',
+ depth: 1,
+ },
+ subagentThreadLease: {
+ token: 'lease-token',
+ taskId,
+ expiresAt: new Date('2099-08-24T12:00:00.000Z'),
+ },
+} as IConversation;
+
+const response = () => {
+ const json = jest.fn();
+ const status = jest.fn(() => ({ json }));
+ return { value: { status } as unknown as Response, status, json };
+};
+
+const request = (body: Record): ServerRequest =>
+ ({
+ params: { parentConversationId, threadId },
+ body,
+ user: { id: 'user-1', tenantId: 'tenant-1' },
+ }) as ServerRequest;
+
+const dependencies = (controlTask = jest.fn()) => ({
+ getConvoOwnership: jest.fn().mockResolvedValue(parent),
+ getSubagentThreadForParent: jest.fn().mockResolvedValue(child),
+ getMessages: jest
+ .fn()
+ .mockResolvedValue([{ messageId: `${taskId}:user`, subagentTask: { status: 'running' } }]),
+ getSubagentTaskControlReceipt: jest.fn().mockResolvedValue(null),
+ recordSubagentTaskControlReceipt: jest.fn().mockResolvedValue(true),
+ store: { controlTask },
+});
+
+describe('subagent control handler', () => {
+ it('rejects fields outside the action-specific public control contract', () => {
+ expect(
+ isValidSubagentControlRequest({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'queue',
+ message: 'Check the primary source.',
+ }),
+ ).toBe(true);
+ expect(
+ isValidSubagentControlRequest({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'queue',
+ message: 'Check the primary source.',
+ answers: ['unrelated moderation input'],
+ }),
+ ).toBe(false);
+ expect(
+ isValidSubagentControlRequest({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'cancel',
+ message: 'unused',
+ }),
+ ).toBe(false);
+ });
+
+ it('returns one bounded public accepted receipt from the authorized live owner', async () => {
+ const controlTask = jest.fn().mockResolvedValue({
+ status: 'accepted',
+ controlId: 'control-1',
+ task: { taskId, threadId, status: 'running' },
+ });
+ const deps = dependencies(controlTask);
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'queue',
+ message: 'Check the primary source.',
+ }),
+ res.value,
+ );
+
+ expect(controlTask).toHaveBeenCalledWith(
+ JSON.stringify({
+ version: 1,
+ userId: 'user-1',
+ parentConversationId,
+ tenantId: 'tenant-1',
+ }),
+ taskId,
+ { action: 'queue', message: 'Check the primary source.' },
+ 'invocation-1',
+ );
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-1',
+ controlId: 'control-1',
+ action: 'queue',
+ status: 'accepted',
+ }),
+ });
+ expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('task');
+ expect(deps.getMessages).toHaveBeenCalledWith(
+ {
+ user: 'user-1',
+ tenantId: 'tenant-1',
+ conversationId: threadId,
+ messageId: `${taskId}:user`,
+ },
+ '+subagentTask',
+ );
+ });
+
+ it('returns the durable applied receipt when settlement races the owner response', async () => {
+ const command = { action: 'queue' as const, message: 'Check the primary source.' };
+ const controlTask = jest.fn().mockResolvedValue({
+ status: 'accepted',
+ controlId: 'control-1',
+ task: { taskId, threadId, status: 'running' },
+ });
+ const deps = dependencies(controlTask);
+ deps.getSubagentTaskControlReceipt.mockResolvedValueOnce(null).mockResolvedValueOnce({
+ invocationId: 'invocation-race',
+ fingerprint: controlFingerprint(command),
+ controlId: 'control-1',
+ action: 'queue',
+ status: 'applied',
+ boundary: 'turn',
+ createdAt: new Date('2026-08-24T12:00:00.000Z'),
+ updatedAt: new Date('2026-08-24T12:00:01.000Z'),
+ });
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(request({ taskId, invocationId: 'invocation-race', ...command }), res.value);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-race',
+ status: 'applied',
+ boundary: 'turn',
+ }),
+ });
+ expect(deps.getSubagentTaskControlReceipt).toHaveBeenCalledTimes(2);
+ });
+
+ it('fails parent authorization closed without contacting a task owner', async () => {
+ const deps = dependencies();
+ deps.getConvoOwnership.mockResolvedValue(null);
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'cancel_message',
+ controlId: 'queued-control',
+ }),
+ res.value,
+ );
+
+ expect(deps.store.controlTask).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ });
+
+ it('reads a tenantless task seed only from tenantless rows', async () => {
+ const controlTask = jest.fn().mockResolvedValue({
+ status: 'accepted',
+ controlId: 'control-1',
+ task: { taskId, threadId, status: 'running' },
+ });
+ const deps = dependencies(controlTask);
+ deps.getConvoOwnership.mockResolvedValue({ ...parent, tenantId: undefined });
+ deps.getSubagentThreadForParent.mockResolvedValue({ ...child, tenantId: undefined });
+ const req = request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'queue',
+ message: 'Check the primary source.',
+ });
+ req.user = { id: 'user-1' } as ServerRequest['user'];
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(req, res.value);
+
+ expect(deps.getMessages).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: 'user-1',
+ conversationId: threadId,
+ messageId: `${taskId}:user`,
+ tenantId: { $exists: false },
+ }),
+ '+subagentTask',
+ );
+ expect(controlTask).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns an authoritative rejection when the selected task is no longer live', async () => {
+ const deps = dependencies(
+ jest.fn().mockResolvedValue({
+ status: 'not_running',
+ task: { taskId, threadId, status: 'completed' },
+ }),
+ );
+ deps.getSubagentThreadForParent.mockResolvedValue({
+ ...child,
+ subagentThreadLease: undefined,
+ });
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'cancel_message',
+ controlId: 'queued-control',
+ }),
+ res.value,
+ );
+
+ expect(deps.store.controlTask).toHaveBeenCalledWith(
+ expect.any(String),
+ taskId,
+ { action: 'cancel_message', controlId: 'queued-control' },
+ 'invocation-1',
+ );
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ controlId: 'queued-control',
+ action: 'cancel_message',
+ status: 'rejected',
+ reason: 'task_not_running',
+ }),
+ });
+ });
+
+ it('replays the durable authoritative receipt before rejecting an expired lease', async () => {
+ const deps = dependencies();
+ deps.getSubagentThreadForParent.mockResolvedValue({
+ ...child,
+ subagentThreadLease: undefined,
+ });
+ deps.getSubagentTaskControlReceipt.mockResolvedValue({
+ invocationId: 'invocation-1',
+ fingerprint: controlFingerprint({ action: 'queue', message: 'Check the primary source.' }),
+ controlId: 'control-1',
+ action: 'queue',
+ status: 'applied',
+ createdAt: new Date('2026-08-24T12:00:00.000Z'),
+ updatedAt: new Date('2026-08-24T12:00:01.000Z'),
+ boundary: 'turn',
+ message: 'Check the primary source.',
+ });
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'queue',
+ message: 'Check the primary source.',
+ }),
+ res.value,
+ );
+
+ expect(deps.store.controlTask).not.toHaveBeenCalled();
+ expect(deps.recordSubagentTaskControlReceipt).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-1',
+ status: 'applied',
+ boundary: 'turn',
+ }),
+ });
+ expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('fingerprint');
+ });
+
+ it('never exposes a private reservation as an accepted public receipt', async () => {
+ const controlTask = jest.fn().mockRejectedValue(new SubagentTaskOwnerUnavailableError());
+ const deps = dependencies(controlTask);
+ deps.getSubagentTaskControlReceipt.mockResolvedValue({
+ invocationId: 'invocation-1',
+ fingerprint: controlFingerprint({ action: 'queue', message: 'Check the primary source.' }),
+ action: 'queue',
+ status: 'reserved',
+ createdAt: new Date('2026-08-24T12:00:00.000Z'),
+ updatedAt: new Date('2026-08-24T12:00:00.000Z'),
+ message: 'Check the primary source.',
+ });
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'queue',
+ message: 'Check the primary source.',
+ }),
+ res.value,
+ );
+
+ expect(controlTask).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-1',
+ status: 'failed',
+ reason: 'owner_unavailable',
+ }),
+ });
+ expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('reserved');
+ });
+
+ it('rejects invocation-id reuse with different command content', async () => {
+ const deps = dependencies();
+ deps.getSubagentTaskControlReceipt.mockResolvedValue({
+ invocationId: 'invocation-1',
+ fingerprint: controlFingerprint({ action: 'queue', message: 'Original command.' }),
+ controlId: 'control-1',
+ action: 'queue',
+ status: 'accepted',
+ createdAt: new Date('2026-08-24T12:00:00.000Z'),
+ updatedAt: new Date('2026-08-24T12:00:00.000Z'),
+ message: 'Original command.',
+ });
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'queue',
+ message: 'Different command.',
+ }),
+ res.value,
+ );
+
+ expect(deps.store.controlTask).not.toHaveBeenCalled();
+ expect(deps.recordSubagentTaskControlReceipt).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-1',
+ status: 'rejected',
+ reason: 'invalid_command',
+ }),
+ });
+ });
+
+ it('preserves a missing cancel_message target in the authoritative rejection', async () => {
+ const deps = dependencies(
+ jest.fn().mockResolvedValue({
+ status: 'control_not_found',
+ task: { taskId, threadId, status: 'running' },
+ }),
+ );
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'cancel_message',
+ controlId: 'missing-control',
+ }),
+ res.value,
+ );
+
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ controlId: 'missing-control',
+ action: 'cancel_message',
+ status: 'rejected',
+ reason: 'control_not_found',
+ }),
+ });
+ });
+
+ it('makes owner unavailability explicit so the same invocation can be retried', async () => {
+ const deps = dependencies(jest.fn().mockRejectedValue(new SubagentTaskOwnerUnavailableError()));
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({ taskId, invocationId: 'invocation-1', action: 'interrupt', message: 'Stop.' }),
+ res.value,
+ );
+
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-1',
+ status: 'failed',
+ reason: 'owner_unavailable',
+ }),
+ });
+ });
+
+ it('returns a retryable failure when routing cannot resolve a live owner', async () => {
+ const deps = dependencies(
+ jest.fn().mockResolvedValue({
+ status: 'not_found',
+ task: { taskId, threadId, status: 'running' },
+ }),
+ );
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({ taskId, invocationId: 'invocation-1', action: 'queue', message: 'Continue.' }),
+ res.value,
+ );
+
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-1',
+ status: 'failed',
+ reason: 'owner_unavailable',
+ }),
+ });
+ });
+
+ it('rejects a task result owned by a sibling child thread', async () => {
+ const deps = dependencies(
+ jest.fn().mockResolvedValue({
+ status: 'accepted',
+ controlId: 'control-1',
+ task: { taskId, threadId: 'sibling-thread', status: 'running' },
+ }),
+ );
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+ deps.getSubagentThreadForParent.mockResolvedValue({
+ ...child,
+ subagentThreadLease: undefined,
+ });
+ deps.getMessages.mockResolvedValue([]);
+
+ await handler(
+ request({ taskId, invocationId: 'invocation-1', action: 'queue', message: 'Continue.' }),
+ res.value,
+ );
+
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
+ expect(deps.store.controlTask).not.toHaveBeenCalled();
+ });
+
+ it('keeps a live pre-seed control retryable without applying it', async () => {
+ const deps = dependencies();
+ deps.getMessages.mockResolvedValue([]);
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({ taskId, invocationId: 'invocation-1', action: 'queue', message: 'Continue.' }),
+ res.value,
+ );
+
+ expect(deps.store.controlTask).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(503);
+ expect(res.json).toHaveBeenCalledWith({
+ receipt: expect.objectContaining({
+ invocationId: 'invocation-1',
+ status: 'failed',
+ reason: 'owner_unavailable',
+ }),
+ });
+ });
+
+ it('rejects malformed controls before authorization or routing', async () => {
+ const deps = dependencies();
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({ taskId, invocationId: 'invocation-1', action: 'steer', message: ' ' }),
+ res.value,
+ );
+
+ expect(deps.getConvoOwnership).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ it('rejects task ids beyond the durable storage bound before authorization', async () => {
+ const deps = dependencies();
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId: 't'.repeat(257),
+ invocationId: 'invocation-1',
+ action: 'cancel',
+ }),
+ res.value,
+ );
+
+ expect(deps.getConvoOwnership).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+
+ it.each(['parentConversationId', 'threadId'] as const)(
+ 'rejects %s beyond the downstream storage bound before authorization',
+ async (field) => {
+ const deps = dependencies();
+ const handler = createSubagentControlHandler(deps);
+ const req = request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'cancel',
+ });
+ (req.params as Record)[field] = 'c'.repeat(257);
+ const res = response();
+
+ await handler(req, res.value);
+
+ expect(deps.getConvoOwnership).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ },
+ );
+
+ it('rejects control ids beyond the durable receipt bound before authorization', async () => {
+ const deps = dependencies();
+ const handler = createSubagentControlHandler(deps);
+ const res = response();
+
+ await handler(
+ request({
+ taskId,
+ invocationId: 'invocation-1',
+ action: 'cancel_message',
+ controlId: 'c'.repeat(257),
+ }),
+ res.value,
+ );
+
+ expect(deps.getConvoOwnership).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ });
+});
diff --git a/packages/api/src/agents/control.ts b/packages/api/src/agents/control.ts
new file mode 100644
index 0000000000..f69efb3a14
--- /dev/null
+++ b/packages/api/src/agents/control.ts
@@ -0,0 +1,279 @@
+import type {
+ SubagentControlAction,
+ SubagentControlReceipt,
+ SubagentControlRequest,
+ SubagentControlResponse,
+} from 'librechat-data-provider';
+import type {
+ ConversationMethods,
+ ISubagentTaskControlReceipt,
+ MessageMethods,
+} from '@librechat/data-schemas';
+import type { SubagentTaskControlCommand, SubagentTaskControlResult } from '@librechat/agents';
+import type { Response } from 'express';
+import type { ServerRequest } from '~/types';
+import { controlFingerprint, SubagentTaskOwnerUnavailableError } from './subagentTaskRouting';
+import { createSubagentThreadScopeId } from './subagentThreads';
+
+const MAX_THREAD_ID_BYTES = 256;
+const MAX_TASK_ID_BYTES = 256;
+const MAX_INVOCATION_ID_BYTES = 128;
+const MAX_CONTROL_MESSAGE_CHARS = 4 * 1024;
+
+type ControlStore = {
+ controlTask(
+ scopeId: string,
+ taskId: string,
+ command: SubagentTaskControlCommand,
+ invocationId: string,
+ ): Promise;
+};
+
+type Dependencies = Pick &
+ Pick & {
+ store: ControlStore;
+ };
+
+type Params = {
+ parentConversationId?: string;
+ threadId?: string;
+};
+
+const validId = (value: unknown, byteLimit = MAX_THREAD_ID_BYTES): value is string =>
+ typeof value === 'string' && value.trim() !== '' && Buffer.byteLength(value, 'utf8') <= byteLimit;
+
+const validAction = (value: unknown): value is SubagentControlAction =>
+ value === 'steer' ||
+ value === 'queue' ||
+ value === 'interrupt' ||
+ value === 'cancel' ||
+ value === 'cancel_message';
+
+const requestKeysForAction = (action: SubagentControlAction): Set => {
+ const keys = new Set(['taskId', 'invocationId', 'action']);
+ if (action === 'cancel_message') keys.add('controlId');
+ else if (action !== 'cancel') keys.add('message');
+ return keys;
+};
+
+const commandFromRequest = (
+ body: SubagentControlRequest,
+): SubagentTaskControlCommand | undefined => {
+ if (!validAction(body.action)) return undefined;
+ if (body.action === 'cancel') return { action: 'cancel' };
+ if (body.action === 'cancel_message') {
+ return validId(body.controlId, MAX_TASK_ID_BYTES)
+ ? { action: 'cancel_message', controlId: body.controlId }
+ : undefined;
+ }
+ if (
+ typeof body.message !== 'string' ||
+ body.message.trim() === '' ||
+ body.message.length > MAX_CONTROL_MESSAGE_CHARS
+ ) {
+ return undefined;
+ }
+ return { action: body.action, message: body.message };
+};
+
+/** Cheap structural admission shared by the Express route and authoritative
+ * handler. It must run before filters, moderation, or owner routing. */
+export const isValidSubagentControlRequest = (value: unknown): value is SubagentControlRequest => {
+ if (value == null || typeof value !== 'object') return false;
+ const body = value as Partial;
+ if (!validAction(body.action)) return false;
+ const allowedKeys = requestKeysForAction(body.action);
+ return (
+ Object.keys(body).every((key) => allowedKeys.has(key)) &&
+ validId(body.taskId, MAX_TASK_ID_BYTES) &&
+ validId(body.invocationId, MAX_INVOCATION_ID_BYTES) &&
+ commandFromRequest(body as SubagentControlRequest) != null
+ );
+};
+
+const commandReceiptFields = (command: SubagentTaskControlCommand) => ({
+ ...(command.action === 'cancel_message' ? { controlId: command.controlId } : {}),
+ ...('message' in command ? { message: command.message } : {}),
+});
+
+const responseReceipt = (
+ invocationId: string,
+ command: SubagentTaskControlCommand,
+ result: SubagentTaskControlResult,
+): SubagentControlReceipt => {
+ const now = new Date().toISOString();
+ let status: SubagentControlReceipt['status'] = 'rejected';
+ let reason: string | undefined;
+ if (result.status === 'accepted')
+ status = command.action === 'cancel_message' ? 'applied' : 'accepted';
+ if (result.status === 'cancelled') status = 'applied';
+ if (result.status === 'not_running') reason = 'task_not_running';
+ if (result.status === 'control_not_found') reason = 'control_not_found';
+ if (result.status === 'invalid') reason = 'invalid_command';
+ if (result.status === 'not_found') {
+ status = 'failed';
+ reason = 'owner_unavailable';
+ }
+ return {
+ invocationId,
+ ...commandReceiptFields(command),
+ ...(command.action !== 'cancel_message' &&
+ result.status === 'accepted' &&
+ result.controlId != null
+ ? { controlId: result.controlId }
+ : {}),
+ action: command.action,
+ status,
+ createdAt: now,
+ updatedAt: now,
+ ...(reason == null ? {} : { reason }),
+ };
+};
+
+const publicStoredReceipt = ({
+ fingerprint: _fingerprint,
+ createdAt,
+ updatedAt,
+ status,
+ ...receipt
+}: ISubagentTaskControlReceipt): SubagentControlReceipt => {
+ if (status === 'reserved') {
+ throw new SubagentTaskOwnerUnavailableError();
+ }
+ return {
+ ...receipt,
+ status,
+ createdAt: createdAt.toISOString(),
+ updatedAt: updatedAt.toISOString(),
+ };
+};
+
+/** Applies one parent-authorized control to the live owner and returns only its public receipt. */
+export function createSubagentControlHandler(deps: Dependencies) {
+ return async (req: ServerRequest, res: Response): Promise => {
+ const userId = req.user?.id;
+ const tenantId = req.user?.tenantId || undefined;
+ const { parentConversationId, threadId } = req.params as Params;
+ const body = (req.body ?? {}) as Partial;
+ const command = commandFromRequest(body as SubagentControlRequest);
+ if (
+ !userId ||
+ !validId(parentConversationId, MAX_THREAD_ID_BYTES) ||
+ !validId(threadId, MAX_THREAD_ID_BYTES) ||
+ parentConversationId === threadId ||
+ !isValidSubagentControlRequest(body) ||
+ command == null
+ ) {
+ res.status(400).json({ error: 'Invalid subagent control request' });
+ return;
+ }
+
+ try {
+ const [parent, child] = await Promise.all([
+ deps.getConvoOwnership(userId, parentConversationId, tenantId ?? null),
+ deps.getSubagentThreadForParent({
+ user: userId,
+ parentConversationId,
+ conversationId: threadId,
+ ...(tenantId == null ? {} : { tenantId }),
+ }),
+ ]);
+ if (
+ parent == null ||
+ child?.subagentThread?.parentConversationId !== parentConversationId ||
+ parent.tenantId !== tenantId ||
+ child.tenantId !== tenantId
+ ) {
+ res.status(404).json({ error: 'Conversation not found' });
+ return;
+ }
+ const fingerprint = controlFingerprint(command);
+ const existing = await deps.getSubagentTaskControlReceipt({
+ userId,
+ conversationId: threadId,
+ taskId: body.taskId,
+ invocationId: body.invocationId,
+ ...(tenantId == null ? {} : { tenantId }),
+ });
+ /** `reserved` is a server-private at-most-once fence, not an authoritative
+ * public receipt. Re-enter the task store so it can return owner-unavailable
+ * without exposing false acceptance or reapplying the command. */
+ if (existing != null && existing.status !== 'reserved') {
+ const receipt =
+ existing.fingerprint === fingerprint
+ ? publicStoredReceipt(existing)
+ : responseReceipt(body.invocationId, command, {
+ status: 'invalid',
+ message: 'This control invocation id was already used for a different command.',
+ });
+ res.status(200).json({ receipt } satisfies SubagentControlResponse);
+ return;
+ }
+ const [taskInput] = await deps.getMessages(
+ {
+ user: userId,
+ conversationId: threadId,
+ messageId: `${body.taskId}:user`,
+ ...(tenantId == null ? { tenantId: { $exists: false } } : { tenantId }),
+ },
+ '+subagentTask',
+ );
+ if (taskInput?.subagentTask == null) {
+ if (
+ child.subagentThreadLease?.taskId === body.taskId &&
+ child.subagentThreadLease.expiresAt.getTime() > Date.now()
+ ) {
+ throw new SubagentTaskOwnerUnavailableError();
+ }
+ res.status(404).json({ error: 'Conversation not found' });
+ return;
+ }
+ const scopeId = createSubagentThreadScopeId({
+ userId,
+ parentConversationId,
+ ...(tenantId == null ? {} : { tenantId }),
+ });
+ /** Retry identity and durable settlement belong to the task store. Route
+ * through that ledger even when the visible lease is stale instead of
+ * synthesizing a rejection that can race the owner's delayed receipt. */
+ const result = await deps.store.controlTask(scopeId, body.taskId, command, body.invocationId);
+ if (result.status === 'not_found') {
+ throw new SubagentTaskOwnerUnavailableError();
+ }
+ if ('task' in result && result.task.threadId !== threadId) {
+ res.status(404).json({ error: 'Conversation not found' });
+ return;
+ }
+ /** Routing returns the SDK task result, whose legacy `accepted` shape cannot
+ * distinguish an accepted command from one that became applied during the
+ * call. The durable ledger is authoritative after the store returns. */
+ const settledReceipt = await deps.getSubagentTaskControlReceipt({
+ userId,
+ conversationId: threadId,
+ taskId: body.taskId,
+ invocationId: body.invocationId,
+ ...(tenantId == null ? {} : { tenantId }),
+ });
+ const receipt =
+ settledReceipt != null && settledReceipt.fingerprint === fingerprint
+ ? publicStoredReceipt(settledReceipt)
+ : responseReceipt(body.invocationId, command, result);
+ res.status(200).json({ receipt } satisfies SubagentControlResponse);
+ } catch (error) {
+ if (error instanceof SubagentTaskOwnerUnavailableError) {
+ const receipt: SubagentControlReceipt = {
+ invocationId: body.invocationId,
+ ...commandReceiptFields(command),
+ action: command.action,
+ status: 'failed',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ reason: 'owner_unavailable',
+ };
+ res.status(503).json({ receipt } satisfies SubagentControlResponse);
+ return;
+ }
+ res.status(500).json({ error: 'Failed to control subagent task' });
+ }
+ };
+}
diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts
index 9444d41bc2..b0220b3848 100644
--- a/packages/api/src/agents/index.ts
+++ b/packages/api/src/agents/index.ts
@@ -7,6 +7,7 @@ export * from './config';
export * from './checkpointer';
export * from './contact';
export * from './context';
+export * from './control';
export * from './conversation';
export * from './discovery';
export * from './edges';
diff --git a/packages/api/src/agents/subagentThreads.ts b/packages/api/src/agents/subagentThreads.ts
index 0474cbf4ba..c118799d94 100644
--- a/packages/api/src/agents/subagentThreads.ts
+++ b/packages/api/src/agents/subagentThreads.ts
@@ -315,6 +315,11 @@ function serializeScope(scope: Omit): string {
return JSON.stringify({ version: SCOPE_VERSION, ...scope });
}
+/** Builds the trusted live-owner routing scope after parent authorization. */
+export function createSubagentThreadScopeId(scope: Omit): string {
+ return serializeScope(scope);
+}
+
function matchesTenant(actual: string | undefined, expected: string | undefined): boolean {
return actual === expected;
}
diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts
index cb652d473f..49c80fdced 100644
--- a/packages/data-provider/src/api-endpoints.ts
+++ b/packages/data-provider/src/api-endpoints.ts
@@ -124,6 +124,9 @@ export const subagentThread = (parentConversationId: string, threadId: string, t
return taskId == null ? endpoint : `${endpoint}?taskId=${encodeURIComponent(taskId)}`;
};
+export const subagentControl = (parentConversationId: string, threadId: string) =>
+ `${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents/${encodeURIComponent(threadId)}/control`;
+
export const genTitle = (conversationId: string) =>
`${conversationsRoot}/gen_title/${encodeURIComponent(conversationId)}`;
diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts
index bffed4c235..4192c66900 100644
--- a/packages/data-provider/src/data-service.ts
+++ b/packages/data-provider/src/data-service.ts
@@ -1014,6 +1014,14 @@ export function getSubagentThread(
return request.get(endpoints.subagentThread(parentConversationId, threadId, taskId));
}
+export function controlSubagentTask(
+ parentConversationId: string,
+ threadId: string,
+ body: t.SubagentControlRequest,
+): Promise {
+ return request.post(endpoints.subagentControl(parentConversationId, threadId), body);
+}
+
export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> {
return request.get(endpoints.getPrompt(id));
}
diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts
index d19d7bf918..65e26d69de 100644
--- a/packages/data-provider/src/keys.ts
+++ b/packages/data-provider/src/keys.ts
@@ -103,6 +103,7 @@ export const DynamicQueryKeys = {
} as const;
export enum MutationKeys {
+ subagentControl = 'subagentControl',
updateLangfuseConnection = 'updateLangfuseConnection',
testLangfuseConnection = 'testLangfuseConnection',
createAgentApiKey = 'createAgentApiKey',
diff --git a/packages/data-provider/src/types/subagents.ts b/packages/data-provider/src/types/subagents.ts
index 4f6f555469..f05fe857fc 100644
--- a/packages/data-provider/src/types/subagents.ts
+++ b/packages/data-provider/src/types/subagents.ts
@@ -66,10 +66,12 @@ export type SubagentActivityItem =
outputTruncated?: boolean;
};
+export type SubagentControlAction = 'steer' | 'queue' | 'interrupt' | 'cancel' | 'cancel_message';
+
export type SubagentControlReceipt = {
invocationId: string;
controlId?: string;
- action: 'steer' | 'queue' | 'interrupt' | 'cancel' | 'cancel_message';
+ action: SubagentControlAction;
status: 'accepted' | 'applied' | 'rejected' | 'failed';
createdAt: string;
updatedAt: string;
@@ -79,6 +81,18 @@ export type SubagentControlReceipt = {
messageTruncated?: boolean;
};
+export type SubagentControlRequest = {
+ taskId: string;
+ invocationId: string;
+ action: SubagentControlAction;
+ message?: string;
+ controlId?: string;
+};
+
+export type SubagentControlResponse = {
+ receipt: SubagentControlReceipt;
+};
+
export type SubagentThreadMessage = {
messageId: string;
parentMessageId: string | null;