🪟 fix: Keep the Subagent Panel Steady and Honest Across Event Deliveries (#15387)

* 🪟 fix: Keep the Subagent Panel Steady and Honest Across Event Deliveries

* 🧮 fix: Budget the Activity Projection at the Database Seam and Scope Retained Views

* 🧮 fix: Charge Label Associations to the Budget and Scope History Flags

* 🧮 fix: Gate Mixed Activity Metadata and Keep the Budgeted Suffix Contiguous

* 🧵 fix: Render Only the Active Thread's Retained Turns
This commit is contained in:
Danny Avila 2026-08-30 21:58:44 -04:00 • committed by GitHub
parent c7f0a8f247
commit c93bd7ec9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 376 additions and 126 deletions

View file

@ -107,7 +107,7 @@ describe('SubagentConversation', () => {
);
expect(screen.getAllByText('com_ui_subagent_trigger_parent_dispatch')).toHaveLength(2);
expect(screen.getAllByText('com_ui_subagent_trigger_external_event')).toHaveLength(1);
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();
@ -115,8 +115,8 @@ describe('SubagentConversation', () => {
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument();
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
expect(container.querySelectorAll('.message-render')).toHaveLength(3);
expect(container.querySelectorAll('.user-turn')).toHaveLength(1);
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();

View file

@ -35,59 +35,82 @@ function TriggerIcon({ kind }: { kind: ChildConversationTurn['trigger']['kind']
);
}
function ExternalEventTrigger({ turn }: { turn: ChildConversationTurn }) {
function ExternalEventTrigger({
turn,
fullWidth,
}: {
turn: ChildConversationTurn;
fullWidth: boolean;
}) {
const localize = useLocalize();
const [expanded, setExpanded] = useState(false);
const details = turn.trigger.externalEvent;
const label = localize('com_ui_subagent_trigger_external_event');
let body: ReactNode;
if (details == null) {
return (
<div className="flex min-h-9 items-center gap-2 text-sm text-text-secondary">
body = (
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary">
<TriggerIcon kind="external_event" />
<span className="font-medium">{label}</span>
<span>{label}</span>
</div>
);
} else {
body = (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto min-h-6 w-full justify-start gap-1.5 px-0 text-left text-xs font-medium text-text-secondary hover:bg-transparent hover:text-text-primary"
>
<TriggerIcon kind="external_event" />
<span>{label}</span>
<span className="min-w-0 truncate font-normal">
{details.eventType} · {details.sourceType}
</span>
<span className="sr-only">{details.occurredAt}</span>
<ChevronDown
size={14}
aria-hidden
className={`ml-auto shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-8 border-l border-border-light py-1 pl-3 text-xs text-text-secondary">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>{localize('com_ui_subagent_event_type')}</dt>
<dd className="break-words text-text-primary">{details.eventType}</dd>
<dt>{localize('com_ui_subagent_event_source')}</dt>
<dd className="break-words text-text-primary">{details.sourceType}</dd>
<dt>{localize('com_ui_subagent_event_received')}</dt>
<dd className="break-words text-text-primary">
{new Date(details.occurredAt).toLocaleString()}
</dd>
{details.expectedActionToolName != null && (
<>
<dt>{localize('com_ui_subagent_event_expected_action')}</dt>
<dd className="break-words text-text-primary">{details.expectedActionToolName}</dd>
</>
)}
</dl>
</CollapsibleContent>
</Collapsible>
);
}
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-auto min-h-9 w-full justify-start gap-2 px-0 text-left text-sm text-text-secondary hover:bg-transparent hover:text-text-primary"
>
<TriggerIcon kind="external_event" />
<span className="font-medium">{label}</span>
<span className="min-w-0 truncate text-xs">
{details.eventType} · {details.sourceType}
</span>
<span className="sr-only">{details.occurredAt}</span>
<ChevronDown
size={15}
aria-hidden
className={`ml-auto shrink-0 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-8 border-l border-border-light py-1 pl-3 text-xs text-text-secondary">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>{localize('com_ui_subagent_event_type')}</dt>
<dd className="break-words text-text-primary">{details.eventType}</dd>
<dt>{localize('com_ui_subagent_event_source')}</dt>
<dd className="break-words text-text-primary">{details.sourceType}</dd>
<dt>{localize('com_ui_subagent_event_received')}</dt>
<dd className="break-words text-text-primary">
{new Date(details.occurredAt).toLocaleString()}
</dd>
{details.expectedActionToolName != null && (
<>
<dt>{localize('com_ui_subagent_event_expected_action')}</dt>
<dd className="break-words text-text-primary">{details.expectedActionToolName}</dd>
</>
)}
</dl>
</CollapsibleContent>
</Collapsible>
<MessageRow
id={`${turn.taskId}:trigger`}
icon={<TriggerIcon kind="external_event" />}
label={label}
footer={null}
timestamp={turn.trigger.createdAt ?? details?.occurredAt}
ariaLabel={label}
headerPrefix=""
isCreatedByUser={true}
fullWidth={fullWidth}
>
{body}
</MessageRow>
);
}
@ -107,7 +130,7 @@ function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; full
[turn.trigger.summary],
);
if (turn.trigger.kind === 'external_event') {
return <ExternalEventTrigger turn={turn} />;
return <ExternalEventTrigger turn={turn} fullWidth={fullWidth} />;
}
return (
<MessageRow

View file

@ -359,7 +359,7 @@ describe('SubagentThreadPanel', () => {
'parent-conversation',
'child-thread',
'task',
undefined,
{ keepPreviousData: true },
);
expect(mockUseSubagentActivityStream).toHaveBeenCalledWith(selection, false);
expect(screen.getByText('Research child')).toBeInTheDocument();
@ -1264,7 +1264,7 @@ describe('SubagentThreadPanel', () => {
'parent-conversation',
'child-thread',
'task',
{ refetchInterval: 2000 },
{ keepPreviousData: true, refetchInterval: 2000 },
);
expect(mockUseSubagentActivityStream).toHaveBeenLastCalledWith(eventSelection, true);
expect(

View file

@ -190,24 +190,33 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
selectedEventActor?.actorId ??
eventSummary?.actorId ??
foregroundTitle;
const { data, isLoading, isError, isReadinessPending, refetch } = useSubagentThreadQuery(
selection.parentConversationId,
threadId,
taskId,
eventTaskRunning ? { refetchInterval: ACTIVE_THREAD_REFRESH_MS } : undefined,
);
const { data, isLoading, isError, isPreviousData, isReadinessPending, refetch } =
useSubagentThreadQuery(selection.parentConversationId, threadId, taskId, {
/** A new delivery re-keys this query to its task. Keeping the previous
* thread view mounted while the fresh one loads stops the whole panel
* from flashing back to a loading dot on every incoming event. */
keepPreviousData: true,
...(eventTaskRunning ? { refetchInterval: ACTIVE_THREAD_REFRESH_MS } : {}),
});
/** Retention crosses every key change, including actor switches. A retained
* view is only meaningful while it describes the SAME child thread; within
* that thread its turn/history rows stay valid across task re-keys, while
* task-scoped fields (selected activity, status, control receipts) must not
* be attributed to the newly selected task. */
const threadView = data?.threadId === threadId ? data : undefined;
const taskView = isPreviousData ? undefined : threadView;
const latestHistoryGeneration = JSON.stringify([
data?.nextCursor ?? null,
...(data?.turns?.map((turn) => turn.taskId) ?? []),
threadView?.nextCursor ?? null,
...(threadView?.turns?.map((turn) => turn.taskId) ?? []),
]);
const latestHistoryGenerationRef = useRef(latestHistoryGeneration);
latestHistoryGenerationRef.current = latestHistoryGeneration;
const durableTerminal =
subagentThreadHasTaskEvidence(data, taskId) &&
(data?.status === 'completed' ||
data?.status === 'failed' ||
data?.status === 'interrupted' ||
data?.status === 'cancelled');
subagentThreadHasTaskEvidence(taskView, taskId) &&
(taskView?.status === 'completed' ||
taskView?.status === 'failed' ||
taskView?.status === 'interrupted' ||
taskView?.status === 'cancelled');
const priorTerminalRef = useRef(false);
useSubagentActivityStream(selection, !durableTerminal || eventTaskRunning);
@ -290,6 +299,10 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
const activeThreadRef = useRef(threadId);
const selectionThreadRef = useRef(threadId);
const selectionGenerationRef = useRef(0);
/** Locally retained turn buffers reset in a passive effect; until it has run
* for the current thread they still hold the previous thread's turns and
* must not render. Stamped inside that reset effect. */
const retainedTurnsGenerationRef = useRef(0);
const turnDetailRequestsRef = useRef(new Set<string>());
const historyRequestRef = useRef<string | null>(null);
const historyHasLoadedRef = useRef(false);
@ -320,6 +333,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
setMovingWindowTurns([]);
setRebaseTurns([]);
setPostRebaseTurns([]);
retainedTurnsGenerationRef.current = selectionGenerationRef.current;
postRebaseTurnsRef.current = [];
setHistoryRebaseActive(false);
historyRebaseActiveRef.current = false;
@ -334,19 +348,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
useEffect(() => {
if (
data?.threadId === threadId &&
(data.historyUnavailable === true ||
(data.historyTruncated === true && data.nextCursor == null))
threadView != null &&
(threadView.historyUnavailable === true ||
(threadView.historyTruncated === true && threadView.nextCursor == null))
) {
setHistoryBoundaryUnavailable(true);
}
}, [
data?.historyTruncated,
data?.historyUnavailable,
data?.nextCursor,
data?.threadId,
threadId,
]);
}, [threadView]);
const loadTurnDetails = useCallback(
async (detailTaskId: string) => {
@ -410,7 +418,8 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
historyCursor !== undefined &&
historyCursorGeneration !== requestedGeneration;
const recoveringRebase = startsRebase || historyRebaseActive;
const cursor = historyCursor === undefined || startsRebase ? data?.nextCursor : historyCursor;
const cursor =
historyCursor === undefined || startsRebase ? threadView?.nextCursor : historyCursor;
const requestKey = `${requestedSelectionGeneration}\u0000${requestedThreadId}\u0000${cursor ?? ''}\u0000${requestedGeneration}`;
if (cursor == null || historyState === 'loading' || historyRequestRef.current != null) {
return;
@ -501,7 +510,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
if (historyRequestRef.current === requestKey) historyRequestRef.current = null;
}
}, [
data?.nextCursor,
threadView?.nextCursor,
historyCursor,
historyCursorGeneration,
historyRebaseActive,
@ -566,7 +575,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
useEffect(() => {
if (transientControl == null) return;
const durableReceipt = data?.controlReceipts?.find(
const durableReceipt = taskView?.controlReceipts?.find(
(receipt) => receipt.invocationId === transientControl.invocationId,
);
if (durableReceipt == null) return;
@ -583,13 +592,13 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
}
if (closesTaskControls(durableReceipt)) setControlsClosed(true);
setControlState(null);
}, [data?.controlReceipts, retryControl, setControlState, transientControl]);
}, [retryControl, setControlState, taskView?.controlReceipts, transientControl]);
useEffect(() => {
if (data?.controlReceipts?.some(closesTaskControls)) {
if (taskView?.controlReceipts?.some(closesTaskControls)) {
setControlsClosed(true);
}
}, [data?.controlReceipts]);
}, [taskView?.controlReceipts]);
const submitControl = useCallback(
(action: SubagentControlAction, controlId?: string, retry?: SubagentControlRequest) => {
@ -707,14 +716,14 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
);
const activity = useMemo(() => {
if (selection.durable == null) return liveActivity;
if (data == null) {
if (taskView == null) {
const activityWithoutData =
progress == null ? { ...liveActivity, status: 'dispatched' as const } : liveActivity;
return transientControl == null
? activityWithoutData
: { ...activityWithoutData, controls: [transientControl] };
}
const durable = adaptDurableThreadActivity(data, selection.durable.taskId);
const durable = adaptDurableThreadActivity(taskView, selection.durable.taskId);
const useLiveItems =
(durable.status === 'running' || durable.status === 'dispatched') &&
liveActivity.items.length > 0;
@ -734,11 +743,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
return merged;
}
return { ...merged, controls: [...(merged.controls ?? []), transientControl] };
}, [data, liveActivity, progress, selection.durable, transientControl]);
}, [liveActivity, progress, selection.durable, taskView, transientControl]);
const panelTitle = selection.event == null ? activity.title : selectedEventActorName;
const latestConversationTurns = useMemo(
() => (data == null ? [] : adaptDurableThreadConversation(data)),
[data],
() => (threadView == null ? [] : adaptDurableThreadConversation(threadView)),
[threadView],
);
const previousLatestTurnsRef = useRef({
threadId,
@ -746,7 +755,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
turns: latestConversationTurns,
});
useEffect(() => {
if (data == null) return;
if (threadView == null) return;
const previous = previousLatestTurnsRef.current;
if (previous.threadId === threadId) {
const latestTaskIds = new Set(latestConversationTurns.map((turn) => turn.taskId));
@ -766,15 +775,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
generation: latestHistoryGeneration,
turns: latestConversationTurns,
};
}, [data, historyRebaseActive, latestConversationTurns, latestHistoryGeneration, threadId]);
}, [historyRebaseActive, latestConversationTurns, latestHistoryGeneration, threadId, threadView]);
const retainedTurnsValid = retainedTurnsGenerationRef.current === selectionGenerationRef.current;
const conversationTurns = useMemo(() => {
const durableTurns = mergeChildConversationTurns(
olderTurns,
movingWindowTurns,
rebaseTurns,
postRebaseTurns,
latestConversationTurns,
);
const durableTurns = retainedTurnsValid
? mergeChildConversationTurns(
olderTurns,
movingWindowTurns,
rebaseTurns,
postRebaseTurns,
latestConversationTurns,
)
: mergeChildConversationTurns(latestConversationTurns);
if (durableTurns.length > 0) {
const selectedTurnIndex = durableTurns.findIndex((turn) => turn.taskId === taskId);
if (selectedTurnIndex >= 0) {
@ -824,6 +836,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
olderTurns,
postRebaseTurns,
rebaseTurns,
retainedTurnsValid,
selection,
taskId,
turnDetailOverrides,
@ -836,19 +849,24 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
const historyCursorUsesLatest =
!historyRebaseActive &&
(historyCursor === undefined || historyCursorGeneration !== latestHistoryGeneration);
const effectiveHistoryCursor = historyCursorUsesLatest ? data?.nextCursor : historyCursor;
const effectiveHistoryCursor = historyCursorUsesLatest ? threadView?.nextCursor : historyCursor;
const showUnavailableHistoryBoundary =
historyBoundaryUnavailable ||
data?.historyUnavailable === true ||
(historyCursorUsesLatest && data?.historyTruncated === true && data.nextCursor == null);
threadView?.historyUnavailable === true ||
(historyCursorUsesLatest &&
threadView?.historyTruncated === true &&
threadView.nextCursor == null);
/** 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);
selection.durable == null || threadView == null || Array.isArray(threadView.turns);
const taskInaccessible = controlInaccessible || transientControl?.reason === 'task_inaccessible';
const controlAvailable =
selection.durable != null && data?.status === 'running' && !taskInaccessible && !controlsClosed;
selection.durable != null &&
taskView?.status === 'running' &&
!taskInaccessible &&
!controlsClosed;
const controlPending =
controlTask.isLoading || transientControl?.status === 'submitted' || retryControl != null;
const showControlFooter =
@ -856,11 +874,11 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
const canContinueAsChat =
selection.host === 'conversation' &&
selection.durable != null &&
data?.subagentKind === 'agent' &&
data.agentId != null &&
data.status === 'completed' &&
subagentThreadHasTaskEvidence(data, taskId) &&
data.messages.some((message) => message.messageId === `${taskId}:assistant`);
taskView?.subagentKind === 'agent' &&
taskView.agentId != null &&
taskView.status === 'completed' &&
subagentThreadHasTaskEvidence(taskView, taskId) &&
taskView.messages.some((message) => message.messageId === `${taskId}:assistant`);
const continueAsChat = useCallback(() => {
if (!canContinueAsChat || selection.durable == null) return;
@ -998,7 +1016,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
)}
<SubagentConversation
turns={conversationTurns}
agentId={data?.agentId}
agentId={threadView?.agentId}
conversationId={threadId || selection.parentConversationId}
stateByTask={conversationStateByTask}
controllableTaskId={

View file

@ -1932,6 +1932,71 @@ describe('Message Operations', () => {
);
});
it('keeps the budgeted timeline a contiguous newest suffix past an oversized entry', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'task-suffix:assistant',
conversationId,
text: '',
user: 'user123',
content: [
...Array.from({ length: 5 }, (_, index) => ({
type: 'text',
text: `older-${index}`,
})),
...Array.from({ length: 8 }, (_, index) => ({
type: 'text',
text: `newest-${index}-${'b'.repeat(8_400)}`,
})),
],
});
const messages = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
limit: 1,
textCodePointLimit: 8_192,
});
const activity = messages[0].subagentActivity as Array<{ type: string; text: string }>;
expect(activity.length).toBeGreaterThan(0);
expect(activity.length).toBeLessThan(8);
expect(activity.every((item) => item.text.startsWith('newest-'))).toBe(true);
expect(activity[activity.length - 1].text).toContain('newest-7-');
expect(JSON.stringify(activity)).not.toContain('older-');
expect(messages[0].subagentActivityProjectionTruncated).toBe(true);
});
it('fits ordinary persisted child activity into the aggregate byte budget, newest first', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'task-budget:assistant',
conversationId,
text: '',
user: 'user123',
content: Array.from({ length: 12 }, (_, index) => ({
type: 'text',
text: `chunk-${index}-${'z'.repeat(8_000)}`,
})),
});
const messages = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
limit: 1,
textCodePointLimit: 8_192,
});
const activity = messages[0].subagentActivity as Array<{ type: string; text: string }>;
expect(activity.length).toBeGreaterThan(0);
expect(activity.length).toBeLessThan(12);
expect(activity[activity.length - 1].text).toContain('chunk-11-');
expect(activity[0].text).toContain(`chunk-${12 - activity.length}-`);
const totalBytes = activity.reduce((sum, item) => sum + item.text.length, 0);
expect(totalBytes).toBeLessThanOrEqual(64 * 1024);
expect(messages[0].subagentActivityProjectionTruncated).toBe(true);
});
it('bounds ordinary persisted child activity before returning it to the API', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
@ -1949,8 +2014,8 @@ describe('Message Operations', () => {
tool_call: {
id: 'move-1',
name: 'submit_move',
args: 'x'.repeat(2_000),
output: 'y'.repeat(4_000),
args: 'x'.repeat(3_000),
output: 'y'.repeat(5_000),
progress: 1,
inputValidationError: true,
},
@ -1990,8 +2055,8 @@ describe('Message Operations', () => {
outputTruncated: true,
}),
);
expect(retainedTool.input.length).toBeLessThan(2_000);
expect(retainedTool.output.length).toBeLessThan(4_000);
expect(retainedTool.input.length).toBeLessThan(3_000);
expect(retainedTool.output.length).toBeLessThan(5_000);
const retainedLabel = messages[0].subagentActivity?.find(
(activity) => (activity as { type?: string }).type === 'activity_label',
) as { agentIds: string[]; labelTruncated: boolean; toolCallIds: string[] };
@ -1999,8 +2064,8 @@ describe('Message Operations', () => {
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));
expect(JSON.stringify(messages[0])).not.toContain('x'.repeat(2_049));
expect(JSON.stringify(messages[0])).not.toContain('y'.repeat(4_097));
});
it('bounds public control receipts before materializing the message page', async () => {

View file

@ -347,13 +347,21 @@ const SUBAGENT_ACTIVITY_SOURCE_CANDIDATE_LIMIT = SUBAGENT_TRANSCRIPT_PAGE_LIMIT
* 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;
/**
* Per-item bounds mirror `SUBAGENT_ACTIVITY_LIMITS` in `packages/api`
* (`src/agents/activity.ts`) at worst-case 4-byte UTF-8, so activity projected
* from ordinary message content is clipped no harder than activity projected
* from a private transcript. The whole view stays bounded downstream by the
* 64KB serialized-activity budget and the 256KB response trim.
*/
export const SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT: number = 100;
const SUBAGENT_MESSAGE_ACTIVITY_TEXT_CODE_POINT_LIMIT = 8192;
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT = 2048;
const SUBAGENT_MESSAGE_ACTIVITY_TOOL_OUTPUT_CODE_POINT_LIMIT = 4096;
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_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT = 64 * 1024;
const SUBAGENT_VIEW_CONTROL_RECEIPT_LIMIT = 32;
const SUBAGENT_VIEW_CONTROL_STRING_CODE_POINT_LIMIT = 128;
@ -2290,7 +2298,130 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
null,
],
};
const boundedActivityContent = {
/** Mixed-typed content metadata must be type-gated before projection, or a
* malformed part could tunnel arbitrarily large values past the byte cap
* through a passthrough field. `$type`-based checks stay DocumentDB-safe. */
const boundedNumber = (path: string) => ({
$cond: [{ $in: [{ $type: path }, ['int', 'long', 'double', 'decimal']] }, path, null],
});
const boundedBoolean = (path: string) => ({
$cond: [{ $eq: [{ $type: path }, 'bool'] }, path, null],
});
const boundedEnum = (path: string, allowed: string[]) => ({
$cond: [{ $in: [path, allowed] }, path, null],
});
/** Estimated serialized bytes for one already-clipped activity item; the
* constants cover the non-string JSON envelope per item type. This is a
* BSON-transfer budget: `$strLenBytes` measures exactly what MongoDB
* ships to the API. JSON escaping can expand strings after transfer,
* which the API bounds precisely — `boundActivity` in
* `packages/api/src/agents/activity.ts` re-fits the same array to 64KB
* of `JSON.stringify` output before anything reaches a response. */
const activityItemBytes = (item: string) => ({
$switch: {
branches: [
{
case: { $eq: [`${item}.type`, 'writing'] },
then: { $add: [{ $strLenBytes: `${item}.text` }, 64] },
},
{
case: { $eq: [`${item}.type`, 'activity_label'] },
then: {
$add: [
{ $strLenBytes: `${item}.label` },
{
$reduce: {
input: {
$concatArrays: [
{
$cond: [{ $isArray: `${item}.toolCallIds` }, `${item}.toolCallIds`, []],
},
{ $cond: [{ $isArray: `${item}.agentIds` }, `${item}.agentIds`, []] },
],
},
initialValue: 0,
in: { $add: ['$$value', { $strLenBytes: '$$this' }, 8] },
},
},
512,
],
},
},
{
case: { $eq: [`${item}.type`, 'tool'] },
then: {
$add: [
{ $strLenBytes: `${item}.input` },
{ $strLenBytes: `${item}.output` },
{ $strLenBytes: `${item}.name` },
{ $strLenBytes: `${item}.toolCallId` },
192,
],
},
},
],
default: 32,
},
});
/** Newest-first fit into the aggregate budget, so raising per-item limits
* cannot multiply into megabytes per row before the API's own trims run. */
const budgetedActivity = (clipped: Record<string, unknown>) => ({
$reverseArray: {
$let: {
vars: {
fitted: {
$reduce: {
input: { $reverseArray: clipped },
initialValue: { items: [] as never[], bytes: 0, done: false },
in: {
$let: {
vars: { size: activityItemBytes('$$this') },
in: {
$cond: [
{
$or: [
'$$value.done',
{
$gt: [
{ $add: ['$$value.bytes', '$$size'] },
SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT,
],
},
],
},
/* The retained timeline must be a contiguous newest
suffix: once one entry does not fit, older entries
are not allowed to fill the gap around it. */
{ items: '$$value.items', bytes: '$$value.bytes', done: true },
{
items: { $concatArrays: ['$$value.items', ['$$this']] },
bytes: { $add: ['$$value.bytes', '$$size'] },
done: false,
},
],
},
},
},
},
},
},
in: '$$fitted.items',
},
},
});
const activityBytesOverBudget = (clipped: Record<string, unknown>) => ({
$gt: [
{
$reduce: {
input: clipped,
initialValue: 0,
in: { $add: ['$$value', activityItemBytes('$$this')] },
},
},
SUBAGENT_MESSAGE_ACTIVITY_TOTAL_BYTE_LIMIT,
],
});
const clippedActivityContent = {
$filter: {
input: {
$map: {
@ -2330,14 +2461,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
'$$part.activity_label',
SUBAGENT_MESSAGE_ACTIVITY_LABEL_CODE_POINT_LIMIT,
),
labelType: '$$part.activity_label_type',
labelType: boundedEnum('$$part.activity_label_type', ['phase']),
toolCallIds: boundedStringArray('$$part.tool_call_ids'),
activityStartIndex: '$$part.activity_start_index',
activityEndIndex: '$$part.activity_end_index',
activityCount: '$$part.activity_count',
activityStartIndex: boundedNumber('$$part.activity_start_index'),
activityEndIndex: boundedNumber('$$part.activity_end_index'),
activityCount: boundedNumber('$$part.activity_count'),
agentIds: boundedStringArray('$$part.agent_ids'),
status: '$$part.status',
pending: '$$part.pending',
status: boundedEnum('$$part.status', ['ok', 'partial', 'failed']),
pending: boundedBoolean('$$part.pending'),
labelTruncated: {
$or: [
stringProjectionTruncated(
@ -2396,9 +2527,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
'$$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',
progress: boundedNumber('$$part.tool_call.progress'),
runStepStatus: boundedEnum('$$part.tool_call.runStepStatus', [
'running',
'completed',
'failed',
'cancelled',
]),
inputValidationError: boundedBoolean(
'$$part.tool_call.inputValidationError',
),
inputTruncated: stringProjectionTruncated(
'$$part.tool_call.args',
SUBAGENT_MESSAGE_ACTIVITY_TOOL_INPUT_CODE_POINT_LIMIT,
@ -2419,6 +2557,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
cond: { $ne: ['$$activity', null] },
},
};
const boundedActivityContent = budgetedActivity(clippedActivityContent);
type ActivitySourceProjection = WithNullSentinels<
Pick<
SubagentThreadViewMessageRecord,
@ -2450,11 +2589,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
},
subagentActivity: boundedActivityContent,
subagentActivityProjectionTruncated: {
$gt: [
$or: [
{
$size: { $cond: [{ $isArray: '$content' }, '$content', []] },
$gt: [
{
$size: { $cond: [{ $isArray: '$content' }, '$content', []] },
},
SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT,
],
},
SUBAGENT_MESSAGE_ACTIVITY_ITEM_LIMIT,
activityBytesOverBudget(clippedActivityContent),
],
},
subagentTask: boundedSubagentTask,