🖼️ fix: Restore Shared Subagent Activity as a Read-Only View (#15108)

This commit is contained in:
Danny Avila 2026-08-21 20:58:14 -04:00 committed by GitHub
parent 199de92c51
commit 08c9cc3d3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 470 additions and 28 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

@ -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',
],
},