🪟 feat: Read Child Threads Through Their Parent (#15073)

* feat: add parent-scoped subagent thread reads

* fix: tighten child thread read bounds

* perf: project child activity messages

* test: update child activity route fixtures

* test: satisfy response mock types

* fix: bound child activity reads at storage

* style: sort child activity imports

* fix: bound child activity storage reads
This commit is contained in:
Danny Avila 2026-08-21 11:10:26 -04:00 committed by GitHub
parent 8c14f03432
commit 634432b2ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 942 additions and 11 deletions

View file

@ -32,6 +32,7 @@ module.exports = {
});
return archiveAllHandler;
}),
createSubagentThreadViewHandler: jest.fn(() => (_req, res) => res.status(200).json({})),
deleteConvoSharedLinksWithCleanup: jest.fn(),
deleteAllSharedLinksWithCleanup: jest.fn(),
deleteAgentCheckpoints: jest.fn(),

View file

@ -5,6 +5,7 @@ const {
isEnabled,
deleteAgentCheckpoints,
createArchiveAllHandler,
createSubagentThreadViewHandler,
resolveImportMaxFileSize,
restoreTenantContextFromReq,
deleteAllSharedLinksWithCleanup,
@ -33,6 +34,11 @@ const assistantClients = {
const router = express.Router();
const archiveAllHandler = createArchiveAllHandler({ archiveAllConvos: db.archiveAllConvos });
const subagentThreadViewHandler = createSubagentThreadViewHandler({
getConvoOwnership: db.getConvoOwnership,
getSubagentThreadForParent: db.getSubagentThreadForParent,
getMessagesForSubagentThreadView: db.getMessagesForSubagentThreadView,
});
router.use(requireJwtAuth);
const isValidProjectFilter = (projectId) =>
@ -79,6 +85,8 @@ router.get('/', async (req, res) => {
}
});
router.get('/:parentConversationId/subagents/:threadId', subagentThreadViewHandler);
router.get('/:conversationId', async (req, res) => {
const { conversationId } = req.params;
const convo = await db.getConvo(req.user.id, conversationId);

View file

@ -51,5 +51,6 @@ export * from './triggers';
export * from './activityLabels';
export * from './activityPhases';
export * from './subagentDelivery';
export * from './view';
export * from './reasoningLabels';
export * from './toolValidation';

View file

@ -0,0 +1,383 @@
import type { IConversation, IMessage } from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
import { createSubagentThreadViewHandler, SUBAGENT_THREAD_VIEW_LIMITS } from './view';
jest.mock('@librechat/data-schemas', () => ({
CLIENT_MESSAGE_SELECT: '-_id -user',
logger: { error: jest.fn() },
}));
const parentConversationId = 'parent-conversation';
const threadId = 'child-thread';
const parent = {
conversationId: parentConversationId,
user: 'user-1',
tenantId: 'tenant-1',
} as IConversation;
const child = {
conversationId: threadId,
user: 'user-1',
tenantId: 'tenant-1',
title: 'Research child',
agent_id: 'agent-1',
updatedAt: new Date('2026-08-21T12:00:00.000Z'),
subagentThreadLease: {
token: 'lease-token',
taskId: 'task-1',
expiresAt: new Date('2099-08-21T12:00:00.000Z'),
},
subagentThread: {
rootConversationId: parentConversationId,
parentConversationId,
parentMessageId: 'parent-message',
parentToolCallId: 'parent-tool-call',
subagentType: 'researcher',
subagentKind: 'agent',
depth: 1,
},
} as IConversation;
const message = (
messageId: string,
status: NonNullable<IMessage['subagentTask']>['status'],
isCreatedByUser = false,
): IMessage =>
({
messageId,
conversationId: threadId,
user: 'user-1',
parentMessageId: isCreatedByUser ? '00000000-0000-0000-0000-000000000000' : 'task-1:user',
sender: isCreatedByUser ? 'User' : 'researcher',
text: isCreatedByUser ? 'Investigate this.' : 'Finished the research.',
isCreatedByUser,
createdAt: new Date(isCreatedByUser ? '2026-08-21T11:00:00.000Z' : '2026-08-21T11:01:00.000Z'),
subagentTask: {
attemptKey: 'attempt-1',
status,
},
}) as IMessage;
const createResponse = () => {
const json = jest.fn();
const status = jest.fn(() => ({ json }));
return {
response: { status } as unknown as Response,
status,
json,
};
};
const createRequest = (params: Record<string, string> = {}): ServerRequest =>
({
params: { parentConversationId, threadId, ...params },
user: { id: 'user-1', tenantId: 'tenant-1' },
}) as ServerRequest;
describe('subagent thread parent-scoped view', () => {
it('returns a bounded public child projection through the owning parent', async () => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const newest = {
...message('task-1:assistant', 'completed'),
text: 'a'.repeat(SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes / 4),
textProjectionTruncated: true,
} as IMessage & { textProjectionTruncated: boolean };
const getMessages = jest
.fn()
.mockResolvedValue([newest, message('task-1:user', 'running', true)]);
const getSubagentThreadForParent = jest.fn().mockResolvedValue(child);
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent,
getMessagesForSubagentThreadView: getMessages,
});
const { response, json } = createResponse();
await handler(createRequest(), response);
expect(getMessages).toHaveBeenCalledWith({
conversationId: threadId,
user: 'user-1',
tenantId: 'tenant-1',
limit: SUBAGENT_THREAD_VIEW_LIMITS.messages + 1,
textCodePointLimit: SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes / 4,
});
expect(getConvoOwnership).toHaveBeenCalledWith('user-1', parentConversationId, 'tenant-1');
expect(json).toHaveBeenCalledWith({
threadId,
parentConversationId,
parentMessageId: 'parent-message',
parentToolCallId: 'parent-tool-call',
subagentType: 'researcher',
subagentKind: 'agent',
agentId: 'agent-1',
title: 'Research child',
status: 'completed',
messages: [
expect.objectContaining({ messageId: 'task-1:user', role: 'user' }),
expect.objectContaining({
messageId: 'task-1:assistant',
role: 'assistant',
textTruncated: true,
}),
],
historyTruncated: false,
updatedAt: '2026-08-21T12:00:00.000Z',
});
expect(Buffer.byteLength(json.mock.calls[0][0].messages[1].text, 'utf8')).toBeLessThanOrEqual(
SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes,
);
expect(Buffer.byteLength(JSON.stringify(json.mock.calls[0][0]), 'utf8')).toBeLessThanOrEqual(
SUBAGENT_THREAD_VIEW_LIMITS.responseBytes,
);
expect(json.mock.calls[0][0].messages[1]).not.toHaveProperty('subagentTask');
});
it('bounds the complete UTF-8 response while retaining the newest history', async () => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const messages = Array.from(
{ length: SUBAGENT_THREAD_VIEW_LIMITS.messages },
(_, index) =>
({
...message(`task-${index}:assistant`, 'completed'),
text: '🧵'.repeat(SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes),
}) as IMessage,
);
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent: jest.fn().mockResolvedValue(child),
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue(messages),
});
const { response, json } = createResponse();
await handler(createRequest(), response);
const view = json.mock.calls[0][0];
expect(Buffer.byteLength(JSON.stringify(view), 'utf8')).toBeLessThanOrEqual(
SUBAGENT_THREAD_VIEW_LIMITS.responseBytes,
);
expect(view.historyTruncated).toBe(true);
expect(view.messages.at(-1).messageId).toBe('task-0:assistant');
});
it('requires tenantless messages when the authenticated request has no tenant', async () => {
const getMessages = jest.fn().mockResolvedValue([]);
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue({ ...parent, tenantId: undefined }),
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, tenantId: undefined, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: getMessages,
});
const { response } = createResponse();
await handler(
{ ...createRequest(), user: { id: 'user-1', tenantId: undefined } } as ServerRequest,
response,
);
expect(getMessages).toHaveBeenCalledWith({
conversationId: threadId,
user: 'user-1',
limit: SUBAGENT_THREAD_VIEW_LIMITS.messages + 1,
textCodePointLimit: SUBAGENT_THREAD_VIEW_LIMITS.messageTextBytes / 4,
});
});
it.each([
['running', 'running'],
['error', 'failed'],
['cancelled', 'cancelled'],
] as const)('normalizes durable %s tasks as %s', async (durableStatus, publicStatus) => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const getMessages = jest
.fn()
.mockResolvedValue([
message(
durableStatus === 'running' ? 'task-1:user' : 'task-1:assistant',
durableStatus,
durableStatus === 'running',
),
]);
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent: jest.fn().mockResolvedValue(child),
getMessagesForSubagentThreadView: getMessages,
});
const { response, json } = createResponse();
await handler(createRequest(), response);
expect(json.mock.calls[0][0]).toEqual(expect.objectContaining({ status: publicStatus }));
});
it('reports a reserved child with no durable task messages as dispatched', async () => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([]),
});
const { response, json } = createResponse();
await handler(createRequest(), response);
expect(json.mock.calls[0][0]).toEqual(
expect.objectContaining({ status: 'dispatched', messages: [] }),
);
});
it('reports a child with an active preparation lease as running before its seed exists', async () => {
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest.fn().mockResolvedValue(child),
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue([]),
});
const { response, json } = createResponse();
await handler(createRequest(), response);
expect(json.mock.calls[0][0]).toEqual(
expect.objectContaining({ status: 'running', messages: [] }),
);
});
it('does not mistake an older completed turn for the active leased turn', async () => {
const activeChild = {
...child,
subagentThreadLease: { ...child.subagentThreadLease!, taskId: 'task-2' },
} as IConversation;
const handler = createSubagentThreadViewHandler({
getConvoOwnership: jest.fn().mockResolvedValue(parent),
getSubagentThreadForParent: jest.fn().mockResolvedValue(activeChild),
getMessagesForSubagentThreadView: jest
.fn()
.mockResolvedValue([message('task-1:assistant', 'completed')]),
});
const { response, json } = createResponse();
await handler(createRequest(), response);
expect(json.mock.calls[0][0]).toEqual(expect.objectContaining({ status: 'running' }));
});
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 handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent: jest.fn().mockResolvedValue(child),
getMessagesForSubagentThreadView: jest.fn().mockResolvedValue(messages),
});
const { response, json } = createResponse();
await handler(createRequest(), response);
const view = json.mock.calls[0][0];
expect(view.historyTruncated).toBe(true);
expect(view.messages).toHaveLength(SUBAGENT_THREAD_VIEW_LIMITS.messages);
expect(view.messages[0].messageId).toBe(
`task-${SUBAGENT_THREAD_VIEW_LIMITS.messages - 1}:assistant`,
);
expect(view.messages.at(-1).messageId).toBe('task-0:assistant');
});
it.each([
['missing parent', null, child, 'tenant-1'],
['missing child', parent, null, 'tenant-1'],
[
'unrelated child',
parent,
{
...child,
subagentThread: { ...child.subagentThread, parentConversationId: 'another-parent' },
},
'tenant-1',
],
['parent tenant mismatch', { ...parent, tenantId: 'tenant-2' }, child, 'tenant-1'],
['child tenant mismatch', parent, { ...child, tenantId: 'tenant-2' }, 'tenant-1'],
])('returns the same 404 for %s', async (_, parentRecord, childRecord, tenantId) => {
const getConvoOwnership = jest.fn().mockResolvedValue(parentRecord);
const getMessages = jest.fn().mockResolvedValue([message('task-1:assistant', 'completed')]);
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent: jest.fn().mockResolvedValue(childRecord),
getMessagesForSubagentThreadView: getMessages,
});
const { response, status, json } = createResponse();
await handler(
{
...createRequest(),
user: { id: 'user-1', tenantId },
} as ServerRequest,
response,
);
expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith({ error: 'Conversation not found' });
expect(getMessages).not.toHaveBeenCalled();
});
it('rejects a child id used as its own parent before reading storage', async () => {
const getConvoOwnership = jest.fn();
const getSubagentThreadForParent = jest.fn();
const getMessages = jest.fn();
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent,
getMessagesForSubagentThreadView: getMessages,
});
const { response, status } = createResponse();
await handler(createRequest({ parentConversationId: threadId }), response);
expect(status).toHaveBeenCalledWith(404);
expect(getConvoOwnership).not.toHaveBeenCalled();
expect(getSubagentThreadForParent).not.toHaveBeenCalled();
expect(getMessages).not.toHaveBeenCalled();
});
it('rejects oversized route identifiers before reading storage', async () => {
const getConvoOwnership = jest.fn();
const getSubagentThreadForParent = jest.fn();
const getMessages = jest.fn();
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent,
getMessagesForSubagentThreadView: getMessages,
});
const { response, status } = createResponse();
await handler(createRequest({ threadId: 'x'.repeat(257) }), response);
expect(status).toHaveBeenCalledWith(404);
expect(getConvoOwnership).not.toHaveBeenCalled();
expect(getSubagentThreadForParent).not.toHaveBeenCalled();
expect(getMessages).not.toHaveBeenCalled();
});
it('marks an unleased running seed as interrupted', async () => {
const getConvoOwnership = jest.fn().mockResolvedValue(parent);
const handler = createSubagentThreadViewHandler({
getConvoOwnership,
getSubagentThreadForParent: jest
.fn()
.mockResolvedValue({ ...child, subagentThreadLease: undefined }),
getMessagesForSubagentThreadView: jest
.fn()
.mockResolvedValue([message('task-1:user', 'running', true)]),
});
const { response, json } = createResponse();
await handler(createRequest(), response);
expect(json.mock.calls[0][0]).toEqual(expect.objectContaining({ status: 'interrupted' }));
});
});

View file

@ -0,0 +1,242 @@
import { logger } from '@librechat/data-schemas';
import type {
ConversationMethods,
MessageMethods,
SubagentThreadViewMessageRecord,
} from '@librechat/data-schemas';
import type {
SubagentThreadMessage,
SubagentThreadStatus,
SubagentThreadView,
} from 'librechat-data-provider';
import type { Response } from 'express';
import type { ServerRequest } from '~/types';
const MAX_THREAD_MESSAGES = 50;
const MAX_MESSAGE_TEXT_BYTES = 32 * 1024;
// MongoDB slices by Unicode code points, so reserve the UTF-8 worst case and
// 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_PUBLIC_ID_BYTES = 512;
const MAX_TITLE_BYTES = 1024;
type SubagentThreadViewDependencies = Pick<
ConversationMethods,
'getConvoOwnership' | 'getSubagentThreadForParent'
> &
Pick<MessageMethods, 'getMessagesForSubagentThreadView'>;
type SubagentThreadViewParams = {
parentConversationId?: string;
threadId?: string;
};
const validConversationId = (value: string | undefined): value is string =>
value != null && value.trim() !== '' && value.length <= 256;
const tenantMatches = (recordTenantId: string | undefined, requestTenantId: string | undefined) =>
recordTenantId === requestTenantId;
const isoDate = (value: Date | string | undefined): string | undefined => {
if (value == null) {
return undefined;
}
return value instanceof Date ? value.toISOString() : value;
};
const truncateUtf8 = (
input: string,
byteLimit: number,
): { text: string; truncated: boolean; bytes: number } => {
const inputBytes = Buffer.byteLength(input, 'utf8');
if (inputBytes <= byteLimit) {
return { text: input, truncated: false, bytes: inputBytes };
}
let low = 0;
let high = input.length;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (Buffer.byteLength(input.slice(0, middle), 'utf8') <= byteLimit) {
low = middle;
} else {
high = middle - 1;
}
}
let end = low;
if (end > 0 && /[\uD800-\uDBFF]/.test(input[end - 1])) {
end -= 1;
}
const text = input.slice(0, end);
return { text, truncated: true, bytes: Buffer.byteLength(text, 'utf8') };
};
const publicMessage = (
message: SubagentThreadViewMessageRecord,
byteLimit: number,
): { message: SubagentThreadMessage; bytes: number } => {
const text = message.text ?? '';
const projected = truncateUtf8(text, Math.min(MAX_MESSAGE_TEXT_BYTES, byteLimit));
return {
message: {
messageId: truncateUtf8(message.messageId, MAX_PUBLIC_ID_BYTES).text,
parentMessageId:
message.parentMessageId == null
? null
: truncateUtf8(message.parentMessageId, MAX_PUBLIC_ID_BYTES).text,
role: message.isCreatedByUser ? 'user' : 'assistant',
text: projected.text,
...(isoDate(message.createdAt) == null ? {} : { createdAt: isoDate(message.createdAt) }),
...(message.error === true ? { error: true } : {}),
...(message.textProjectionTruncated === true || projected.truncated
? { textTruncated: true }
: {}),
},
bytes: projected.bytes,
};
};
const publicStatus = (
messages: SubagentThreadViewMessageRecord[],
activeLeaseTaskId: string | undefined,
): SubagentThreadStatus => {
if (activeLeaseTaskId != null) {
const activeTaskMessage = messages.find(
(message) =>
message.messageId === `${activeLeaseTaskId}:user` ||
message.messageId === `${activeLeaseTaskId}:assistant`,
);
if (
activeTaskMessage?.subagentTask?.status == null ||
activeTaskMessage.subagentTask.status === 'running'
) {
return 'running';
}
return publicStatus([activeTaskMessage], undefined);
}
const message = messages.find((candidate) => candidate.subagentTask != null);
switch (message?.subagentTask?.status) {
case 'running':
return 'interrupted';
case 'completed':
return 'completed';
case 'error':
return 'failed';
case 'cancelled':
return 'cancelled';
default:
return 'dispatched';
}
};
const notFound = (res: Response): void => {
res.status(404).json({ error: 'Conversation not found' });
};
/** Reads one durable child through its parent without reopening ordinary conversation reads. */
export function createSubagentThreadViewHandler(deps: SubagentThreadViewDependencies) {
return async (req: ServerRequest, res: Response): Promise<void> => {
const userId = req.user?.id;
const tenantId = req.user?.tenantId || undefined;
const { parentConversationId, threadId } = req.params as SubagentThreadViewParams;
if (
!userId ||
!validConversationId(parentConversationId) ||
!validConversationId(threadId) ||
parentConversationId === threadId
) {
notFound(res);
return;
}
try {
const now = new Date();
const [parent, child] = await Promise.all([
deps.getConvoOwnership(userId, parentConversationId, tenantId ?? null),
deps.getSubagentThreadForParent({
user: userId,
parentConversationId,
conversationId: threadId,
...(tenantId == null ? {} : { tenantId }),
}),
]);
const lineage = child?.subagentThread;
const authorized =
parent != null &&
child != null &&
lineage != null &&
lineage.parentConversationId === parentConversationId &&
tenantMatches(parent.tenantId, tenantId) &&
tenantMatches(child.tenantId, tenantId);
if (!authorized || lineage == null || child == null) {
notFound(res);
return;
}
const messages = await deps.getMessagesForSubagentThreadView({
conversationId: threadId,
user: userId,
...(tenantId == null ? {} : { tenantId }),
limit: MAX_THREAD_MESSAGES + 1,
textCodePointLimit: MAX_MESSAGE_TEXT_PROJECTION_CODE_POINTS,
});
const historyTruncated = messages.length > MAX_THREAD_MESSAGES;
const newestFirst = historyTruncated ? messages.slice(0, MAX_THREAD_MESSAGES) : messages;
const activeLeaseTaskId =
child.subagentThreadLease != null && child.subagentThreadLease.expiresAt > now
? child.subagentThreadLease.taskId
: undefined;
const projectedNewestFirst: SubagentThreadMessage[] = [];
let remainingTextBytes = MAX_RESPONSE_TEXT_BYTES;
for (const message of newestFirst) {
if (remainingTextBytes === 0) {
break;
}
const projected = publicMessage(message, remainingTextBytes);
projectedNewestFirst.push(projected.message);
remainingTextBytes -= projected.bytes;
}
const view: SubagentThreadView = {
threadId,
parentConversationId,
parentMessageId: truncateUtf8(lineage.parentMessageId, MAX_PUBLIC_ID_BYTES).text,
parentToolCallId: truncateUtf8(lineage.parentToolCallId, MAX_PUBLIC_ID_BYTES).text,
subagentType: truncateUtf8(lineage.subagentType, MAX_PUBLIC_ID_BYTES).text,
subagentKind: lineage.subagentKind,
...(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),
messages: projectedNewestFirst.reverse(),
historyTruncated: historyTruncated || projectedNewestFirst.length < newestFirst.length,
...(isoDate(child.updatedAt) == null ? {} : { updatedAt: isoDate(child.updatedAt) }),
};
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');
}
view.messages.shift();
view.historyTruncated = true;
}
res.status(200).json(view);
} catch (error) {
logger.error('[subagentThreads] Failed to read child thread through parent', error);
res.status(500).json({ error: 'Failed to load subagent thread' });
}
};
}
export const SUBAGENT_THREAD_VIEW_LIMITS: Readonly<{
messages: number;
messageTextBytes: number;
responseTextBytes: number;
responseBytes: number;
}> = {
messages: MAX_THREAD_MESSAGES,
messageTextBytes: MAX_MESSAGE_TEXT_BYTES,
responseTextBytes: MAX_RESPONSE_TEXT_BYTES,
responseBytes: MAX_RESPONSE_BYTES,
};

View file

@ -116,6 +116,9 @@ export const conversations = (params: q.ConversationListParams) => {
export const conversationById = (id: string) => `${conversationsRoot}/${id}`;
export const subagentThread = (parentConversationId: string, threadId: string) =>
`${conversationsRoot}/${encodeURIComponent(parentConversationId)}/subagents/${encodeURIComponent(threadId)}`;
export const genTitle = (conversationId: string) =>
`${conversationsRoot}/gen_title/${encodeURIComponent(conversationId)}`;

View file

@ -1002,6 +1002,13 @@ export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage
return request.get(endpoints.messages({ conversationId }));
}
export function getSubagentThread(
parentConversationId: string,
threadId: string,
): Promise<t.SubagentThreadView> {
return request.get(endpoints.subagentThread(parentConversationId, threadId));
}
export function getPrompt(id: string): Promise<{ prompt: t.TPrompt }> {
return request.get(endpoints.getPrompt(id));
}

View file

@ -34,6 +34,7 @@ export * from './types/runs';
export * from './types/web';
export * from './types/graph';
export * from './types/insights';
export * from './types/subagents';
/* access permissions */
export * from './accessPermissions';
/* query/mutation keys */

View file

@ -93,6 +93,7 @@ export enum QueryKeys {
/* Scheduled chats */
schedules = 'schedules',
schedule = 'schedule',
subagentThread = 'subagentThread',
}
// Dynamic query keys that require parameters

View file

@ -17,6 +17,7 @@ import type { TMinimalFeedback } from './feedback';
import type { ContentTypes } from './types/runs';
export * from './schemas';
export * from './types/subagents';
export type TMessages = TMessage[];

View file

@ -1,2 +1,3 @@
export * from './queries';
export * from './mcpServers';
export * from './subagents';

View file

@ -0,0 +1,32 @@
export type SubagentThreadStatus =
| 'dispatched'
| 'running'
| 'completed'
| 'failed'
| 'interrupted'
| 'cancelled';
export type SubagentThreadMessage = {
messageId: string;
parentMessageId: string | null;
role: 'user' | 'assistant';
text: string;
createdAt?: string;
error?: boolean;
textTruncated?: boolean;
};
export type SubagentThreadView = {
threadId: string;
parentConversationId: string;
parentMessageId: string;
parentToolCallId: string;
subagentType: string;
subagentKind: 'agent' | 'graph';
agentId?: string;
title: string;
status: SubagentThreadStatus;
messages: SubagentThreadMessage[];
historyTruncated: boolean;
updatedAt?: string;
};

View file

@ -1490,6 +1490,7 @@ describe('Conversation Operations', () => {
await Conversation.create({
conversationId: mockConversationData.conversationId,
user: 'user123',
tenantId: 'tenant-a',
title: 'Test Conversation',
endpoint: EModelEndpoint.openAI,
});
@ -1500,6 +1501,7 @@ describe('Conversation Operations', () => {
);
expect(result?.user).toBe('user123');
expect(result?.tenantId).toBe('tenant-a');
expect(result).not.toHaveProperty('title');
expect(result).not.toHaveProperty('messages');
expect(result).not.toHaveProperty('endpoint');
@ -1544,6 +1546,36 @@ describe('Conversation Operations', () => {
});
expect(result).not.toHaveProperty('title');
});
it('selects the exact tenant when conversation identifiers collide', async () => {
await runAsSystem(async () => {
await Conversation.create([
{
conversationId: 'shared-conversation-id',
user: 'user123',
title: 'Tenantless parent',
endpoint: EModelEndpoint.agents,
},
{
conversationId: 'shared-conversation-id',
user: 'user123',
tenantId: 'tenant-a',
title: 'Tenant parent',
endpoint: EModelEndpoint.agents,
},
]);
});
const tenantless = await methods.getConvoOwnership('user123', 'shared-conversation-id', null);
const tenant = await methods.getConvoOwnership(
'user123',
'shared-conversation-id',
'tenant-a',
);
expect(tenantless?.tenantId).toBeUndefined();
expect(tenant?.tenantId).toBe('tenant-a');
});
});
describe('getConvoRetention', () => {
@ -3396,6 +3428,72 @@ describe('Conversation Operations', () => {
expect(saved?.subagentThread).toEqual(lineage);
});
it('reads a child only through its exact parent and tenant with the private lease', async () => {
const parentConversationId = uuidv4();
const conversationId = uuidv4();
await Conversation.create([
{
conversationId: parentConversationId,
user: 'view-user',
tenantId: 'tenant-a',
endpoint: EModelEndpoint.agents,
},
{
conversationId,
user: 'view-user',
tenantId: 'tenant-a',
endpoint: EModelEndpoint.agents,
subagentThread: {
rootConversationId: parentConversationId,
parentConversationId,
parentMessageId: 'parent-message',
parentToolCallId: 'parent-tool',
subagentType: 'researcher',
subagentKind: 'agent',
depth: 1,
},
subagentThreadLease: {
token: 'private-token',
taskId: 'task-1',
expiresAt: new Date('2099-08-21T12:00:00.000Z'),
},
},
]);
await expect(
methods.getSubagentThreadForParent({
user: 'view-user',
parentConversationId,
conversationId,
tenantId: 'tenant-a',
}),
).resolves.toEqual(
expect.objectContaining({
conversationId,
subagentThreadLease: expect.objectContaining({ taskId: 'task-1' }),
}),
);
await expect(
methods.getSubagentThreadForParent({
user: 'view-user',
parentConversationId: 'another-parent',
conversationId,
tenantId: 'tenant-a',
}),
).resolves.toBeNull();
await expect(
methods.getSubagentThreadForParent({
user: 'view-user',
parentConversationId,
conversationId,
tenantId: 'tenant-b',
}),
).resolves.toBeNull();
expect(await methods.getConvo('view-user', conversationId)).not.toHaveProperty(
'subagentThreadLease',
);
});
it('admits one cross-replica owner and fences renewal and release by token', async () => {
const conversationId = uuidv4();
await Conversation.create({

View file

@ -38,6 +38,17 @@ type ConversationUpdateResult = {
};
};
export type SubagentThreadReadRecord = Pick<
IConversation,
| 'conversationId'
| 'tenantId'
| 'title'
| 'agent_id'
| 'updatedAt'
| 'subagentThread'
| 'subagentThreadLease'
>;
const ARCHIVE_CONVERSATION_BATCH_SIZE = 500;
const PROJECT_STATS_REFRESH_CONCURRENCY = 10;
const PROJECT_STATS_REFRESH_MAX_PASSES = 2;
@ -150,6 +161,12 @@ export interface ConversationMethods {
convoMap: Record<string, unknown>;
}>;
getConvo(user: string, conversationId: string): Promise<IConversation | null>;
getSubagentThreadForParent(input: {
user: string;
parentConversationId: string;
conversationId: string;
tenantId?: string;
}): Promise<SubagentThreadReadRecord | null>;
reserveSubagentThread(input: {
user: string;
conversationId: string;
@ -192,7 +209,8 @@ export interface ConversationMethods {
getConvoOwnership(
user: string,
conversationId: string,
): Promise<Pick<IConversation, 'user' | 'subagentThread'> | null>;
tenantId?: string | null,
): Promise<Pick<IConversation, 'user' | 'tenantId' | 'subagentThread'> | null>;
getConvoRetention(
user: string,
conversationId: string,
@ -254,6 +272,31 @@ export function createConversationMethods(
}
}
/** Resolves a child only through its owning parent and includes its private live lease. */
async function getSubagentThreadForParent(input: {
user: string;
parentConversationId: string;
conversationId: string;
tenantId?: string;
}): Promise<SubagentThreadReadRecord | null> {
try {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
return await Conversation.findOne({
user: input.user,
conversationId: input.conversationId,
'subagentThread.parentConversationId': input.parentConversationId,
...subagentLeaseTenantFilter(input.tenantId),
})
.select(
'conversationId tenantId title agent_id updatedAt subagentThread +subagentThreadLease',
)
.lean<SubagentThreadReadRecord>();
} catch (error) {
logger.error('[getSubagentThreadForParent] Error getting child conversation', error);
throw new Error('Error getting child conversation');
}
}
/** Creates immutable child lineage exactly once without overwriting a concurrent winner. */
async function reserveSubagentThread(input: {
user: string;
@ -436,12 +479,19 @@ export function createConversationMethods(
* without materializing the full conversation document (preset spread +
* message ObjectId array).
*/
async function getConvoOwnership(user: string, conversationId: string) {
async function getConvoOwnership(user: string, conversationId: string, tenantId?: string | null) {
try {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
return await Conversation.findOne({ user, conversationId }, 'user subagentThread').lean<
Pick<IConversation, 'user' | 'subagentThread'>
>();
const tenantFilter =
tenantId === undefined ? {} : subagentLeaseTenantFilter(tenantId ?? undefined);
return await Conversation.findOne(
{
user,
conversationId,
...tenantFilter,
},
'user tenantId subagentThread',
).lean<Pick<IConversation, 'user' | 'tenantId' | 'subagentThread'>>();
} catch (error) {
logger.error('[getConvoOwnership] Error checking conversation ownership', error);
throw new Error('Error checking conversation ownership');
@ -1566,6 +1616,7 @@ export function createConversationMethods(
getConvosByCursor,
getConvosQueried,
getConvo,
getSubagentThreadForParent,
reserveSubagentThread,
acquireSubagentThreadLease,
renewSubagentThreadLease,

View file

@ -48,6 +48,7 @@ import {
createMessageMethods,
CLIENT_MESSAGE_SELECT,
type MessageMethods,
type SubagentThreadViewMessageRecord,
type SubagentTaskResultClaim,
} from './message';
import { createConversationMethods, type ConversationMethods } from './conversation';
@ -376,6 +377,7 @@ export type {
PresetMethods,
ConversationTagMethods,
MessageMethods,
SubagentThreadViewMessageRecord,
SubagentTaskResultClaim,
ConversationMethods,
ChatProjectMethods,

View file

@ -21,6 +21,9 @@ let mongoServer: InstanceType<typeof MongoMemoryServer>;
let Message: mongoose.Model<IMessage>;
let saveMessage: ReturnType<typeof createMessageMethods>['saveMessage'];
let getMessages: ReturnType<typeof createMessageMethods>['getMessages'];
let getMessagesForSubagentThreadView: ReturnType<
typeof createMessageMethods
>['getMessagesForSubagentThreadView'];
let updateMessage: ReturnType<typeof createMessageMethods>['updateMessage'];
let updateToolCallResult: ReturnType<typeof createMessageMethods>['updateToolCallResult'];
let deleteMessages: ReturnType<typeof createMessageMethods>['deleteMessages'];
@ -44,6 +47,7 @@ beforeAll(async () => {
const methods = createMessageMethods(mongoose);
saveMessage = methods.saveMessage;
getMessages = methods.getMessages;
getMessagesForSubagentThreadView = methods.getMessagesForSubagentThreadView;
updateMessage = methods.updateMessage;
updateToolCallResult = methods.updateToolCallResult;
deleteMessages = methods.deleteMessages;
@ -621,7 +625,7 @@ describe('Message Operations', () => {
it('declares the compound index that serves the conversation fetch and its sort', () => {
const indexes = Message.schema.indexes() as Array<[Record<string, number>, unknown]>;
expect(indexes).toContainEqual([
{ conversationId: 1, user: 1, createdAt: 1 },
{ conversationId: 1, user: 1, createdAt: 1, _id: 1 },
expect.anything(),
]);
});
@ -684,6 +688,32 @@ describe('Message Operations', () => {
});
});
describe('getMessagesForSubagentThreadView', () => {
it('bounds text in MongoDB before returning the public projection', async () => {
const conversationId = uuidv4();
await saveMessage(mockCtx, {
messageId: 'bounded-message',
conversationId,
text: '🧵'.repeat(20_000),
user: 'user123',
});
const messages = await getMessagesForSubagentThreadView({
user: 'user123',
conversationId,
limit: 1,
textCodePointLimit: 8_192,
});
expect(messages).toHaveLength(1);
expect(Array.from(messages[0].text ?? '')).toHaveLength(8_192);
expect(Buffer.byteLength(messages[0].text ?? '', 'utf8')).toBeLessThanOrEqual(32 * 1024);
expect(messages[0].textProjectionTruncated).toBe(true);
expect(messages[0]).not.toHaveProperty('user');
expect(messages[0]).not.toHaveProperty('conversationId');
});
});
describe('deleteMessages', () => {
it('should delete messages with the correct filter', async () => {
// Save some messages for different users

View file

@ -53,6 +53,17 @@ export type SubagentTaskResultClaim =
| { status: 'claimed'; message: IMessage }
| { status: 'acquired'; message: IMessage };
export type SubagentThreadViewMessageRecord = Pick<
IMessage,
| 'messageId'
| 'parentMessageId'
| 'isCreatedByUser'
| 'text'
| 'createdAt'
| 'error'
| 'subagentTask'
> & { textProjectionTruncated?: boolean };
export interface MessageMethods {
saveMessage(
ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] },
@ -110,6 +121,13 @@ export interface MessageMethods {
select?: string,
options?: MessageQueryOptions,
): Promise<IMessage[]>;
getMessagesForSubagentThreadView(input: {
user: string;
conversationId: string;
tenantId?: string;
limit: number;
textCodePointLimit: number;
}): Promise<SubagentThreadViewMessageRecord[]>;
getMessage(params: { user: string; messageId: string }): Promise<IMessage | null>;
getMessagesByCursor(
filter: FilterQuery<IMessage>,
@ -717,6 +735,55 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
}
}
/**
* Reads the fixed public child-thread projection and truncates text inside
* MongoDB so oversized persisted messages are never materialized by the API.
*/
async function getMessagesForSubagentThreadView(input: {
user: string;
conversationId: string;
tenantId?: string;
limit: number;
textCodePointLimit: number;
}): Promise<SubagentThreadViewMessageRecord[]> {
try {
const Message = mongoose.models.Message as Model<IMessage>;
return await Message.aggregate<SubagentThreadViewMessageRecord>([
{
$match: {
user: input.user,
conversationId: input.conversationId,
...(input.tenantId == null
? { tenantId: { $exists: false } }
: { tenantId: input.tenantId }),
},
},
{ $sort: { createdAt: -1, _id: -1 } },
{ $limit: input.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,
subagentTask: 1,
},
},
]);
} catch (err) {
logger.error('Error getting bounded subagent thread messages:', err);
throw err;
}
}
/**
* Retrieves a single message from the database.
*/
@ -806,6 +873,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
releaseSubagentTaskResultClaim,
deleteMessagesSince,
getMessages,
getMessagesForSubagentThreadView,
getMessage,
getMessagesByCursor,
searchMessages,

View file

@ -245,12 +245,13 @@ messageSchema.index({
/**
* Serves the conversation fetch ({conversationId, user} filter + createdAt
* sort) from the index alone; without it Mongo fetches every full document in
* the conversation and sorts them in memory. tenantId is deliberately not in
* the middle: untenanted deployments issue no tenantId predicate, and a gap in
* the prefix would push the sort back into memory for them.
* sort) and the deterministic child-thread view sort from the index alone;
* without it Mongo fetches every full document in the conversation and sorts
* them in memory. tenantId is deliberately not in the middle: untenanted
* deployments issue no tenantId predicate, and a gap in the prefix would push
* the sort back into memory for them.
*/
messageSchema.index({ conversationId: 1, user: 1, createdAt: 1 });
messageSchema.index({ conversationId: 1, user: 1, createdAt: 1, _id: 1 });
/** Bounds parent-run completion snapshots without scanning a user's message history. */
messageSchema.index(