mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🖼️ fix: Restore Shared Subagent Activity as a Read-Only View (#15108)
This commit is contained in:
parent
199de92c51
commit
08c9cc3d3d
16 changed files with 470 additions and 28 deletions
|
|
@ -14,7 +14,9 @@ import store, {
|
|||
subagentProgressByToolCallId,
|
||||
subagentProgressKey,
|
||||
} from '~/store';
|
||||
import { adaptLivePersistedActivity } from '~/components/Chat/Subagents/adapters';
|
||||
import { MessageContext } from '~/Providers/MessageContext';
|
||||
import { useShareContext } from '~/Providers/ShareContext';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { parseSubagentBackgroundHandle } from './handle';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
|
@ -166,6 +168,7 @@ export default function SubagentCall({
|
|||
hideAttachments = false,
|
||||
}: SubagentCallProps) {
|
||||
const localize = useLocalize();
|
||||
const { isSharedConvo, shareId } = useShareContext();
|
||||
const parentMessageContext = useContext(MessageContext);
|
||||
const parentMessageId = parentMessageContext.messageId?.trim() ?? '';
|
||||
const partIndex = parentMessageContext.partIndex ?? 0;
|
||||
|
|
@ -181,7 +184,8 @@ export default function SubagentCall({
|
|||
[output, args],
|
||||
);
|
||||
const parentConversationId = parentMessageContext.conversationId?.trim() ?? '';
|
||||
const canOpenDurablePanel = backgroundHandle != null && parentConversationId !== '';
|
||||
const canOpenDurablePanel =
|
||||
isSharedConvo !== true && backgroundHandle != null && parentConversationId !== '';
|
||||
|
||||
const subagentType = progress?.subagentType ?? extractSubagentType(args);
|
||||
const isSelfSpawn = subagentType === 'self';
|
||||
|
|
@ -277,8 +281,26 @@ export default function SubagentCall({
|
|||
* the name isn't resolvable (agent map miss). */
|
||||
const subagentNameLabel = !isSelfSpawn && subagentAgent?.name ? subagentAgent.name : '';
|
||||
|
||||
const canOpenDetails = useMemo(
|
||||
() =>
|
||||
isSharedConvo !== true ||
|
||||
adaptLivePersistedActivity({
|
||||
title: '',
|
||||
progress: null,
|
||||
persistedContent,
|
||||
legacyOutput: backgroundHandle == null ? output : undefined,
|
||||
initialProgress,
|
||||
isSubmitting: false,
|
||||
runStepStatus,
|
||||
approvalVisibility: 'hidden',
|
||||
}).items.length > 0,
|
||||
[backgroundHandle, initialProgress, isSharedConvo, output, persistedContent, runStepStatus],
|
||||
);
|
||||
|
||||
const panelSelection = useMemo(
|
||||
() => ({
|
||||
host: isSharedConvo === true ? ('share' as const) : ('conversation' as const),
|
||||
...(isSharedConvo === true && shareId != null ? { shareId } : {}),
|
||||
parentConversationId,
|
||||
parentMessageId,
|
||||
toolCallId,
|
||||
|
|
@ -303,6 +325,7 @@ export default function SubagentCall({
|
|||
backgroundHandle,
|
||||
canOpenDurablePanel,
|
||||
initialProgress,
|
||||
isSharedConvo,
|
||||
isSubmitting,
|
||||
output,
|
||||
parentConversationId,
|
||||
|
|
@ -311,6 +334,7 @@ export default function SubagentCall({
|
|||
persistedContent,
|
||||
prompt,
|
||||
runStepStatus,
|
||||
shareId,
|
||||
subagentType,
|
||||
toolCallId,
|
||||
],
|
||||
|
|
@ -327,16 +351,24 @@ export default function SubagentCall({
|
|||
}, [panelSelection, parentMessageId, partIndex, setSelectedSubagent, toolCallId]);
|
||||
|
||||
const openDetails = useCallback(() => {
|
||||
if (!canOpenDetails) return;
|
||||
resetCurrentArtifactId();
|
||||
setArtifactsVisible(false);
|
||||
setSelectedSubagent(panelSelection);
|
||||
}, [panelSelection, resetCurrentArtifactId, setArtifactsVisible, setSelectedSubagent]);
|
||||
}, [
|
||||
canOpenDetails,
|
||||
panelSelection,
|
||||
resetCurrentArtifactId,
|
||||
setArtifactsVisible,
|
||||
setSelectedSubagent,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDetails}
|
||||
disabled={!canOpenDetails}
|
||||
data-subagent-thread={
|
||||
canOpenDurablePanel ? backgroundHandle?.subagent_thread_id : undefined
|
||||
}
|
||||
|
|
@ -344,7 +376,8 @@ export default function SubagentCall({
|
|||
data-subagent-parent-message={parentMessageId}
|
||||
data-subagent-part-index={partIndex}
|
||||
className={cn(
|
||||
'group my-1.5 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition hover:bg-surface-tertiary',
|
||||
'my-1.5 flex w-full flex-col gap-1 rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-left transition',
|
||||
canOpenDetails ? 'group hover:bg-surface-tertiary' : 'cursor-default opacity-80',
|
||||
running && !detachedStatusUnknown && 'animate-pulse-slow',
|
||||
)}
|
||||
aria-label={headerText}
|
||||
|
|
@ -384,11 +417,13 @@ export default function SubagentCall({
|
|||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="shrink-0 text-text-secondary transition group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{canOpenDetails && (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="shrink-0 text-text-secondary transition group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="w-full space-y-0.5 pl-5 font-mono text-xs text-text-secondary">
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ const OpenSubagentPanel = () => {
|
|||
const open = () => {
|
||||
setConversation({ conversationId: 'parent-conversation' } as TConversation);
|
||||
setSelection({
|
||||
host: 'conversation',
|
||||
parentConversationId: 'parent-conversation',
|
||||
parentMessageId: 'parent-message',
|
||||
toolCallId: 'tool-call',
|
||||
|
|
|
|||
|
|
@ -96,7 +96,11 @@ export default function Presentation({ children }: { children: React.ReactNode }
|
|||
}, [artifactsElement, resetSelectedSubagent, selectedSubagent]);
|
||||
|
||||
const subagentElement = useMemo(() => {
|
||||
if (selectedSubagent == null || selectedSubagent.parentConversationId !== conversationId) {
|
||||
if (
|
||||
selectedSubagent == null ||
|
||||
selectedSubagent.host !== 'conversation' ||
|
||||
selectedSubagent.parentConversationId !== conversationId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import SubagentCall from '~/components/Chat/Messages/Content/Parts/SubagentCall';
|
||||
import SharedSubagentActivityDialog from './SharedSubagentActivityDialog';
|
||||
import { MessageContext } from '~/Providers/MessageContext';
|
||||
import { ShareContext } from '~/Providers/ShareContext';
|
||||
|
||||
const mockUseSubagentThreadQuery = jest.fn();
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useSubagentThreadQuery: (...args: unknown[]) => mockUseSubagentThreadQuery(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize:
|
||||
() =>
|
||||
(key: string, values?: Record<number, string>): string => {
|
||||
if (key === 'com_ui_subagent_dialog_title') return `Agent ${values?.[0] ?? ''}`;
|
||||
if (key === 'com_ui_subagent_complete') return 'Ran agent';
|
||||
if (key === 'com_ui_subagent_activity') return 'Agent activity';
|
||||
return key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({ useAgentsMapContext: () => ({}) }));
|
||||
jest.mock('~/components/Share/MessageIcon', () => ({ __esModule: true, default: () => null }));
|
||||
jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => [] }));
|
||||
|
||||
jest.mock('./SubagentActivity', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
activity,
|
||||
}: {
|
||||
activity: { title: string; items: Array<{ type: string; text?: string }> };
|
||||
}) => (
|
||||
<div data-testid="shared-subagent-activity">
|
||||
<span>{activity.title}</span>
|
||||
{activity.items.map((item, index) => (
|
||||
<span key={index}>{item.text ?? item.type}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const persistedContent = (text: string): TMessageContentParts[] => [
|
||||
{ type: ContentTypes.TEXT, text } as TMessageContentParts,
|
||||
];
|
||||
|
||||
const detachedOutput = JSON.stringify({
|
||||
background_task_id: 'task-1',
|
||||
subagent_thread_id: 'thread-1',
|
||||
tool: 'subagent',
|
||||
subagent_type: 'researcher',
|
||||
status: 'running',
|
||||
message:
|
||||
'Started subagent "researcher" background task. Poll the host background-task tool with background_task_id "task-1".',
|
||||
});
|
||||
|
||||
function renderSharedCall(input: {
|
||||
output?: string;
|
||||
persistedContent?: TMessageContentParts[];
|
||||
detached?: boolean;
|
||||
}) {
|
||||
return render(
|
||||
<RecoilRoot>
|
||||
<ShareContext.Provider value={{ isSharedConvo: true, shareId: 'share-1' }}>
|
||||
<MessageContext.Provider
|
||||
value={{
|
||||
conversationId: 'shared-conversation',
|
||||
messageId: 'shared-parent',
|
||||
isExpanded: false,
|
||||
}}
|
||||
>
|
||||
<SubagentCall
|
||||
toolCallId="shared-call"
|
||||
initialProgress={1}
|
||||
args={{
|
||||
subagent_type: 'researcher',
|
||||
description: 'Review the release.',
|
||||
run_in_background: input.detached === true,
|
||||
}}
|
||||
output={input.output}
|
||||
persistedContent={input.persistedContent}
|
||||
/>
|
||||
<SharedSubagentActivityDialog shareId="share-1" />
|
||||
</MessageContext.Provider>
|
||||
</ShareContext.Provider>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('SharedSubagentActivityDialog', () => {
|
||||
beforeEach(() => mockUseSubagentThreadQuery.mockClear());
|
||||
|
||||
it('opens readable foreground activity from the shared message payload and restores focus', async () => {
|
||||
renderSharedCall({
|
||||
output: 'Legacy fallback.',
|
||||
persistedContent: persistedContent('Shared review complete.'),
|
||||
});
|
||||
const trigger = screen.getByRole('button', { name: 'Ran agent' });
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shared review complete.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it('renders detached persisted activity without performing the private durable query', () => {
|
||||
renderSharedCall({
|
||||
output: detachedOutput,
|
||||
persistedContent: persistedContent('Detached work survived refresh.'),
|
||||
detached: true,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Agent activity' }));
|
||||
|
||||
expect(screen.getByText('Detached work survived refresh.')).toBeInTheDocument();
|
||||
expect(mockUseSubagentThreadQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('makes a detached shared card without persisted activity explicitly noninteractive', () => {
|
||||
renderSharedCall({ output: detachedOutput, detached: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Agent activity' })).toBeDisabled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(mockUseSubagentThreadQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
import { adaptLivePersistedActivity } from './adapters';
|
||||
import SubagentActivity from './SubagentActivity';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/** Public-share fallback for subagent activity already embedded in the shared message payload. */
|
||||
export default function SharedSubagentActivityDialog({ shareId }: { shareId?: string }) {
|
||||
const localize = useLocalize();
|
||||
const selected = useRecoilValue(activeSubagentPanel);
|
||||
const resetSelection = useResetRecoilState(activeSubagentPanel);
|
||||
const selection = selected?.host === 'share' && selected.shareId === shareId ? selected : null;
|
||||
const restoreSelectionRef = useRef(selection);
|
||||
if (selection != null) restoreSelectionRef.current = selection;
|
||||
const title =
|
||||
selection?.subagentType === 'self'
|
||||
? localize('com_ui_subagent_dialog_title_self')
|
||||
: localize('com_ui_subagent_dialog_title', { 0: selection?.subagentType ?? '' });
|
||||
const activity = useMemo(
|
||||
() =>
|
||||
adaptLivePersistedActivity({
|
||||
title,
|
||||
prompt: selection?.prompt,
|
||||
progress: null,
|
||||
persistedContent: selection?.persistedContent,
|
||||
legacyOutput: selection?.legacyOutput,
|
||||
initialProgress: selection?.initialProgress ?? 1,
|
||||
isSubmitting: false,
|
||||
runStepStatus: selection?.runStepStatus,
|
||||
approvalVisibility: 'hidden',
|
||||
}),
|
||||
[selection, title],
|
||||
);
|
||||
|
||||
const restoreTriggerFocus = useCallback((event: Event) => {
|
||||
event.preventDefault();
|
||||
const selectionToRestore = restoreSelectionRef.current;
|
||||
if (selectionToRestore == null) return;
|
||||
requestAnimationFrame(() => {
|
||||
const trigger = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-subagent-tool-call]'),
|
||||
).find(
|
||||
(element) =>
|
||||
element.dataset.subagentToolCall === selectionToRestore.toolCallId &&
|
||||
element.dataset.subagentParentMessage === selectionToRestore.parentMessageId &&
|
||||
element.dataset.subagentPartIndex === String(selectionToRestore.partIndex),
|
||||
);
|
||||
trigger?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => resetSelection(), [resetSelection]);
|
||||
useEffect(() => {
|
||||
if (selected?.host === 'share' && selected.shareId !== shareId) resetSelection();
|
||||
}, [resetSelection, selected, shareId]);
|
||||
|
||||
return (
|
||||
<OGDialog open={selection != null} onOpenChange={(open) => !open && resetSelection()}>
|
||||
<OGDialogContent
|
||||
className="flex h-[min(90vh,48rem)] w-11/12 max-w-3xl flex-col gap-0 overflow-hidden p-0"
|
||||
onCloseAutoFocus={restoreTriggerFocus}
|
||||
>
|
||||
<OGDialogHeader className="shrink-0 border-b border-border-light px-5 py-4 pr-14">
|
||||
<OGDialogTitle className="truncate text-left text-base" title={activity.title}>
|
||||
{activity.title}
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<SubagentActivity activity={activity} />
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ jest.mock('lucide-react', () => ({
|
|||
}));
|
||||
|
||||
const selection: ActiveSubagentPanel = {
|
||||
host: 'conversation',
|
||||
parentConversationId: 'parent-conversation',
|
||||
parentMessageId: 'parent-message',
|
||||
toolCallId: 'tool-call',
|
||||
|
|
@ -179,6 +180,7 @@ describe('SubagentThreadPanel', () => {
|
|||
isReadinessPending: false,
|
||||
});
|
||||
const foreground: ActiveSubagentPanel = {
|
||||
host: 'conversation',
|
||||
parentConversationId: 'parent-conversation',
|
||||
parentMessageId: 'parent-message',
|
||||
toolCallId: 'foreground-call',
|
||||
|
|
|
|||
|
|
@ -145,6 +145,34 @@ describe('child activity adapters', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps shared-message activity read-only by omitting approval controls', () => {
|
||||
const activity = adaptLivePersistedActivity({
|
||||
title: 'researcher',
|
||||
progress: null,
|
||||
persistedContent: [
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: {
|
||||
id: 'tool',
|
||||
name: 'protected_tool',
|
||||
args: '{}',
|
||||
output: '',
|
||||
progress: 0.1,
|
||||
approval: { expires_at: 123 },
|
||||
},
|
||||
},
|
||||
] as unknown as TMessageContentParts[],
|
||||
initialProgress: 1,
|
||||
isSubmitting: false,
|
||||
approvalVisibility: 'hidden',
|
||||
});
|
||||
|
||||
expect(activity.items[0]).toEqual(
|
||||
expect.objectContaining({ type: 'tool', name: 'protected_tool' }),
|
||||
);
|
||||
expect(activity.items[0]).not.toHaveProperty('approval');
|
||||
});
|
||||
|
||||
it('uses the exact assistant row as terminal authority for an older API response', () => {
|
||||
const oldView = {
|
||||
threadId: 'thread',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ type ContentToolCall = {
|
|||
const contentPartsToActivity = (
|
||||
parts: TMessageContentParts[],
|
||||
reasoningVisibility: 'visible' | 'marker',
|
||||
approvalVisibility: 'visible' | 'hidden',
|
||||
): ChildActivityItem[] =>
|
||||
parts.flatMap((part, index): ChildActivityItem[] => {
|
||||
if (part.type === ContentTypes.TEXT) {
|
||||
|
|
@ -85,7 +86,9 @@ const contentPartsToActivity = (
|
|||
...(tool.args == null ? {} : { input: tool.args }),
|
||||
...(tool.output == null ? {} : { output: tool.output }),
|
||||
status: runStepStatus ?? (completed ? 'completed' : 'running'),
|
||||
...(tool.approval == null ? {} : { approval: tool.approval }),
|
||||
...(tool.approval == null || approvalVisibility === 'hidden'
|
||||
? {}
|
||||
: { approval: tool.approval }),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
|
@ -129,11 +132,16 @@ export function adaptLivePersistedActivity(input: {
|
|||
isSubmitting: boolean;
|
||||
runStepStatus?: PartMetadata['runStepStatus'];
|
||||
reasoningVisibility?: 'visible' | 'marker';
|
||||
approvalVisibility?: 'visible' | 'hidden';
|
||||
}): ChildActivity {
|
||||
const persisted = input.persistedContent ?? [];
|
||||
const live = (input.progress?.contentParts ?? []) as TMessageContentParts[];
|
||||
const parts = persisted.length > 0 ? persisted : live;
|
||||
const items = contentPartsToActivity(parts, input.reasoningVisibility ?? 'visible');
|
||||
const items = contentPartsToActivity(
|
||||
parts,
|
||||
input.reasoningVisibility ?? 'visible',
|
||||
input.approvalVisibility ?? 'visible',
|
||||
);
|
||||
if (items.length === 0 && input.legacyOutput != null && input.legacyOutput !== '') {
|
||||
items.push({ type: 'writing', text: input.legacyOutput });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
TooltipAnchor,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import SharedSubagentActivityDialog from '~/components/Chat/Subagents/SharedSubagentActivityDialog';
|
||||
import { cn, DEFAULT_APP_TITLE, getResponseStatus, selectActiveBranchTail } from '~/utils';
|
||||
import { ThemeSelector, LangSelector } from '~/components/Appearance';
|
||||
import { ShareMessagesProvider } from './ShareMessagesProvider';
|
||||
|
|
@ -239,6 +240,7 @@ function SharedView() {
|
|||
{artifactsContainer}
|
||||
</main>
|
||||
</div>
|
||||
<SharedSubagentActivityDialog shareId={shareId} />
|
||||
</ShareContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface SubagentProgress {
|
|||
|
||||
/** One child invocation selected for the shared read-only activity panel. */
|
||||
export type ActiveSubagentPanel = {
|
||||
host: 'conversation' | 'share';
|
||||
shareId?: string;
|
||||
parentConversationId: string;
|
||||
parentMessageId: string;
|
||||
toolCallId: string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createSubagentThreadViewHandler, SUBAGENT_THREAD_VIEW_LIMITS } from './
|
|||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
CLIENT_MESSAGE_SELECT: '-_id -user',
|
||||
SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT: 256 * 1024,
|
||||
logger: { error: jest.fn() },
|
||||
}));
|
||||
|
||||
|
|
@ -259,6 +260,34 @@ describe('subagent thread parent-scoped view', () => {
|
|||
expect(JSON.stringify(json.mock.calls[0][0])).not.toContain('Wrong task.');
|
||||
});
|
||||
|
||||
it('falls back to the bounded final message when storage omits an oversized transcript', async () => {
|
||||
const selected = {
|
||||
...message('task-1:assistant', 'completed'),
|
||||
text: 'The bounded final answer.',
|
||||
subagentTranscriptProjectionTruncated: true,
|
||||
} as IMessage & { subagentTranscriptProjectionTruncated: boolean };
|
||||
const handler = createSubagentThreadViewHandler({
|
||||
getConvoOwnership: jest.fn().mockResolvedValue(parent),
|
||||
getSubagentThreadForParent: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
|
||||
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([selected]),
|
||||
});
|
||||
const { response, json } = createResponse();
|
||||
|
||||
await handler(createRequest({}, { taskId: 'task-1' }), response);
|
||||
|
||||
const view = json.mock.calls[0][0];
|
||||
expect(view).toEqual(
|
||||
expect.objectContaining({
|
||||
activity: [],
|
||||
activityTruncated: true,
|
||||
messages: [expect.objectContaining({ text: 'The bounded final answer.' })],
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(view)).not.toContain('subagentTranscript');
|
||||
});
|
||||
|
||||
it('bounds the complete UTF-8 response while retaining the newest history', async () => {
|
||||
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
|
||||
const messages = Array.from(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { logger, SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT } from '@librechat/data-schemas';
|
||||
import type {
|
||||
ConversationMethods,
|
||||
MessageMethods,
|
||||
|
|
@ -204,23 +204,30 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
|
|||
child.subagentThreadLease != null && child.subagentThreadLease.expiresAt > now
|
||||
? child.subagentThreadLease.taskId
|
||||
: undefined;
|
||||
const selectedTranscript =
|
||||
const selectedMessage =
|
||||
requestedTaskId == null
|
||||
? undefined
|
||||
: newestFirst.find((message) => message.messageId === `${requestedTaskId}:assistant`)
|
||||
?.subagentTranscript;
|
||||
: newestFirst.find((message) => message.messageId === `${requestedTaskId}:assistant`);
|
||||
const selectedTranscript = selectedMessage?.subagentTranscript;
|
||||
const selectedInput =
|
||||
requestedTaskId == null
|
||||
? undefined
|
||||
: newestFirst.find((message) => message.messageId === `${requestedTaskId}:user`);
|
||||
const projectedActivity =
|
||||
selectedTranscript != null && selectedTranscript.taskId === requestedTaskId
|
||||
? projectSubagentActivity(
|
||||
selectedTranscript.messagesJson,
|
||||
selectedTranscript.mode,
|
||||
selectedInput?.textProjectionTruncated === true ? undefined : selectedInput?.text,
|
||||
)
|
||||
: { activity: [], truncated: selectedTranscript != null };
|
||||
let projectedActivity: ReturnType<typeof projectSubagentActivity> = {
|
||||
activity: [],
|
||||
truncated: false,
|
||||
};
|
||||
if (selectedMessage?.subagentTranscriptProjectionTruncated === true) {
|
||||
projectedActivity = { activity: [], truncated: true };
|
||||
} else if (selectedTranscript != null && selectedTranscript.taskId === requestedTaskId) {
|
||||
projectedActivity = projectSubagentActivity(
|
||||
selectedTranscript.messagesJson,
|
||||
selectedTranscript.mode,
|
||||
selectedInput?.textProjectionTruncated === true ? undefined : selectedInput?.text,
|
||||
);
|
||||
} else if (selectedTranscript != null) {
|
||||
projectedActivity = { activity: [], truncated: true };
|
||||
}
|
||||
const projectedNewestFirst: SubagentThreadMessage[] = [];
|
||||
let remainingTextBytes = MAX_RESPONSE_TEXT_BYTES;
|
||||
for (const message of newestFirst) {
|
||||
|
|
@ -272,6 +279,7 @@ export const SUBAGENT_THREAD_VIEW_LIMITS: Readonly<{
|
|||
responseBytes: number;
|
||||
activityItems: number;
|
||||
activityBytes: number;
|
||||
activitySourceBytes: number;
|
||||
}> = {
|
||||
messages: MAX_THREAD_MESSAGES,
|
||||
messageTextBytes: MAX_MESSAGE_TEXT_BYTES,
|
||||
|
|
@ -279,4 +287,5 @@ export const SUBAGENT_THREAD_VIEW_LIMITS: Readonly<{
|
|||
responseBytes: MAX_RESPONSE_BYTES,
|
||||
activityItems: SUBAGENT_ACTIVITY_LIMITS.items,
|
||||
activityBytes: SUBAGENT_ACTIVITY_LIMITS.bytes,
|
||||
activitySourceBytes: SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export { createModels } from './models';
|
|||
export {
|
||||
createMethods,
|
||||
CLIENT_MESSAGE_SELECT,
|
||||
SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
|
||||
RoleConflictError,
|
||||
DEFAULT_REFRESH_TOKEN_EXPIRY,
|
||||
DEFAULT_SESSION_EXPIRY,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { createConversationTagMethods, type ConversationTagMethods } from './con
|
|||
import {
|
||||
createMessageMethods,
|
||||
CLIENT_MESSAGE_SELECT,
|
||||
SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
|
||||
type MessageMethods,
|
||||
type SubagentThreadViewMessageRecord,
|
||||
type SubagentTaskResultClaim,
|
||||
|
|
@ -146,7 +147,7 @@ export {
|
|||
};
|
||||
export { tokenValues, cacheTokenValues, premiumTokenValues, defaultRate, createTxMethods };
|
||||
export { permissionBitSupersets };
|
||||
export { CLIENT_MESSAGE_SELECT };
|
||||
export { CLIENT_MESSAGE_SELECT, SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT };
|
||||
export {
|
||||
partitionIssues,
|
||||
validateSkillName,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { RetentionMode } from 'librechat-data-provider';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import type { IMessage } from '..';
|
||||
import { createMessageMethods, CLIENT_MESSAGE_SELECT } from './message';
|
||||
import {
|
||||
createMessageMethods,
|
||||
CLIENT_MESSAGE_SELECT,
|
||||
SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
|
||||
} from './message';
|
||||
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
|
||||
import { createModels } from '../models';
|
||||
import logger from '~/config/winston';
|
||||
|
|
@ -750,6 +754,39 @@ describe('Message Operations', () => {
|
|||
expect(messages[0]).toHaveProperty('messageId', 'task-a:assistant');
|
||||
expect(messages[0]).toHaveProperty('subagentTranscript.taskId', 'task-a');
|
||||
});
|
||||
|
||||
it('omits an oversized private transcript before returning the application result', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await saveMessage(mockCtx, {
|
||||
messageId: 'task-large:assistant',
|
||||
conversationId,
|
||||
text: 'The bounded public answer remains available.',
|
||||
user: 'user123',
|
||||
subagentTranscript: {
|
||||
taskId: 'task-large',
|
||||
mode: 'append',
|
||||
messagesJson: JSON.stringify([
|
||||
{
|
||||
type: 'ai',
|
||||
data: { content: 'x'.repeat(SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT + 1) },
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const messages = await getMessagesForSubagentThreadView({
|
||||
user: 'user123',
|
||||
conversationId,
|
||||
limit: 1,
|
||||
textCodePointLimit: 8_192,
|
||||
taskId: 'task-large',
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].text).toBe('The bounded public answer remains available.');
|
||||
expect(messages[0]).not.toHaveProperty('subagentTranscript');
|
||||
expect(messages[0].subagentTranscriptProjectionTruncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMessages', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ import logger from '~/config/winston';
|
|||
/** Simple UUID v4 regex to replace zod validation */
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Maximum private transcript JSON that may cross the MongoDB projection seam
|
||||
* for the bounded public subagent-activity view. This gives the sanitizer
|
||||
* enough source headroom while preventing multi-megabyte transcripts from
|
||||
* being materialized merely to produce a 64 KiB public activity response.
|
||||
*/
|
||||
export const SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT: number = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Exclusion projection for message reads that feed the chat client (the
|
||||
* conversation GET and shared-link reads). Every excluded field is either
|
||||
|
|
@ -63,7 +71,10 @@ export type SubagentThreadViewMessageRecord = Pick<
|
|||
| 'error'
|
||||
| 'subagentTranscript'
|
||||
| 'subagentTask'
|
||||
> & { textProjectionTruncated?: boolean };
|
||||
> & {
|
||||
textProjectionTruncated?: boolean;
|
||||
subagentTranscriptProjectionTruncated?: boolean;
|
||||
};
|
||||
|
||||
export interface MessageMethods {
|
||||
saveMessage(
|
||||
|
|
@ -751,6 +762,21 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
}): Promise<SubagentThreadViewMessageRecord[]> {
|
||||
try {
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const selectedAssistantMessageId =
|
||||
input.taskId == null ? undefined : `${input.taskId}:assistant`;
|
||||
const transcriptJsonBytes = {
|
||||
$strLenBytes: {
|
||||
$convert: {
|
||||
input: '$subagentTranscript.messagesJson',
|
||||
to: 'string',
|
||||
onError: '',
|
||||
onNull: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
const transcriptIsString = {
|
||||
$eq: [{ $type: '$subagentTranscript.messagesJson' }, 'string'],
|
||||
};
|
||||
return await Message.aggregate<SubagentThreadViewMessageRecord>([
|
||||
{
|
||||
$match: {
|
||||
|
|
@ -770,6 +796,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
},
|
||||
{ $sort: { createdAt: -1, _id: -1 } },
|
||||
{ $limit: input.limit },
|
||||
...(input.taskId == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
$set: {
|
||||
_subagentTranscriptSourceBytes: transcriptJsonBytes,
|
||||
_subagentTranscriptSourceIsString: transcriptIsString,
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
|
|
@ -789,8 +825,48 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
: {
|
||||
subagentTranscript: {
|
||||
$cond: [
|
||||
{ $eq: ['$messageId', `${input.taskId}:assistant`] },
|
||||
'$subagentTranscript',
|
||||
{
|
||||
$and: [
|
||||
{ $eq: ['$messageId', selectedAssistantMessageId] },
|
||||
'$_subagentTranscriptSourceIsString',
|
||||
{
|
||||
$lte: [
|
||||
'$_subagentTranscriptSourceBytes',
|
||||
SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
taskId: '$subagentTranscript.taskId',
|
||||
mode: '$subagentTranscript.mode',
|
||||
messagesJson: '$subagentTranscript.messagesJson',
|
||||
},
|
||||
'$$REMOVE',
|
||||
],
|
||||
},
|
||||
subagentTranscriptProjectionTruncated: {
|
||||
$cond: [
|
||||
{
|
||||
$and: [
|
||||
{ $eq: ['$messageId', selectedAssistantMessageId] },
|
||||
{
|
||||
$ne: [{ $type: '$subagentTranscript.messagesJson' }, 'missing'],
|
||||
},
|
||||
{
|
||||
$or: [
|
||||
{ $eq: ['$_subagentTranscriptSourceIsString', false] },
|
||||
{
|
||||
$gt: [
|
||||
'$_subagentTranscriptSourceBytes',
|
||||
SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
'$$REMOVE',
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue