🧵 feat: Unify Subagent Child Threads (#15261)

* feat: unify subagent child thread rendering

* chore: sort subagent UI imports

* fix: preserve bounded subagent thread context

* fix: render persisted child activity in unified timeline

* fix: bound durable subagent activity projections

* perf: cap subagent activity source scans

* chore: sort subagent thread imports

* fix: align completed child messages

* fix: bound selected subagent activity reads

* fix: bound child activity storage reads

* fix: preserve child receipt truncation state

* test: preserve projected receipt truncation

* test: align child completion e2e

* test: isolate Stable Diffusion logger mock
This commit is contained in:
Danny Avila 2026-08-27 06:04:13 -04:00 committed by GitHub
parent 44edcbe014
commit f0eda61638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2482 additions and 255 deletions

View file

@ -6,13 +6,9 @@ jest.mock('axios', () => ({ post: jest.fn() }), { virtual: true });
jest.mock('fs');
jest.mock('sharp', () => jest.fn(), { virtual: true });
jest.mock('uuid', () => ({ v4: jest.fn() }), { virtual: true });
jest.mock(
'@librechat/data-schemas',
() => ({
logger: { error: jest.fn() },
}),
{ virtual: true },
);
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn() },
}));
jest.mock(
'@librechat/agents/langchain/tools',
() => ({

View file

@ -31,6 +31,9 @@ jest.mock('~/hooks/MCP', () => ({ useMCPServerNames: () => [] }));
jest.mock('./SubagentActivity', () => ({
__esModule: true,
SubagentActivityScrollSurface: ({ children }: { children: React.ReactNode }) => (
<div data-testid="shared-scroll-surface">{children}</div>
),
default: ({
activity,
}: {
@ -45,6 +48,30 @@ jest.mock('./SubagentActivity', () => ({
),
}));
jest.mock('./SubagentConversation', () => ({
__esModule: true,
default: ({
turns,
}: {
turns: Array<{
taskId: string;
trigger: { summary: string };
activity: { items: Array<{ type: string; text?: string }> };
}>;
}) => (
<div data-testid="subagent-conversation">
{turns.map((turn) => (
<div key={turn.taskId}>
{turn.trigger.summary}
{turn.activity.items.map((item, index) => (
<span key={index}>{item.text ?? item.type}</span>
))}
</div>
))}
</div>
),
}));
const persistedContent = (text: string): TMessageContentParts[] => [
{ type: ContentTypes.TEXT, text } as TMessageContentParts,
];

View file

@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useRecoilValue, useResetRecoilState } from 'recoil';
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
import { SubagentActivityScrollSurface } from './SubagentActivity';
import SubagentConversation from './SubagentConversation';
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. */
@ -67,14 +68,23 @@ export default function SharedSubagentActivityDialog({ shareId }: { shareId?: st
{activity.title}
</OGDialogTitle>
</OGDialogHeader>
<SubagentActivity
activityId={
selection == null
? undefined
: `${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`
}
activity={activity}
/>
<SubagentActivityScrollSurface padded={false}>
<SubagentConversation
turns={[
{
taskId:
selection == null
? 'shared-subagent'
: `${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`,
trigger: {
kind: 'parent_dispatch',
summary: selection?.prompt ?? '',
},
activity,
},
]}
/>
</SubagentActivityScrollSurface>
</OGDialogContent>
</OGDialog>
);

View file

@ -270,7 +270,7 @@ describe('SubagentActivity', () => {
expect(screen.getByTestId('tool-approval')).toBeInTheDocument();
});
it('marks bounded activity as shortened without expanding tool details', () => {
it('marks bounded tool details as shortened without claiming history is missing', () => {
render(
<SubagentActivity
activity={{
@ -290,7 +290,8 @@ describe('SubagentActivity', () => {
);
expect(screen.queryByText('bounded input')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_history_truncated')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_activity_details_truncated')).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_thread_history_truncated')).not.toBeInTheDocument();
});
it('renders writing, reasoning, grouped tools, and collapsed details', () => {

View file

@ -278,34 +278,39 @@ function SubagentPrompt({ prompt }: { prompt: string }) {
);
}
export default function SubagentActivity({
export const hasTruncatedActivityDetails = (activity: ChildActivity): boolean =>
activity.items.some(
(item) =>
(item.type === 'writing' && item.textTruncated === true) ||
(item.type === 'activity_label' && item.labelTruncated === true) ||
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
);
export function SubagentActivityContent({
activity,
activityId,
state = 'ready',
embedded = false,
showPrompt = true,
showDetailTruncationNotice = true,
conversationId = null,
onCancelControl,
}: {
activity: ChildActivity;
activityId?: string;
state?: 'ready' | 'loading' | 'error';
embedded?: boolean;
showPrompt?: boolean;
showDetailTruncationNotice?: boolean;
conversationId?: string | null;
onCancelControl?: (controlId: string) => void;
}) {
const localize = useLocalize();
const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
const StatusIcon = subagentStatusIcon(activity.status);
const reasoningMarkerLabel = localize('com_ui_subagent_ticker_reasoning');
const parts = useMemo(
() => activity.items.map((item) => toContentPart(item, reasoningMarkerLabel)),
[activity.items, reasoningMarkerLabel],
);
const activityTruncated =
activity.activityTruncated === true ||
activity.items.some(
(item) =>
(item.type === 'writing' && item.textTruncated === true) ||
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
);
const activityDetailsTruncated = hasTruncatedActivityDetails(activity);
let body: React.ReactNode;
if (state === 'loading') {
@ -331,7 +336,7 @@ export default function SubagentActivity({
<ContentParts
content={parts}
messageId={activityId ?? 'subagent-activity-panel'}
conversationId={null}
conversationId={conversationId}
isCreatedByUser={false}
isLast
isSubmitting={isSubmitting}
@ -340,25 +345,9 @@ export default function SubagentActivity({
);
}
const statusHeader = (
<div className="shrink-0 border-b border-border-light px-4 py-2">
<div
className={cn(
'flex items-center gap-1 text-xs text-text-secondary',
activity.status === 'failed' || activity.status === 'interrupted'
? 'text-status-error'
: '',
)}
aria-live="polite"
>
<StatusIcon size={13} aria-hidden />
<span>{localize(subagentStatusLabelKey(activity.status))}</span>
</div>
</div>
);
const content = (
return (
<div className="flex max-w-full flex-col gap-0">
{activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
{showPrompt && activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
<SubagentControlHistory
controls={activity.controls ?? []}
onCancelControl={onCancelControl}
@ -368,14 +357,69 @@ export default function SubagentActivity({
{localize('com_ui_subagent_control_history_truncated')}
</div>
)}
{activityTruncated && (
{activity.activityTruncated === true && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_thread_history_truncated')}
</div>
)}
{showDetailTruncationNotice && activityDetailsTruncated && (
<div className="mb-3 text-xs italic text-text-secondary">
{localize('com_ui_subagent_activity_details_truncated')}
</div>
)}
{body}
</div>
);
}
export function SubagentStatus({ activity }: { activity: ChildActivity }) {
const localize = useLocalize();
const StatusIcon = subagentStatusIcon(activity.status);
return (
<div
className={cn(
'flex items-center gap-1 text-xs text-text-secondary',
activity.status === 'failed' || activity.status === 'interrupted'
? 'text-status-error'
: '',
)}
aria-live="polite"
>
<StatusIcon size={13} aria-hidden />
<span>{localize(subagentStatusLabelKey(activity.status))}</span>
</div>
);
}
export default function SubagentActivity({
activity,
activityId,
state = 'ready',
embedded = false,
showPrompt = true,
onCancelControl,
}: {
activity: ChildActivity;
activityId?: string;
state?: 'ready' | 'loading' | 'error';
embedded?: boolean;
showPrompt?: boolean;
onCancelControl?: (controlId: string) => void;
}) {
const statusHeader = (
<div className="shrink-0 border-b border-border-light px-4 py-2">
<SubagentStatus activity={activity} />
</div>
);
const content = (
<SubagentActivityContent
activity={activity}
activityId={activityId}
state={state}
showPrompt={showPrompt}
onCancelControl={onCancelControl}
/>
);
if (embedded) {
return (

View file

@ -0,0 +1,118 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
import { render, screen } from '@testing-library/react';
import type { ChildConversationTurn } from './adapters';
import SubagentConversation from './SubagentConversation';
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/Providers', () => ({
useAgentsMapContext: () => undefined,
}));
jest.mock('~/components/Chat/Messages/Content/ContentParts', () => ({
__esModule: true,
default: ({
content,
messageId,
}: {
content: Array<Record<string, unknown>>;
messageId: string;
}) => (
<div data-testid="shared-content-parts" data-message-id={messageId}>
{content.map((part, index) => (
<span key={index}>
{(part.text as string | undefined) ??
(part.think as string | undefined) ??
(part.tool_call as { name?: string } | undefined)?.name ??
''}
</span>
))}
</div>
),
}));
jest.mock('~/components/Chat/Messages/Content/Container', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
jest.mock('~/components/Chat/Messages/Content/Parts', () => ({
EmptyText: () => <div data-testid="thinking-cursor" />,
}));
jest.mock('lucide-react', () => ({
AlertCircle: () => null,
Bot: () => null,
CheckCircle2: () => null,
Clock3: () => null,
CornerDownRight: () => null,
Radio: () => null,
XCircle: () => null,
}));
const turns: ChildConversationTurn[] = [
{
taskId: 'task-1',
trigger: {
kind: 'parent_dispatch',
summary: 'Investigate the release.',
createdAt: '2026-08-25T12:00:00.000Z',
},
activity: {
title: 'Research child',
status: 'completed',
items: [
{ type: 'reasoning', text: 'Checked the constraints.' },
{
type: 'tool',
toolCallId: 'search-1',
name: 'search',
status: 'completed',
outputTruncated: true,
},
{ type: 'writing', text: 'The release is ready.' },
],
},
},
{
taskId: 'task-2',
trigger: {
kind: 'external_event',
summary: 'A deployment event arrived.',
},
activity: {
title: 'Research child',
status: 'running',
items: [],
},
},
];
describe('SubagentConversation', () => {
it('renders host triggers and child activity through the main chat row and content modules', () => {
const { container } = render(
<RecoilRoot>
<SubagentConversation turns={turns} />
</RecoilRoot>,
);
expect(screen.getAllByText('com_ui_subagent_trigger_parent_dispatch')).toHaveLength(2);
expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(2);
expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
expect(screen.getByText('Checked the constraints.')).toBeInTheDocument();
expect(screen.getByText('search')).toBeInTheDocument();
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_thread_status_running')).toBeInTheDocument();
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
expect(container.querySelectorAll('.message-render')).toHaveLength(4);
expect(container.querySelectorAll('.user-turn')).toHaveLength(2);
expect(container.querySelectorAll('.agent-turn')).toHaveLength(2);
expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument();
expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument();
expect(screen.getAllByText('com_ui_subagent_activity_details_truncated')).toHaveLength(1);
});
});

View file

@ -0,0 +1,195 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Bot, CornerDownRight, Radio } from 'lucide-react';
import { ContentTypes, EModelEndpoint } from 'librechat-data-provider';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { ChildConversationTurn } from './adapters';
import type { TranslationKeys } from '~/hooks';
import {
hasTruncatedActivityDetails,
SubagentActivityContent,
SubagentStatus,
} from './SubagentActivity';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import { useAgentsMapContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import store from '~/store';
const TRIGGER_LABELS = {
parent_dispatch: 'com_ui_subagent_trigger_parent_dispatch',
parent_continuation: 'com_ui_subagent_trigger_parent_continuation',
external_event: 'com_ui_subagent_trigger_external_event',
} as const satisfies Record<ChildConversationTurn['trigger']['kind'], TranslationKeys>;
function TriggerIcon({ kind }: { kind: ChildConversationTurn['trigger']['kind'] }) {
const Icon = kind === 'external_event' ? Radio : CornerDownRight;
return (
<span className="flex size-6 items-center justify-center rounded-full bg-surface-tertiary text-text-secondary">
<Icon size={14} aria-hidden />
</span>
);
}
function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; fullWidth: boolean }) {
const localize = useLocalize();
const label = localize(TRIGGER_LABELS[turn.trigger.kind]);
const content = useMemo<TMessageContentParts[]>(
() =>
turn.trigger.summary === ''
? []
: [
{
type: ContentTypes.TEXT,
text: turn.trigger.summary,
} as TMessageContentParts,
],
[turn.trigger.summary],
);
return (
<MessageRow
id={`${turn.taskId}:trigger`}
icon={<TriggerIcon kind={turn.trigger.kind} />}
label={label}
footer={null}
timestamp={turn.trigger.createdAt}
ariaLabel={label}
headerPrefix=""
isCreatedByUser={true}
fullWidth={fullWidth}
>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<TriggerIcon kind={turn.trigger.kind} />
<span>{label}</span>
</div>
{content.length > 0 && (
<ContentParts
content={content}
messageId={`${turn.taskId}:trigger`}
conversationId={null}
isCreatedByUser={true}
isLast={false}
isSubmitting={false}
isLatestMessage={false}
/>
)}
{turn.trigger.summaryTruncated === true && (
<div className="mt-1 text-xs italic text-text-secondary">
{localize('com_ui_subagent_trigger_truncated')}
</div>
)}
</MessageRow>
);
}
function ChildMessage({
turn,
state,
agentId,
conversationId,
fullWidth,
onCancelControl,
}: {
turn: ChildConversationTurn;
state: 'ready' | 'loading' | 'error';
agentId?: string;
conversationId?: string | null;
fullWidth: boolean;
onCancelControl?: (controlId: string) => void;
}) {
const agentsMap = useAgentsMapContext();
const agent = agentId == null ? undefined : agentsMap?.[agentId];
const label = agent?.name ?? turn.activity.title;
const iconData = {
endpoint: EModelEndpoint.agents,
modelLabel: label,
isCreatedByUser: false,
};
return (
<MessageRow
id={`${turn.taskId}:assistant`}
icon={
agent == null ? (
<span className="flex size-6 items-center justify-center rounded-full bg-surface-tertiary text-text-secondary">
<Bot size={14} aria-hidden />
</span>
) : (
<MessageIcon iconData={iconData} agent={agent} />
)
}
label={label}
footer={
turn.activity.status === 'completed' ? null : <SubagentStatus activity={turn.activity} />
}
ariaLabel={label}
headerPrefix=""
isCreatedByUser={false}
fullWidth={fullWidth}
>
<SubagentActivityContent
activity={turn.activity}
activityId={`${turn.taskId}:assistant`}
state={state}
showPrompt={false}
showDetailTruncationNotice={false}
conversationId={conversationId}
onCancelControl={onCancelControl}
/>
</MessageRow>
);
}
export default function SubagentConversation({
turns,
agentId,
conversationId,
stateByTask,
controllableTaskId,
onCancelControl,
}: {
turns: ChildConversationTurn[];
agentId?: string;
conversationId?: string | null;
stateByTask?: ReadonlyMap<string, 'ready' | 'loading' | 'error'>;
controllableTaskId?: string;
onCancelControl?: (taskId: string, controlId: string) => void;
}) {
const localize = useLocalize();
const fullWidth = useRecoilValue(store.maximizeChatSpace);
const hasShortenedDetails = turns.some((turn) => hasTruncatedActivityDetails(turn.activity));
return (
<div className="flex flex-col gap-6 py-4" data-subagent-conversation>
{hasShortenedDetails && (
<div className="px-4 text-xs italic text-text-secondary">
{localize('com_ui_subagent_activity_details_truncated')}
</div>
)}
{turns.map((turn) => (
<section
key={turn.taskId}
className="flex flex-col gap-4"
data-subagent-thread-turn={turn.taskId}
>
<div className="px-4">
<TriggerMessage turn={turn} fullWidth={fullWidth} />
</div>
<div className="px-4">
<ChildMessage
turn={turn}
agentId={agentId}
conversationId={conversationId}
fullWidth={fullWidth}
state={stateByTask?.get(turn.taskId) ?? 'ready'}
onCancelControl={
onCancelControl == null || turn.taskId !== controllableTaskId
? undefined
: (controlId) => onCancelControl(turn.taskId, controlId)
}
/>
</div>
</section>
))}
</div>
);
}

View file

@ -146,6 +146,35 @@ jest.mock('./SubagentActivity', () => ({
),
}));
jest.mock('./SubagentConversation', () => ({
__esModule: true,
default: ({
turns,
stateByTask,
}: {
turns: Array<{
taskId: string;
trigger: { summary: string };
activity: { items: Array<{ text?: string }> };
}>;
stateByTask?: ReadonlyMap<string, string>;
}) => (
<div
data-testid="subagent-conversation"
data-state={turns[0] == null ? undefined : stateByTask?.get(turns[0].taskId)}
>
{turns.map((turn) => (
<div key={turn.taskId} data-testid="conversation-turn">
{turn.trigger.summary}
{turn.activity.items.map((item, index) => (
<span key={index}>{item.text}</span>
))}
</div>
))}
</div>
),
}));
jest.mock('@librechat/client', () => {
const mockReact = jest.requireActual<typeof import('react')>('react');
const MockSelectContext = mockReact.createContext((_value: string): void => {});
@ -196,6 +225,7 @@ jest.mock('lucide-react', () => ({
AlertCircle: () => null,
Bot: () => null,
CornerDownRight: () => null,
CornerUpLeft: () => null,
CheckCircle2: () => null,
Clock3: () => null,
ListEnd: () => null,
@ -225,6 +255,7 @@ const completedView: SubagentThreadView = {
parentToolCallId: 'tool-call',
subagentType: 'researcher',
subagentKind: 'agent',
depth: 1,
agentId: 'agent-1',
title: 'Research child',
status: 'completed',
@ -297,6 +328,7 @@ describe('SubagentThreadPanel', () => {
);
expect(mockUseSubagentActivityStream).toHaveBeenCalledWith(selection, false);
expect(screen.getByText('Research child')).toBeInTheDocument();
expect(screen.getByText('com_ui_subagent_depth')).toBeInTheDocument();
expect(screen.getByText('Investigate the release.')).toBeInTheDocument();
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'completed');
@ -312,6 +344,111 @@ describe('SubagentThreadPanel', () => {
);
});
it('returns to and restores focus on the originating parent activity', async () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: completedView,
isLoading: false,
isError: false,
isReadinessPending: false,
});
let active: ActiveSubagentPanel | null = selection;
const Observer = () => {
active = useRecoilValue(activeSubagentPanel);
return null;
};
const { container } = render(
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, selection)}>
<button
type="button"
data-subagent-tool-call="tool-call"
data-subagent-parent-message="parent-message"
data-subagent-part-index="2"
/>
<Observer />
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_view_in_parent' }));
expect(active).toBeNull();
await waitFor(() =>
expect(container.querySelector('[data-subagent-tool-call="tool-call"]')).toHaveFocus(),
);
});
it('renders the durable branch as one conversation-native chronological thread', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
turns: [
{
taskId: 'task-earlier',
trigger: { kind: 'parent_dispatch', summary: 'Initial request.' },
status: 'completed',
activity: [{ type: 'writing', text: 'Initial response.' }],
activityTruncated: false,
messages: [],
},
{
taskId: 'task',
trigger: { kind: 'parent_continuation', summary: 'Follow-up request.' },
status: 'completed',
activity: [{ type: 'writing', text: 'Follow-up response.' }],
activityTruncated: false,
messages: [completedView.messages[1]],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(screen.getByTestId('subagent-conversation')).toBeInTheDocument();
expect(screen.getAllByTestId('conversation-turn')).toHaveLength(2);
expect(screen.getByText(/Initial request/)).toBeInTheDocument();
expect(screen.getByText(/Follow-up request/)).toBeInTheDocument();
expect(screen.queryByTestId('shared-activity')).not.toBeInTheDocument();
});
it('keeps the exact selected activity when the bounded response retains only newer turns', () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: {
...completedView,
turns: [
{
taskId: 'task-newer',
trigger: { kind: 'parent_continuation', summary: 'A newer request.' },
status: 'completed',
activity: [{ type: 'writing', text: 'A newer response.' }],
activityTruncated: false,
messages: [],
},
],
},
isLoading: false,
isError: false,
isReadinessPending: false,
});
render(
<RecoilRoot>
<SubagentThreadPanel selection={selection} />
</RecoilRoot>,
);
expect(screen.getAllByTestId('conversation-turn')).toHaveLength(2);
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
expect(screen.getByText(/A newer request/)).toBeInTheDocument();
});
it('submits one command invocation, blocks duplicate clicks, and shows its receipt', async () => {
mockUseSubagentThreadQuery.mockReturnValue({
data: { ...completedView, status: 'running', controlReceipts: [] },
@ -835,7 +972,7 @@ describe('SubagentThreadPanel', () => {
expect(screen.getByText('Review this change.')).toBeInTheDocument();
expect(screen.getByText('Review complete.')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'ready');
expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'ready');
expect(screen.queryByRole('button', { name: 'com_ui_continue_chat' })).not.toBeInTheDocument();
});
@ -995,8 +1132,8 @@ describe('SubagentThreadPanel', () => {
</RecoilRoot>,
);
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'loading');
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'dispatched');
expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'loading');
expect(screen.queryByTestId('shared-activity')).not.toBeInTheDocument();
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(selection, true);
});
@ -1115,7 +1252,8 @@ describe('SubagentThreadPanel', () => {
</RecoilRoot>,
);
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'error');
expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'error');
expect(screen.queryByTestId('shared-activity')).not.toBeInTheDocument();
});
it('shows live detached activity while its durable view is still becoming ready', () => {
@ -1153,8 +1291,8 @@ describe('SubagentThreadPanel', () => {
);
expect(screen.getByText('Live child update.')).toBeInTheDocument();
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-state', 'ready');
expect(screen.getByTestId('shared-activity')).toHaveAttribute('data-status', 'running');
expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'ready');
expect(screen.queryByTestId('shared-activity')).not.toBeInTheDocument();
});
it('exposes the focus-trapped mobile overlay as a modal dialog', () => {

View file

@ -1,7 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { v4 } from 'uuid';
import { ForkOptions } from 'librechat-data-provider';
import { Bot, CornerDownRight, ListEnd, MessagesSquare, OctagonX, X, Zap } from 'lucide-react';
import {
Bot,
CornerDownRight,
CornerUpLeft,
ListEnd,
MessagesSquare,
OctagonX,
X,
Zap,
} from 'lucide-react';
import {
useRecoilCallback,
useRecoilState,
@ -43,12 +52,17 @@ import {
subagentProgressByToolCallId,
subagentProgressKey,
} from '~/store/subagents';
import {
adaptDurableThreadActivity,
adaptDurableThreadConversation,
adaptLivePersistedActivity,
} from './adapters';
import useSubagentActivityStream from '~/data-provider/Subagents/useSubagentActivityStream';
import SubagentActivity, { SubagentActivityScrollSurface } from './SubagentActivity';
import { adaptDurableThreadActivity, adaptLivePersistedActivity } from './adapters';
import ApprovalProvider from '~/components/Chat/Messages/Content/ApprovalContext';
import { useFocusTrap, useLocalize, useNavigateToConvo } from '~/hooks';
import { useParentSubagents } from './ParentSubagentsProvider';
import SubagentConversation from './SubagentConversation';
import { eventSubagentSelection } from './eventSelection';
import { useAgentsMapContext } from '~/Providers';
@ -474,6 +488,50 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
}
return { ...merged, controls: [...(merged.controls ?? []), transientControl] };
}, [data, liveActivity, progress, selection.durable, transientControl]);
const conversationTurns = useMemo(() => {
const durableTurns = data == null ? [] : adaptDurableThreadConversation(data);
if (durableTurns.length > 0) {
const selectedTurnIndex = durableTurns.findIndex((turn) => turn.taskId === taskId);
if (selectedTurnIndex >= 0) {
return durableTurns.map((turn, index) =>
index === selectedTurnIndex ? { ...turn, activity } : turn,
);
}
// The API keeps the exact selected activity even when its bounded
// chronological turn is the first item removed from the response.
// Preserve that selection ahead of the retained newer continuation.
return [
{
taskId: taskId || `${selection.parentMessageId}:${selection.toolCallId}`,
trigger: {
kind:
selection.event == null
? ('parent_continuation' as const)
: ('external_event' as const),
summary: selection.prompt ?? activity.prompt ?? '',
},
activity,
},
...durableTurns,
];
}
return [
{
taskId: taskId || `${selection.parentMessageId}:${selection.toolCallId}`,
trigger: {
kind:
selection.event == null ? ('parent_dispatch' as const) : ('external_event' as const),
summary: selection.prompt ?? activity.prompt ?? '',
},
activity,
},
];
}, [activity, data, selection, taskId]);
/** During a rolling deployment an older API replica can omit `turns`. Keep
* that response readable through the same deep activity renderer; every
* current host otherwise enters the conversation-native rendering seam. */
const hasConversationProjection =
selection.durable == null || data == null || Array.isArray(data.turns);
const taskInaccessible = controlInaccessible || transientControl?.reason === 'task_inaccessible';
const controlAvailable =
selection.durable != null && data?.status === 'running' && !taskInaccessible && !controlsClosed;
@ -578,6 +636,62 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
</div>
);
}
const conversationStateByTask = useMemo(
() => new Map([[taskId || conversationTurns[0]?.taskId || '', panelState] as const]),
[conversationTurns, panelState, taskId],
);
let activityPanel: ReactNode;
if (hasConversationProjection) {
activityPanel = (
<SubagentActivityScrollSurface padded={false}>
{data?.historyTruncated === true && (
<div
role="note"
className="border-b border-border-light px-4 py-3 text-sm text-text-secondary"
>
{localize('com_ui_subagent_thread_history_truncated')}
</div>
)}
<SubagentConversation
turns={conversationTurns}
agentId={data?.agentId}
conversationId={threadId || selection.parentConversationId}
stateByTask={conversationStateByTask}
controllableTaskId={
controlAvailable && !controlPending ? selection.durable?.taskId : undefined
}
onCancelControl={(_controlledTaskId, controlId) =>
submitControl('cancel_message', controlId)
}
/>
</SubagentActivityScrollSurface>
);
} else if (selection.event != null && (eventSummary?.tasks.length ?? 0) > 1) {
activityPanel = (
<SubagentActivityScrollSurface padded={false}>
<div data-subagent-thread-timeline>
{timelinePrefix}
{visibleEventTasks.map(renderEventTask)}
</div>
</SubagentActivityScrollSurface>
);
} else {
activityPanel = (
<SubagentActivity
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activity={activity}
state={panelState}
showPrompt={false}
onCancelControl={
controlAvailable && !controlPending
? (controlId) => submitControl('cancel_message', controlId)
: undefined
}
/>
);
}
return (
<aside
@ -595,7 +709,25 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
<h2 className="truncate text-sm font-semibold" title={activity.title}>
{activity.title}
</h2>
{data?.depth != null && (
<div className="truncate text-xs text-text-secondary">
{localize('com_ui_subagent_depth', { 0: String(data.depth) })}
</div>
)}
</div>
{selection.host === 'conversation' && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={close}
aria-label={localize('com_ui_subagent_view_in_parent')}
className="h-8 shrink-0 gap-1.5"
>
<CornerUpLeft size={15} aria-hidden="true" />
<span className="hidden lg:inline">{localize('com_ui_subagent_view_in_parent')}</span>
</Button>
)}
{canContinueAsChat && (
<Button
type="button"
@ -656,26 +788,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
<ApprovalProvider
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
>
{selection.event != null && (eventSummary?.tasks.length ?? 0) > 1 ? (
<SubagentActivityScrollSurface padded={false}>
<div data-subagent-thread-timeline>
{timelinePrefix}
{visibleEventTasks.map(renderEventTask)}
</div>
</SubagentActivityScrollSurface>
) : (
<SubagentActivity
key={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${selection.partIndex}`}
activity={activity}
state={panelState}
onCancelControl={
controlAvailable && !controlPending
? (controlId) => submitControl('cancel_message', controlId)
: undefined
}
/>
)}
{activityPanel}
</ApprovalProvider>
{showControlFooter && (
<div className="shrink-0 border-t border-border-light p-3">
@ -795,6 +908,7 @@ function HistoricalEventTaskActivity({
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${task.taskId}`}
activity={activity}
state={state}
showPrompt={false}
embedded
/>
);

View file

@ -396,6 +396,7 @@ describe('child activity adapters', () => {
input: '{"query":"release"}',
output: 'Found it.',
status: 'completed',
inputValidationError: true,
},
{ type: 'writing', text: 'Durable answer.' },
],
@ -425,6 +426,9 @@ describe('child activity adapters', () => {
items: view.activity,
}),
);
expect(adaptDurableThreadActivity(view, 'task').items[0]).toEqual(
expect.objectContaining({ inputValidationError: true }),
);
});
it('redacts detached live reasoning while retaining its activity marker', () => {

View file

@ -4,6 +4,8 @@ import type {
PartMetadata,
SubagentActivityItem,
SubagentControlReceipt,
SubagentThreadTriggerKind,
SubagentThreadTurn,
SubagentThreadStatus,
SubagentThreadView,
TMessageContentParts,
@ -45,6 +47,7 @@ export type ChildActivityItem =
agentIds?: string[];
status?: 'ok' | 'partial' | 'failed';
pending?: boolean;
labelTruncated?: boolean;
};
export type ChildActivity = {
@ -61,6 +64,17 @@ export type ChildActivity = {
controlsTruncated?: boolean;
};
export type ChildConversationTurn = {
taskId: string;
trigger: {
kind: SubagentThreadTriggerKind;
summary: string;
createdAt?: string;
summaryTruncated?: boolean;
};
activity: ChildActivity;
};
type ContentToolCall = {
id?: string;
args?: string | Record<string, unknown>;
@ -164,6 +178,7 @@ const publicActivityToChildActivity = (items: SubagentActivityItem[]): ChildActi
return {
...item,
...(item.input == null ? {} : { input: item.input }),
...(item.inputValidationError === true ? { inputValidationError: true } : {}),
};
});
@ -323,13 +338,35 @@ export function adaptDurableThreadActivity(
items,
controls: view.controlReceipts ?? [],
controlsTruncated: view.controlReceiptsTruncated === true,
activityTruncated:
view.activityTruncated ||
view.historyTruncated ||
(view.activity ?? []).some(
(item) =>
(item.type === 'writing' && item.textTruncated === true) ||
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
),
activityTruncated: view.activityTruncated,
};
}
const adaptDurableTurn = (turn: SubagentThreadTurn, title: string): ChildConversationTurn => {
const items = publicActivityToChildActivity(turn.activity ?? []);
const response = turn.messages.find((message) => message.role === 'assistant');
if (items.length === 0 && response?.text != null && response.text !== '') {
items.push({
type: 'writing',
text: response.text,
...(response.textTruncated === true ? { textTruncated: true } : {}),
});
}
return {
taskId: turn.taskId,
trigger: turn.trigger,
activity: {
title,
status: turn.status,
items,
controls: turn.controlReceipts ?? [],
controlsTruncated: turn.controlReceiptsTruncated === true,
activityTruncated: turn.activityTruncated,
},
};
};
/** Adapts the branch-selected durable history into one chronological child conversation. */
export function adaptDurableThreadConversation(view: SubagentThreadView): ChildConversationTurn[] {
return (view.turns ?? []).map((turn) => adaptDurableTurn(turn, view.title));
}

View file

@ -49,6 +49,7 @@ export const subagentThreadHasTaskEvidence = (
view: SubagentThreadView | undefined,
taskId: string,
): boolean =>
view?.turns?.some((turn) => turn.taskId === taskId) === true ||
view?.messages.some(
(message) =>
message.messageId === `${taskId}:user` || message.messageId === `${taskId}:assistant`,

View file

@ -2215,6 +2215,7 @@
"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_activity_details_truncated": "Some activity details were shortened.",
"com_ui_subagent_control_interrupt": "Interrupt",
"com_ui_subagent_control_message": "Message to subagent",
"com_ui_subagent_control_message_truncated": "Message shortened for display.",
@ -2243,6 +2244,8 @@
"com_ui_subagent_thread_history_truncated": "Earlier activity is not shown.",
"com_ui_subagent_thread_load_error": "The agent activity could not be loaded.",
"com_ui_subagent_thread_panel": "Child agent activity",
"com_ui_subagent_depth": "Level {{0}} child",
"com_ui_subagent_view_in_parent": "View in parent",
"com_ui_subagent_thread_read_only": "This child thread is view-only here. Its parent agent owns this execution and can continue it with the saved thread history.",
"com_ui_subagent_thread_status_cancelled": "Cancelled",
"com_ui_subagent_thread_status_completed": "Completed",
@ -2250,6 +2253,10 @@
"com_ui_subagent_thread_status_failed": "Failed",
"com_ui_subagent_thread_status_interrupted": "Interrupted",
"com_ui_subagent_thread_status_running": "Running",
"com_ui_subagent_trigger_external_event": "External event",
"com_ui_subagent_trigger_parent_continuation": "Parent agent continuation",
"com_ui_subagent_trigger_parent_dispatch": "Parent agent dispatch",
"com_ui_subagent_trigger_truncated": "Trigger summary shortened for display.",
"com_ui_subagent_turn": "Turn",
"com_ui_subagent_running": "Running agent",
"com_ui_subagent_scroll_to_bottom": "Scroll to latest",

View file

@ -110,8 +110,10 @@ test.describe('detached subagent activity', () => {
await expect(panel).toContainText('child-1-phase-10');
await expect.poll(() => activityRequests.length).toBe(1);
await expect(panel.getByText('Completed', { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(panel).toContainText(`E2E detached child 1 complete ${label}`);
await expect(panel).toContainText(`E2E detached child 1 complete ${label}`, {
timeout: 30_000,
});
await expect(panel.getByText('Completed', { exact: true })).toHaveCount(0);
await expect.poll(() => finishedActivityRequests.length).toBe(1);
const activityStreamBody = await activityResponse.text();
expect(activityStreamBody).toContain('"event":"on_subagent_update"');
@ -122,8 +124,10 @@ test.describe('detached subagent activity', () => {
await panel.getByRole('button', { name: 'Close' }).click();
await expect(panel).not.toBeVisible();
await cards.nth(1).click();
await expect(panel.getByText('Completed', { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(panel).toContainText(`E2E detached child 2 complete ${label}`);
await expect(panel).toContainText(`E2E detached child 2 complete ${label}`, {
timeout: 30_000,
});
await expect(panel.getByText('Completed', { exact: true })).toHaveCount(0);
await panel.getByRole('button', { name: 'Close' }).click();
await page.reload();
@ -133,8 +137,8 @@ test.describe('detached subagent activity', () => {
);
await expect(restoredCards).toHaveCount(2);
await restoredCards.first().click();
await expect(panel.getByText('Completed', { exact: true })).toBeVisible();
await expect(panel).toContainText(`E2E detached child 1 complete ${label}`);
await expect(panel.getByText('Completed', { exact: true })).toHaveCount(0);
await panel.getByRole('button', { name: 'Close' }).click();
await page.getByRole('button', { name: 'Chat History' }).click();

View file

@ -1,6 +1,77 @@
import { projectSubagentActivity, SUBAGENT_ACTIVITY_LIMITS } from './activity';
import {
projectPersistedMessageActivity,
projectPersistedMessageActivityJson,
projectSubagentActivity,
SUBAGENT_ACTIVITY_LIMITS,
} from './activity';
describe('durable subagent activity projection', () => {
it('projects ordinary persisted chat content into the shared activity vocabulary', () => {
const projection = projectPersistedMessageActivity([
{ type: 'reasoning' },
{
type: 'activity_label',
label: 'Selected a legal move',
labelType: 'phase',
toolCallIds: ['move-1'],
labelTruncated: true,
},
{
type: 'tool',
toolCallId: 'move-1',
name: 'submit_move',
input: '{"uci":"e2e4"}',
output: '{"accepted":true}',
progress: 1,
inputValidationError: true,
inputTruncated: true,
outputTruncated: true,
},
{ type: 'writing', text: 'Move submitted.' },
]);
expect(projection).toEqual({
activity: [
{ type: 'reasoning' },
{
type: 'activity_label',
label: 'Selected a legal move',
labelType: 'phase',
toolCallIds: ['move-1'],
labelTruncated: true,
},
{
type: 'tool',
toolCallId: 'move-1',
name: 'submit_move',
input: '{"uci":"e2e4"}',
output: '{"accepted":true}',
status: 'completed',
inputValidationError: true,
inputTruncated: true,
outputTruncated: true,
},
{ type: 'writing', text: 'Move submitted.' },
],
truncated: false,
});
});
it('validates a settlement-time public activity projection without private transcript parsing', () => {
const projection = projectPersistedMessageActivityJson(
JSON.stringify([{ type: 'reasoning' }, { type: 'writing', text: 'Public result.' }]),
);
expect(projection).toEqual({
activity: [{ type: 'reasoning' }, { type: 'writing', text: 'Public result.' }],
truncated: false,
});
expect(projectPersistedMessageActivityJson('{')).toEqual({
activity: [],
truncated: true,
});
});
it('keeps visible text and tool lifecycle while dropping private metadata and reasoning text', () => {
const projection = projectSubagentActivity(
JSON.stringify([

View file

@ -95,6 +95,10 @@ const fitNewestItemToSerializedBudget = (item: SubagentActivityItem): SubagentAc
if (serializedBytes([item]) <= MAX_ACTIVITY_BYTES) return item;
if (item.type === 'writing') return shrinkStringField(item, 'text', 'textTruncated');
if (item.type === 'reasoning') return item;
if (item.type === 'activity_label') {
const withoutAssociations = { ...item, toolCallIds: undefined, agentIds: undefined };
return shrinkStringField(withoutAssociations, 'label', 'labelTruncated');
}
// Preserve the terminal output as long as possible: discard oversized input
// first, then trim output and finally public identity fields if a provider
@ -108,6 +112,145 @@ const fitNewestItemToSerializedBudget = (item: SubagentActivityItem): SubagentAc
return shrinkStringField(tool, 'toolCallId');
};
const boundActivity = (items: SubagentActivityItem[], sourceTruncated: boolean): Projection => {
let activity = items;
let truncated = sourceTruncated;
if (activity.length > MAX_ACTIVITY_ITEMS) {
activity = activity.slice(-MAX_ACTIVITY_ITEMS);
truncated = true;
}
while (activity.length > 1 && serializedBytes(activity) > MAX_ACTIVITY_BYTES) {
activity.shift();
truncated = true;
}
if (activity.length === 1 && serializedBytes(activity) > MAX_ACTIVITY_BYTES) {
activity[0] = fitNewestItemToSerializedBudget(activity[0]);
truncated = true;
}
return { activity, truncated };
};
const visibleStatus = (value: unknown): 'running' | 'completed' | 'failed' | 'cancelled' => {
if (value === 'completed' || value === 'failed' || value === 'cancelled') return value;
return 'running';
};
const finiteNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const stringArray = (value: unknown): string[] | undefined => {
if (!Array.isArray(value)) return undefined;
const result = value.filter((candidate): candidate is string => typeof candidate === 'string');
return result.length === 0 ? undefined : result;
};
/**
* Validates the storage-bounded ordinary message-content projection. This is
* the durable fallback for runs that persisted normal LibreChat content but
* did not write a separate private subagent transcript.
*/
export function projectPersistedMessageActivity(
value: unknown,
sourceTruncated = false,
): Projection {
if (!Array.isArray(value)) return { activity: [], truncated: sourceTruncated };
let truncated = sourceTruncated;
const activity = value.flatMap((candidate): SubagentActivityItem[] => {
if (!isRecord(candidate) || typeof candidate.type !== 'string') {
truncated = true;
return [];
}
if (candidate.type === 'writing') {
if (typeof candidate.text !== 'string') {
truncated = true;
return [];
}
return [
{
type: 'writing',
text: candidate.text,
...(candidate.textTruncated === true ? { textTruncated: true } : {}),
},
];
}
if (candidate.type === 'reasoning') return [{ type: 'reasoning' }];
if (candidate.type === 'activity_label') {
if (typeof candidate.label !== 'string') {
truncated = true;
return [];
}
const toolCallIds = stringArray(candidate.toolCallIds);
const agentIds = stringArray(candidate.agentIds);
const activityStartIndex = finiteNumber(candidate.activityStartIndex);
const activityEndIndex = finiteNumber(candidate.activityEndIndex);
const activityCount = finiteNumber(candidate.activityCount);
return [
{
type: 'activity_label',
label: candidate.label,
...(candidate.labelType === 'phase' ? { labelType: 'phase' as const } : {}),
...(toolCallIds == null ? {} : { toolCallIds }),
...(activityStartIndex == null ? {} : { activityStartIndex }),
...(activityEndIndex == null ? {} : { activityEndIndex }),
...(activityCount == null ? {} : { activityCount }),
...(agentIds == null ? {} : { agentIds }),
...(candidate.status === 'ok' ||
candidate.status === 'partial' ||
candidate.status === 'failed'
? { status: candidate.status }
: {}),
...(typeof candidate.pending === 'boolean' ? { pending: candidate.pending } : {}),
...(candidate.labelTruncated === true ? { labelTruncated: true } : {}),
},
];
}
if (candidate.type !== 'tool') {
truncated = true;
return [];
}
if (typeof candidate.toolCallId !== 'string' || typeof candidate.name !== 'string') {
truncated = true;
return [];
}
const completed =
finiteNumber(candidate.progress) != null && finiteNumber(candidate.progress)! >= 1;
const output = typeof candidate.output === 'string' ? candidate.output : undefined;
const runStepStatus = visibleStatus(candidate.runStepStatus);
let status: MutableToolActivity['status'] = runStepStatus;
if (candidate.runStepStatus == null) {
status = completed || output != null ? 'completed' : 'running';
}
return [
{
type: 'tool',
toolCallId: candidate.toolCallId,
name: candidate.name,
...(typeof candidate.input === 'string' && candidate.input !== ''
? { input: candidate.input }
: {}),
...(output == null || output === '' ? {} : { output }),
status,
...(candidate.inputValidationError === true ? { inputValidationError: true } : {}),
...(candidate.inputTruncated === true ? { inputTruncated: true } : {}),
...(candidate.outputTruncated === true ? { outputTruncated: true } : {}),
},
];
});
return boundActivity(activity, truncated);
}
/** Validates a storage-bounded, settlement-time public activity projection. */
export function projectPersistedMessageActivityJson(
activityJson: string,
sourceTruncated = false,
): Projection {
try {
return projectPersistedMessageActivity(JSON.parse(activityJson) as unknown, sourceTruncated);
} catch {
return { activity: [], truncated: true };
}
}
const visibleContent = (value: unknown): { text: string; hasReasoning: boolean } => {
if (typeof value === 'string') return { text: value, hasReasoning: false };
if (!Array.isArray(value)) return { text: '', hasReasoning: false };
@ -292,20 +435,8 @@ export function projectSubagentActivity(
append(orphan);
}
let boundedActivity = activity.filter((entry) => entry.active).map((entry) => entry.item);
if (boundedActivity.length > MAX_ACTIVITY_ITEMS) {
boundedActivity = boundedActivity.slice(-MAX_ACTIVITY_ITEMS);
truncated = true;
}
while (boundedActivity.length > 1 && serializedBytes(boundedActivity) > MAX_ACTIVITY_BYTES) {
boundedActivity.shift();
truncated = true;
}
if (boundedActivity.length === 1 && serializedBytes(boundedActivity) > MAX_ACTIVITY_BYTES) {
boundedActivity[0] = fitNewestItemToSerializedBudget(boundedActivity[0]);
truncated = true;
}
return { activity: boundedActivity, truncated };
const boundedActivity = activity.filter((entry) => entry.active).map((entry) => entry.item);
return boundActivity(boundedActivity, truncated);
}
export const SUBAGENT_ACTIVITY_LIMITS = {

View file

@ -323,7 +323,7 @@ describe('SubagentThreadTaskStore', () => {
expect(conversation?.subagentThread).not.toHaveProperty('userRunnable');
const messages = await methods.getMessages(
{ user: userId, conversationId: threadId },
'+subagentTranscript',
'+subagentTranscript +subagentActivityProjection',
);
expect(messages.map((message) => message.text)).toEqual([
'Investigate the issue.',
@ -333,6 +333,12 @@ describe('SubagentThreadTaskStore', () => {
taskId: requireAccepted(started).task.taskId,
mode: 'append',
});
expect(messages[1].subagentActivityProjection).toEqual({
taskId: requireAccepted(started).task.taskId,
version: 1,
activityJson: JSON.stringify([{ type: 'writing', text: 'Completed the investigation.' }]),
truncated: false,
});
});
it('registers a host-safe wakeup before child provider work begins', async () => {

View file

@ -48,6 +48,7 @@ import { createSubagentAttemptKey, createSubagentThreadId } from './subagentThre
import { runWithDetachedSubagentUsage } from './subagentTaskContext';
import { SUBAGENT_COMPLETION_DELIVERY } from './subagentDelivery';
import { createConcurrencyLimiter } from '~/utils/promise';
import { projectSubagentActivity } from './activity';
import { InMemoryEventTransport } from '~/stream';
import { aggregateEmittedUsage } from './usage';
@ -3060,6 +3061,23 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore {
prepared.initialStoredMessages,
result.messages,
);
const activityProjection =
subagentTranscript == null
? undefined
: projectSubagentActivity(
subagentTranscript.messagesJson,
subagentTranscript.mode,
request.input,
);
const subagentActivityProjection =
activityProjection == null
? undefined
: {
taskId,
version: 1 as const,
activityJson: JSON.stringify(activityProjection.activity),
truncated: activityProjection.truncated,
};
const conversation = await this.requireCurrentConversation(
scope,
request,
@ -3078,6 +3096,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore {
isCreatedByUser: false,
unfinished: false,
...(subagentTranscript == null ? {} : { subagentTranscript }),
...(subagentActivityProjection == null ? {} : { subagentActivityProjection }),
subagentTask: {
attemptKey: prepared.attemptKey,
parentRunId: request.parentRunId,

View file

@ -122,12 +122,34 @@ describe('subagent thread parent-scoped view', () => {
parentToolCallId: 'parent-tool-call',
subagentType: 'researcher',
subagentKind: 'agent',
depth: 1,
agentId: 'agent-1',
title: 'Research child',
status: 'completed',
activity: [],
activityTruncated: false,
controlReceipts: [],
turns: [
{
taskId: 'task-1',
trigger: {
kind: 'parent_dispatch',
summary: 'Investigate this.',
createdAt: '2026-08-21T11:00:00.000Z',
},
status: 'completed',
activity: [],
activityTruncated: false,
controlReceipts: [],
messages: [
expect.objectContaining({
messageId: 'task-1:assistant',
role: 'assistant',
textTruncated: true,
}),
],
},
],
messages: [
expect.objectContaining({ messageId: 'task-1:user', role: 'user' }),
expect.objectContaining({
@ -146,6 +168,109 @@ describe('subagent thread parent-scoped view', () => {
SUBAGENT_THREAD_VIEW_LIMITS.responseBytes,
);
expect(json.mock.calls[0][0].messages[1]).not.toHaveProperty('subagentTask');
expect(JSON.stringify(json.mock.calls[0][0])).not.toMatch(
/subagentTranscript|messagesJson|attemptKey|lease-token/,
);
});
it('returns branch-selected child turns as one chronological conversation', async () => {
const firstInput = {
...message('task-1:user', 'running', true),
parentMessageId: '00000000-0000-0000-0000-000000000000',
} as IMessage;
const firstAssistant = {
...message('task-1:assistant', 'completed'),
parentMessageId: 'task-1:user',
subagentTranscript: {
taskId: 'task-1',
mode: 'append' as const,
messagesJson: JSON.stringify([{ type: 'ai', data: { content: 'First answer.' } }]),
},
} as IMessage;
const secondInput = {
...message('task-2:user', 'running', true),
parentMessageId: 'task-1:assistant',
text: 'Continue with the new event.',
createdAt: new Date('2026-08-21T11:02:00.000Z'),
} as IMessage;
const secondAssistant = {
...message('task-2:assistant', 'completed'),
parentMessageId: 'task-2:user',
createdAt: new Date('2026-08-21T11:03:00.000Z'),
subagentTranscript: {
taskId: 'task-2',
mode: 'append' as const,
messagesJson: JSON.stringify([{ type: 'ai', data: { content: 'Second answer.' } }]),
},
} as IMessage;
const abandoned = {
...message('abandoned:assistant', 'error'),
parentMessageId: 'task-1:user',
createdAt: new Date('2026-08-21T11:01:30.000Z'),
} as IMessage;
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest.fn().mockResolvedValue({
...child,
subagentThreadLease: undefined,
}),
getMessagesForSubagentThreadView: jest
.fn()
.mockResolvedValue([secondAssistant, secondInput, abandoned, firstAssistant, firstInput]),
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'task-2' }), response);
const view = json.mock.calls[0][0];
expect(view.turns).toEqual([
expect.objectContaining({
taskId: 'task-1',
trigger: expect.objectContaining({
kind: 'parent_dispatch',
summary: 'Investigate this.',
}),
activity: [{ type: 'writing', text: 'First answer.' }],
}),
expect.objectContaining({
taskId: 'task-2',
trigger: expect.objectContaining({
kind: 'parent_continuation',
summary: 'Continue with the new event.',
}),
activity: [{ type: 'writing', text: 'Second answer.' }],
}),
]);
expect(JSON.stringify(view)).not.toContain('abandoned');
expect(view.historyTruncated).toBe(true);
});
it('labels a retained continuation honestly when its task ancestor was truncated', async () => {
const continuationInput = {
...message('task-2:user', 'running', true),
parentMessageId: 'task-1:assistant',
text: 'Continue from the missing earlier task.',
} as IMessage;
const continuationAssistant = {
...message('task-2:assistant', 'completed'),
parentMessageId: 'task-2:user',
} as IMessage;
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest.fn().mockResolvedValue({
...child,
subagentThreadLease: undefined,
}),
getMessagesForSubagentThreadView: jest
.fn()
.mockResolvedValue([continuationAssistant, continuationInput]),
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'task-2' }), response);
expect(json.mock.calls[0][0].historyTruncated).toBe(true);
expect(json.mock.calls[0][0].turns[0].trigger.kind).toBe('parent_continuation');
});
it("returns only the selected task's sanitized bounded activity", async () => {
@ -187,7 +312,9 @@ describe('subagent thread parent-scoped view', () => {
await handler(createRequest({}, { taskId: 'task-1' }), response);
expect(getMessages).toHaveBeenCalledWith(expect.objectContaining({ taskId: 'task-1' }));
expect(getMessages).toHaveBeenCalledWith(
expect.not.objectContaining({ taskId: expect.anything() }),
);
const view = json.mock.calls[0][0];
expect(view.activity).toEqual([
{ type: 'reasoning' },
@ -204,8 +331,59 @@ describe('subagent thread parent-scoped view', () => {
expect(view.messages[0]).not.toHaveProperty('subagentTranscript');
});
it('selects an exact older task outside the rolling conversation page', async () => {
const recent = Array.from(
{ length: SUBAGENT_THREAD_VIEW_LIMITS.messages + 1 },
(_, index) =>
({
...message(`recent-${index}:assistant`, 'completed'),
text: '🧵'.repeat(SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes),
createdAt: new Date(Date.UTC(2026, 7, 22, 12, index)),
}) as IMessage,
);
const selectedInput = {
...message('selected-old:user', 'running', true),
text: 'Original selected prompt.',
createdAt: new Date('2026-08-21T10:00:00.000Z'),
} as IMessage;
const selected = {
...message('selected-old:assistant', 'completed'),
text: 'Selected result.',
createdAt: new Date('2026-08-21T10:01:00.000Z'),
subagentActivityProjectionJson: JSON.stringify([
{ type: 'writing', text: 'Selected durable result.' },
]),
} as IMessage & { subagentActivityProjectionJson: string };
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: jest
.fn()
.mockResolvedValue([...recent, selected, selectedInput]),
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'selected-old' }), response);
const view = json.mock.calls[0][0];
expect(view.status).toBe('completed');
expect(view.activity).toEqual([{ type: 'writing', text: 'Selected durable result.' }]);
expect(view.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
messageId: 'selected-old:assistant',
text: 'Selected result.',
}),
]),
);
expect(view.historyTruncated).toBe(true);
});
it('returns bounded authoritative control receipts without private fingerprints', async () => {
const input = message('task-1:user', 'running', true);
Object.assign(input.subagentTask!, { controlReceiptsProjectionTruncated: true });
input.subagentTask!.controlReceipts = [
{
invocationId: 'private-reservation',
@ -215,7 +393,7 @@ describe('subagent thread parent-scoped view', () => {
createdAt: new Date('2026-08-21T09:59:59.000Z'),
updatedAt: new Date('2026-08-21T09:59:59.000Z'),
},
...Array.from({ length: 32 }, (_, index) => ({
...Array.from({ length: 31 }, (_, index) => ({
invocationId: `earlier-${index}`,
fingerprint: `private-${index}`,
action: 'queue' as const,
@ -387,6 +565,44 @@ describe('subagent thread parent-scoped view', () => {
expect(view.messages.at(-1).messageId).toBe('task-0:assistant');
});
it('preserves the selected assistant while trimming a large chronological response', async () => {
const chronological = Array.from({ length: 8 }, (_, index) => {
const input = {
...message(`task-${index}:user`, 'running', true),
parentMessageId:
index === 0 ? '00000000-0000-0000-0000-000000000000' : `task-${index - 1}:assistant`,
text: '🧵'.repeat(SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes),
createdAt: new Date(Date.UTC(2026, 7, 21, 12, index * 2)),
} as IMessage;
const assistant = {
...message(`task-${index}:assistant`, 'completed'),
parentMessageId: `task-${index}:user`,
text: '🧵'.repeat(SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes),
createdAt: new Date(Date.UTC(2026, 7, 21, 12, index * 2 + 1)),
} as IMessage;
return [input, assistant];
}).flat();
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([...chronological].reverse()),
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'task-0' }), response);
const view = json.mock.calls[0][0];
expect(view.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ messageId: 'task-0:assistant' })]),
);
expect(Buffer.byteLength(JSON.stringify(view), 'utf8')).toBeLessThanOrEqual(
SUBAGENT_THREAD_VIEW_LIMITS.responseBytes,
);
expect(view.historyTruncated).toBe(true);
});
it('requires tenantless messages when the authenticated request has no tenant', async () => {
const getMessages = jest.fn().mockResolvedValue([]);
const handler = createSubagentThreadViewHandler({
@ -492,8 +708,16 @@ describe('subagent thread parent-scoped view', () => {
it('keeps the newest bounded tail and marks older history as truncated', async () => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const messages = Array.from({ length: SUBAGENT_THREAD_VIEW_LIMITS.messages + 1 }, (_, index) =>
message(`task-${index}:assistant`, 'completed'),
const messages = Array.from(
{ length: SUBAGENT_THREAD_VIEW_LIMITS.messages + 1 },
(_, index) =>
({
...message(`task-${index}:assistant`, 'completed'),
parentMessageId:
index === SUBAGENT_THREAD_VIEW_LIMITS.messages
? '00000000-0000-0000-0000-000000000000'
: `task-${index + 1}:assistant`,
}) as IMessage,
);
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
@ -513,6 +737,34 @@ describe('subagent thread parent-scoped view', () => {
expect(view.messages.at(-1).messageId).toBe('task-0:assistant');
});
it('marks a retained branch whose older task ancestor is unavailable as truncated', async () => {
const input = {
...message('task-2:user', 'running', true),
parentMessageId: 'task-1:assistant',
} as IMessage;
const assistant = {
...message('task-2:assistant', 'completed'),
parentMessageId: 'task-2:user',
} as IMessage;
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([assistant, input]),
});
const { response, json } = createResponse();
await handler(createRequest({}, { taskId: 'task-2' }), response);
expect(json.mock.calls[0][0]).toEqual(
expect.objectContaining({
historyTruncated: true,
turns: [expect.objectContaining({ taskId: 'task-2' })],
}),
);
});
it.each([
['missing parent', null, child, 'tenant-1'],
['missing child', parent, null, 'tenant-1'],
@ -816,9 +1068,29 @@ describe('parent child-thread index', () => {
const getMessagesForSubagentThreadView = jest.fn().mockResolvedValue([
{
messageId: 'delivery-1:assistant',
parentMessageId: 'delivery-1:user',
isCreatedByUser: false,
text: 'Event result',
createdAt: new Date('2026-08-21T11:01:00.000Z'),
subagentActivity: [
{
type: 'tool',
toolCallId: 'move-1',
name: 'submit_move',
input: '{"uci":"e2e4"}',
output: '{"accepted":true}',
progress: 1,
},
{ type: 'writing', text: 'Move submitted.' },
],
},
{
messageId: 'delivery-1:user',
parentMessageId: null,
isCreatedByUser: true,
text: 'Safe instruction. {"privateRoutingKey":"must-not-leak"}',
textProjectionTruncated: true,
createdAt: new Date('2026-08-21T11:00:00.000Z'),
},
]);
const handler = createSubagentThreadViewHandler({
@ -830,9 +1102,35 @@ describe('parent child-thread index', () => {
await handler(createRequest({ threadId: 'event-thread' }, { taskId: 'delivery-1' }), response);
expect(json.mock.calls[0][0]).toEqual(expect.objectContaining({ status: 'completed' }));
expect(json.mock.calls[0][0]).toEqual(
expect.objectContaining({
status: 'completed',
turns: [
expect.objectContaining({
taskId: 'delivery-1',
trigger: expect.objectContaining({ kind: 'external_event', summary: '' }),
activity: [
expect.objectContaining({
type: 'tool',
toolCallId: 'move-1',
status: 'completed',
}),
{ type: 'writing', text: 'Move submitted.' },
],
}),
],
}),
);
expect(json.mock.calls[0][0].messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ messageId: 'delivery-1:user', text: '' }),
expect.objectContaining({ messageId: 'delivery-1:assistant', text: 'Event result' }),
]),
);
expect(json.mock.calls[0][0].turns[0].trigger).not.toHaveProperty('summaryTruncated');
expect(JSON.stringify(json.mock.calls[0][0])).not.toContain('privateRoutingKey');
expect(getMessagesForSubagentThreadView).toHaveBeenCalledWith(
expect.objectContaining({ taskId: 'delivery-1' }),
expect.not.objectContaining({ taskId: expect.anything() }),
);
});

View file

@ -6,6 +6,7 @@ import type {
SubagentControlReceipt,
SubagentThreadMessage,
SubagentThreadStatus,
SubagentThreadTurn,
SubagentThreadView,
} from 'librechat-data-provider';
import type {
@ -17,7 +18,12 @@ import type {
} from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
import { projectSubagentActivity, SUBAGENT_ACTIVITY_LIMITS } from './activity';
import {
projectPersistedMessageActivity,
projectPersistedMessageActivityJson,
projectSubagentActivity,
SUBAGENT_ACTIVITY_LIMITS,
} from './activity';
const MAX_THREAD_MESSAGES = 50;
const MAX_MESSAGE_TEXT_BYTES = 32 * 1024;
@ -25,7 +31,7 @@ const MAX_MESSAGE_TEXT_BYTES = 32 * 1024;
// keep the storage projection at or below the public byte ceiling.
const MAX_MESSAGE_TEXT_PROJECTION_CODE_POINTS = Math.floor(MAX_MESSAGE_TEXT_BYTES / 4);
const MAX_RESPONSE_TEXT_BYTES = 128 * 1024;
const MAX_RESPONSE_BYTES = 160 * 1024;
const MAX_RESPONSE_BYTES = 256 * 1024;
const MAX_PUBLIC_ID_BYTES = 512;
const MAX_TITLE_BYTES = 1024;
const MAX_PARENT_CHILDREN = 64;
@ -97,8 +103,9 @@ const truncateUtf8 = (
const publicMessage = (
message: SubagentThreadViewMessageRecord,
byteLimit: number,
redactText = false,
): { message: SubagentThreadMessage; bytes: number } => {
const text = message.text ?? '';
const text = redactText ? '' : (message.text ?? '');
const projected = truncateUtf8(text, Math.min(MAX_MESSAGE_TEXT_BYTES, byteLimit));
return {
message: {
@ -163,7 +170,12 @@ const publicControlReceipts = (
: {}),
};
});
return { receipts: retained, truncated: retained.length < visible.length };
return {
receipts: retained,
truncated:
(input?.subagentTask as { controlReceiptsProjectionTruncated?: boolean } | null | undefined)
?.controlReceiptsProjectionTruncated === true || retained.length < visible.length,
};
};
const publicStatus = (
@ -175,11 +187,9 @@ const publicStatus = (
activeLeaseTaskId != null &&
(requestedTaskId == null || requestedTaskId === activeLeaseTaskId)
) {
const activeTaskMessage = messages.find(
(message) =>
message.messageId === `${activeLeaseTaskId}:user` ||
message.messageId === `${activeLeaseTaskId}:assistant`,
);
const activeTaskMessage =
messages.find((message) => message.messageId === `${activeLeaseTaskId}:assistant`) ??
messages.find((message) => message.messageId === `${activeLeaseTaskId}:user`);
if (
activeTaskMessage?.subagentTask?.status == null ||
activeTaskMessage.subagentTask.status === 'running'
@ -188,9 +198,11 @@ const publicStatus = (
}
return publicStatus([activeTaskMessage], undefined);
}
const message = messages.find(
const taskMessages = messages.filter(
(candidate) => requestedTaskId == null || candidate.messageId.startsWith(`${requestedTaskId}:`),
);
const message =
taskMessages.find((candidate) => candidate.messageId.endsWith(':assistant')) ?? taskMessages[0];
let persistedStatus = message?.subagentTask?.status;
if (persistedStatus == null && message != null) {
if (message.isCreatedByUser) {
@ -228,6 +240,121 @@ const taskIdFromMessageId = (messageId: string): string | undefined => {
return validTaskId(taskId) ? taskId : undefined;
};
const canonicalThreadBranch = (
newestFirst: SubagentThreadViewMessageRecord[],
): SubagentThreadViewMessageRecord[] => {
const byId = new Map(newestFirst.map((message) => [message.messageId, message]));
const branch: SubagentThreadViewMessageRecord[] = [];
const visited = new Set<string>();
let current: SubagentThreadViewMessageRecord | undefined = newestFirst[0];
while (current != null && !visited.has(current.messageId)) {
branch.push(current);
visited.add(current.messageId);
current = current.parentMessageId == null ? undefined : byId.get(current.parentMessageId);
}
return branch.reverse();
};
const projectedTaskActivity = (
assistant: SubagentThreadViewMessageRecord | undefined,
input: SubagentThreadViewMessageRecord | undefined,
taskId: string,
): ReturnType<typeof projectSubagentActivity> => {
if (assistant?.subagentActivityProjectionJson != null) {
return projectPersistedMessageActivityJson(
assistant.subagentActivityProjectionJson,
assistant.subagentActivityProjectionTruncated === true,
);
}
if (assistant?.subagentTranscriptProjectionTruncated === true) {
return { activity: [], truncated: true };
}
const transcript = assistant?.subagentTranscript;
if (transcript == null) {
return projectPersistedMessageActivity(
assistant?.subagentActivity,
assistant?.subagentActivityProjectionTruncated === true,
);
}
if (transcript.taskId !== taskId) return { activity: [], truncated: true };
return projectSubagentActivity(
transcript.messagesJson,
transcript.mode,
input?.textProjectionTruncated === true ? undefined : input?.text,
);
};
const publicThreadTurns = (
branch: SubagentThreadViewMessageRecord[],
publicMessagesById: Map<string, SubagentThreadMessage>,
activeLeaseTaskId: string | undefined,
eventThread: boolean,
): SubagentThreadTurn[] => {
const records = new Map<
string,
{
taskId: string;
input?: SubagentThreadViewMessageRecord;
assistant?: SubagentThreadViewMessageRecord;
}
>();
const taskOrder: string[] = [];
for (const message of branch) {
const taskId = taskIdFromMessageId(message.messageId);
if (taskId == null) continue;
let record = records.get(taskId);
if (record == null) {
record = { taskId };
records.set(taskId, record);
taskOrder.push(taskId);
}
if (message.messageId.endsWith(':user')) record.input = message;
if (message.messageId.endsWith(':assistant')) record.assistant = message;
}
return taskOrder.flatMap((taskId): SubagentThreadTurn[] => {
const record = records.get(taskId);
if (record == null) return [];
const projected = projectedTaskActivity(record.assistant, record.input, taskId);
const input = record.input == null ? undefined : publicMessagesById.get(record.input.messageId);
const assistant =
record.assistant == null ? undefined : publicMessagesById.get(record.assistant.messageId);
const controls = publicControlReceipts(branch, taskId);
let triggerKind: SubagentThreadTurn['trigger']['kind'] = 'parent_continuation';
if (eventThread) triggerKind = 'external_event';
else if (
record.input != null &&
(record.input.parentMessageId == null ||
taskIdFromMessageId(record.input.parentMessageId) == null)
) {
triggerKind = 'parent_dispatch';
}
return [
{
taskId,
trigger: {
kind: triggerKind,
summary: eventThread ? '' : (input?.text ?? ''),
...(input?.createdAt == null ? {} : { createdAt: input.createdAt }),
...(!eventThread && input?.textTruncated === true ? { summaryTruncated: true } : {}),
},
status: publicStatus(
[record.assistant, record.input].filter(
(message): message is SubagentThreadViewMessageRecord => message != null,
),
activeLeaseTaskId,
taskId,
),
activity: projected.activity,
activityTruncated: projected.truncated,
controlReceipts: controls.receipts,
...(controls.truncated ? { controlReceiptsTruncated: true } : {}),
messages: assistant == null ? [] : [assistant],
},
];
});
};
const publicTaskStatus = (
status: NonNullable<ParentSubagentTaskRecord['tasks'][number]['status']>,
active: boolean,
@ -449,55 +576,94 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
conversationId: threadId,
user: userId,
...(tenantId == null ? {} : { tenantId }),
...(requestedTaskId == null ? {} : { selectedTaskId: requestedTaskId }),
limit: MAX_THREAD_MESSAGES + 1,
textCodePointLimit: MAX_MESSAGE_TEXT_PROJECTION_CODE_POINTS,
...(requestedTaskId == null ? {} : { taskId: requestedTaskId }),
});
const historyTruncated = messages.length > MAX_THREAD_MESSAGES;
const newestFirst = historyTruncated ? messages.slice(0, MAX_THREAD_MESSAGES) : messages;
let historyTruncated = messages.length > MAX_THREAD_MESSAGES;
const newestFirst = messages.slice(0, MAX_THREAD_MESSAGES);
const branch = canonicalThreadBranch(newestFirst);
if (branch.length < newestFirst.length) historyTruncated = true;
const branchRootParentId = branch[0]?.parentMessageId;
if (branchRootParentId != null && taskIdFromMessageId(branchRootParentId) != null) {
historyTruncated = true;
}
const activeLeaseTaskId =
child.subagentThreadLease != null && child.subagentThreadLease.expiresAt > now
? child.subagentThreadLease.taskId
: undefined;
const eventThread = lineage.parentToolCallId.startsWith('event-binding:');
const selectedRecords =
requestedTaskId == null
? []
: messages.filter(
(message) =>
message.messageId === `${requestedTaskId}:assistant` ||
message.messageId === `${requestedTaskId}:user`,
);
const selectedMessage =
requestedTaskId == null
? undefined
: newestFirst.find((message) => message.messageId === `${requestedTaskId}:assistant`);
const selectedTranscript = selectedMessage?.subagentTranscript;
: selectedRecords.find((message) => message.messageId === `${requestedTaskId}:assistant`);
const selectedInput =
requestedTaskId == null
? undefined
: newestFirst.find((message) => message.messageId === `${requestedTaskId}:user`);
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 };
: selectedRecords.find((message) => message.messageId === `${requestedTaskId}:user`);
const projectedActivity =
requestedTaskId == null
? { activity: [], truncated: false }
: projectedTaskActivity(selectedMessage, selectedInput, requestedTaskId);
const publicSource = [...branch];
const publicSourceIds = new Set(publicSource.map((message) => message.messageId));
for (const record of selectedRecords) {
if (!publicSourceIds.has(record.messageId)) publicSource.push(record);
}
const projectedNewestFirst: SubagentThreadMessage[] = [];
let remainingTextBytes = MAX_RESPONSE_TEXT_BYTES;
for (const message of newestFirst) {
const selectedAssistantRecord = selectedRecords.find(
(message) => message.messageId === `${requestedTaskId}:assistant`,
);
const selectedAssistantProjection =
selectedAssistantRecord == null
? undefined
: publicMessage(selectedAssistantRecord, MAX_MESSAGE_TEXT_BYTES);
const projectedById = new Map<string, SubagentThreadMessage>();
if (selectedAssistantProjection != null) {
projectedById.set(
selectedAssistantProjection.message.messageId,
selectedAssistantProjection.message,
);
}
let remainingTextBytes = MAX_RESPONSE_TEXT_BYTES - (selectedAssistantProjection?.bytes ?? 0);
for (const message of [...publicSource].reverse()) {
if (projectedById.has(message.messageId)) continue;
if (remainingTextBytes === 0) {
break;
}
const projected = publicMessage(message, remainingTextBytes);
projectedNewestFirst.push(projected.message);
const projected = publicMessage(
message,
remainingTextBytes,
eventThread && message.isCreatedByUser,
);
projectedById.set(projected.message.messageId, projected.message);
remainingTextBytes -= projected.bytes;
}
const projectedControls =
requestedTaskId == null
? { receipts: [], truncated: false }
: publicControlReceipts(newestFirst, requestedTaskId);
: publicControlReceipts(selectedRecords, requestedTaskId);
const projectedMessages = publicSource.flatMap((message) => {
const projected = projectedById.get(message.messageId);
return projected == null ? [] : [projected];
});
const projectedMessagesById = new Map(
projectedMessages.map((message) => [message.messageId, message]),
);
const turns = publicThreadTurns(
branch,
projectedMessagesById,
activeLeaseTaskId,
eventThread,
);
const view: SubagentThreadView = {
threadId,
parentConversationId,
@ -507,26 +673,47 @@ export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependen
: `event-thread:${truncateUtf8(threadId, MAX_PUBLIC_ID_BYTES - 13).text}`,
subagentType: truncateUtf8(lineage.subagentType, MAX_PUBLIC_ID_BYTES).text,
subagentKind: lineage.subagentKind,
depth:
typeof lineage.depth === 'number' && Number.isFinite(lineage.depth)
? Math.max(0, Math.min(1, lineage.depth))
: 1,
...(child.agent_id == null
? {}
: { agentId: truncateUtf8(child.agent_id, MAX_PUBLIC_ID_BYTES).text }),
title: truncateUtf8(child.title ?? `Subagent: ${lineage.subagentType}`, MAX_TITLE_BYTES)
.text,
status: publicStatus(newestFirst, activeLeaseTaskId, requestedTaskId),
status: publicStatus(messages, activeLeaseTaskId, requestedTaskId),
activity: projectedActivity.activity,
activityTruncated: projectedActivity.truncated,
controlReceipts: projectedControls.receipts,
...(projectedControls.truncated ? { controlReceiptsTruncated: true } : {}),
messages: projectedNewestFirst.reverse(),
historyTruncated: historyTruncated || projectedNewestFirst.length < newestFirst.length,
turns,
messages: projectedMessages,
historyTruncated: historyTruncated || projectedMessages.length < publicSource.length,
...(isoDate(child.updatedAt) == null ? {} : { updatedAt: isoDate(child.updatedAt) }),
};
const selectedAssistantId =
requestedTaskId == null ? undefined : `${requestedTaskId}:assistant`;
while (Buffer.byteLength(JSON.stringify(view), 'utf8') > MAX_RESPONSE_BYTES) {
if (view.messages.length === 0) {
throw new Error('Subagent thread projection exceeded its response limit');
if ((view.turns?.length ?? 0) > 1) {
view.turns?.shift();
view.historyTruncated = true;
continue;
}
view.messages.shift();
view.historyTruncated = true;
const removableMessageIndex = view.messages.findIndex(
(message) => message.messageId !== selectedAssistantId,
);
if (removableMessageIndex >= 0) {
view.messages.splice(removableMessageIndex, 1);
view.historyTruncated = true;
continue;
}
if (view.turns?.length === 1) {
view.turns = [];
view.historyTruncated = true;
continue;
}
throw new Error('Subagent thread projection exceeded its response limit');
}
res.status(200).json(view);
} catch (error) {

View file

@ -55,6 +55,19 @@ export type SubagentActivityItem =
| {
type: 'reasoning';
}
| {
type: 'activity_label';
label: string;
labelType?: 'phase';
toolCallIds?: string[];
activityStartIndex?: number;
activityEndIndex?: number;
activityCount?: number;
agentIds?: string[];
status?: 'ok' | 'partial' | 'failed';
pending?: boolean;
labelTruncated?: boolean;
}
| {
type: 'tool';
toolCallId: string;
@ -62,6 +75,7 @@ export type SubagentActivityItem =
input?: string;
output?: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
inputValidationError?: true;
inputTruncated?: boolean;
outputTruncated?: boolean;
};
@ -103,6 +117,31 @@ export type SubagentThreadMessage = {
textTruncated?: boolean;
};
export type SubagentThreadTriggerKind =
| 'parent_dispatch'
| 'parent_continuation'
| 'external_event';
/**
* One chronological child execution boundary. The trigger is host-authored,
* while activity and messages are bounded public projections of the child run.
*/
export type SubagentThreadTurn = {
taskId: string;
trigger: {
kind: SubagentThreadTriggerKind;
summary: string;
createdAt?: string;
summaryTruncated?: boolean;
};
status: SubagentThreadStatus;
activity: SubagentActivityItem[];
activityTruncated: boolean;
controlReceipts?: SubagentControlReceipt[];
controlReceiptsTruncated?: boolean;
messages: SubagentThreadMessage[];
};
export type SubagentThreadView = {
threadId: string;
parentConversationId: string;
@ -110,6 +149,8 @@ export type SubagentThreadView = {
parentToolCallId: string;
subagentType: string;
subagentKind: 'agent' | 'graph';
/** Product recursion level, currently bounded to one by the host runtime. */
depth?: number;
agentId?: string;
title: string;
status: SubagentThreadStatus;
@ -120,6 +161,8 @@ export type SubagentThreadView = {
controlReceipts?: SubagentControlReceipt[];
/** True when older authoritative command receipts were omitted from this view. */
controlReceiptsTruncated?: boolean;
/** Chronological, branch-selected child history for conversation-native rendering. */
turns?: SubagentThreadTurn[];
messages: SubagentThreadMessage[];
historyTruncated: boolean;
updatedAt?: string;

View file

@ -6,6 +6,8 @@ import type { IMessage } from '..';
import {
createMessageMethods,
CLIENT_MESSAGE_SELECT,
SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT,
SUBAGENT_TRANSCRIPT_PAGE_LIMIT,
SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
} from './message';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
@ -1090,7 +1092,7 @@ describe('Message Operations', () => {
expect(messages[0]).not.toHaveProperty('conversationId');
});
it('projects the private transcript only for the explicitly selected task', async () => {
it('projects bounded private transcripts for the retained linear history', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'task-a:assistant',
@ -1120,12 +1122,232 @@ describe('Message Operations', () => {
conversationId,
limit: 10,
textCodePointLimit: 8_192,
taskId: 'task-a',
});
expect(messages).toHaveLength(2);
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
messageId: 'task-a:assistant',
subagentTranscript: expect.objectContaining({ taskId: 'task-a' }),
}),
expect.objectContaining({
messageId: 'task-b:assistant',
subagentTranscript: expect.objectContaining({ taskId: 'task-b' }),
}),
]),
);
});
it('bounds ordinary persisted child activity before returning it to the API', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'task-content:assistant',
conversationId,
text: '',
user: 'user123',
content: [
...Array.from({ length: SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT + 2 }, (_, index) => ({
type: 'text',
text: `activity-${index}`,
})),
{
type: 'tool_call',
tool_call: {
id: 'move-1',
name: 'submit_move',
args: 'x'.repeat(2_000),
output: 'y'.repeat(4_000),
progress: 1,
inputValidationError: true,
},
},
{
type: 'activity_label',
activity_label: 'Coordinating agents',
tool_call_ids: Array.from({ length: 32 }, (_, index) => `tool-${index}`),
agent_ids: Array.from({ length: 32 }, (_, index) => `agent-${index}`),
},
],
});
const messages = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
limit: 1,
textCodePointLimit: 8_192,
});
expect(messages).toHaveLength(1);
expect(messages[0]).toHaveProperty('messageId', 'task-a:assistant');
expect(messages[0]).toHaveProperty('subagentTranscript.taskId', 'task-a');
expect(messages[0].subagentActivity).toHaveLength(SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT);
expect(messages[0].subagentActivityProjectionTruncated).toBe(true);
const retainedTool = messages[0].subagentActivity?.find(
(activity) => (activity as { type?: string }).type === 'tool',
) as {
input: string;
output: string;
};
expect(retainedTool).toEqual(
expect.objectContaining({
type: 'tool',
toolCallId: 'move-1',
name: 'submit_move',
inputValidationError: true,
inputTruncated: true,
outputTruncated: true,
}),
);
expect(retainedTool.input.length).toBeLessThan(2_000);
expect(retainedTool.output.length).toBeLessThan(4_000);
const retainedLabel = messages[0].subagentActivity?.find(
(activity) => (activity as { type?: string }).type === 'activity_label',
) as { agentIds: string[]; labelTruncated: boolean; toolCallIds: string[] };
expect(retainedLabel.toolCallIds).toHaveLength(8);
expect(retainedLabel.agentIds).toHaveLength(8);
expect(retainedLabel.labelTruncated).toBe(true);
expect(JSON.stringify(messages[0])).not.toContain('activity-0');
expect(JSON.stringify(messages[0])).not.toContain('x'.repeat(1_000));
expect(JSON.stringify(messages[0])).not.toContain('y'.repeat(2_000));
});
it('bounds public control receipts before materializing the message page', async () => {
const conversationId = uuidv4();
const createdAt = new Date('2026-08-21T12:00:00.000Z');
const accepted = Array.from({ length: 16 }, (_, index) => ({
invocationId: `accepted-${index}`,
fingerprint: `private-fingerprint-${index}`,
action: 'steer' as const,
status: 'accepted' as const,
createdAt,
updatedAt: createdAt,
message: 'a'.repeat(4_096),
}));
const terminal = Array.from({ length: 48 }, (_, index) => ({
invocationId: `applied-${index}`,
fingerprint: `private-fingerprint-terminal-${index}`,
action: 'queue' as const,
status: 'applied' as const,
createdAt,
updatedAt: createdAt,
message: 't'.repeat(4_096),
}));
await saveMessage(mockCtx, {
messageId: 'task-controls:user',
conversationId,
text: 'Control the child.',
user: 'user123',
subagentTask: {
attemptKey: 'private-attempt-key',
requestFingerprint: 'private-request-fingerprint',
status: 'running',
controlReceipts: [...accepted, ...terminal],
},
});
const [message] = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
limit: 1,
textCodePointLimit: 8_192,
});
expect(message.subagentTask?.status).toBe('running');
expect(message.subagentTask?.controlReceipts).toHaveLength(32);
expect(message.subagentTask?.controlReceiptsProjectionTruncated).toBe(true);
expect(message.subagentTask?.controlReceipts?.slice(0, 16)).toEqual(
expect.arrayContaining(
accepted.map((receipt) =>
expect.objectContaining({ invocationId: receipt.invocationId, status: 'accepted' }),
),
),
);
expect(message.subagentTask?.controlReceipts?.slice(16)).toEqual(
terminal
.slice(-16)
.map((receipt) =>
expect.objectContaining({ invocationId: receipt.invocationId, status: 'applied' }),
),
);
for (const receipt of message.subagentTask?.controlReceipts ?? []) {
expect(Buffer.byteLength(receipt.message ?? '', 'utf8')).toBeLessThanOrEqual(512);
expect(receipt.messageTruncated).toBe(true);
expect(receipt).not.toHaveProperty('fingerprint');
}
expect(message.subagentTask).not.toHaveProperty('attemptKey');
expect(message.subagentTask).not.toHaveProperty('requestFingerprint');
});
it('bounds transcript materialization while retaining the exact selected task', async () => {
const conversationId = uuidv4();
const aggregateSpy = jest.spyOn(Message, 'aggregate');
for (let index = 0; index < SUBAGENT_TRANSCRIPT_PAGE_LIMIT + 6; index += 1) {
await saveMessage(mockCtx, {
messageId: `task-${index}:assistant`,
conversationId,
text: `Answer ${index}`,
user: 'user123',
createdAt: new Date(Date.UTC(2026, 7, 21, 12, index)),
subagentTranscript: {
taskId: `task-${index}`,
mode: 'append',
messagesJson: JSON.stringify([{ type: 'ai', data: { content: `Answer ${index}` } }]),
},
});
}
const messages = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
selectedTaskId: 'task-0',
limit: SUBAGENT_TRANSCRIPT_PAGE_LIMIT,
textCodePointLimit: 8_192,
});
const materialized = messages.filter((message) => message.subagentTranscript != null);
expect(messages).toHaveLength(SUBAGENT_TRANSCRIPT_PAGE_LIMIT + 1);
expect(materialized).toHaveLength(SUBAGENT_TRANSCRIPT_PAGE_LIMIT);
expect(materialized).toEqual(
expect.arrayContaining([
expect.objectContaining({
messageId: 'task-0:assistant',
subagentTranscript: expect.objectContaining({ taskId: 'task-0' }),
}),
]),
);
expect(
messages.filter(
(message) =>
message.subagentTranscript == null &&
message.subagentTranscriptProjectionTruncated === true,
),
).toHaveLength(1);
expect(aggregateSpy).toHaveBeenCalledTimes(3);
const messagesPipeline = aggregateSpy.mock.calls[0][0] as unknown as Array<
Record<string, unknown>
>;
const recentSourcesPipeline = aggregateSpy.mock.calls[1][0] as unknown as Array<
Record<string, unknown>
>;
const selectedPipeline = aggregateSpy.mock.calls[2][0] as unknown as Array<
Record<string, unknown>
>;
expect(messagesPipeline[2]).toEqual({ $limit: SUBAGENT_TRANSCRIPT_PAGE_LIMIT });
expect(messagesPipeline).not.toEqual(
expect.arrayContaining([expect.objectContaining({ $facet: expect.anything() })]),
);
expect(recentSourcesPipeline[2]).toEqual({
$limit: SUBAGENT_TRANSCRIPT_PAGE_LIMIT * 2,
});
expect(selectedPipeline[0]).toEqual(
expect.objectContaining({
$match: expect.objectContaining({
messageId: {
$in: ['task-0:user', 'task-0:assistant'],
},
}),
}),
);
aggregateSpy.mockRestore();
});
it('omits an oversized private transcript before returning the application result', async () => {
@ -1152,7 +1374,6 @@ describe('Message Operations', () => {
conversationId,
limit: 1,
textCodePointLimit: 8_192,
taskId: 'task-large',
});
expect(messages).toHaveLength(1);
@ -1160,6 +1381,47 @@ describe('Message Operations', () => {
expect(messages[0]).not.toHaveProperty('subagentTranscript');
expect(messages[0].subagentTranscriptProjectionTruncated).toBe(true);
});
it('prefers the bounded settlement projection without materializing its private transcript', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'task-projected:assistant',
conversationId,
text: 'The public answer remains available.',
user: 'user123',
subagentTranscript: {
taskId: 'task-projected',
mode: 'append',
messagesJson: JSON.stringify([
{
type: 'ai',
data: { content: 'private'.repeat(SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT) },
},
]),
},
subagentActivityProjection: {
taskId: 'task-projected',
version: 1,
activityJson: JSON.stringify([{ type: 'writing', text: 'Public result.' }]),
truncated: false,
},
});
const messages = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
selectedTaskId: 'task-projected',
limit: 1,
textCodePointLimit: 8_192,
});
expect(messages).toHaveLength(1);
expect(messages[0].subagentActivityProjectionJson).toBe(
JSON.stringify([{ type: 'writing', text: 'Public result.' }]),
);
expect(messages[0]).not.toHaveProperty('subagentTranscript');
expect(messages[0]).not.toHaveProperty('subagentTranscriptProjectionTruncated');
});
});
describe('listSubagentTasksForThreads', () => {

View file

@ -287,6 +287,30 @@ function getSteerUserSubmittedPaths(content: unknown): string[] {
* being materialized merely to produce a 64 KiB public activity response.
*/
export const SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT: number = 256 * 1024;
const SUBAGENT_ACTIVITY_PROJECTION_SOURCE_BYTE_LIMIT = 64 * 1024;
/**
* Maximum activity sources materialized for one child-view poll. New writers
* supply at most four 64 KiB public projections; legacy rows fall back to at
* most four 256 KiB private transcripts during a rolling deployment.
*/
export const SUBAGENT_TRANSCRIPT_PAGE_LIMIT: number = 4;
const SUBAGENT_ACTIVITY_SOURCE_CANDIDATE_LIMIT = SUBAGENT_TRANSCRIPT_PAGE_LIMIT * 2;
/**
* Ordinary persisted message content is the authoritative refresh source when
* an execution did not write a private subagent transcript. Project only the
* visible activity vocabulary and bound it before MongoDB returns the row.
*/
export const SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT: number = 16;
const SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT = 2048;
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT = 512;
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT = 1024;
const SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT = 128;
const SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT = 512;
const SUBAGENT_MESSAGE_ACTIVITY_LABEL_IDS_LIMIT = 8;
const SUBAGENT_VIEW_CONTROL_RECEIPT_LIMIT = 32;
const SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT = 128;
/**
* Exclusion projection for message reads that feed the chat client (the
@ -342,10 +366,21 @@ export type SubagentThreadViewMessageRecord = Pick<
| 'error'
| 'unfinished'
| 'subagentTranscript'
| 'subagentTask'
> & {
textProjectionTruncated?: boolean;
subagentTranscriptProjectionTruncated?: boolean;
/** Storage-bounded visible content; validated into the public activity type by the API. */
subagentActivity?: unknown[];
subagentActivityProjectionJson?: string;
subagentActivityProjectionTruncated?: boolean;
/** Storage-bounded task state; private replay and execution fields never cross this seam. */
subagentTask?: {
status?: NonNullable<IMessage['subagentTask']>['status'];
controlReceipts?: Array<
Omit<StoredSubagentControlReceipt, 'fingerprint'> & { fingerprint?: never }
>;
controlReceiptsProjectionTruncated?: boolean;
};
};
export type ParentSubagentTaskRecord = {
@ -463,9 +498,9 @@ export interface MessageMethods {
user: string;
conversationId: string;
tenantId?: string;
selectedTaskId?: string;
limit: number;
textCodePointLimit: number;
taskId?: string;
}): Promise<SubagentThreadViewMessageRecord[]>;
listSubagentTasksForThreads(input: {
user: string;
@ -1426,14 +1461,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
user: string;
conversationId: string;
tenantId?: string;
selectedTaskId?: string;
limit: number;
textCodePointLimit: number;
taskId?: string;
}): Promise<SubagentThreadViewMessageRecord[]> {
try {
const Message = mongoose.models.Message as Model<IMessage>;
const selectedAssistantMessageId =
input.taskId == null ? undefined : `${input.taskId}:assistant`;
const transcriptJsonBytes = {
$strLenBytes: {
$convert: {
@ -1447,105 +1480,568 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
const transcriptIsString = {
$eq: [{ $type: '$subagentTranscript.messagesJson' }, 'string'],
};
return await Message.aggregate<SubagentThreadViewMessageRecord>([
{
$match: {
user: input.user,
conversationId: input.conversationId,
...(input.tenantId == null
? { tenantId: { $exists: false } }
: { tenantId: input.tenantId }),
...(input.taskId == null
? {}
: {
messageId: {
$in: [`${input.taskId}:user`, `${input.taskId}:assistant`],
},
}),
const activityProjectionJsonBytes = {
$strLenBytes: {
$convert: {
input: '$subagentActivityProjection.activityJson',
to: 'string',
onError: '',
onNull: '',
},
},
{ $sort: { createdAt: -1, _id: -1 } },
{ $limit: input.limit },
...(input.taskId == null
? []
: [
{
$addFields: {
_subagentTranscriptSourceBytes: transcriptJsonBytes,
_subagentTranscriptSourceIsString: transcriptIsString,
};
const activityProjectionIsString = {
$eq: [{ $type: '$subagentActivityProjection.activityJson' }, 'string'],
};
const activityProjectionAvailable = {
$and: [
{ $eq: ['$subagentActivityProjection.version', 1] },
'$_subagentActivityProjectionSourceIsString',
{
$lte: [
'$_subagentActivityProjectionSourceBytes',
SUBAGENT_ACTIVITY_PROJECTION_SOURCE_BYTE_LIMIT,
],
},
],
};
const transcriptAvailable = {
$and: [
'$_subagentTranscriptSourceIsString',
{
$lte: ['$_subagentTranscriptSourceBytes', SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT],
},
],
};
const boundedString = (path: string, codePointLimit: number) => ({
$substrCP: [
{
$cond: [{ $eq: [{ $type: path }, 'string'] }, path, ''],
},
0,
codePointLimit,
],
});
const stringProjectionTruncated = (path: string, codePointLimit: number) => ({
$gt: [
{
$strLenCP: {
$cond: [{ $eq: [{ $type: path }, 'string'] }, path, ''],
},
},
codePointLimit,
],
});
const boundedStringArray = (path: string) => ({
$map: {
input: {
$slice: [
{ $cond: [{ $isArray: path }, path, []] },
SUBAGENT_MESSAGE_ACTIVITY_LABEL_IDS_LIMIT,
],
},
as: 'value',
in: boundedString('$$value', SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT),
},
});
const boundedControlReceipt = {
invocationId: boundedString(
'$$receipt.invocationId',
SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT,
),
controlId: {
$cond: [
{ $eq: [{ $type: '$$receipt.controlId' }, 'string'] },
boundedString('$$receipt.controlId', SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT),
'$$REMOVE',
],
},
action: '$$receipt.action',
status: '$$receipt.status',
createdAt: '$$receipt.createdAt',
updatedAt: '$$receipt.updatedAt',
boundary: '$$receipt.boundary',
reason: {
$cond: [
{ $eq: [{ $type: '$$receipt.reason' }, 'string'] },
boundedString('$$receipt.reason', SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT),
'$$REMOVE',
],
},
message: {
$cond: [
{ $eq: [{ $type: '$$receipt.message' }, 'string'] },
boundedString('$$receipt.message', SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT),
'$$REMOVE',
],
},
messageTruncated: {
$or: [
{ $eq: ['$$receipt.messageTruncated', true] },
stringProjectionTruncated(
'$$receipt.message',
SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT,
),
],
},
};
const boundedSubagentTask = {
$cond: [
{ $eq: [{ $type: '$subagentTask' }, 'object'] },
{
status: '$subagentTask.status',
controlReceipts: {
$let: {
vars: {
visible: {
$filter: {
input: {
$cond: [
{ $isArray: '$subagentTask.controlReceipts' },
'$subagentTask.controlReceipts',
[],
],
},
as: 'receipt',
cond: { $ne: ['$$receipt.status', 'reserved'] },
},
},
},
in: {
$let: {
vars: {
accepted: {
$slice: [
{
$filter: {
input: '$$visible',
as: 'receipt',
cond: { $eq: ['$$receipt.status', 'accepted'] },
},
},
SUBAGENT_VIEW_CONTROL_RECEIPT_LIMIT,
],
},
terminal: {
$filter: {
input: '$$visible',
as: 'receipt',
cond: { $ne: ['$$receipt.status', 'accepted'] },
},
},
},
in: {
$map: {
input: {
$concatArrays: [
'$$accepted',
{
$let: {
vars: {
allowance: {
$subtract: [
SUBAGENT_VIEW_CONTROL_RECEIPT_LIMIT,
{ $size: '$$accepted' },
],
},
},
in: {
$cond: [
{ $gt: ['$$allowance', 0] },
{ $slice: ['$$terminal', { $multiply: [-1, '$$allowance'] }] },
[],
],
},
},
},
],
},
as: 'receipt',
in: boundedControlReceipt,
},
},
},
},
},
]),
},
controlReceiptsProjectionTruncated: {
$gt: [
{
$size: {
$filter: {
input: {
$cond: [
{ $isArray: '$subagentTask.controlReceipts' },
'$subagentTask.controlReceipts',
[],
],
},
as: 'receipt',
cond: { $ne: ['$$receipt.status', 'reserved'] },
},
},
},
SUBAGENT_VIEW_CONTROL_RECEIPT_LIMIT,
],
},
},
'$$REMOVE',
],
};
const boundedActivityContent = {
$filter: {
input: {
$map: {
input: {
$slice: [
{ $cond: [{ $isArray: '$content' }, '$content', []] },
-SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT,
],
},
as: 'part',
in: {
$switch: {
branches: [
{
case: { $eq: ['$$part.type', 'text'] },
then: {
type: 'writing',
text: boundedString(
'$$part.text',
SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT,
),
textTruncated: stringProjectionTruncated(
'$$part.text',
SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT,
),
},
},
{
case: { $in: ['$$part.type', ['think', 'reasoning']] },
then: { type: 'reasoning' },
},
{
case: { $eq: ['$$part.type', 'activity_label'] },
then: {
type: 'activity_label',
label: boundedString(
'$$part.activity_label',
SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT,
),
labelType: '$$part.activity_label_type',
toolCallIds: boundedStringArray('$$part.tool_call_ids'),
activityStartIndex: '$$part.activity_start_index',
activityEndIndex: '$$part.activity_end_index',
activityCount: '$$part.activity_count',
agentIds: boundedStringArray('$$part.agent_ids'),
status: '$$part.status',
pending: '$$part.pending',
labelTruncated: {
$or: [
stringProjectionTruncated(
'$$part.activity_label',
SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT,
),
{
$gt: [
{
$size: {
$cond: [
{ $isArray: '$$part.tool_call_ids' },
'$$part.tool_call_ids',
[],
],
},
},
SUBAGENT_MESSAGE_ACTIVITY_LABEL_IDS_LIMIT,
],
},
{
$gt: [
{
$size: {
$cond: [
{ $isArray: '$$part.agent_ids' },
'$$part.agent_ids',
[],
],
},
},
SUBAGENT_MESSAGE_ACTIVITY_LABEL_IDS_LIMIT,
],
},
],
},
},
},
{
case: { $eq: ['$$part.type', 'tool_call'] },
then: {
type: 'tool',
toolCallId: boundedString(
'$$part.tool_call.id',
SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT,
),
name: boundedString(
'$$part.tool_call.name',
SUBAGENT_MESSAGE_ACTIVITY_ID_CODE_POINT_LIMIT,
),
input: boundedString(
'$$part.tool_call.args',
SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT,
),
output: boundedString(
'$$part.tool_call.output',
SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT,
),
progress: '$$part.tool_call.progress',
runStepStatus: '$$part.tool_call.runStepStatus',
inputValidationError: '$$part.tool_call.inputValidationError',
inputTruncated: stringProjectionTruncated(
'$$part.tool_call.args',
SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT,
),
outputTruncated: stringProjectionTruncated(
'$$part.tool_call.output',
SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT,
),
},
},
],
default: null,
},
},
},
},
as: 'activity',
cond: { $ne: ['$$activity', null] },
},
};
type ActivitySourceProjection = Pick<
SubagentThreadViewMessageRecord,
| 'messageId'
| 'subagentTranscript'
| 'subagentActivityProjectionJson'
| 'subagentActivityProjectionTruncated'
>;
const boundedMessageProjection = {
_id: 0,
messageId: 1,
parentMessageId: 1,
isCreatedByUser: 1,
text: {
$substrCP: [{ $ifNull: ['$text', ''] }, 0, input.textCodePointLimit],
},
textProjectionTruncated: {
$gt: [{ $strLenCP: { $ifNull: ['$text', ''] } }, input.textCodePointLimit],
},
createdAt: 1,
error: 1,
unfinished: 1,
subagentTranscriptProjectionTruncated: {
$cond: [
{ $ne: [{ $type: '$subagentTranscript.messagesJson' }, 'missing'] },
true,
'$$REMOVE',
],
},
subagentActivity: boundedActivityContent,
subagentActivityProjectionTruncated: {
$gt: [
{
$size: { $cond: [{ $isArray: '$content' }, '$content', []] },
},
SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT,
],
},
subagentTask: boundedSubagentTask,
};
const sourceMetadataProjection = {
_subagentTranscriptSourceBytes: transcriptJsonBytes,
_subagentTranscriptSourceIsString: transcriptIsString,
_subagentActivityProjectionSourceBytes: activityProjectionJsonBytes,
_subagentActivityProjectionSourceIsString: activityProjectionIsString,
};
const activitySourceProjection = {
_id: 0,
messageId: 1,
subagentActivityProjectionJson: {
$cond: [
activityProjectionAvailable,
'$subagentActivityProjection.activityJson',
'$$REMOVE',
],
},
subagentActivityProjectionTruncated: {
$cond: [activityProjectionAvailable, '$subagentActivityProjection.truncated', '$$REMOVE'],
},
subagentTranscript: {
$cond: [
activityProjectionAvailable,
'$$REMOVE',
{
taskId: '$subagentTranscript.taskId',
mode: '$subagentTranscript.mode',
messagesJson: '$subagentTranscript.messagesJson',
},
],
},
};
const baseMatch = {
user: input.user,
conversationId: input.conversationId,
...(input.tenantId == null
? { tenantId: { $exists: false } }
: { tenantId: input.tenantId }),
};
/** Keep rows as independent MongoDB results. A `$facet` would combine the
* complete page into one BSON document and could exceed MongoDB's 16 MiB
* document limit before the API applies its smaller public byte budget. */
const messagesPromise = Message.aggregate<SubagentThreadViewMessageRecord>([
{ $match: baseMatch },
{ $sort: { createdAt: -1, _id: -1 } },
{ $limit: input.limit },
{ $project: boundedMessageProjection },
]);
const recentSourcesPromise = Message.aggregate<ActivitySourceProjection>([
{ $match: baseMatch },
{ $sort: { createdAt: -1, _id: -1 } },
{ $limit: SUBAGENT_ACTIVITY_SOURCE_CANDIDATE_LIMIT },
{
$project: {
_id: 0,
messageId: 1,
parentMessageId: 1,
isCreatedByUser: 1,
text: {
$substrCP: [{ $ifNull: ['$text', ''] }, 0, input.textCodePointLimit],
},
textProjectionTruncated: {
$gt: [{ $strLenCP: { $ifNull: ['$text', ''] } }, input.textCodePointLimit],
},
createdAt: 1,
error: 1,
unfinished: 1,
...(input.taskId == null
$match: {
...(input.selectedTaskId == null
? {}
: {
subagentTranscript: {
$cond: [
{
$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',
],
},
}),
subagentTask: 1,
: { messageId: { $ne: `${input.selectedTaskId}:assistant` } }),
$or: [
{ 'subagentActivityProjection.activityJson': { $exists: true } },
{ 'subagentTranscript.messagesJson': { $exists: true } },
],
},
},
{ $addFields: sourceMetadataProjection },
{
$match: {
$expr: {
$or: [
{
$and: [
activityProjectionAvailable,
{
$eq: [
'$messageId',
{ $concat: ['$subagentActivityProjection.taskId', ':assistant'] },
],
},
],
},
{
$and: [
transcriptAvailable,
{
$eq: [
'$messageId',
{ $concat: ['$subagentTranscript.taskId', ':assistant'] },
],
},
],
},
],
},
},
},
{ $limit: SUBAGENT_TRANSCRIPT_PAGE_LIMIT - (input.selectedTaskId == null ? 0 : 1) },
{ $project: activitySourceProjection },
]);
const selectedProjectionPromise =
input.selectedTaskId == null
? Promise.resolve([
{
selectedMessages: [] as SubagentThreadViewMessageRecord[],
selectedSources: [] as ActivitySourceProjection[],
},
])
: Message.aggregate<{
selectedMessages: SubagentThreadViewMessageRecord[];
selectedSources: ActivitySourceProjection[];
}>([
{
$match: {
...baseMatch,
messageId: {
$in: [`${input.selectedTaskId}:user`, `${input.selectedTaskId}:assistant`],
},
},
},
{ $limit: 2 },
{
$facet: {
selectedMessages: [{ $project: boundedMessageProjection }],
selectedSources: [
{ $match: { messageId: `${input.selectedTaskId}:assistant` } },
{ $limit: 1 },
{ $addFields: sourceMetadataProjection },
{
$match: {
$or: [
{
'subagentActivityProjection.taskId': input.selectedTaskId,
'subagentActivityProjection.version': 1,
_subagentActivityProjectionSourceIsString: true,
_subagentActivityProjectionSourceBytes: {
$lte: SUBAGENT_ACTIVITY_PROJECTION_SOURCE_BYTE_LIMIT,
},
},
{
'subagentTranscript.taskId': input.selectedTaskId,
_subagentTranscriptSourceIsString: true,
_subagentTranscriptSourceBytes: {
$lte: SUBAGENT_TRANSCRIPT_SOURCE_BYTE_LIMIT,
},
},
],
},
},
{ $project: activitySourceProjection },
],
},
},
]);
const [messages, recentSources, [selectedProjection]] = await Promise.all([
messagesPromise,
recentSourcesPromise,
selectedProjectionPromise,
]);
if (selectedProjection == null) return [];
const sourcesByMessageId = new Map(
[...selectedProjection.selectedSources, ...recentSources].map((record) => [
record.messageId,
record,
]),
);
const retainedMessageIds = new Set(messages.map((message) => message.messageId));
for (const message of selectedProjection.selectedMessages) {
if (retainedMessageIds.has(message.messageId)) continue;
messages.push(message);
retainedMessageIds.add(message.messageId);
}
return messages.map((message) => {
const source = sourcesByMessageId.get(message.messageId);
if (source == null) return message;
const projected = { ...message };
if (source.subagentActivityProjectionJson != null) {
delete projected.subagentTranscriptProjectionTruncated;
return {
...projected,
subagentActivityProjectionJson: source.subagentActivityProjectionJson,
...(source.subagentActivityProjectionTruncated === true
? { subagentActivityProjectionTruncated: true }
: {}),
};
}
if (source.subagentTranscript == null) return message;
delete projected.subagentTranscriptProjectionTruncated;
return { ...projected, subagentTranscript: source.subagentTranscript };
});
} catch (err) {
logger.error('Error getting bounded subagent thread messages:', err);
throw err;

View file

@ -155,6 +155,17 @@ const messageSchema: Schema<IMessage> = new Schema(
select: false,
default: undefined,
},
subagentActivityProjection: {
type: {
taskId: { type: String, required: true },
version: { type: Number, enum: [1], required: true },
activityJson: { type: String, required: true },
truncated: { type: Boolean, required: true },
},
_id: false,
select: false,
default: undefined,
},
/** Durable, server-only marker used to make detached retries at-most-once. */
subagentTask: {
type: {

View file

@ -87,6 +87,13 @@ export interface IMessage extends Document {
mode: 'append' | 'replace';
messagesJson: string;
};
/** Server-private bounded rendering projection derived once at child settlement. */
subagentActivityProjection?: {
taskId: string;
version: 1;
activityJson: string;
truncated: boolean;
};
/** Server-private durable idempotency marker for one detached subagent turn. */
subagentTask?: {
attemptKey: string;