@@ -795,6 +908,7 @@ function HistoricalEventTaskActivity({
activityId={`${selection.parentMessageId}\u0000${selection.toolCallId}\u0000${task.taskId}`}
activity={activity}
state={state}
+ showPrompt={false}
embedded
/>
);
diff --git a/client/src/components/Chat/Subagents/adapters.test.ts b/client/src/components/Chat/Subagents/adapters.test.ts
index be737f6f0e..8c0c162bea 100644
--- a/client/src/components/Chat/Subagents/adapters.test.ts
+++ b/client/src/components/Chat/Subagents/adapters.test.ts
@@ -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', () => {
diff --git a/client/src/components/Chat/Subagents/adapters.ts b/client/src/components/Chat/Subagents/adapters.ts
index 846006359b..5b323adcb0 100644
--- a/client/src/components/Chat/Subagents/adapters.ts
+++ b/client/src/components/Chat/Subagents/adapters.ts
@@ -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
;
@@ -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));
+}
diff --git a/client/src/data-provider/Subagents/queries.ts b/client/src/data-provider/Subagents/queries.ts
index c74b080fe3..290b1e2f86 100644
--- a/client/src/data-provider/Subagents/queries.ts
+++ b/client/src/data-provider/Subagents/queries.ts
@@ -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`,
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 208bd2a1ee..a9d83109e1 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -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",
diff --git a/e2e/specs/mock/subagent-activity.spec.ts b/e2e/specs/mock/subagent-activity.spec.ts
index 97512266f5..c37fc1c3fd 100644
--- a/e2e/specs/mock/subagent-activity.spec.ts
+++ b/e2e/specs/mock/subagent-activity.spec.ts
@@ -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();
diff --git a/packages/api/src/agents/activity.spec.ts b/packages/api/src/agents/activity.spec.ts
index d2b397771b..b3b2182245 100644
--- a/packages/api/src/agents/activity.spec.ts
+++ b/packages/api/src/agents/activity.spec.ts
@@ -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([
diff --git a/packages/api/src/agents/activity.ts b/packages/api/src/agents/activity.ts
index e4cbe20a11..fab88a1587 100644
--- a/packages/api/src/agents/activity.ts
+++ b/packages/api/src/agents/activity.ts
@@ -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 = {
diff --git a/packages/api/src/agents/subagentThreads.spec.ts b/packages/api/src/agents/subagentThreads.spec.ts
index 4861022d7b..238bf02019 100644
--- a/packages/api/src/agents/subagentThreads.spec.ts
+++ b/packages/api/src/agents/subagentThreads.spec.ts
@@ -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 () => {
diff --git a/packages/api/src/agents/subagentThreads.ts b/packages/api/src/agents/subagentThreads.ts
index c118799d94..5fed02cfdd 100644
--- a/packages/api/src/agents/subagentThreads.ts
+++ b/packages/api/src/agents/subagentThreads.ts
@@ -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,
diff --git a/packages/api/src/agents/view.spec.ts b/packages/api/src/agents/view.spec.ts
index 1e99c99c70..a4570d4b22 100644
--- a/packages/api/src/agents/view.spec.ts
+++ b/packages/api/src/agents/view.spec.ts
@@ -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() }),
);
});
diff --git a/packages/api/src/agents/view.ts b/packages/api/src/agents/view.ts
index b52ab0f35c..7fe00a70a7 100644
--- a/packages/api/src/agents/view.ts
+++ b/packages/api/src/agents/view.ts
@@ -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();
+ 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 => {
+ 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,
+ 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,
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 = {
- 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();
+ 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) {
diff --git a/packages/data-provider/src/types/subagents.ts b/packages/data-provider/src/types/subagents.ts
index f05fe857fc..2aa70fab00 100644
--- a/packages/data-provider/src/types/subagents.ts
+++ b/packages/data-provider/src/types/subagents.ts
@@ -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;
diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts
index 57e7940cf6..5ca6d060cf 100644
--- a/packages/data-schemas/src/methods/message.spec.ts
+++ b/packages/data-schemas/src/methods/message.spec.ts
@@ -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
+ >;
+ const recentSourcesPipeline = aggregateSpy.mock.calls[1][0] as unknown as Array<
+ Record
+ >;
+ const selectedPipeline = aggregateSpy.mock.calls[2][0] as unknown as Array<
+ Record
+ >;
+ 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', () => {
diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts
index b3bd6e3d05..e03b40b21c 100644
--- a/packages/data-schemas/src/methods/message.ts
+++ b/packages/data-schemas/src/methods/message.ts
@@ -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['status'];
+ controlReceipts?: Array<
+ Omit & { 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;
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 {
try {
const Message = mongoose.models.Message as Model;
- 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([
- {
- $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([
+ { $match: baseMatch },
+ { $sort: { createdAt: -1, _id: -1 } },
+ { $limit: input.limit },
+ { $project: boundedMessageProjection },
+ ]);
+ const recentSourcesPromise = Message.aggregate([
+ { $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;
diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts
index b058fcb8e3..83775b7de4 100644
--- a/packages/data-schemas/src/schema/message.ts
+++ b/packages/data-schemas/src/schema/message.ts
@@ -155,6 +155,17 @@ const messageSchema: Schema = 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: {
diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts
index 7e3bacac61..f8a963911b 100644
--- a/packages/data-schemas/src/types/message.ts
+++ b/packages/data-schemas/src/types/message.ts
@@ -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;