mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🧵 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:
parent
44edcbe014
commit
f0eda61638
25 changed files with 2482 additions and 255 deletions
|
|
@ -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',
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
195
client/src/components/Chat/Subagents/SubagentConversation.tsx
Normal file
195
client/src/components/Chat/Subagents/SubagentConversation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue