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