diff --git a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx
index 8fe9e566cf..8c20c728bb 100644
--- a/client/src/components/Chat/Subagents/SubagentConversation.test.tsx
+++ b/client/src/components/Chat/Subagents/SubagentConversation.test.tsx
@@ -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();
diff --git a/client/src/components/Chat/Subagents/SubagentConversation.tsx b/client/src/components/Chat/Subagents/SubagentConversation.tsx
index 26025a6da4..c6a7f8b4d0 100644
--- a/client/src/components/Chat/Subagents/SubagentConversation.tsx
+++ b/client/src/components/Chat/Subagents/SubagentConversation.tsx
@@ -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 (
-
+ body = (
+
- {label}
+ {label}
);
+ } else {
+ body = (
+
+
+
+
+
+
+ - {localize('com_ui_subagent_event_type')}
+ - {details.eventType}
+ - {localize('com_ui_subagent_event_source')}
+ - {details.sourceType}
+ - {localize('com_ui_subagent_event_received')}
+ -
+ {new Date(details.occurredAt).toLocaleString()}
+
+ {details.expectedActionToolName != null && (
+ <>
+ - {localize('com_ui_subagent_event_expected_action')}
+ - {details.expectedActionToolName}
+ >
+ )}
+
+
+
+ );
}
return (
-
-
-
-
-
-
- - {localize('com_ui_subagent_event_type')}
- - {details.eventType}
- - {localize('com_ui_subagent_event_source')}
- - {details.sourceType}
- - {localize('com_ui_subagent_event_received')}
- -
- {new Date(details.occurredAt).toLocaleString()}
-
- {details.expectedActionToolName != null && (
- <>
- - {localize('com_ui_subagent_event_expected_action')}
- - {details.expectedActionToolName}
- >
- )}
-
-
-
+
}
+ label={label}
+ footer={null}
+ timestamp={turn.trigger.createdAt ?? details?.occurredAt}
+ ariaLabel={label}
+ headerPrefix=""
+ isCreatedByUser={true}
+ fullWidth={fullWidth}
+ >
+ {body}
+
);
}
@@ -107,7 +130,7 @@ function TriggerMessage({ turn, fullWidth }: { turn: ChildConversationTurn; full
[turn.trigger.summary],
);
if (turn.trigger.kind === 'external_event') {
- return
;
+ return
;
}
return (
{
'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(
diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
index 9548372649..b39f5fa375 100644
--- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
+++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
@@ -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());
const historyRequestRef = useRef(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
)}
{
);
});
+ 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 () => {
diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts
index 14f1da3078..2806858847 100644
--- a/packages/data-schemas/src/methods/message.ts
+++ b/packages/data-schemas/src/methods/message.ts
@@ -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) => ({
+ $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) => ({
+ $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,