mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🪟 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:
parent
8c14f03432
commit
634432b2ae
18 changed files with 942 additions and 11 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
383
packages/api/src/agents/view.spec.ts
Normal file
383
packages/api/src/agents/view.spec.ts
Normal 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' }));
|
||||
});
|
||||
});
|
||||
242
packages/api/src/agents/view.ts
Normal file
242
packages/api/src/agents/view.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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)}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export enum QueryKeys {
|
|||
/* Scheduled chats */
|
||||
schedules = 'schedules',
|
||||
schedule = 'schedule',
|
||||
subagentThread = 'subagentThread',
|
||||
}
|
||||
|
||||
// Dynamic query keys that require parameters
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './queries';
|
||||
export * from './mcpServers';
|
||||
export * from './subagents';
|
||||
|
|
|
|||
32
packages/data-provider/src/types/subagents.ts
Normal file
32
packages/data-provider/src/types/subagents.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue