🎛️ 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

@ -4,11 +4,22 @@ const generationJobManager = {
abortJob: jest.fn().mockResolvedValue({ success: true }),
};
const subagentActivityHandlerInputs = [];
const moderatedTexts = [];
const moderateText = jest.fn((req, _res, next) => {
moderatedTexts.push(req.body?.text);
next();
});
const messageIpLimiter = jest.fn((_req, _res, next) => next());
const messageUserLimiter = jest.fn((_req, _res, next) => next());
module.exports = {
archiveAllHandler,
generationJobManager,
subagentActivityHandlerInputs,
moderateText,
moderatedTexts,
messageIpLimiter,
messageUserLimiter,
agents: () => ({ sleep: jest.fn() }),
@ -40,6 +51,36 @@ module.exports = {
return archiveAllHandler;
}),
createSubagentThreadViewHandler: jest.fn(() => (_req, res) => res.status(200).json({})),
createSubagentControlHandler: jest.fn(() => (_req, res) => res.status(200).json({})),
isValidSubagentControlRequest: jest.fn((body) => {
if (body == null || typeof body !== 'object') return false;
const commonKeys = ['taskId', 'invocationId', 'action'];
let allowedKeys = [...commonKeys, 'message'];
if (body.action === 'cancel_message') allowedKeys = [...commonKeys, 'controlId'];
if (body.action === 'cancel') allowedKeys = commonKeys;
if (Object.keys(body).some((key) => !allowedKeys.includes(key))) return false;
if (typeof body.taskId !== 'string' || body.taskId.length === 0 || body.taskId.length > 256) {
return false;
}
if (
typeof body.invocationId !== 'string' ||
body.invocationId.length === 0 ||
body.invocationId.length > 128
) {
return false;
}
if (body.action === 'cancel') return true;
if (body.action === 'cancel_message') {
return typeof body.controlId === 'string' && body.controlId.length > 0;
}
return (
['steer', 'queue', 'interrupt'].includes(body.action) &&
typeof body.message === 'string' &&
body.message.trim() !== '' &&
body.message.length <= 4 * 1024
);
}),
exemptAgentTriggerFromIpLimiter: jest.fn(() => false),
createParentSubagentIndexHandler: jest.fn(
() => (_req, res) => res.status(200).json({ threads: [] }),
),
@ -116,6 +157,9 @@ module.exports = {
forkUserLimiter: (req, res, next) => next(),
})),
configMiddleware: (req, res, next) => next(),
moderateText,
messageIpLimiter,
messageUserLimiter,
validateConvoAccess: (req, res, next) => next(),
}),

View file

@ -2,12 +2,32 @@ const express = require('express');
const request = require('supertest');
const MOCKS = '../__test-utils__/convos-route-mocks';
const { archiveAllHandler, generationJobManager, subagentActivityHandlerInputs } = require(MOCKS);
const {
archiveAllHandler,
generationJobManager,
moderateText,
moderatedTexts,
messageIpLimiter,
messageUserLimiter,
subagentActivityHandlerInputs,
} = require(MOCKS);
const priorLimitMessageIp = process.env.LIMIT_MESSAGE_IP;
const priorLimitMessageUser = process.env.LIMIT_MESSAGE_USER;
process.env.LIMIT_MESSAGE_IP = 'true';
process.env.LIMIT_MESSAGE_USER = 'true';
jest.mock('@librechat/agents', () => require(MOCKS).agents());
jest.mock('@librechat/api', () =>
require(MOCKS).api({
createContentFilter: jest.fn(() => (req, res, next) => next()),
createContentFilter: jest.fn((options) => (req, res, next) => {
const extracted = [...options.extract(req)];
if (JSON.stringify(extracted).includes('BLOCK-CONTROL')) {
return res.status(400).json({ error: 'content_filter_block' });
}
next();
}),
extractStoredMessageContent: jest.fn((input) => [input]),
inspectContent: jest.fn(() => null),
extractConversationTitleContent: jest.fn(() => []),
contentFilterBlockResponse: jest.fn(),
@ -77,8 +97,16 @@ describe('Convos Routes', () => {
app.use('/api/convos', convosRouter);
});
afterAll(() => {
if (priorLimitMessageIp == null) delete process.env.LIMIT_MESSAGE_IP;
else process.env.LIMIT_MESSAGE_IP = priorLimitMessageIp;
if (priorLimitMessageUser == null) delete process.env.LIMIT_MESSAGE_USER;
else process.env.LIMIT_MESSAGE_USER = priorLimitMessageUser;
});
beforeEach(() => {
jest.clearAllMocks();
moderatedTexts.length = 0;
generationJobManager.getJob.mockResolvedValue(null);
generationJobManager.abortJob.mockResolvedValue({ success: true });
});
@ -96,6 +124,71 @@ describe('Convos Routes', () => {
);
});
it('filters and moderates subagent guidance as ordinary user text before control handling', async () => {
const response = await request(app).post('/api/convos/parent/subagents/child/control').send({
taskId: 'task-1',
invocationId: 'invocation-1',
action: 'queue',
message: 'Guide the child.',
});
expect(response.status).toBe(200);
expect(messageIpLimiter).toHaveBeenCalledTimes(1);
expect(messageUserLimiter).toHaveBeenCalledTimes(1);
expect(moderateText).toHaveBeenCalledTimes(1);
expect(moderatedTexts).toEqual(['Guide the child.']);
moderateText.mockClear();
moderatedTexts.length = 0;
const blocked = await request(app).post('/api/convos/parent/subagents/child/control').send({
taskId: 'task-1',
invocationId: 'invocation-2',
action: 'interrupt',
message: 'BLOCK-CONTROL',
});
expect(blocked.status).toBe(400);
expect(blocked.body).toEqual({ error: 'content_filter_block' });
expect(moderateText).not.toHaveBeenCalled();
moderateText.mockClear();
const oversized = await request(app)
.post('/api/convos/parent/subagents/child/control')
.send({
taskId: 'task-1',
invocationId: 'invocation-3',
action: 'queue',
message: 'x'.repeat(4 * 1024 + 1),
});
expect(oversized.status).toBe(400);
expect(oversized.body).toEqual({ error: 'Invalid subagent control request' });
expect(moderateText).not.toHaveBeenCalled();
const cancelled = await request(app).post('/api/convos/parent/subagents/child/control').send({
taskId: 'task-1',
invocationId: 'invocation-4',
action: 'cancel',
});
expect(cancelled.status).toBe(200);
expect(moderateText).not.toHaveBeenCalled();
const crafted = await request(app)
.post('/api/convos/parent/subagents/child/control')
.send({
taskId: 'task-1',
invocationId: 'invocation-5',
action: 'queue',
message: 'Use only this bounded guidance.',
answers: ['This unrelated field must not reach moderation.'],
});
expect(crafted.status).toBe(400);
expect(crafted.body).toEqual({ error: 'Invalid subagent control request' });
expect(moderateText).not.toHaveBeenCalled();
});
describe('GET /:conversationId', () => {
it('returns an ordinary owned conversation', async () => {
getConvo.mockResolvedValue({ conversationId: 'ordinary', title: 'Ordinary' });

View file

@ -6,6 +6,9 @@ const {
deleteAgentCheckpoints,
createArchiveAllHandler,
createSubagentActivityStreamHandler,
createSubagentControlHandler,
isValidSubagentControlRequest,
exemptAgentTriggerFromIpLimiter,
createParentSubagentIndexHandler,
createSubagentThreadViewHandler,
resolveImportMaxFileSize,
@ -17,6 +20,7 @@ const {
isContentFilterError,
contentFilterBlockResponse,
extractConversationTitleContent,
extractStoredMessageContent,
GenerationJobManager,
isStopConfirmed,
} = require('@librechat/api');
@ -27,6 +31,9 @@ const {
validateConvoAccess,
createForkLimiters,
configMiddleware,
messageIpLimiter,
messageUserLimiter,
moderateText,
} = require('~/server/middleware');
const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork');
const { storage, importFileFilter } = require('~/server/routes/files/multer');
@ -57,6 +64,60 @@ const filterConversationTitle = createContentFilter({
getFilters: (req) => req.config?.filters,
extract: (req) => extractConversationTitleContent(req.body),
});
const filterSubagentControlMessage = createContentFilter({
getFilters: (req) => req.config?.filters,
getLegacyPii: (req) => req.config?.messageFilter?.pii,
extract: (req) =>
['steer', 'queue', 'interrupt'].includes(req.body?.action)
? extractStoredMessageContent({ text: req.body?.message })
: [],
});
const unless = (isExempt, middleware) => (req, res, next) =>
isExempt(req) ? next() : middleware(req, res, next);
const subagentControlLimiters = [];
if (isEnabled(process.env.LIMIT_MESSAGE_IP)) {
subagentControlLimiters.push(unless(exemptAgentTriggerFromIpLimiter, messageIpLimiter));
}
if (isEnabled(process.env.LIMIT_MESSAGE_USER)) {
subagentControlLimiters.push(messageUserLimiter);
}
function validateSubagentControlRequest(req, res, next) {
if (!isValidSubagentControlRequest(req.body)) {
return res.status(400).json({ error: 'Invalid subagent control request' });
}
next();
}
/** Present guidance to the existing moderation middleware as ordinary user text.
* The controller continues to consume `message`; `text` is restored before it runs. */
async function moderateSubagentControlMessage(req, res, next) {
const body = (req.body ??= {});
if (!['steer', 'queue', 'interrupt'].includes(body.action)) {
next();
return;
}
const hadText = Object.prototype.hasOwnProperty.call(body, 'text');
const originalText = body.text;
if (typeof body.message === 'string') {
body.text = body.message;
}
const restore = () => {
if (hadText) {
body.text = originalText;
} else {
delete body.text;
}
};
try {
await moderateText(req, res, (error) => {
restore();
next(error);
});
} finally {
restore();
}
}
const subagentActivityStreamHandler = createSubagentActivityStreamHandler(
{
getConvoOwnership: db.getConvoOwnership,
@ -67,6 +128,13 @@ const subagentActivityStreamHandler = createSubagentActivityStreamHandler(
subscribe: subagentThreadTaskStore.subscribeActivity.bind(subagentThreadTaskStore),
},
);
const subagentControlHandler = createSubagentControlHandler({
getConvoOwnership: db.getConvoOwnership,
getSubagentThreadForParent: db.getSubagentThreadForParent,
getMessages: db.getMessages,
getSubagentTaskControlReceipt: db.getSubagentTaskControlReceipt,
store: subagentThreadTaskStore,
});
router.use(requireJwtAuth);
const isValidProjectFilter = (projectId) =>
@ -117,6 +185,15 @@ router.get(
'/:parentConversationId/subagents/:threadId/tasks/:taskId/activity',
subagentActivityStreamHandler,
);
router.post(
'/:parentConversationId/subagents/:threadId/control',
configMiddleware,
...subagentControlLimiters,
validateSubagentControlRequest,
filterSubagentControlMessage,
moderateSubagentControlMessage,
subagentControlHandler,
);
router.get('/:parentConversationId/subagents', parentSubagentIndexHandler);
router.get('/:parentConversationId/subagents/:threadId', subagentThreadViewHandler);

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,

View file

@ -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<string, unknown>): 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<string, string>)[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);
});
});

View file

@ -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<SubagentTaskControlResult>;
};
type Dependencies = Pick<ConversationMethods, 'getConvoOwnership' | 'getSubagentThreadForParent'> &
Pick<MessageMethods, 'getMessages' | 'getSubagentTaskControlReceipt'> & {
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<string> => {
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<SubagentControlRequest>;
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<void> => {
const userId = req.user?.id;
const tenantId = req.user?.tenantId || undefined;
const { parentConversationId, threadId } = req.params as Params;
const body = (req.body ?? {}) as Partial<SubagentControlRequest>;
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' });
}
};
}

View file

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

View file

@ -315,6 +315,11 @@ function serializeScope(scope: Omit<SubagentThreadScope, 'version'>): string {
return JSON.stringify({ version: SCOPE_VERSION, ...scope });
}
/** Builds the trusted live-owner routing scope after parent authorization. */
export function createSubagentThreadScopeId(scope: Omit<SubagentThreadScope, 'version'>): string {
return serializeScope(scope);
}
function matchesTenant(actual: string | undefined, expected: string | undefined): boolean {
return actual === expected;
}

View file

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

View file

@ -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<t.SubagentControlResponse> {
return request.post(endpoints.subagentControl(parentConversationId, threadId), body);
}
export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> {
return request.get(endpoints.getPrompt(id));
}

View file

@ -103,6 +103,7 @@ export const DynamicQueryKeys = {
} as const;
export enum MutationKeys {
subagentControl = 'subagentControl',
updateLangfuseConnection = 'updateLangfuseConnection',
testLangfuseConnection = 'testLangfuseConnection',
createAgentApiKey = 'createAgentApiKey',

View file

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