LibreChat/api/server/controllers/agents/client.test.js
2026-08-23 02:06:57 -04:00

6151 lines
198 KiB
JavaScript

const mockCreateRun = jest.fn();
const mockCaptureAgentCheckpointGeneration = jest.fn();
const mockDeleteAgentCheckpoint = jest.fn();
const mockIsHITLEnabled = jest.fn().mockReturnValue(false);
const mockRecordCollectedUsage = jest.fn();
const mockDetachedUsageRecorder = jest.fn();
const mockCreateDetachedSubagentUsageRecorder = jest.fn(() => mockDetachedUsageRecorder);
const mockGetAgentCheckpointer = jest.fn();
const mockBuildAgentScopedContext = jest.fn((...args) =>
jest.requireActual('@librechat/api').buildAgentScopedContext(...args),
);
const mockFormatAgentMessages = jest.fn(() => ({
messages: [],
indexTokenCountMap: {},
summary: undefined,
boundaryTokenAdjustment: undefined,
}));
const { Providers } = require('@librechat/agents');
const { Constants, ContentTypes, EModelEndpoint } = require('librechat-data-provider');
const { GenerationJobManager, createStreamServices } = require('@librechat/api');
const BaseClient = require('~/app/clients/BaseClient');
const AgentClient = require('./client');
const { resolveConfigServers } = require('~/server/services/MCP');
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
createMetadataAggregator: () => ({
handleLLMEnd: jest.fn(),
collected: [],
}),
formatAgentMessages: (...args) => mockFormatAgentMessages(...args),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
checkAccess: jest.fn(),
createRun: (...args) => mockCreateRun(...args),
countFormattedMessageTokens: jest.fn(() => 42),
countTokens: jest.fn((text) => Math.ceil(String(text ?? '').length / 4)),
createTokenCounter: jest.fn(() => jest.fn(() => 0)),
createDetachedSubagentUsageRecorder: (...args) =>
mockCreateDetachedSubagentUsageRecorder(...args),
captureAgentCheckpointGeneration: (...args) => mockCaptureAgentCheckpointGeneration(...args),
deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
decrementPendingRequest: jest.fn(async () => {}),
initializeAgent: jest.fn(),
isHITLEnabled: (...args) => mockIsHITLEnabled(...args),
createMemoryProcessor: jest.fn(),
isMemoryAgentEnabled: jest.fn((config) => {
if (!config || config.disabled === true) return false;
const agent = config.agent;
if (agent?.enabled !== true) return false;
return Boolean(agent.id || (agent.provider && agent.model));
}),
loadAgent: jest.fn(),
maybePrewarmCodeSandbox: jest.fn(),
recordCollectedUsage: (...args) => mockRecordCollectedUsage(...args),
getAgentCheckpointer: mockGetAgentCheckpointer,
}));
describe('AgentClient - final model-bound content protection', () => {
const filters = {
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: [
{
id: 'provider-bound-secret',
label: 'provider-bound secret',
regex: 'PROVIDER-BOUND-[A-Z]+',
},
],
},
},
};
const makeClient = () => {
const client = Object.create(AgentClient.prototype);
client.options = {
resendFiles: true,
req: { config: { filters } },
};
client.authorizedHistoricalFiles = new Map();
client.setModelBoundStoredMessages([
{
role: 'user',
isCreatedByUser: true,
text: 'Old PROVIDER-BOUND-SECRET',
messageId: 'pruned-source',
},
{
role: 'user',
isCreatedByUser: true,
text: 'Safe retained text',
messageId: 'retained-source',
},
]);
return client;
};
it('inspects only persisted source rows retained by the provider payload', () => {
const callback = makeClient().createModelBoundChatModelCallback();
expect(callback).toEqual(expect.objectContaining({ raiseError: true, awaitHandlers: true }));
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'user',
content: 'Safe retained text',
additional_kwargs: { sourceMessageId: 'retained-source' },
},
],
]),
).not.toThrow();
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'user',
content: 'Old PROVIDER-BOUND-SECRET',
additional_kwargs: { sourceMessageId: 'pruned-source' },
},
],
]),
).toThrow(expect.objectContaining({ code: 'content_filter_block' }));
});
it('inspects synthetic summarizer and mid-run model input without a source row', () => {
const callback = makeClient().createModelBoundChatModelCallback();
expect(() =>
callback.handleChatModelStart(undefined, [
[{ role: 'user', content: 'Synthetic PROVIDER-BOUND-SUMMARY input' }],
]),
).toThrow(expect.objectContaining({ code: 'content_filter_block' }));
});
it('preflights legacy restored history before user-message persistence', () => {
const client = makeClient();
client.options.req.config.messageFilter = {
pii: {
starterPatterns: [],
customPatterns: [
{
id: 'legacy-provider-bound-secret',
label: 'legacy provider-bound secret',
regex: 'PROVIDER-BOUND-[A-Z]+',
},
],
},
};
expect(() => client.assertStoredModelBoundContent()).toThrow(
expect.objectContaining({ code: 'content_filter_block' }),
);
});
it('keeps source-aware restored history enforcement at the final provider boundary', () => {
const client = makeClient();
expect(() => client.assertStoredModelBoundContent()).not.toThrow();
});
it('allows safe restored history under legacy message filtering', () => {
const client = makeClient();
client.options.req.config.messageFilter = {
pii: {
starterPatterns: [],
customPatterns: [
{
id: 'legacy-provider-bound-secret',
label: 'legacy provider-bound secret',
regex: 'PROVIDER-BOUND-[A-Z]+',
},
],
},
};
client.setModelBoundStoredMessages([
{
role: 'user',
isCreatedByUser: true,
text: 'Safe restored text',
messageId: 'safe-source',
},
]);
expect(() => client.assertStoredModelBoundContent()).not.toThrow();
});
it('keeps materialized current attachments inspectable when historical replay is disabled', () => {
const client = makeClient();
const currentFile = {
file_id: 'current-file',
filename: 'safe.png',
type: 'image/png',
text: 'Safe current OCR content',
};
client.options.resendFiles = false;
client.options.attachments = [
{ file_id: 'current-file', filename: 'safe.png', type: 'image/png' },
];
client.modelBoundCurrentFiles = [currentFile];
client.options.req.config.filters = {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
};
client.message_file_map = { 'retained-source': [currentFile] };
expect(() =>
client.createModelBoundChatModelCallback().handleChatModelStart(undefined, [
[
{
role: 'user',
content: 'Safe retained text',
additional_kwargs: { sourceMessageId: 'retained-source' },
},
],
]),
).not.toThrow();
});
});
describe('AgentClient - detached subagent usage', () => {
it('records each detached call from an immutable snapshot after parent disposal', async () => {
mockRecordCollectedUsage.mockClear();
mockCreateDetachedSubagentUsageRecorder.mockClear();
mockDetachedUsageRecorder.mockClear();
const client = Object.create(AgentClient.prototype);
const childTokenConfig = { input: 1, output: 2 };
client.user = 'user-123';
client.conversationId = 'conversation-123';
client.responseMessageId = 'response-123';
client.model = 'primary-model';
client.options = {
req: { user: { id: 'request-user' } },
agent: { model_parameters: { model: 'fallback-model' } },
endpointTokenConfig: { input: 3, output: 4 },
endpointTokenConfigByAgentId: new Map([['agent-child', childTokenConfig]]),
};
const balance = { enabled: true };
const transactions = { enabled: true };
const usage = {
usage_type: 'subagent',
input_tokens: 100,
output_tokens: 20,
agentId: 'agent-child',
};
const recordUsage = client.buildDetachedSubagentUsageRecorder(balance, transactions);
client.user = null;
client.conversationId = null;
client.responseMessageId = null;
client.model = null;
client.options = null;
await recordUsage(usage);
expect(mockCreateDetachedSubagentUsageRecorder).toHaveBeenCalledTimes(1);
const [deps, billing] = mockCreateDetachedSubagentUsageRecorder.mock.calls[0];
expect(deps).toEqual({
spendTokens: expect.any(Function),
spendStructuredTokens: expect.any(Function),
pricing: {
getMultiplier: expect.any(Function),
getCacheMultiplier: expect.any(Function),
},
bulkWriteOps: {
insertMany: expect.any(Function),
updateBalance: expect.any(Function),
},
isPrincipalActive: expect.any(Function),
});
expect(billing).toEqual({
user: 'user-123',
conversationId: 'conversation-123',
model: 'primary-model',
messageId: 'response-123',
balance,
transactions,
endpointTokenConfig: { input: 3, output: 4 },
endpointTokenConfigByAgentId: expect.any(Map),
});
expect(billing.endpointTokenConfigByAgentId.get('agent-child')).toBe(childTokenConfig);
expect(mockDetachedUsageRecorder).toHaveBeenCalledWith(usage);
});
});
describe('AgentClient - subagent parent persistence', () => {
it('matches the graph starting wave for parallel roots and cyclic fallback', () => {
expect(
AgentClient.getStartingAgentIds([
{ id: 'agent-a', edges: [{ from: 'agent-a', to: 'agent-c' }] },
{ id: 'agent-b' },
{ id: 'agent-c' },
]),
).toEqual(['agent-a', 'agent-b']);
expect(
AgentClient.getStartingAgentIds([
{
id: 'agent-a',
edges: [
{ from: 'agent-a', to: 'agent-b' },
{ from: 'agent-b', to: 'agent-a' },
],
},
{ id: 'agent-b' },
]),
).toEqual(['agent-a']);
});
it('registers the parent user-message write before detached child dispatch can proceed', async () => {
const userMessagePromise = Promise.resolve({
message: { messageId: 'parent-user-message', conversationId: 'parent-conversation' },
});
const registerParentPersistence = jest.fn();
const upstreamGetReqData = jest.fn();
const baseSend = jest
.spyOn(BaseClient.prototype, 'sendMessage')
.mockImplementation(async (_message, opts) => {
opts.getReqData({ userMessagePromise });
return { ok: true };
});
const client = Object.create(AgentClient.prototype);
client.options = {
subagentTasks: {
scopeId: 'trusted-parent-scope',
store: { registerParentPersistence },
},
};
await client.sendMessage('Start the parent turn.', { getReqData: upstreamGetReqData });
expect(upstreamGetReqData).toHaveBeenCalledWith({ userMessagePromise });
expect(registerParentPersistence).toHaveBeenCalledWith(
'trusted-parent-scope',
userMessagePromise,
);
baseSend.mockRestore();
});
});
describe('AgentClient - label settlement', () => {
it('drains a trailing fill enqueued by an in-flight reasoning revision', async () => {
const client = Object.create(AgentClient.prototype);
const first = deferred();
const trailing = deferred();
const scope = { closed: false, abort: new AbortController(), detach: jest.fn() };
client.activityLabelScopes = [scope];
client.pendingActivityLabelFills = [
first.promise.finally(() => {
client.pendingActivityLabelFills.push(trailing.promise);
}),
];
let settled = false;
const settlement = client.settleActivityLabels(1_000).then(() => {
settled = true;
});
first.resolve();
await new Promise((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
trailing.resolve();
await settlement;
expect(scope.closed).toBe(false);
expect(scope.detach).toHaveBeenCalledTimes(1);
expect(client.pendingActivityLabelFills).toEqual([]);
});
});
describe('AgentClient - reasoning label accounting', () => {
function createReasoningLabelClient(generateReasoningLabel) {
const client = Object.create(AgentClient.prototype);
client.options = { req: { user: { id: 'user-123' } } };
client.conversationId = 'conversation-123';
client.parentMessageId = 'parent-123';
client.responseMessageId = 'response-123';
client.run = { generateReasoningLabel };
client.resolveReasoningLabelLLM = jest.fn(async () => ({
provider: Providers.OPENAI,
clientOptions: { model: 'reasoning-label-model' },
endpointTokenConfig: { input: 1, output: 2 },
sameEndpoint: false,
}));
client.recordActivityLabelUsage = jest.fn(async () => undefined);
return client;
}
it('estimates output tokens from the raw model completion before title normalization', async () => {
const rawCompletion = 'Inspecting the cache race\nThis extra explanation also consumed tokens.';
const client = createReasoningLabelClient(
jest.fn(async ({ chainOptions }) => {
const callback = chainOptions.callbacks[0];
callback.handleChatModelStart(undefined, [[{ content: 'captured SDK prompt' }]]);
callback.handleLLMEnd({
generations: [
[
{
text: rawCompletion,
message: { content: [{ type: 'text', text: rawCompletion }] },
},
],
],
});
return { label: 'Inspecting the cache race' };
}),
);
const generated = await client.generateReasoningLabelViaRun({
visibleReasoning: 'x'.repeat(500),
reasoningStepId: 'reasoning-1',
revision: 1,
status: 'streaming',
signal: new AbortController().signal,
});
await generated.collectUsage(generated.label);
const usageCall = client.recordActivityLabelUsage.mock.calls[0];
expect(usageCall[6]()).toEqual({
promptText: 'captured SDK prompt',
completionText: rawCompletion,
});
expect(usageCall[7]).toBe('reasoning-label');
});
it('falls back to the returned label when no raw completion callback is available', async () => {
const client = createReasoningLabelClient(
jest.fn(async () => ({ label: 'Inspecting the cache race' })),
);
const generated = await client.generateReasoningLabelViaRun({
visibleReasoning: 'x'.repeat(500),
reasoningStepId: 'reasoning-1',
revision: 1,
status: 'streaming',
signal: new AbortController().signal,
});
await generated.collectUsage(generated.label);
expect(client.recordActivityLabelUsage.mock.calls[0][6]()).toMatchObject({
completionText: 'Inspecting the cache race',
});
});
});
describe('AgentClient - interrupt discovery persistence', () => {
beforeEach(async () => {
await GenerationJobManager.destroy();
GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
GenerationJobManager.initialize();
});
afterEach(async () => {
await GenerationJobManager.destroy();
});
it('makes the run discovery snapshot durable when the run pauses', async () => {
const streamId = 'conversation-discovered-pause';
const job = await GenerationJobManager.createJob(streamId, 'user-123', streamId);
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: { endpoint: EModelEndpoint.agents, agent_id: 'agent-123' },
config: { endpoints: { [EModelEndpoint.agents]: {} } },
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
});
client.conversationId = streamId;
client.responseMessageId = 'response-discovered-pause';
client.jobCreatedAt = job.createdAt;
await client.handleRunInterrupt(
{
getInterrupt: () => ({
interruptId: 'ask-interrupt',
threadId: streamId,
payload: {
type: 'ask_user_question',
question: { question: 'Proceed?' },
},
}),
getDiscoveredTools: () => ['save_issue_mcp_linear'],
getRunMessages: () => [],
},
streamId,
);
const paused = await GenerationJobManager.getJob(streamId);
expect(paused?.status).toBe('requires_action');
expect(paused?.metadata.discoveredTools).toEqual(['save_issue_mcp_linear']);
});
it('caps an event-bound pause at the inherited binding deadline', async () => {
const now = Date.now();
const streamId = 'conversation-event-bound-pause';
const job = await GenerationJobManager.createJob(streamId, 'user-123', streamId);
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: { endpoint: EModelEndpoint.agents, agent_id: 'agent-123' },
config: { endpoints: { [EModelEndpoint.agents]: { checkpointer: { ttl: 3600 } } } },
_agentEventBindingRetention: {
/** RetentionMode.ALL conversations are not temporary but still have a deadline. */
isTemporary: false,
expiredAt: new Date(now + 5_000),
},
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
});
client.conversationId = streamId;
client.responseMessageId = 'response-event-bound-pause';
client.jobCreatedAt = job.createdAt;
await client.handleRunInterrupt(
{
getInterrupt: () => ({
interruptId: 'ask-interrupt',
threadId: streamId,
payload: {
type: 'ask_user_question',
question: { question: 'Proceed?' },
},
}),
getDiscoveredTools: () => [],
getRunMessages: () => [],
},
streamId,
);
const paused = await GenerationJobManager.getJob(streamId);
expect(paused?.metadata.pendingAction.expiresAt).toBeGreaterThanOrEqual(now + 4_900);
expect(paused?.metadata.pendingAction.expiresAt).toBeLessThanOrEqual(now + 5_000);
});
});
jest.mock('~/server/services/Config', () => ({
getMCPServerTools: jest.fn(),
}));
jest.mock('~/server/services/MCP', () => ({
resolveConfigServers: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/models', () => ({
bulkInsertTransactions: jest.fn(),
getCacheMultiplier: jest.fn(),
getAgent: jest.fn(),
getMultiplier: jest.fn(),
getFiles: jest.fn(),
getMessages: jest.fn(),
getRoleByName: jest.fn(),
getUserMemories: jest.fn(),
getFormattedMemories: jest.fn(),
isAgentTriggerPrincipalActive: jest.fn().mockResolvedValue(true),
spendStructuredTokens: jest.fn(),
spendTokens: jest.fn(),
updateBalance: jest.fn(),
}));
// Mock getMCPManager
const mockFormatInstructions = jest.fn();
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
formatInstructionsForContext: mockFormatInstructions,
})),
}));
describe('AgentClient - applyHideSequentialOutputsFilter', () => {
const textPart = (text) => ({ type: ContentTypes.TEXT, text });
const toolCallPart = (id) => ({ type: ContentTypes.TOOL_CALL, tool_call: { id } });
it('keeps only the last non-label part + tool_call parts when filtering is on', () => {
const ctx = {
options: { agent: { hide_sequential_outputs: true } },
contentParts: [
textPart('intermediate'),
toolCallPart('tc1'),
textPart('reasoning'),
textPart('final'),
],
};
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
expect(ctx.contentParts).toEqual([toolCallPart('tc1'), textPart('final')]);
});
it('keeps the final text when a parent phase marker is appended after it', () => {
const tool = toolCallPart('tc1');
const final = textPart('final');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Completed the investigation',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
};
const ctx = {
options: { agent: { hide_sequential_outputs: true } },
contentParts: [textPart('intermediate'), tool, final, phase],
};
const previousParts = [...ctx.contentParts];
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(ctx.contentParts).toEqual([tool, final, phase]);
expect(phase.activity_start_index).toBe(0);
expect(phase.activity_end_index).toBe(1);
});
it('keeps an appended phase before the final text when all phase children are filtered', () => {
const final = textPart('final');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Completed both reasoning activities',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
};
const ctx = {
options: { agent: { hide_sequential_outputs: true } },
contentParts: [
{ type: ContentTypes.THINK, think: 'first' },
{ type: ContentTypes.THINK, think: 'second' },
final,
phase,
],
};
const previousParts = [...ctx.contentParts];
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(ctx.contentParts).toEqual([final, phase]);
expect(phase.activity_start_index).toBe(0);
expect(phase.activity_end_index).toBe(0);
});
it('is a no-op when hide_sequential_outputs is off', () => {
const parts = [textPart('a'), textPart('b')];
const ctx = { options: { agent: { hide_sequential_outputs: false } }, contentParts: parts };
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
expect(ctx.contentParts).toEqual([textPart('a'), textPart('b')]);
});
it('rebases phase bounds across skill prepends and sequential filtering', () => {
const reasoning = { type: ContentTypes.THINK, think: 'checking' };
const activityTool = toolCallPart('activity-tool');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Resolved the session issue',
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 2,
};
const final = textPart('final');
const previousParts = [reasoning, activityTool, phase, final];
const skillCard = toolCallPart('manual-skill');
const ctx = {
options: { agent: { hide_sequential_outputs: true } },
contentParts: [skillCard, ...previousParts],
};
AgentClient.prototype.applyHideSequentialOutputsFilter.call(ctx);
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(ctx.contentParts).toEqual([skillCard, activityTool, phase, final]);
expect(phase.activity_start_index).toBe(1);
expect(phase.activity_end_index).toBe(2);
});
it('rebases phase bounds over reshaped sparse content without retaining holes', () => {
const reasoning = { type: ContentTypes.THINK, think: 'planning' };
const toolCall = toolCallPart('tc-sparse');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Searched for tools',
activity_label_type: 'phase',
activity_start_index: 1,
};
const final = textPart('answer');
const contentParts = [];
contentParts[0] = reasoning;
contentParts[2] = toolCall;
contentParts[3] = phase;
contentParts[4] = final;
const previousParts = [...contentParts];
const ctx = { options: { agent: {} }, contentParts: [toolCall, phase, final] };
expect(() =>
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts),
).not.toThrow();
expect(phase.activity_start_index).toBe(0);
});
it('rebases explicit bounds using only defined sparse slots', () => {
const toolCall = toolCallPart('tc-large-sparse');
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Searched the sparse transcript',
activity_label_type: 'phase',
activity_start_index: 5,
activity_end_index: 999_999,
};
const previousParts = [];
previousParts[5] = toolCall;
previousParts[999_999] = phase;
const ctx = { options: { agent: {} }, contentParts: [toolCall, phase] };
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(phase.activity_start_index).toBe(0);
expect(phase.activity_end_index).toBe(1);
});
it('preserves a sparse phase reservation when completion does not reshape content', () => {
const firstTool = toolCallPart('tool-1');
const secondTool = toolCallPart('tool-2');
const firstLabel = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Recorded the first result',
tool_call_ids: ['tool-1'],
};
const secondLabel = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Recorded the second result',
tool_call_ids: ['tool-2'],
};
const phase = {
type: ContentTypes.ACTIVITY_LABEL,
activity_label: 'Verified both results',
activity_label_type: 'phase',
activity_start_index: 0,
};
const final = { type: ContentTypes.TEXT, text: 'Final answer', phase: 'final_answer' };
const contentParts = [];
contentParts[1] = { type: ContentTypes.TEXT, text: '', phase: 'final_answer' };
contentParts[2] = firstLabel;
contentParts[3] = secondTool;
contentParts[4] = secondLabel;
contentParts[5] = phase;
contentParts[6] = final;
const previousParts = [...contentParts];
const ctx = { options: { agent: {} }, contentParts };
AgentClient.prototype.rebaseActivityPhaseBounds.call(ctx, previousParts);
expect(phase.activity_start_index).toBe(0);
contentParts[0] = firstTool;
const phaseChildren = contentParts.slice(
phase.activity_start_index,
contentParts.indexOf(phase),
);
expect(phaseChildren.map((part) => part?.tool_call?.id).filter(Boolean)).toEqual([
'tool-1',
'tool-2',
]);
});
});
describe('AgentClient - activity phase completion', () => {
it('completes an uninterrupted root run', () => {
const complete = jest.fn();
AgentClient.prototype.completeActivityPhase.call(
{},
{ getInterrupt: () => undefined },
{ complete },
);
expect(complete).toHaveBeenCalledTimes(1);
});
it('retains phase state when the root run pauses for HITL', () => {
const complete = jest.fn();
AgentClient.prototype.completeActivityPhase.call(
{},
{ getInterrupt: () => ({ payload: { type: 'tool_approval' } }) },
{ complete },
);
expect(complete).not.toHaveBeenCalled();
});
});
describe('AgentClient - startup telemetry', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('overlaps run creation with checkpoint pruning and joins both before stream processing', async () => {
let releaseCheckpoint;
let checkpointStarted;
const runCreation = deferred();
const checkpointStartedPromise = new Promise((resolve) => {
checkpointStarted = resolve;
});
const checkpointPromise = new Promise((resolve) => {
releaseCheckpoint = resolve;
});
const processStream = jest.fn().mockResolvedValue();
const run = {
Graph: null,
processStream,
getCalibrationRatio: jest.fn(() => 0),
};
const startupTelemetry = {
mark: jest.fn(),
setStreamId: jest.fn(),
recordGenerationEvent: jest.fn(),
end: jest.fn(),
};
mockCreateRun.mockReturnValue(runCreation.promise);
mockIsHITLEnabled.mockReturnValue(true);
mockDeleteAgentCheckpoint.mockImplementation(() => {
checkpointStarted();
return checkpointPromise;
});
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: {},
config: { endpoints: { [EModelEndpoint.agents]: { toolApproval: {} } } },
_resumableStreamId: 'conversation-123',
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
hide_sequential_outputs: false,
},
endpointTokenConfig: {},
eventHandlers: {},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
startupTelemetry,
checkpointNamespace: '1000',
});
client.conversationId = 'conversation-123';
client.responseMessageId = 'response-123';
client.parentMessageId = 'parent-123';
client.recordCollectedUsage = jest.fn().mockResolvedValue();
const completionPromise = client.chatCompletion({ payload: [] });
await checkpointStartedPromise;
expect(mockCreateRun).toHaveBeenCalledTimes(1);
expect(mockCreateRun.mock.calls[0][0]).toEqual(
expect.objectContaining({
modelCallbacks: [
expect.objectContaining({
name: 'librechat-model-bound-content-filter',
raiseError: true,
}),
],
}),
);
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(
'conversation-123',
undefined,
undefined,
{ throwOnError: true, checkpointNamespace: '1000' },
);
expect(startupTelemetry.mark.mock.calls.map(([milestone]) => milestone)).toEqual([
'run_input_prepared',
]);
expect(processStream).not.toHaveBeenCalled();
runCreation.resolve(run);
await Promise.resolve();
expect(startupTelemetry.mark.mock.calls.map(([milestone]) => milestone)).toEqual([
'run_input_prepared',
'run_created',
]);
expect(processStream).not.toHaveBeenCalled();
releaseCheckpoint();
await completionPromise;
expect(startupTelemetry.mark.mock.calls.map(([milestone]) => milestone)).toEqual([
'run_input_prepared',
'run_created',
'stream_processing_started',
]);
expect(processStream).toHaveBeenCalledTimes(1);
expect(processStream.mock.calls[0][1]).toEqual(
expect.objectContaining({
configurable: expect.objectContaining({
thread_id: 'conversation-123',
checkpoint_ns: '',
__librechat_checkpoint_ns: '1000',
}),
}),
);
expect(processStream.mock.calls[0][1]).not.toHaveProperty('callbacks');
});
it('propagates final model callback policy errors instead of persisting a generic error part', async () => {
jest.clearAllMocks();
let policyError;
try {
require('@librechat/api').assertModelBoundContent({
filters: {
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-BLOCK' }],
},
},
},
storedMessages: [{ role: 'user', isCreatedByUser: true, text: 'PRIVATE-BLOCK' }],
});
} catch (error) {
policyError = error;
}
expect(policyError).toEqual(expect.objectContaining({ code: 'content_filter_block' }));
const processStream = jest.fn().mockRejectedValue(policyError);
mockCreateRun.mockResolvedValue({
Graph: null,
processStream,
getCalibrationRatio: jest.fn(() => 0),
});
mockIsHITLEnabled.mockReturnValue(false);
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: {},
config: { endpoints: { [EModelEndpoint.agents]: {} } },
_resumableStreamId: 'conversation-policy',
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
hide_sequential_outputs: false,
},
endpointTokenConfig: {},
eventHandlers: {},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
});
client.conversationId = 'conversation-policy';
client.responseMessageId = 'response-policy';
client.parentMessageId = 'parent-policy';
client.recordCollectedUsage = jest.fn().mockResolvedValue();
await expect(client.chatCompletion({ payload: [] })).rejects.toBe(policyError);
expect(client.contentParts).not.toEqual(
expect.arrayContaining([expect.objectContaining({ type: ContentTypes.ERROR })]),
);
});
it('does not expose or process a fresh graph when strict checkpoint pruning fails', async () => {
jest.clearAllMocks();
const checkpointGeneration = {
threadId: 'conversation-123',
checkpointIds: ['legacy-root', 'legacy-child'],
};
const processStream = jest.fn().mockResolvedValue();
const run = {
Graph: { id: 'must-not-be-exposed' },
processStream,
getCalibrationRatio: jest.fn(() => 0),
};
mockCreateRun.mockResolvedValue(run);
mockIsHITLEnabled.mockReturnValue(true);
mockCaptureAgentCheckpointGeneration.mockResolvedValue(checkpointGeneration);
mockDeleteAgentCheckpoint.mockRejectedValue(new Error('checkpoint prune failed'));
jest.spyOn(GenerationJobManager, 'getJobStore').mockReturnValue({
getJob: jest.fn().mockResolvedValue({ createdAt: 1000, status: 'running' }),
});
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: {},
config: { endpoints: { [EModelEndpoint.agents]: { toolApproval: {} } } },
_resumableStreamId: 'conversation-123',
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
hide_sequential_outputs: false,
},
endpointTokenConfig: {},
eventHandlers: {},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
startupTelemetry: {
mark: jest.fn(),
setStreamId: jest.fn(),
recordGenerationEvent: jest.fn(),
end: jest.fn(),
},
});
client.conversationId = 'conversation-123';
client.jobCreatedAt = 1000;
client.responseMessageId = 'response-123';
client.parentMessageId = 'parent-123';
client.recordCollectedUsage = jest.fn().mockResolvedValue();
await client.chatCompletion({ payload: [] });
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(
'conversation-123',
undefined,
checkpointGeneration,
{ throwOnError: true },
);
expect(processStream).not.toHaveBeenCalled();
expect(client.run).not.toBe(run);
expect(client.contentParts).toEqual(
expect.arrayContaining([
expect.objectContaining({
[ContentTypes.ERROR]: expect.stringContaining('checkpoint prune failed'),
}),
]),
);
});
it('does not let a stale v1 fresh prune delete a paused v2 replacement generation', async () => {
jest.clearAllMocks();
const checkpointGeneration = {
threadId: 'conversation-123',
checkpointIds: ['legacy-root', 'legacy-child'],
};
const processStream = jest.fn().mockResolvedValue();
const run = {
Graph: { id: 'stale-v1-graph' },
processStream,
getCalibrationRatio: jest.fn(() => 0),
};
mockCreateRun.mockResolvedValue(run);
mockIsHITLEnabled.mockReturnValue(true);
mockCaptureAgentCheckpointGeneration.mockResolvedValue(checkpointGeneration);
const getJob = jest.fn().mockResolvedValue({
createdAt: 2000,
status: 'requires_action',
checkpointNamespace: '2000',
});
jest.spyOn(GenerationJobManager, 'getJobStore').mockReturnValue({ getJob });
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: {},
config: { endpoints: { [EModelEndpoint.agents]: { toolApproval: {} } } },
_resumableStreamId: 'conversation-123',
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
hide_sequential_outputs: false,
},
endpointTokenConfig: {},
eventHandlers: {},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
});
client.conversationId = 'conversation-123';
client.jobCreatedAt = 1000;
client.responseMessageId = 'response-123';
client.parentMessageId = 'parent-123';
client.recordCollectedUsage = jest.fn().mockResolvedValue();
await client.chatCompletion({ payload: [] });
expect(mockCaptureAgentCheckpointGeneration).toHaveBeenCalledWith(
'conversation-123',
undefined,
{ throwOnError: true },
);
expect(getJob).toHaveBeenCalledTimes(1);
expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled();
expect(processStream).not.toHaveBeenCalled();
expect(client.run).not.toBe(run);
expect(client.contentParts).toEqual(
expect.arrayContaining([
expect.objectContaining({
[ContentTypes.ERROR]: expect.stringContaining(
'Generation replaced before legacy checkpoint cleanup',
),
}),
]),
);
});
});
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Mock run object
mockRun = {
generateTitle: jest.fn().mockResolvedValue({
title: 'Generated Title',
}),
};
// Mock agent - with both endpoint and provider
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI, // Use a valid provider as endpoint for getProviderConfig
provider: EModelEndpoint.openAI, // Add provider property
model_parameters: {
model: 'gpt-4',
},
};
// Mock request and response
mockReq = {
user: {
id: 'user-123',
},
body: {
model: 'gpt-4',
endpoint: EModelEndpoint.openAI,
key: null,
},
config: {
endpoints: {
[EModelEndpoint.openAI]: {
// Match the agent endpoint
titleModel: 'gpt-3.5-turbo',
titlePrompt: 'Custom title prompt',
titleMethod: 'structured',
titlePromptTemplate: 'Template: {{content}}',
},
},
},
};
mockRes = {};
// Mock options
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
endpointTokenConfig: {},
};
// Create client instance
client = new AgentClient(mockOptions);
client.run = mockRun;
client.responseMessageId = 'response-123';
client.conversationId = 'convo-123';
client.contentParts = [{ type: 'text', text: 'Test content' }];
client.recordCollectedUsage = jest.fn().mockResolvedValue(); // Mock as async function that resolves
});
describe('titleConvo method', () => {
it('should throw error if run is not initialized', async () => {
client.run = null;
await expect(
client.titleConvo({ text: 'Test', abortController: new AbortController() }),
).rejects.toThrow('Run not initialized');
});
it('waits for the run in immediate mode instead of throwing', async () => {
client.run = null;
const abortController = new AbortController();
const titlePromise = client.titleConvo({ text: 'Test', abortController, immediate: true });
// Simulate `chatCompletion` assigning the run (client.js: `this.run = run`).
client.run = mockRun;
client._resolveRun(mockRun);
await titlePromise;
expect(mockRun.generateTitle).toHaveBeenCalled();
});
it('passes empty contentParts in immediate mode (title from the user input only)', async () => {
client.contentParts = [{ type: 'text', text: 'Streaming response so far' }];
const abortController = new AbortController();
await client.titleConvo({ text: 'Hello there', abortController, immediate: true });
const call = mockRun.generateTitle.mock.calls[0][0];
expect(call.contentParts).toEqual([]);
expect(call.inputText).toBe('Hello there');
});
it('uses live contentParts in non-immediate (final) mode', async () => {
client.contentParts = [{ type: 'text', text: 'Full response' }];
const abortController = new AbortController();
await client.titleConvo({ text: 'Hello there', abortController });
const call = mockRun.generateTitle.mock.calls[0][0];
expect(call.contentParts).toEqual([{ type: 'text', text: 'Full response' }]);
});
it('rejects promptly when aborted before the run initializes in immediate mode', async () => {
client.run = null;
const abortController = new AbortController();
abortController.abort();
await expect(
client.titleConvo({ text: 'Test', abortController, immediate: true }),
).rejects.toThrow('Aborted before run initialization');
expect(mockRun.generateTitle).not.toHaveBeenCalled();
});
it('should use titlePrompt from endpoint config', async () => {
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
titlePrompt: 'Custom title prompt',
}),
);
});
it('should use titlePromptTemplate from endpoint config', async () => {
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
titlePromptTemplate: 'Template: {{content}}',
}),
);
});
it('should use titleMethod from endpoint config', async () => {
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
provider: Providers.OPENAI,
titleMethod: 'structured',
}),
);
});
it('should use titleModel from endpoint config when provided', async () => {
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Check that generateTitle was called with correct clientOptions
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
expect(generateTitleCall.clientOptions.model).toBe('gpt-3.5-turbo');
});
it('preserves Anthropic custom headers on title requests despite omitTitleOptions', async () => {
const prevKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'sk-ant-test';
try {
const req = {
user: { id: 'user-123' },
body: { model: 'claude-sonnet-4-5', endpoint: EModelEndpoint.anthropic, key: null },
config: {
endpoints: {
[EModelEndpoint.anthropic]: {
headers: { 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}' },
},
},
},
};
const agent = {
id: 'agent-anthropic',
endpoint: EModelEndpoint.anthropic,
provider: EModelEndpoint.anthropic,
model_parameters: { model: 'claude-sonnet-4-5' },
};
const anthropicClient = new AgentClient({ req, res: {}, agent, endpointTokenConfig: {} });
anthropicClient.run = mockRun;
anthropicClient.responseMessageId = 'response-123';
anthropicClient.conversationId = 'convo-123';
anthropicClient.contentParts = [{ type: 'text', text: 'Test content' }];
anthropicClient.recordCollectedUsage = jest.fn().mockResolvedValue();
await anthropicClient.titleConvo({ text: 'Hello', abortController: new AbortController() });
const defaultHeaders =
mockRun.generateTitle.mock.calls[0][0].clientOptions?.clientOptions?.defaultHeaders;
// Custom header survives the `omitTitleOptions` strip and resolves the conversationId
expect(defaultHeaders?.['X-Conversation-Id']).toBe('convo-123');
// Provider-managed beta header is preserved alongside it
expect(defaultHeaders?.['anthropic-beta']).toBeDefined();
} finally {
if (prevKey === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = prevKey;
}
}
});
it('should handle missing endpoint config gracefully', async () => {
// Remove endpoint config
mockReq.config = { endpoints: {} };
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
titlePrompt: undefined,
titlePromptTemplate: undefined,
titleMethod: undefined,
}),
);
});
it('should use agent model when titleModel is not provided', async () => {
// Remove titleModel from config
mockReq.config = {
endpoints: {
[EModelEndpoint.openAI]: {
titlePrompt: 'Custom title prompt',
titleMethod: 'structured',
titlePromptTemplate: 'Template: {{content}}',
// titleModel is omitted
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
});
it('should not use titleModel when it equals CURRENT_MODEL constant', async () => {
mockReq.config = {
endpoints: {
[EModelEndpoint.openAI]: {
titleModel: Constants.CURRENT_MODEL,
titlePrompt: 'Custom title prompt',
titleMethod: 'structured',
titlePromptTemplate: 'Template: {{content}}',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
});
it('should pass all required parameters to generateTitle', async () => {
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
expect(mockRun.generateTitle).toHaveBeenCalledWith({
provider: expect.any(String),
inputText: text,
contentParts: client.contentParts,
clientOptions: expect.objectContaining({
model: 'gpt-3.5-turbo',
}),
titlePrompt: 'Custom title prompt',
titlePromptTemplate: 'Template: {{content}}',
titleMethod: 'structured',
chainOptions: expect.objectContaining({
signal: abortController.signal,
}),
});
});
it('should record collected usage after title generation', async () => {
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
expect(client.recordCollectedUsage).toHaveBeenCalledWith({
model: 'gpt-3.5-turbo',
context: 'title',
collectedUsage: expect.any(Array),
balance: {
enabled: false,
},
transactions: {
enabled: true,
},
messageId: 'response-123',
});
});
it('should return the generated title', async () => {
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
expect(result).toBe('Generated Title');
});
it('should sanitize the generated title by removing think blocks', async () => {
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
mockRun.generateTitle.mockResolvedValue({
title: titleWithThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should remove the <think> block and return only the clean title
expect(result).toBe('User Hi Greeting');
expect(result).not.toContain('<think>');
expect(result).not.toContain('</think>');
});
it('should return fallback title when sanitization results in empty string', async () => {
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
mockRun.generateTitle.mockResolvedValue({
title: titleOnlyThinkBlock,
});
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return the fallback title since sanitization would result in empty string
expect(result).toBe('Untitled Conversation');
});
it('does not log provider error content when title generation fails', async () => {
const { logger } = require('@librechat/data-schemas');
const privateValue = 'PRIVATE-TITLE-PROMPT';
const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
mockRun.generateTitle.mockRejectedValue(
Object.assign(new Error(`Provider echoed ${privateValue}`), {
code: 'ERR_REMOTE',
response: { status: 422, data: { prompt: privateValue } },
}),
);
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
expect(result).toBeUndefined();
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(privateValue);
expect(errorSpy).toHaveBeenCalledWith(
'[api/server/controllers/agents/client.js #titleConvo] Error',
expect.objectContaining({ type: 'Error' }),
);
errorSpy.mockRestore();
});
it('should skip title generation when titleConvo is set to false', async () => {
// Set titleConvo to false in endpoint config
mockReq.config = {
endpoints: {
[EModelEndpoint.openAI]: {
titleConvo: false,
titleModel: 'gpt-3.5-turbo',
titlePrompt: 'Custom title prompt',
titleMethod: 'structured',
titlePromptTemplate: 'Template: {{content}}',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip title generation for temporary chats', async () => {
// Set isTemporary to true
mockReq.body.isTemporary = true;
const text = 'Test temporary chat';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip title generation when titleConvo is false in all config', async () => {
// Set titleConvo to false in "all" config
mockReq.config = {
endpoints: {
all: {
titleConvo: false,
titleModel: 'gpt-4o-mini',
titlePrompt: 'All config title prompt',
titleMethod: 'completion',
titlePromptTemplate: 'All config template',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should skip title generation when titleConvo is false for custom endpoint scenario', async () => {
// This test validates the behavior when customEndpointConfig (retrieved via
// getProviderConfig for custom endpoints) has titleConvo: false.
//
// The code path is:
// 1. endpoints?.all is checked (undefined in this test)
// 2. endpoints?.[endpoint] is checked (our test config)
// 3. Would fall back to titleProviderConfig.customEndpointConfig (for real custom endpoints)
//
// We simulate a custom endpoint scenario using a dynamically named endpoint config
// Create a unique endpoint name that represents a custom endpoint
const customEndpointName = 'customEndpoint';
// Configure the endpoint to have titleConvo: false
// This simulates what would be in customEndpointConfig for a real custom endpoint
mockReq.config = {
endpoints: {
// No 'all' config - so it will check endpoints[endpoint]
// This config represents what customEndpointConfig would contain
[customEndpointName]: {
titleConvo: false,
titleModel: 'custom-model-v1',
titlePrompt: 'Custom endpoint title prompt',
titleMethod: 'completion',
titlePromptTemplate: 'Custom template: {{content}}',
baseURL: 'https://api.custom-llm.com/v1',
apiKey: 'test-custom-key',
// Additional custom endpoint properties
models: {
default: ['custom-model-v1', 'custom-model-v2'],
},
},
},
};
// Set up agent to use our custom endpoint
// Use openAI as base but override with custom endpoint name for this test
mockAgent.endpoint = EModelEndpoint.openAI;
mockAgent.provider = EModelEndpoint.openAI;
// Override the endpoint in the config to point to our custom config
mockReq.config.endpoints[EModelEndpoint.openAI] =
mockReq.config.endpoints[customEndpointName];
delete mockReq.config.endpoints[customEndpointName];
const text = 'Test custom endpoint conversation';
const abortController = new AbortController();
const result = await client.titleConvo({ text, abortController });
// Should return undefined without generating title because titleConvo is false
expect(result).toBeUndefined();
// generateTitle should NOT have been called
expect(mockRun.generateTitle).not.toHaveBeenCalled();
// recordCollectedUsage should NOT have been called
expect(client.recordCollectedUsage).not.toHaveBeenCalled();
});
it('should pass titleEndpoint configuration to generateTitle', async () => {
// Mock the API key just for this test
const originalApiKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'test-api-key';
// Add titleEndpoint to the config
mockReq.config = {
endpoints: {
[EModelEndpoint.openAI]: {
titleModel: 'gpt-3.5-turbo',
titleEndpoint: EModelEndpoint.anthropic,
titleMethod: 'structured',
titlePrompt: 'Custom title prompt',
titlePromptTemplate: 'Custom template',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify generateTitle was called with the custom configuration
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
titleMethod: 'structured',
provider: Providers.ANTHROPIC,
titlePrompt: 'Custom title prompt',
titlePromptTemplate: 'Custom template',
}),
);
// Restore the original API key
if (originalApiKey) {
process.env.ANTHROPIC_API_KEY = originalApiKey;
} else {
delete process.env.ANTHROPIC_API_KEY;
}
});
it('should use all config when endpoint config is missing', async () => {
// Set 'all' config without endpoint-specific config
mockReq.config = {
endpoints: {
all: {
titleModel: 'gpt-4o-mini',
titlePrompt: 'All config title prompt',
titleMethod: 'completion',
titlePromptTemplate: 'All config template: {{content}}',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify generateTitle was called with 'all' config values
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
titleMethod: 'completion',
titlePrompt: 'All config title prompt',
titlePromptTemplate: 'All config template: {{content}}',
}),
);
// Check that the model was set from 'all' config
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
});
it('should prioritize all config over endpoint config for title settings', async () => {
// Set both endpoint and 'all' config
mockReq.config = {
endpoints: {
[EModelEndpoint.openAI]: {
titleModel: 'gpt-3.5-turbo',
titlePrompt: 'Endpoint title prompt',
titleMethod: 'structured',
// titlePromptTemplate is omitted to test fallback
},
all: {
titleModel: 'gpt-4o-mini',
titlePrompt: 'All config title prompt',
titleMethod: 'completion',
titlePromptTemplate: 'All config template',
},
},
};
const text = 'Test conversation text';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify 'all' config takes precedence over endpoint config
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
titleMethod: 'completion',
titlePrompt: 'All config title prompt',
titlePromptTemplate: 'All config template',
}),
);
// Check that the model was set from 'all' config
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
});
it('should use all config with titleEndpoint and verify provider switch', async () => {
// Mock the API key for the titleEndpoint provider
const originalApiKey = process.env.ANTHROPIC_API_KEY;
process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
// Set comprehensive 'all' config with all new title options
mockReq.config = {
endpoints: {
all: {
titleConvo: true,
titleModel: 'claude-3-haiku-20240307',
titleMethod: 'completion', // Testing the new default method
titlePrompt: 'Generate a concise, descriptive title for this conversation',
titlePromptTemplate: 'Conversation summary: {{content}}',
titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic
},
},
};
const text = 'Test conversation about AI and machine learning';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify all config values were used
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
provider: Providers.ANTHROPIC, // Critical: Verify provider switched to Anthropic
titleMethod: 'completion',
titlePrompt: 'Generate a concise, descriptive title for this conversation',
titlePromptTemplate: 'Conversation summary: {{content}}',
inputText: text,
contentParts: client.contentParts,
}),
);
// Verify the model was set from 'all' config
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
expect(generateTitleCall.clientOptions.model).toBe('claude-3-haiku-20240307');
// Verify other client options are set correctly
expect(generateTitleCall.clientOptions).toMatchObject({
model: 'claude-3-haiku-20240307',
// Note: Anthropic's getOptions may set its own maxTokens value
});
// Restore the original API key
if (originalApiKey) {
process.env.ANTHROPIC_API_KEY = originalApiKey;
} else {
delete process.env.ANTHROPIC_API_KEY;
}
});
it('should test all titleMethod options from all config', async () => {
// Test each titleMethod: 'completion', 'functions', 'structured'
const titleMethods = ['completion', 'functions', 'structured'];
for (const method of titleMethods) {
// Clear previous calls
mockRun.generateTitle.mockClear();
// Set 'all' config with specific titleMethod
mockReq.config = {
endpoints: {
all: {
titleModel: 'gpt-4o-mini',
titleMethod: method,
titlePrompt: `Testing ${method} method`,
titlePromptTemplate: `Template for ${method}: {{content}}`,
},
},
};
const text = `Test conversation for ${method} method`;
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify the correct titleMethod was used
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
titleMethod: method,
titlePrompt: `Testing ${method} method`,
titlePromptTemplate: `Template for ${method}: {{content}}`,
}),
);
}
});
describe('Azure-specific title generation', () => {
let originalEnv;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Save original environment variables
originalEnv = { ...process.env };
// Mock Azure API keys
process.env.AZURE_OPENAI_API_KEY = 'test-azure-key';
process.env.AZURE_API_KEY = 'test-azure-key';
process.env.EASTUS_API_KEY = 'test-eastus-key';
process.env.EASTUS2_API_KEY = 'test-eastus2-key';
});
afterEach(() => {
// Restore environment variables
process.env = originalEnv;
});
it('should use OPENAI provider for Azure serverless endpoints', async () => {
// Set up Azure endpoint with serverless config
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI;
mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true,
titleModel: 'grok-3',
titleMethod: 'completion',
titlePrompt: 'Azure serverless title prompt',
streamRate: 35,
modelGroupMap: {
'grok-3': {
group: 'Azure AI Foundry',
deploymentName: 'grok-3',
},
},
groupMap: {
'Azure AI Foundry': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://test.services.ai.azure.com/models',
version: '2024-05-01-preview',
serverless: true,
models: {
'grok-3': {
deploymentName: 'grok-3',
},
},
},
},
},
},
};
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'grok-3';
const text = 'Test Azure serverless conversation';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify provider was switched to OPENAI for serverless
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
provider: Providers.OPENAI, // Should be OPENAI for serverless
titleMethod: 'completion',
titlePrompt: 'Azure serverless title prompt',
}),
);
});
it('should use AZURE provider for Azure endpoints with instanceName', async () => {
// Set up Azure endpoint
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI;
mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true,
titleModel: 'gpt-4o',
titleMethod: 'structured',
titlePrompt: 'Azure instance title prompt',
streamRate: 35,
modelGroupMap: {
'gpt-4o': {
group: 'eastus',
deploymentName: 'gpt-4o',
},
},
groupMap: {
eastus: {
apiKey: '${EASTUS_API_KEY}',
instanceName: 'region-instance',
version: '2024-02-15-preview',
models: {
'gpt-4o': {
deploymentName: 'gpt-4o',
},
},
},
},
},
},
};
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4o';
const text = 'Test Azure instance conversation';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify provider remains AZURE with instanceName
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
provider: Providers.AZURE,
titleMethod: 'structured',
titlePrompt: 'Azure instance title prompt',
}),
);
});
it('should handle Azure titleModel with CURRENT_MODEL constant', async () => {
// Set up Azure endpoint
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI;
mockAgent.model_parameters.model = 'gpt-4o-latest';
mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true,
titleModel: Constants.CURRENT_MODEL,
titleMethod: 'functions',
streamRate: 35,
modelGroupMap: {
'gpt-4o-latest': {
group: 'region-eastus',
deploymentName: 'gpt-4o-mini',
version: '2024-02-15-preview',
},
},
groupMap: {
'region-eastus': {
apiKey: '${EASTUS2_API_KEY}',
instanceName: 'test-instance',
version: '2024-12-01-preview',
models: {
'gpt-4o-latest': {
deploymentName: 'gpt-4o-mini',
version: '2024-02-15-preview',
},
},
},
},
},
},
};
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4o-latest';
const text = 'Test Azure current model';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify it uses the correct model when titleModel is CURRENT_MODEL
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
// When CURRENT_MODEL is used with Azure, the model gets mapped to the deployment name
// In this case, 'gpt-4o-latest' is mapped to 'gpt-4o-mini' deployment
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
// Also verify that CURRENT_MODEL constant was not passed as the model
expect(generateTitleCall.clientOptions.model).not.toBe(Constants.CURRENT_MODEL);
});
it('should handle Azure with multiple model groups', async () => {
// Set up Azure endpoint
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI;
mockReq.config = {
endpoints: {
[EModelEndpoint.azureOpenAI]: {
titleConvo: true,
titleModel: 'o1-mini',
titleMethod: 'completion',
streamRate: 35,
modelGroupMap: {
'gpt-4o': {
group: 'eastus',
deploymentName: 'gpt-4o',
},
'o1-mini': {
group: 'region-eastus',
deploymentName: 'o1-mini',
},
'codex-mini': {
group: 'codex-mini',
deploymentName: 'codex-mini',
},
},
groupMap: {
eastus: {
apiKey: '${EASTUS_API_KEY}',
instanceName: 'region-eastus',
version: '2024-02-15-preview',
models: {
'gpt-4o': {
deploymentName: 'gpt-4o',
},
},
},
'region-eastus': {
apiKey: '${EASTUS2_API_KEY}',
instanceName: 'region-eastus2',
version: '2024-12-01-preview',
models: {
'o1-mini': {
deploymentName: 'o1-mini',
},
},
},
'codex-mini': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://example.cognitiveservices.azure.com/openai/',
version: '2025-04-01-preview',
serverless: true,
models: {
'codex-mini': {
deploymentName: 'codex-mini',
},
},
},
},
},
},
};
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'o1-mini';
const text = 'Test Azure multi-group conversation';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify correct model and provider are used
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
provider: Providers.AZURE,
titleMethod: 'completion',
}),
);
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
expect(generateTitleCall.clientOptions.model).toBe('o1-mini');
expect(generateTitleCall.clientOptions.maxTokens).toBeUndefined(); // o1 models shouldn't have maxTokens
});
it('should use all config as fallback for Azure endpoints', async () => {
// Set up Azure endpoint with minimal config
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
mockAgent.provider = EModelEndpoint.azureOpenAI;
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
mockReq.body.model = 'gpt-4';
// Set 'all' config as fallback with a serverless Azure config
mockReq.config = {
endpoints: {
all: {
titleConvo: true,
titleModel: 'gpt-4',
titleMethod: 'structured',
titlePrompt: 'Fallback title prompt from all config',
titlePromptTemplate: 'Template: {{content}}',
modelGroupMap: {
'gpt-4': {
group: 'default-group',
deploymentName: 'gpt-4',
},
},
groupMap: {
'default-group': {
apiKey: '${AZURE_API_KEY}',
baseURL: 'https://default.openai.azure.com/',
version: '2024-02-15-preview',
serverless: true,
models: {
'gpt-4': {
deploymentName: 'gpt-4',
},
},
},
},
},
},
};
const text = 'Test Azure with all config fallback';
const abortController = new AbortController();
await client.titleConvo({ text, abortController });
// Verify all config is used
expect(mockRun.generateTitle).toHaveBeenCalledWith(
expect.objectContaining({
provider: Providers.OPENAI, // Should be OPENAI when no instanceName
titleMethod: 'structured',
titlePrompt: 'Fallback title prompt from all config',
titlePromptTemplate: 'Template: {{content}}',
}),
);
});
});
});
describe('getOptions method - GPT-5+ model handling', () => {
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-5',
},
};
mockReq = {
app: {
locals: {},
},
user: {
id: 'user-123',
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
client = new AgentClient(mockOptions);
});
it('should move maxTokens to modelKwargs.max_completion_tokens for GPT-5 models', () => {
const clientOptions = {
model: 'gpt-5',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic that handles GPT-5+ models
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toBeDefined();
expect(clientOptions.modelKwargs.max_completion_tokens).toBe(2048);
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
});
it('should move maxTokens to modelKwargs.max_output_tokens for GPT-5 models with useResponsesApi', () => {
const clientOptions = {
model: 'gpt-5',
maxTokens: 2048,
temperature: 0.7,
useResponsesApi: true,
};
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toBeDefined();
expect(clientOptions.modelKwargs.max_output_tokens).toBe(2048);
expect(clientOptions.temperature).toBe(0.7); // Other options should remain
});
it('should handle GPT-5+ models with existing modelKwargs', () => {
const clientOptions = {
model: 'gpt-6',
maxTokens: 1500,
temperature: 0.8,
modelKwargs: {
customParam: 'value',
},
};
// Simulate the getOptions logic
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs).toEqual({
customParam: 'value',
max_completion_tokens: 1500,
});
});
it('should not modify maxTokens for non-GPT-5+ models', () => {
const clientOptions = {
model: 'gpt-4',
maxTokens: 2048,
temperature: 0.7,
};
// Simulate the getOptions logic
if (/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) && clientOptions.maxTokens != null) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
// Should not be modified since it's GPT-4
expect(clientOptions.maxTokens).toBe(2048);
expect(clientOptions.modelKwargs).toBeUndefined();
});
it('should handle various GPT-5+ model formats', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-8', shouldTransform: true },
{ model: 'gpt-9-mini', shouldTransform: true },
{ model: 'gpt-4', shouldTransform: false },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
{ model: 'claude-3', shouldTransform: false },
];
testCases.forEach(({ model, shouldTransform }) => {
const clientOptions = {
model,
maxTokens: 1000,
};
// Simulate the getOptions logic
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (shouldTransform) {
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(1000);
} else {
expect(clientOptions.maxTokens).toBe(1000);
expect(clientOptions.modelKwargs).toBeUndefined();
}
});
});
it('should not swap max token param for older models when using useResponsesApi', () => {
const testCases = [
{ model: 'gpt-5.1', shouldTransform: true },
{ model: 'gpt-5.1-chat-latest', shouldTransform: true },
{ model: 'gpt-5.1-codex', shouldTransform: true },
{ model: 'gpt-5', shouldTransform: true },
{ model: 'gpt-5-turbo', shouldTransform: true },
{ model: 'gpt-6', shouldTransform: true },
{ model: 'gpt-7-preview', shouldTransform: true },
{ model: 'gpt-8', shouldTransform: true },
{ model: 'gpt-9-mini', shouldTransform: true },
{ model: 'gpt-4', shouldTransform: false },
{ model: 'gpt-4o', shouldTransform: false },
{ model: 'gpt-3.5-turbo', shouldTransform: false },
{ model: 'claude-3', shouldTransform: false },
];
testCases.forEach(({ model, shouldTransform }) => {
const clientOptions = {
model,
maxTokens: 1000,
useResponsesApi: true,
};
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
const paramName =
clientOptions.useResponsesApi === true ? 'max_output_tokens' : 'max_completion_tokens';
clientOptions.modelKwargs[paramName] = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (shouldTransform) {
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_output_tokens).toBe(1000);
} else {
expect(clientOptions.maxTokens).toBe(1000);
expect(clientOptions.modelKwargs).toBeUndefined();
}
});
});
it('should not transform if maxTokens is null or undefined', () => {
const testCases = [
{ model: 'gpt-5', maxTokens: null },
{ model: 'gpt-5', maxTokens: undefined },
{ model: 'gpt-6', maxTokens: 0 }, // Should transform even if 0
];
testCases.forEach(({ model, maxTokens }, index) => {
const clientOptions = {
model,
maxTokens,
temperature: 0.7,
};
// Simulate the getOptions logic
if (
/\bgpt-[5-9](?:\.\d+)?\b/i.test(clientOptions.model) &&
clientOptions.maxTokens != null
) {
clientOptions.modelKwargs = clientOptions.modelKwargs ?? {};
clientOptions.modelKwargs.max_completion_tokens = clientOptions.maxTokens;
delete clientOptions.maxTokens;
}
if (index < 2) {
// null or undefined cases
expect(clientOptions.maxTokens).toBe(maxTokens);
expect(clientOptions.modelKwargs).toBeUndefined();
} else {
// 0 case - should transform
expect(clientOptions.maxTokens).toBeUndefined();
expect(clientOptions.modelKwargs?.max_completion_tokens).toBe(0);
}
});
});
});
describe('buildMessages with MCP server instructions', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock to default behavior
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nTest MCP instructions here',
);
const { DynamicStructuredTool } = require('@librechat/agents/langchain/tools');
// Create mock MCP tools with the delimiter pattern
const mockMCPTool1 = new DynamicStructuredTool({
name: `tool1${Constants.mcp_delimiter}server1`,
description: 'Test MCP tool 1',
schema: {},
func: async () => 'result',
});
const mockMCPTool2 = new DynamicStructuredTool({
name: `tool2${Constants.mcp_delimiter}server2`,
description: 'Test MCP tool 2',
schema: {},
func: async () => 'result',
});
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Base agent instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [mockMCPTool1, mockMCPTool2],
};
mockReq = {
user: {
id: 'user-123',
},
body: {
endpoint: EModelEndpoint.openAI,
},
config: {},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
endpoint: EModelEndpoint.agents,
};
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.shouldSummarize = false;
client.maxContextTokens = 4096;
});
it('loads RAG, memory, attachment, and MCP context without serial waits', async () => {
const ragContext = deferred();
const memoryContext = deferred();
const mcpConfig = deferred();
client.contextHandlers = {
createContext: jest.fn(() => ragContext.promise),
};
client.useMemory = jest.fn(() => memoryContext.promise);
resolveConfigServers.mockReturnValueOnce(mcpConfig.promise);
const buildPromise = client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Load all context.',
isCreatedByUser: true,
},
],
null,
{},
);
expect(client.contextHandlers.createContext).toHaveBeenCalledTimes(1);
expect(client.useMemory).toHaveBeenCalledTimes(1);
expect(resolveConfigServers).toHaveBeenCalledWith(mockReq);
ragContext.resolve('Retrieved context');
memoryContext.resolve(undefined);
mcpConfig.resolve({});
await buildPromise;
expect(client.augmentedPrompt).toBe('Retrieved context');
expect(client.options.agent.additional_instructions).toContain('Retrieved context');
});
it('starts independent context and current-file work at their earliest dependency barriers', async () => {
const requestAttachments = deferred();
const memoryContext = deferred();
const mcpConfig = deferred();
const agentScopedContext = deferred();
const fileContext = deferred();
const providerAttachments = deferred();
const requestFile = {
file_id: 'request-file',
filename: 'request.txt',
source: 'text',
type: 'text/plain',
};
client.options.attachments = requestAttachments.promise;
client.useMemory = jest.fn(() => memoryContext.promise);
resolveConfigServers.mockReturnValueOnce(mcpConfig.promise);
mockBuildAgentScopedContext.mockReturnValueOnce(agentScopedContext.promise);
client.addFileContextToMessage = jest.fn(() => fileContext.promise);
client.processAttachments = jest.fn(() => providerAttachments.promise);
const buildPromise = client.buildMessages(
[
{
messageId: 'msg-early-context',
parentMessageId: null,
sender: 'User',
text: 'Load the request file.',
isCreatedByUser: true,
},
],
'msg-early-context',
{},
);
expect(client.useMemory).toHaveBeenCalledTimes(1);
expect(resolveConfigServers).toHaveBeenCalledWith(mockReq);
expect(mockBuildAgentScopedContext).not.toHaveBeenCalled();
expect(client.addFileContextToMessage).not.toHaveBeenCalled();
expect(client.processAttachments).not.toHaveBeenCalled();
requestAttachments.resolve([requestFile]);
await Promise.resolve();
expect(mockBuildAgentScopedContext).toHaveBeenCalledTimes(1);
const scopedContextArgs = mockBuildAgentScopedContext.mock.calls[0][0];
expect([...scopedContextArgs.sharedRunAttachmentIds]).toEqual(['request-file']);
expect(client.addFileContextToMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageId: 'msg-early-context' }),
[requestFile],
);
expect(client.processAttachments).toHaveBeenCalledWith(
expect.objectContaining({ messageId: 'msg-early-context' }),
[requestFile],
);
providerAttachments.resolve([requestFile]);
await Promise.resolve();
expect(client.options.attachments).toBe(requestAttachments.promise);
fileContext.resolve();
memoryContext.resolve(undefined);
mcpConfig.resolve({});
agentScopedContext.resolve(new Map());
await buildPromise;
expect(client.options.attachments).toEqual([requestFile]);
});
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# MCP Server Instructions\n\nUse these tools carefully',
);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with correct server names
expect(mockFormatInstructions).toHaveBeenCalledWith(['server1', 'server2'], {});
// Verify the instructions do NOT contain [object Promise]
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify the instructions DO contain the MCP instructions
expect(client.options.agent.instructions).toContain('# MCP Server Instructions');
expect(client.options.agent.instructions).toContain('Use these tools carefully');
// Verify the base instructions are also included (from agent config, not buildOptions)
expect(client.options.agent.instructions).toContain('Base agent instructions');
});
it('blocks fetched MCP instructions before they become model-bound', async () => {
const privateInstruction = 'PRIVATE-MCP-INSTRUCTION';
mockReq.config.filters = {
agentInstructions: {
pii: {
fields: ['instructions'],
starterPatterns: [],
customPatterns: [
{
id: 'private-mcp-instruction',
label: 'private MCP instruction',
regex: privateInstruction,
},
],
},
},
};
mockFormatInstructions.mockResolvedValue(privateInstruction);
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
null,
{},
),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'agent_instruction', field: 'instructions' },
});
});
it('should handle MCP instructions with ephemeral agent', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(
'# Ephemeral MCP Instructions\n\nSpecial ephemeral instructions',
);
// Set up ephemeral agent with MCP servers
mockReq.body.ephemeralAgent = {
mcp: ['ephemeral-server1', 'ephemeral-server2'],
};
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Test ephemeral',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Ephemeral instructions',
additional_instructions: null,
});
// Verify formatInstructionsForContext was called with ephemeral server names
expect(mockFormatInstructions).toHaveBeenCalledWith(
['ephemeral-server1', 'ephemeral-server2'],
{},
);
// Verify no [object Promise] in instructions
expect(client.options.agent.instructions).not.toContain('[object Promise]');
// Verify ephemeral MCP instructions are included
expect(client.options.agent.instructions).toContain('# Ephemeral MCP Instructions');
expect(client.options.agent.instructions).toContain('Special ephemeral instructions');
});
it('should handle empty MCP instructions gracefully', async () => {
// Set empty return value for this test
mockFormatInstructions.mockResolvedValue('');
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions only',
additional_instructions: null,
});
// Verify the instructions still work without MCP content (from agent config, not buildOptions)
expect(client.options.agent.instructions).toBe('Base agent instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
it('should handle MCP instructions error gracefully', async () => {
// Set error return for this test
mockFormatInstructions.mockRejectedValue(new Error('MCP error'));
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
// Should not throw
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
// Should still have base instructions without MCP content (from agent config, not buildOptions)
expect(client.options.agent.instructions).toContain('Base agent instructions');
expect(client.options.agent.instructions).not.toContain('[object Promise]');
});
});
describe('buildMessages with request and agent-scoped context attachments', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
const makeTextFile = (file_id, filename, text) => ({
user: 'user-123',
file_id,
filename,
filepath: `/uploads/${filename}`,
object: 'file',
type: 'text/plain',
bytes: text.length,
embedded: false,
usage: 0,
source: 'text',
text,
});
const makeUploadedFile = (file_id, filename, type) => ({
user: 'user-123',
file_id,
filename,
filepath: `/uploads/${filename}`,
object: 'file',
type,
bytes: 128,
embedded: false,
usage: 0,
source: 'local',
});
beforeEach(() => {
jest.clearAllMocks();
mockFormatInstructions.mockResolvedValue('');
require('@librechat/api').countFormattedMessageTokens.mockImplementation(() => 42);
require('~/models').getFiles.mockReset().mockResolvedValue([]);
require('~/models').getUserMemories.mockReset().mockResolvedValue([]);
mockAgent = {
id: 'primary-agent',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Primary instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [],
};
mockReq = {
user: {
id: 'user-123',
personalization: {
memories: true,
},
},
body: {
endpoint: EModelEndpoint.openAI,
fileTokenLimit: 1000,
},
config: {
memory: {
disabled: true,
},
},
};
mockRes = {};
client = new AgentClient({
req: mockReq,
res: mockRes,
agent: mockAgent,
endpoint: EModelEndpoint.agents,
});
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.shouldSummarize = false;
client.maxContextTokens = 4096;
client.useMemory = jest.fn().mockResolvedValue(undefined);
});
it.each(['primary', 'handoff'])(
'blocks %s agent dynamic tool context before it becomes model-bound',
async (agentScope) => {
const privateContext = 'PRIVATE-DYNAMIC-TOOL-CONTEXT';
mockReq.config.filters = {
files: {
pii: {
fields: ['content'],
starterPatterns: [],
customPatterns: [
{
id: 'private-dynamic-context',
label: 'private dynamic context',
regex: privateContext,
},
],
},
},
};
const dynamicToolContextMap = {
empty: '',
dynamic: privateContext,
ignored: 123,
};
if (agentScope === 'primary') {
client.options.agent.dynamicToolContextMap = dynamicToolContextMap;
} else {
client.agentConfigs = new Map([
[
'handoff-agent',
{
...mockAgent,
id: 'handoff-agent',
dynamicToolContextMap,
},
],
]);
}
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'file', field: 'content' },
});
},
);
it('blocks a late-loaded tool definition on a nested pure subagent', async () => {
const privateDescription = 'PRIVATE-NESTED-TOOL-DEFINITION';
mockReq.config.filters = {
agentInstructions: {
pii: {
fields: ['description'],
starterPatterns: [],
customPatterns: [
{
id: 'private-nested-tool-definition',
label: 'private nested tool definition',
regex: privateDescription,
},
],
},
},
};
const nestedPureSubagent = {
...mockAgent,
id: 'nested-pure-subagent',
toolDefinitions: [
{
name: 'nested_lookup',
description: privateDescription,
parameters: { type: 'object' },
},
],
};
client.options.agent.subagentAgentConfigs = [
{
...mockAgent,
id: 'pure-subagent',
subagentAgentConfigs: [nestedPureSubagent],
},
];
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'agent_instruction', field: 'description' },
});
});
it('blocks dynamic tool context on a nested pure subagent', async () => {
const privateContext = 'PRIVATE-NESTED-DYNAMIC-CONTEXT';
mockReq.config.filters = {
files: {
pii: {
fields: ['content'],
starterPatterns: [],
customPatterns: [
{
id: 'private-nested-dynamic-context',
label: 'private nested dynamic context',
regex: privateContext,
},
],
},
},
};
client.options.agent.subagentAgentConfigs = [
{
...mockAgent,
id: 'pure-subagent',
subagentAgentConfigs: [
{
...mockAgent,
id: 'nested-pure-subagent',
dynamicToolContextMap: { nested_lookup: privateContext },
},
],
},
];
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'file', field: 'content' },
});
});
it('blocks hydrated file context on a nested pure subagent', async () => {
const privateFileText = 'PRIVATE-NESTED-HYDRATED-FILE';
mockReq.config.filters = {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
customPatterns: [
{
id: 'private-nested-hydrated-file',
label: 'private nested hydrated file',
regex: privateFileText,
},
],
},
},
};
client.options.agent.subagentAgentConfigs = [
{
...mockAgent,
id: 'pure-subagent',
subagentAgentConfigs: [
{
...mockAgent,
id: 'nested-pure-subagent',
agentContextAttachments: [makeTextFile('nested-file', 'nested.txt', privateFileText)],
},
],
},
];
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'file', field: 'extracted_text' },
});
});
it('blocks loaded memory from a nested pure subagent partition', async () => {
const privateMemory = 'PRIVATE-NESTED-MEMORY';
mockReq.config.filters = {
memories: {
pii: {
fields: ['value'],
starterPatterns: [],
customPatterns: [
{
id: 'private-nested-memory',
label: 'private nested memory',
regex: privateMemory,
},
],
},
},
};
client.useMemory.mockResolvedValue({
withKeys: 'safe shared memory',
withoutKeys: 'safe shared memory',
});
require('~/models').getFormattedMemories.mockImplementation(({ agentId }) =>
Promise.resolve(
agentId === 'nested-pure-subagent'
? { withKeys: privateMemory, withoutKeys: privateMemory }
: { withKeys: 'safe memory', withoutKeys: 'safe memory' },
),
);
require('~/models').getUserMemories.mockImplementation(({ agentId }) =>
Promise.resolve(
agentId === 'nested-pure-subagent' ? [{ key: 'private', value: privateMemory }] : [],
),
);
client.options.agent.subagentAgentConfigs = [
{
...mockAgent,
id: 'pure-subagent',
subagentAgentConfigs: [
{
...mockAgent,
id: 'nested-pure-subagent',
memory_scope: 'agent',
memoryToolsRegistered: true,
},
],
},
];
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'memory', field: 'value' },
});
});
it('blocks current attachment content before attachment processing', async () => {
const privateFilename = 'PRIVATE-FILE.txt';
mockReq.config.filters = {
files: {
pii: {
fields: ['name'],
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: privateFilename }],
},
},
};
client.options.attachments = [
makeTextFile('current-file', privateFilename, 'otherwise safe content'),
];
client.addFileContextToMessage = jest.fn();
client.processAttachments = jest.fn();
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Read this file.',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toMatchObject({ code: 'content_filter_block' });
expect(client.addFileContextToMessage).not.toHaveBeenCalled();
expect(client.processAttachments).not.toHaveBeenCalled();
});
it('accepts an owner-resolved historical file under fail-closed inspection', async () => {
mockReq.config.filters = {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
};
const historicalFile = makeTextFile(
'historical-file',
'history.txt',
'Safe canonical content',
);
client.authorizedHistoricalFiles = new Map([['historical-file', historicalFile]]);
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Use the historical file.',
isCreatedByUser: true,
files: [{ file_id: 'historical-file' }],
},
],
'msg-1',
{},
),
).resolves.toEqual(expect.objectContaining({ prompt: expect.any(Array) }));
});
it('preserves persisted source identity through both agent formatting passes', async () => {
mockReq.config.filters = {
messages: {
pii: {
fields: ['content_part'],
starterPatterns: [],
customPatterns: [
{ id: 'private', label: 'private value', regex: 'PRIVATE-USER-STEER' },
],
},
},
};
const storedMessage = {
messageId: 'assistant-mixed',
parentMessageId: null,
sender: 'Assistant',
role: 'assistant',
isCreatedByUser: false,
content: [
{ type: ContentTypes.TEXT, text: 'Safe model output' },
{ type: ContentTypes.STEER, steer: 'PRIVATE-USER-STEER' },
],
userSubmittedPaths: ['/content/1/steer'],
};
client.setModelBoundStoredMessages([storedMessage]);
const result = await client.buildMessages([storedMessage], 'assistant-mixed', {}, {});
expect(result.prompt).toEqual([expect.objectContaining({ messageId: 'assistant-mixed' })]);
expect(
require('@librechat/api').countFormattedMessageTokens.mock.calls.some(
([message]) => message?.messageId === 'assistant-mixed',
),
).toBe(true);
const { messages: providerMessages } = jest
.requireActual('@librechat/agents')
.formatAgentMessages(result.prompt);
expect(providerMessages.length).toBeGreaterThanOrEqual(2);
expect(
providerMessages.every(
(message) =>
message.additional_kwargs?.sourceMessageId === 'assistant-mixed' ||
message.id === 'assistant-mixed',
),
).toBe(true);
expect(() =>
client
.createModelBoundChatModelCallback()
.handleChatModelStart(undefined, [providerMessages]),
).toThrow(expect.objectContaining({ code: 'content_filter_block' }));
});
it('preserves persisted assistant attribution at the final agent provider boundary', async () => {
const marker = 'E2E-PERSISTED-UNATTRIBUTED-ASSISTANT';
mockReq.config.filters = {
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: [
{
id: 'persisted-unattributed-assistant',
label: 'persisted unattributed assistant content',
regex: `^${marker}$`,
},
],
},
unattributedAssistantContent: 'inspect',
},
};
const storedMessage = {
messageId: 'legacy-assistant',
parentMessageId: null,
sender: 'Assistant',
text: marker,
isCreatedByUser: false,
};
client.setModelBoundStoredMessages([storedMessage]);
const result = await client.buildMessages([storedMessage], 'legacy-assistant', {}, {});
const { messages: providerMessages } = jest
.requireActual('@librechat/agents')
.formatAgentMessages(result.prompt);
expect(() =>
client
.createModelBoundChatModelCallback()
.handleChatModelStart(undefined, [providerMessages]),
).toThrow(expect.objectContaining({ code: 'content_filter_block' }));
client.setModelBoundStoredMessages([{ ...storedMessage, isUserSubmitted: false }]);
expect(() =>
client
.createModelBoundChatModelCallback()
.handleChatModelStart(undefined, [providerMessages]),
).not.toThrow();
});
it('ignores historical file refs when this agent does not replay files', async () => {
mockReq.config.filters = {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
};
client.options.resendFiles = false;
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Continue without the deleted file.',
isCreatedByUser: true,
files: [{ file_id: 'deleted-historical-file' }],
},
],
'msg-1',
{},
),
).resolves.toEqual(expect.objectContaining({ prompt: expect.any(Array) }));
});
it('filters historical attachment content only when its source survives pruning', async () => {
const privateText = 'PRIVATE-HISTORICAL-CONTENT';
mockReq.config.filters = {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: privateText }],
},
},
};
client.options.resendFiles = true;
require('~/models').getFiles.mockResolvedValue([
makeTextFile('historical-file', 'history.txt', privateText),
]);
client.addFileContextToMessage = jest.fn();
client.processAttachments = jest.fn();
const hydratedMessages = await client.addPreviousAttachments([
{
messageId: 'msg-1',
parentMessageId: null,
isCreatedByUser: true,
files: [{ file_id: 'historical-file' }],
},
]);
client.setModelBoundStoredMessages([
...hydratedMessages,
{
messageId: 'safe-message',
role: 'user',
text: 'Safe retained turn',
isCreatedByUser: true,
},
]);
const callback = client.createModelBoundChatModelCallback();
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'human',
content: 'Safe retained turn',
additional_kwargs: { sourceMessageId: 'safe-message' },
},
],
]),
).not.toThrow();
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'human',
content: 'Use the historical file.',
additional_kwargs: { sourceMessageId: 'msg-1' },
},
],
]),
).toThrow(expect.objectContaining({ code: 'content_filter_block' }));
expect(client.addFileContextToMessage).toHaveBeenCalled();
expect(client.processAttachments).toHaveBeenCalled();
});
it('does not run historical file contexts through the pre-pruning build preflight', async () => {
const privateText = 'PRIVATE-PRUNED-HISTORICAL-FILE';
mockReq.config.filters = {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: privateText }],
},
},
};
const historicalFile = makeTextFile('historical-file', 'history.txt', privateText);
const storedMessages = [
{
messageId: 'file-message',
parentMessageId: null,
sender: 'User',
role: 'user',
text: 'Use the historical file.',
fileContext: privateText,
files: [{ file_id: 'historical-file' }],
isCreatedByUser: true,
},
{
messageId: 'safe-message',
parentMessageId: 'file-message',
sender: 'User',
role: 'user',
text: 'Safe retained turn',
isCreatedByUser: true,
},
];
client.authorizedHistoricalFiles = new Map([['historical-file', historicalFile]]);
client.message_file_map = { 'file-message': [historicalFile] };
client.setModelBoundStoredMessages(storedMessages);
await expect(client.buildMessages(storedMessages, 'safe-message', {}, {})).resolves.toEqual(
expect.objectContaining({ prompt: expect.any(Array) }),
);
const callback = client.createModelBoundChatModelCallback();
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'human',
content: 'Safe retained turn',
additional_kwargs: { sourceMessageId: 'safe-message' },
},
],
]),
).not.toThrow();
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'human',
content: 'Use the historical file.',
additional_kwargs: { sourceMessageId: 'file-message' },
},
],
]),
).toThrow(expect.objectContaining({ code: 'content_filter_block' }));
});
it('filters historical steer attachments only when their source survives pruning', async () => {
const privateText = 'PRIVATE-STEER-FILE-CONTENT';
mockReq.config.filters = {
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: privateText }],
},
},
};
client.options.resendFiles = true;
require('~/models').getFiles.mockResolvedValue([
makeTextFile('steer-file', 'steer.txt', privateText),
]);
const storedMessages = await client.addPreviousAttachments([
{
messageId: 'assistant-msg',
parentMessageId: null,
isCreatedByUser: false,
content: [
{
type: ContentTypes.STEER,
steer: 'Read the attached file.',
files: [{ file_id: 'steer-file' }],
},
],
},
]);
client.setModelBoundStoredMessages([
...storedMessages,
{
messageId: 'safe-message',
role: 'user',
text: 'Safe retained turn',
isCreatedByUser: true,
},
]);
client.modelBoundSteerFileIdsBySourceMessageId = new Map([
['assistant-msg', new Set(['steer-file'])],
]);
const callback = client.createModelBoundChatModelCallback();
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'human',
content: 'Safe retained turn',
additional_kwargs: { sourceMessageId: 'safe-message' },
},
],
]),
).not.toThrow();
expect(() =>
callback.handleChatModelStart(undefined, [
[
{
role: 'human',
content: 'Read the attached file.',
additional_kwargs: { sourceMessageId: 'assistant-msg' },
},
],
]),
).toThrow(expect.objectContaining({ code: 'content_filter_block' }));
});
it('fails closed when canonical memory loading fails under active policy', async () => {
mockReq.config.filters = {
memories: {
pii: {
fields: ['key'],
starterPatterns: ['sk_prefix'],
},
},
};
require('~/models').getUserMemories.mockRejectedValue(new Error('memory read failed'));
client.useMemory.mockResolvedValue({
withKeys: '["key": "sk-private-memory"]',
withoutKeys: 'safe value',
});
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Use my preferences.',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toThrow('memory read failed');
});
it('inspects formatted memory text when canonical loading returns no rows', async () => {
const privateMemory = 'PRIVATE-MEMORY';
mockReq.config.filters = {
memories: {
pii: {
fields: ['value'],
starterPatterns: [],
customPatterns: [{ id: 'private', label: 'private value', regex: privateMemory }],
},
},
};
require('~/models').getUserMemories.mockResolvedValue([]);
client.useMemory.mockResolvedValue({
withKeys: privateMemory,
withoutKeys: privateMemory,
});
await expect(
client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Use my preferences.',
isCreatedByUser: true,
},
],
'msg-1',
{},
),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'memory', field: 'value' },
});
});
it.each([
['CSV', 'csv-file', 'sample.csv', 'text/csv'],
[
'XLSX',
'xlsx-file',
'sample.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
],
])(
'routes default-supported provider uploads like %s as request documents without custom file config',
async (_label, file_id, filename, type) => {
const currentFile = makeUploadedFile(file_id, filename, type);
const message = {
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: `Read this ${filename}.`,
isCreatedByUser: true,
};
client.addDocuments = jest.fn(async (targetMessage, attachments) => {
targetMessage.documents = attachments.map((file) => ({
type: 'input_file',
filename: file.filename,
file_data: `data:${file.type};base64,Y29sMQox`,
}));
return attachments;
});
const files = await client.processAttachments(message, [currentFile]);
expect(client.addDocuments).toHaveBeenCalledWith(message, [currentFile]);
expect(message.documents).toEqual([
expect.objectContaining({
type: 'input_file',
filename,
}),
]);
expect(files).toEqual([currentFile]);
},
);
it('places request context inline and applies each agent context doc only once', async () => {
const requestFile = makeTextFile('request-file', 'request.txt', 'Shared request context');
const primaryContext = makeTextFile(
'primary-context',
'primary.txt',
'Primary private context',
);
const handoffContext = makeTextFile(
'handoff-context',
'handoff.txt',
'Handoff private context',
);
const handoffAgent = {
id: 'handoff-agent',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Handoff instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [],
};
client.options.attachments = [requestFile];
client.options.agentContextAttachmentsByAgentId = new Map([
['primary-agent', [primaryContext]],
['handoff-agent', [handoffContext]],
]);
client.agentConfigs = new Map([['handoff-agent', handoffAgent]]);
const result = await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Use the available context.',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
expect(result.prompt[0].content).toContain('Shared request context');
expect(mockAgent.additional_instructions).toContain('Primary private context');
expect(mockAgent.additional_instructions).not.toContain('Shared request context');
expect(mockAgent.additional_instructions).not.toContain('Handoff private context');
expect(handoffAgent.additional_instructions).toContain('Handoff private context');
expect(handoffAgent.additional_instructions).not.toContain('Shared request context');
expect(handoffAgent.additional_instructions).not.toContain('Primary private context');
});
it('places current request file context on the latest user message', async () => {
const currentFile = makeTextFile('current-file', 'current.txt', 'Current turn file body');
const previousFileContext =
'Attached document(s):\n```md\n# "previous.txt"\nPrevious turn file body\n```';
client.options.attachments = [currentFile];
const result = await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'What is written here?',
isCreatedByUser: true,
fileContext: previousFileContext,
},
{
messageId: 'msg-2',
parentMessageId: 'msg-1',
sender: 'Assistant',
text: 'It describes the previous file.',
isCreatedByUser: false,
},
{
messageId: 'msg-3',
parentMessageId: 'msg-2',
sender: 'User',
text: 'What is written here?',
isCreatedByUser: true,
},
],
'msg-3',
{},
);
expect(result.prompt[0].content).toContain('Previous turn file body');
expect(result.prompt[2].content).toContain('Current turn file body');
expect(result.prompt[2].content).toContain('What is written here?');
expect(result.prompt[2].content).not.toContain('Previous turn file body');
expect(client.memoryPayload[2].content).toContain('What is written here?');
expect(client.memoryPayload[2].content).not.toContain('Current turn file body');
expect(mockAgent.additional_instructions ?? '').not.toContain('Current turn file body');
expect(result.prompt[2].content.indexOf('Current turn file body')).toBeLessThan(
result.prompt[2].content.indexOf('What is written here?'),
);
});
it('persists canonical token counts while counting request file context for the prompt', async () => {
const { countFormattedMessageTokens } = require('@librechat/api');
const currentFile = makeTextFile('current-file', 'current.txt', 'Current turn file body');
countFormattedMessageTokens.mockImplementation(({ content }) => {
const text = Array.isArray(content)
? content.map((part) => part.text ?? part[ContentTypes.TEXT] ?? '').join('\n')
: String(content ?? '');
return text.includes('Current turn file body') ? 200 : 20;
});
client.options.attachments = [currentFile];
const result = await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'What is written here?',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
expect(result.prompt[0].content).toContain('Current turn file body');
expect(result.tokenCountMap['msg-1']).toBe(20);
expect(result.promptTokens).toBe(200);
expect(client.indexTokenCountMap[0]).toBe(200);
expect(client.memoryPayload[0].content).toBe('What is written here?');
});
it('does not duplicate a file that is both request context and scoped context', async () => {
const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context');
client.options.attachments = [sharedFile];
client.options.agentContextAttachmentsByAgentId = new Map([['primary-agent', [sharedFile]]]);
client.agentConfigs = new Map();
const result = await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Use the available context.',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
const inlineOccurrences = (result.prompt[0].content.match(/Shared duplicate context/g) ?? [])
.length;
expect(inlineOccurrences).toBe(1);
expect(mockAgent.additional_instructions ?? '').not.toContain('Shared duplicate context');
});
it('keeps direct chats with context-doc agents working without request attachments', async () => {
const primaryContext = makeTextFile(
'primary-context',
'primary.txt',
'Direct primary context',
);
client.options.agentContextAttachmentsByAgentId = new Map([
['primary-agent', [primaryContext]],
]);
client.agentConfigs = new Map();
await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Answer from your context.',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
expect(mockAgent.additional_instructions).toContain('Direct primary context');
});
it('hydrates a pure lazy subagent with its own File Context when selected', async () => {
const childContext = makeTextFile('child-context', 'child.txt', 'Pure child private context');
const resolvedChild = {
id: 'pure-child-agent',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Pure child instructions',
model_parameters: { model: 'gpt-4' },
tools: [],
agentContextAttachments: [childContext],
};
const descriptor = {
id: resolvedChild.id,
resolve: jest.fn().mockResolvedValue(resolvedChild),
};
mockAgent.lazySubagentConfigs = [descriptor];
client.agentConfigs = new Map();
await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Answer from the child context.',
isCreatedByUser: true,
},
],
'msg-1',
{},
);
const resolved = await descriptor.resolve({ signal: new AbortController().signal });
expect(resolved).toBe(resolvedChild);
expect(resolvedChild.additional_instructions).toContain('Pure child private context');
expect(mockAgent.additional_instructions ?? '').not.toContain('Pure child private context');
expect(mockBuildAgentScopedContext).toHaveBeenLastCalledWith(
expect.objectContaining({
agentIds: ['pure-child-agent'],
attachmentsByAgentId: new Map([['pure-child-agent', [childContext]]]),
}),
);
});
});
describe('provider-native YouTube file preflight', () => {
const youtubeUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
const createClient = ({ provider, filters }) => {
const req = {
user: { id: 'user-123' },
body: { endpoint: EModelEndpoint.agents },
config: {
memory: { disabled: true },
...(filters && { filters }),
},
};
const client = new AgentClient({
req,
res: {},
endpoint: EModelEndpoint.agents,
agent: {
id: `${provider}-agent`,
endpoint: EModelEndpoint.agents,
provider,
instructions: 'Summarize the submitted video.',
model_parameters: { model: 'gemini-2.5-flash' },
tools: [{ urlContext: {} }],
},
});
const invokeModel = jest.fn().mockResolvedValue({ completion: [], metadata: {} });
client.shouldSummarize = false;
client.maxContextTokens = 4096;
client.useMemory = jest.fn().mockResolvedValue(undefined);
client.loadHistory = jest.fn().mockResolvedValue([]);
client.skipSaveUserMessage = true;
client.saveMessageToDatabase = jest.fn().mockResolvedValue({});
client.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
client.getTokenCountForResponse = jest.fn(() => 0);
client.sendCompletion = invokeModel;
return { client, invokeModel };
};
const sendYouTubeMessage = (client) =>
client.sendMessage(`Summarize ${youtubeUrl}`, {
conversationId: 'youtube-conversation',
parentMessageId: Constants.NO_PARENT,
user: 'user-123',
});
beforeEach(() => {
jest.clearAllMocks();
mockFormatInstructions.mockResolvedValue('');
require('@librechat/api').countFormattedMessageTokens.mockImplementation(() => 42);
require('~/models').getFiles.mockReset().mockResolvedValue([]);
require('~/models').getUserMemories.mockReset().mockResolvedValue([]);
});
it.each([Providers.GOOGLE, Providers.VERTEXAI])(
'blocks a late %s fileUri before model invocation under strict content policy',
async (provider) => {
const { client, invokeModel } = createClient({
provider,
filters: {
files: {
pii: {
fields: ['content'],
starterPatterns: [],
uninspectable: 'block',
},
},
},
});
await expect(sendYouTubeMessage(client)).rejects.toMatchObject({
code: 'content_filter_uninspectable',
body: { source: 'file', field: 'content' },
});
expect(invokeModel).not.toHaveBeenCalled();
},
);
it.each([Providers.GOOGLE, Providers.VERTEXAI])(
'keeps late %s fileUri compatible when file protection is off',
async (provider) => {
const { client, invokeModel } = createClient({ provider });
await expect(sendYouTubeMessage(client)).resolves.toMatchObject({
isCreatedByUser: false,
});
expect(invokeModel).toHaveBeenCalledTimes(1);
expect(invokeModel.mock.calls[0][0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'media', fileUri: youtubeUrl }),
]),
}),
]),
);
},
);
it('allows a known non-audio Vertex fileUri under transcript-only strict policy', async () => {
const { client, invokeModel } = createClient({
provider: Providers.VERTEXAI,
filters: {
files: {
pii: {
fields: ['transcript'],
starterPatterns: [],
uninspectable: 'block',
},
},
},
});
await expect(sendYouTubeMessage(client)).resolves.toMatchObject({
isCreatedByUser: false,
});
expect(invokeModel).toHaveBeenCalledTimes(1);
expect(invokeModel.mock.calls[0][0]).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({
type: 'media',
mimeType: 'video/mp4',
fileUri: youtubeUrl,
}),
]),
}),
]),
);
});
});
describe('runMemory method', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
let mockProcessMemory;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: {
model: 'gpt-4',
},
};
mockReq = {
user: {
id: 'user-123',
personalization: {
memories: true,
},
},
};
// Mock getAppConfig for memory tests
mockReq.config = {
memory: {
messageWindowSize: 3,
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
mockProcessMemory = jest.fn().mockResolvedValue([]);
client = new AgentClient(mockOptions);
client.processMemory = mockProcessMemory;
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
});
it('should filter out image URLs from message content', async () => {
const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
const messages = [
new HumanMessage({
content: [
{
type: 'text',
text: 'What is in this image?',
},
{
type: 'image_url',
image_url: {
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
detail: 'auto',
},
},
],
}),
new AIMessage('I can see a small red pixel in the image.'),
new HumanMessage({
content: [
{
type: 'text',
text: 'What about this one?',
},
{
type: 'image_url',
image_url: {
url: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/',
detail: 'high',
},
},
],
}),
];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
// Verify the buffer message was created
expect(processedMessage.constructor.name).toBe('HumanMessage');
expect(processedMessage.content).toContain('# Current Chat:');
// Verify that image URLs are not in the buffer string
expect(processedMessage.content).not.toContain('image_url');
expect(processedMessage.content).not.toContain('data:image');
expect(processedMessage.content).not.toContain('base64');
// Verify text content is preserved
expect(processedMessage.content).toContain('What is in this image?');
expect(processedMessage.content).toContain('I can see a small red pixel in the image.');
expect(processedMessage.content).toContain('What about this one?');
});
it('should handle messages with only text content', async () => {
const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
const messages = [
new HumanMessage('Hello, how are you?'),
new AIMessage('I am doing well, thank you!'),
new HumanMessage('That is great to hear.'),
];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
expect(processedMessage.content).toContain('Hello, how are you?');
expect(processedMessage.content).toContain('I am doing well, thank you!');
expect(processedMessage.content).toContain('That is great to hear.');
});
it('should keep original roles separate for content policy inspection', async () => {
const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
const messages = [new HumanMessage('Safe user input'), new AIMessage('PRIVATE-MODEL-OUTPUT')];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const [flattenedMessages, inspectionMessages] = mockProcessMemory.mock.calls[0];
expect(flattenedMessages).toHaveLength(1);
expect(flattenedMessages[0].constructor.name).toBe('HumanMessage');
expect(flattenedMessages[0].content).toContain('PRIVATE-MODEL-OUTPUT');
expect(inspectionMessages).toHaveLength(2);
expect(inspectionMessages[0].constructor.name).toBe('HumanMessage');
expect(inspectionMessages[0].content).toBe('Safe user input');
expect(inspectionMessages[1].constructor.name).toBe('AIMessage');
expect(inspectionMessages[1].content).toBe('PRIVATE-MODEL-OUTPUT');
});
it('should handle mixed content types correctly', async () => {
const { HumanMessage } = require('@librechat/agents/langchain/messages');
const { ContentTypes } = require('librechat-data-provider');
const messages = [
new HumanMessage({
content: [
{
type: 'text',
text: 'Here is some text',
},
{
type: ContentTypes.IMAGE_URL,
image_url: {
url: 'https://example.com/image.png',
},
},
{
type: 'text',
text: ' and more text',
},
],
}),
];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
// Should contain text parts but not image URLs
expect(processedMessage.content).toContain('Here is some text');
expect(processedMessage.content).toContain('and more text');
expect(processedMessage.content).not.toContain('example.com/image.png');
expect(processedMessage.content).not.toContain('IMAGE_URL');
});
it('should preserve original messages without mutation', async () => {
const { HumanMessage } = require('@librechat/agents/langchain/messages');
const originalContent = [
{
type: 'text',
text: 'Original text',
},
{
type: 'image_url',
image_url: {
url: 'data:image/png;base64,ABC123',
},
},
];
const messages = [
new HumanMessage({
content: [...originalContent],
}),
];
await client.runMemory(messages);
// Verify original message wasn't mutated
expect(messages[0].content).toHaveLength(2);
expect(messages[0].content[1].type).toBe('image_url');
expect(messages[0].content[1].image_url.url).toBe('data:image/png;base64,ABC123');
});
it('should handle message window size correctly', async () => {
const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
const messages = [
new HumanMessage('Message 1'),
new AIMessage('Response 1'),
new HumanMessage('Message 2'),
new AIMessage('Response 2'),
new HumanMessage('Message 3'),
new AIMessage('Response 3'),
];
// Window size is set to 3 in mockReq
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
// Should only include last 3 messages due to window size
expect(processedMessage.content).toContain('Message 3');
expect(processedMessage.content).toContain('Response 3');
expect(processedMessage.content).not.toContain('Message 1');
expect(processedMessage.content).not.toContain('Response 1');
});
it('should cap memory input tokens and preserve recent content', async () => {
const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages');
mockReq.config.memory.maxInputTokens = 12;
const messages = [
new HumanMessage(`OLDER_CONTENT ${'a'.repeat(600)}`),
new AIMessage('Intermediate response'),
new HumanMessage('Please remember LATEST_MEMORY_MARKER'),
];
await client.runMemory(messages);
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
const processedMessage = mockProcessMemory.mock.calls[0][0][0];
expect(processedMessage.content).toContain('LATEST_MEMORY_MARKER');
expect(processedMessage.content).not.toContain('OLDER_CONTENT');
expect(Math.ceil(processedMessage.content.length / 4)).toBeLessThanOrEqual(12);
});
it('should return early if processMemory is not set', async () => {
const { HumanMessage } = require('@librechat/agents/langchain/messages');
client.processMemory = null;
const result = await client.runMemory([new HumanMessage('Test')]);
expect(result).toBeUndefined();
expect(mockProcessMemory).not.toHaveBeenCalled();
});
it('should contain automatic memory rejection and log only bounded metadata', async () => {
const { HumanMessage } = require('@librechat/agents/langchain/messages');
const { logger } = require('@librechat/data-schemas');
const sensitiveValue = 'PRIVATE-MEMORY-REJECTION-CONTENT';
const contentFilterError = new Error(sensitiveValue);
contentFilterError.code = 'content_filter_block';
mockProcessMemory.mockRejectedValueOnce(contentFilterError);
const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
try {
await expect(client.runMemory([new HumanMessage('Safe message')])).resolves.toBeUndefined();
expect(mockProcessMemory).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith('Memory Agent failed to process memory', {
type: 'Error',
});
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(sensitiveValue);
} finally {
errorSpy.mockRestore();
}
});
});
describe('getMessagesForConversation - mapMethod and mapCondition', () => {
const createMessage = (id, parentId, text, extras = {}) => ({
messageId: id,
parentMessageId: parentId,
text,
isCreatedByUser: false,
...extras,
});
it('should apply mapMethod to all messages when mapCondition is not provided', () => {
const messages = [
createMessage('msg-1', null, 'First message'),
createMessage('msg-2', 'msg-1', 'Second message'),
createMessage('msg-3', 'msg-2', 'Third message'),
];
const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-3',
mapMethod,
});
expect(result).toHaveLength(3);
expect(mapMethod).toHaveBeenCalledTimes(3);
result.forEach((msg) => {
expect(msg.mapped).toBe(true);
});
});
it('should apply mapMethod only to messages where mapCondition returns true', () => {
const messages = [
createMessage('msg-1', null, 'First message', { addedConvo: false }),
createMessage('msg-2', 'msg-1', 'Second message', { addedConvo: true }),
createMessage('msg-3', 'msg-2', 'Third message', { addedConvo: true }),
createMessage('msg-4', 'msg-3', 'Fourth message', { addedConvo: false }),
];
const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
const mapCondition = (msg) => msg.addedConvo === true;
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-4',
mapMethod,
mapCondition,
});
expect(result).toHaveLength(4);
expect(mapMethod).toHaveBeenCalledTimes(2);
expect(result[0].mapped).toBeUndefined();
expect(result[1].mapped).toBe(true);
expect(result[2].mapped).toBe(true);
expect(result[3].mapped).toBeUndefined();
});
it('should not apply mapMethod when mapCondition returns false for all messages', () => {
const messages = [
createMessage('msg-1', null, 'First message', { addedConvo: false }),
createMessage('msg-2', 'msg-1', 'Second message', { addedConvo: false }),
];
const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
const mapCondition = (msg) => msg.addedConvo === true;
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-2',
mapMethod,
mapCondition,
});
expect(result).toHaveLength(2);
expect(mapMethod).not.toHaveBeenCalled();
result.forEach((msg) => {
expect(msg.mapped).toBeUndefined();
});
});
it('should not call mapMethod when mapMethod is null', () => {
const messages = [
createMessage('msg-1', null, 'First message'),
createMessage('msg-2', 'msg-1', 'Second message'),
];
const mapCondition = jest.fn(() => true);
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-2',
mapMethod: null,
mapCondition,
});
expect(result).toHaveLength(2);
expect(mapCondition).not.toHaveBeenCalled();
});
it('should handle mapCondition with complex logic', () => {
const messages = [
createMessage('msg-1', null, 'User message', { isCreatedByUser: true, addedConvo: true }),
createMessage('msg-2', 'msg-1', 'Assistant response', { addedConvo: true }),
createMessage('msg-3', 'msg-2', 'Another user message', { isCreatedByUser: true }),
createMessage('msg-4', 'msg-3', 'Another response', { addedConvo: true }),
];
const mapMethod = jest.fn((msg) => ({ ...msg, processed: true }));
const mapCondition = (msg) => msg.addedConvo === true && !msg.isCreatedByUser;
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-4',
mapMethod,
mapCondition,
});
expect(result).toHaveLength(4);
expect(mapMethod).toHaveBeenCalledTimes(2);
expect(result[0].processed).toBeUndefined();
expect(result[1].processed).toBe(true);
expect(result[2].processed).toBeUndefined();
expect(result[3].processed).toBe(true);
});
it('should preserve message order after applying mapMethod with mapCondition', () => {
const messages = [
createMessage('msg-1', null, 'First', { addedConvo: true }),
createMessage('msg-2', 'msg-1', 'Second', { addedConvo: false }),
createMessage('msg-3', 'msg-2', 'Third', { addedConvo: true }),
];
const mapMethod = (msg) => ({ ...msg, text: `[MAPPED] ${msg.text}` });
const mapCondition = (msg) => msg.addedConvo === true;
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-3',
mapMethod,
mapCondition,
});
expect(result[0].text).toBe('[MAPPED] First');
expect(result[1].text).toBe('Second');
expect(result[2].text).toBe('[MAPPED] Third');
});
it('should work with summary option alongside mapMethod and mapCondition', () => {
const messages = [
createMessage('msg-1', null, 'First', { addedConvo: false }),
createMessage('msg-2', 'msg-1', 'Second', {
summary: 'Summary of conversation',
addedConvo: true,
}),
createMessage('msg-3', 'msg-2', 'Third', { addedConvo: true }),
createMessage('msg-4', 'msg-3', 'Fourth', { addedConvo: false }),
];
const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
const mapCondition = (msg) => msg.addedConvo === true;
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-4',
mapMethod,
mapCondition,
summary: true,
});
/** Traversal stops at msg-2 (has summary), so we get msg-4 -> msg-3 -> msg-2 */
expect(result).toHaveLength(3);
expect(result[0].content).toEqual([{ type: 'text', text: 'Summary of conversation' }]);
expect(result[0].role).toBe('system');
expect(result[0].mapped).toBe(true);
expect(result[1].mapped).toBe(true);
expect(result[2].mapped).toBeUndefined();
});
it('should handle empty messages array', () => {
const mapMethod = jest.fn();
const mapCondition = jest.fn();
const result = AgentClient.getMessagesForConversation({
messages: [],
parentMessageId: 'msg-1',
mapMethod,
mapCondition,
});
expect(result).toHaveLength(0);
expect(mapMethod).not.toHaveBeenCalled();
expect(mapCondition).not.toHaveBeenCalled();
});
it('should handle undefined mapCondition explicitly', () => {
const messages = [
createMessage('msg-1', null, 'First'),
createMessage('msg-2', 'msg-1', 'Second'),
];
const mapMethod = jest.fn((msg) => ({ ...msg, mapped: true }));
const result = AgentClient.getMessagesForConversation({
messages,
parentMessageId: 'msg-2',
mapMethod,
mapCondition: undefined,
});
expect(result).toHaveLength(2);
expect(mapMethod).toHaveBeenCalledTimes(2);
result.forEach((msg) => {
expect(msg.mapped).toBe(true);
});
});
});
describe('buildMessages - memory context for parallel agents', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'primary-agent',
name: 'Primary Agent',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Primary agent instructions',
model_parameters: {
model: 'gpt-4',
},
tools: [],
};
mockReq = {
user: {
id: 'user-123',
personalization: {
memories: true,
},
},
body: {
endpoint: EModelEndpoint.openAI,
},
config: {
memory: {
disabled: false,
},
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
endpoint: EModelEndpoint.agents,
};
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
client.shouldSummarize = false;
client.maxContextTokens = 4096;
});
it('should only pass memory context to the primary agent by default', async () => {
const memoryContent = 'User prefers dark mode. User is a software developer.';
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
const parallelAgent1 = {
id: 'parallel-agent-1',
name: 'Parallel Agent 1',
instructions: 'Parallel agent 1 instructions',
provider: EModelEndpoint.openAI,
};
const parallelAgent2 = {
id: 'parallel-agent-2',
name: 'Parallel Agent 2',
instructions: 'Parallel agent 2 instructions',
provider: EModelEndpoint.anthropic,
};
client.agentConfigs = new Map([
['parallel-agent-1', parallelAgent1],
['parallel-agent-2', parallelAgent2],
]);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
expect(client.useMemory).toHaveBeenCalled();
expect(client.options.agent.instructions).toContain('Primary agent instructions');
expect(client.options.agent.instructions).not.toContain(memoryContent);
expect(client.options.agent.additional_instructions).toContain(memoryContent);
expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions');
expect(parallelAgent1.instructions).not.toContain(memoryContent);
expect(parallelAgent1.additional_instructions ?? '').not.toContain(memoryContent);
expect(parallelAgent2.instructions).toContain('Parallel agent 2 instructions');
expect(parallelAgent2.instructions).not.toContain(memoryContent);
expect(parallelAgent2.additional_instructions ?? '').not.toContain(memoryContent);
});
it('applies scoped context to graph-only members without promoting them', async () => {
client.useMemory = jest.fn().mockResolvedValue(undefined);
const graphMember = {
id: 'graph-member',
name: 'Graph Member',
instructions: 'Graph member instructions',
provider: EModelEndpoint.openAI,
};
mockAgent.subagentGraphConfigs = [
{
definition: { type: 'review_team' },
memberConfigs: [mockAgent, graphMember],
},
];
client.agentConfigs = new Map();
mockBuildAgentScopedContext.mockResolvedValueOnce(
new Map([['graph-member', 'Graph member context']]),
);
await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
null,
{ instructions: 'Base instructions', additional_instructions: null },
);
expect(mockBuildAgentScopedContext).toHaveBeenCalledWith(
expect.objectContaining({ agentIds: ['primary-agent', 'graph-member'] }),
);
expect(graphMember.additional_instructions).toContain('Graph member context');
expect(client.agentConfigs).toEqual(new Map());
});
it('applies scoped context to graph members resolved by a lazy child', async () => {
client.useMemory = jest.fn().mockResolvedValue(undefined);
const graphMember = {
id: 'lazy-graph-member',
name: 'Lazy Graph Member',
instructions: 'Lazy graph member instructions',
provider: EModelEndpoint.openAI,
};
const resolvedChild = {
id: 'lazy-child',
name: 'Lazy Child',
instructions: 'Lazy child instructions',
provider: EModelEndpoint.openAI,
subagentGraphConfigs: [
{
definition: { type: 'lazy_team' },
memberConfigs: [graphMember],
},
],
};
const descriptor = {
id: 'lazy-child',
resolve: jest.fn().mockResolvedValue(resolvedChild),
};
mockAgent.lazySubagentConfigs = [descriptor];
client.agentConfigs = new Map();
mockBuildAgentScopedContext.mockResolvedValueOnce(new Map()).mockResolvedValueOnce(
new Map([
['lazy-child', 'Lazy child context'],
['lazy-graph-member', 'Lazy graph member context'],
]),
);
await client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
],
null,
{ instructions: 'Base instructions', additional_instructions: null },
);
const resolved = await descriptor.resolve({ signal: new AbortController().signal });
expect(resolved).toBe(resolvedChild);
expect(resolvedChild.additional_instructions).toContain('Lazy child context');
expect(graphMember.additional_instructions).toContain('Lazy graph member context');
expect(mockBuildAgentScopedContext).toHaveBeenLastCalledWith(
expect.objectContaining({ agentIds: ['lazy-child', 'lazy-graph-member'] }),
);
});
it('should pass memory context to parallel agents when automatic memory updates are enabled', async () => {
const memoryContent = 'User prefers dark mode. User is a software developer.';
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
mockReq.config.memory.agent = {
enabled: true,
id: 'memory-agent',
};
const parallelAgent = {
id: 'parallel-agent-1',
name: 'Parallel Agent 1',
instructions: 'Parallel agent instructions',
provider: EModelEndpoint.openAI,
};
client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
expect(client.options.agent.instructions).toContain('Primary agent instructions');
expect(client.options.agent.instructions).not.toContain(memoryContent);
expect(client.options.agent.additional_instructions).toContain(memoryContent);
expect(parallelAgent.instructions).toContain('Parallel agent instructions');
expect(parallelAgent.instructions).not.toContain(memoryContent);
expect(parallelAgent.additional_instructions).toContain(memoryContent);
});
it('should not modify parallel agents when no memory context is available', async () => {
client.useMemory = jest.fn().mockResolvedValue(undefined);
const parallelAgent = {
id: 'parallel-agent-1',
name: 'Parallel Agent 1',
instructions: 'Original parallel instructions',
provider: EModelEndpoint.openAI,
};
client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
});
expect(parallelAgent.instructions).toBe('Original parallel instructions');
});
it('should handle parallel agents without existing instructions when memory stays primary-only', async () => {
const memoryContent = 'User is a data scientist.';
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
const parallelAgentNoInstructions = {
id: 'parallel-agent-no-instructions',
name: 'Parallel Agent No Instructions',
provider: EModelEndpoint.openAI,
};
client.agentConfigs = new Map([
['parallel-agent-no-instructions', parallelAgentNoInstructions],
]);
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await client.buildMessages(messages, null, {
instructions: null,
additional_instructions: null,
});
expect(client.options.agent.additional_instructions).toContain(memoryContent);
expect(parallelAgentNoInstructions.instructions).toBeUndefined();
expect(parallelAgentNoInstructions.additional_instructions ?? '').not.toContain(
memoryContent,
);
});
it('should not modify agentConfigs when none exist', async () => {
const memoryContent = 'User prefers concise responses.';
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
client.agentConfigs = null;
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await expect(
client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
}),
).resolves.not.toThrow();
expect(client.options.agent.additional_instructions).toContain(memoryContent);
});
it('should handle empty agentConfigs map', async () => {
const memoryContent = 'User likes detailed explanations.';
client.useMemory = jest
.fn()
.mockResolvedValue({ withKeys: memoryContent, withoutKeys: memoryContent });
client.agentConfigs = new Map();
const messages = [
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Hello',
isCreatedByUser: true,
},
];
await expect(
client.buildMessages(messages, null, {
instructions: 'Base instructions',
additional_instructions: null,
}),
).resolves.not.toThrow();
expect(client.options.agent.additional_instructions).toContain(memoryContent);
});
});
describe('useMemory method - prelimAgent assignment', () => {
let client;
let mockReq;
let mockRes;
let mockAgent;
let mockOptions;
let mockCheckAccess;
let mockLoadAgent;
let mockInitializeAgent;
let mockCreateMemoryProcessor;
let mockGetFormattedMemories;
beforeEach(() => {
jest.clearAllMocks();
mockAgent = {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
instructions: 'Test instructions',
model: 'gpt-4',
model_parameters: {
model: 'gpt-4',
},
};
mockReq = {
user: {
id: 'user-123',
personalization: {
memories: true,
},
},
config: {
memory: {
agent: {
enabled: true,
id: 'agent-123',
},
},
endpoints: {
[EModelEndpoint.agents]: {
allowedProviders: [EModelEndpoint.openAI],
},
},
},
};
mockRes = {};
mockOptions = {
req: mockReq,
res: mockRes,
agent: mockAgent,
};
mockCheckAccess = require('@librechat/api').checkAccess;
mockLoadAgent = require('@librechat/api').loadAgent;
mockInitializeAgent = require('@librechat/api').initializeAgent;
mockCreateMemoryProcessor = require('@librechat/api').createMemoryProcessor;
mockGetFormattedMemories = require('~/models').getFormattedMemories;
mockGetFormattedMemories.mockResolvedValue({
withKeys: '',
withoutKeys: '',
totalTokens: 0,
});
});
it('should use current agent when memory config agent.id matches current agent id', async () => {
mockCheckAccess.mockResolvedValue(true);
mockInitializeAgent.mockResolvedValue({
...mockAgent,
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockLoadAgent).not.toHaveBeenCalled();
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: mockAgent,
}),
expect.any(Object),
);
});
it('should bind memory processing to the current generation epoch', async () => {
mockReq._resumableStreamId = 'convo-123';
mockCheckAccess.mockResolvedValue(true);
mockInitializeAgent.mockResolvedValue({
...mockAgent,
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient({ ...mockOptions, jobCreatedAt: 1234 });
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockCreateMemoryProcessor).toHaveBeenCalledWith(
expect.objectContaining({
streamId: 'convo-123',
jobCreatedAt: 1234,
}),
);
});
it('should pass only source-aware filters to automatic memory processing', async () => {
const filters = {
memories: {
pii: {
fields: ['value'],
},
},
};
const legacyPii = {
starterPatterns: ['email'],
};
mockReq.config.filters = filters;
mockReq.config.messageFilter = { pii: legacyPii };
mockCheckAccess.mockResolvedValue(true);
mockInitializeAgent.mockResolvedValue({
...mockAgent,
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockCreateMemoryProcessor).toHaveBeenCalledWith(
expect.objectContaining({
filters,
}),
);
expect(mockCreateMemoryProcessor.mock.calls[0][0]).not.toHaveProperty('legacyPii');
expect(mockCreateMemoryProcessor.mock.calls[0][0]).not.toHaveProperty('contentInspection');
});
it('should load different agent when memory config agent.id differs from current agent id', async () => {
const differentAgentId = 'different-agent-456';
const differentAgent = {
id: differentAgentId,
provider: EModelEndpoint.openAI,
model: 'gpt-4',
instructions: 'Different agent instructions',
};
mockReq.config.memory.agent.id = differentAgentId;
mockCheckAccess.mockResolvedValue(true);
mockLoadAgent.mockResolvedValue(differentAgent);
mockInitializeAgent.mockResolvedValue({
...differentAgent,
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockLoadAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent_id: differentAgentId,
}),
expect.any(Object),
);
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: differentAgent,
}),
expect.any(Object),
);
});
it('should return existing memories without auto-processing when memory agent is not enabled', async () => {
mockReq.config.memory = {
personalize: true,
};
mockCheckAccess.mockResolvedValue(true);
mockGetFormattedMemories.mockResolvedValue({
withKeys: 'food: likes pasta',
withoutKeys: 'likes pasta',
totalTokens: 3,
});
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
const result = await client.useMemory();
expect(result).toEqual({ withKeys: 'food: likes pasta', withoutKeys: 'likes pasta' });
expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
expect(client.processMemory).toBeUndefined();
});
it('should not initialize auto-processing when no memories exist', async () => {
mockReq.config.memory = {
personalize: true,
};
mockCheckAccess.mockResolvedValue(true);
mockGetFormattedMemories.mockResolvedValue({
withKeys: '',
withoutKeys: '',
totalTokens: 0,
});
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
const result = await client.useMemory();
expect(result).toEqual({ withKeys: '', withoutKeys: '' });
expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
expect(client.processMemory).toBeUndefined();
});
it('should return existing memories without auto-processing when memory agent config lacks explicit enablement', async () => {
mockReq.config.memory.agent = {
id: 'agent-123',
};
mockCheckAccess.mockResolvedValue(true);
mockGetFormattedMemories.mockResolvedValue({
withKeys: 'tone: concise',
withoutKeys: 'prefers concise answers',
totalTokens: 4,
});
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
const result = await client.useMemory();
expect(result).toEqual({ withKeys: 'tone: concise', withoutKeys: 'prefers concise answers' });
expect(mockLoadAgent).not.toHaveBeenCalled();
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
});
it('should return undefined when loading memories fails without auto-processing', async () => {
const { logger } = require('@librechat/data-schemas');
const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
mockReq.config.memory = {
personalize: true,
};
mockCheckAccess.mockResolvedValue(true);
mockGetFormattedMemories.mockRejectedValue(new Error('DB connection failed'));
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
const result = await client.useMemory();
expect(result).toBeUndefined();
expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' });
expect(mockInitializeAgent).not.toHaveBeenCalled();
expect(mockCreateMemoryProcessor).not.toHaveBeenCalled();
expect(client.processMemory).toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
'[api/server/controllers/agents/client.js #useMemory] Error loading memories',
{ type: 'Error' },
);
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('DB connection failed');
errorSpy.mockRestore();
});
it('should create ephemeral agent when no id but model and provider are specified', async () => {
mockReq.config.memory = {
agent: {
enabled: true,
model: 'gpt-4',
provider: EModelEndpoint.openAI,
},
};
mockCheckAccess.mockResolvedValue(true);
mockInitializeAgent.mockResolvedValue({
id: Constants.EPHEMERAL_AGENT_ID,
model: 'gpt-4',
provider: EModelEndpoint.openAI,
});
mockCreateMemoryProcessor.mockResolvedValue([undefined, jest.fn()]);
client = new AgentClient(mockOptions);
client.conversationId = 'convo-123';
client.responseMessageId = 'response-123';
await client.useMemory();
expect(mockLoadAgent).not.toHaveBeenCalled();
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: expect.objectContaining({
id: Constants.EPHEMERAL_AGENT_ID,
model: 'gpt-4',
provider: EModelEndpoint.openAI,
}),
}),
expect.any(Object),
);
});
});
});
describe('AgentClient - finalizeSubagentContent', () => {
/** Verifies the backend persistence path: per-subagent
* `createContentAggregator` instances (populated by the callbacks
* ON_SUBAGENT_UPDATE handler) have their `contentParts` harvested
* onto the matching parent `subagent` tool_call at message-save time
* so a page refresh shows the same activity the user saw live. */
const { GraphEvents } = jest.requireActual('@librechat/agents');
const { getDefaultHandlers } = require('./callbacks');
const makeClient = (subagentAggregatorsByToolCallId) => {
const client = new AgentClient({
req: { user: { id: 'u' }, body: {}, config: { endpoints: {} } },
res: {},
agent: {
id: 'agent',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
},
contentParts: [],
subagentAggregatorsByToolCallId,
});
return client;
};
const event = (phase, data, parentToolCallId = 'call_sub') => ({
runId: 'parent-run',
subagentRunId: 'child-run',
subagentType: 'self',
subagentAgentId: 'child',
parentToolCallId,
phase,
data,
timestamp: '2026-04-17T00:00:00Z',
});
/** Feeds a SubagentUpdateEvent sequence through the real
* `ON_SUBAGENT_UPDATE` handler so we exercise the same get-or-create
* aggregator logic the live request uses, rather than constructing
* aggregators directly in the test. */
const runSubagentEvents = async (events) => {
const map = new Map();
const handlers = getDefaultHandlers({
res: { write: jest.fn(), writableEnded: false },
aggregateContent: jest.fn(),
toolEndCallback: jest.fn(),
collectedUsage: [],
subagentAggregatorsByToolCallId: map,
});
const handler = handlers[GraphEvents.ON_SUBAGENT_UPDATE];
for (const e of events) {
await handler.handle(GraphEvents.ON_SUBAGENT_UPDATE, e);
}
return map;
};
it('attaches aggregated subagent_content to the matching subagent tool_call part', async () => {
const buffer = await runSubagentEvents([
event('run_step', {
id: 'step_msg',
index: 0,
stepDetails: { type: 'message_creation' },
}),
event('message_delta', {
id: 'step_msg',
delta: { content: [{ type: 'text', text: 'Hello ' }] },
}),
event('message_delta', {
id: 'step_msg',
delta: { content: [{ type: 'text', text: 'world!' }] },
}),
event('run_step', {
id: 'step_tool',
index: 1,
stepDetails: {
type: 'tool_calls',
tool_calls: [{ id: 'inner_1', name: 'calculator', args: '{}' }],
},
}),
event('run_step_completed', {
id: 'step_tool',
index: 1,
result: {
id: 'step_tool',
type: 'tool_call',
tool_call: {
id: 'inner_1',
name: 'calculator',
output: '4',
progress: 1,
},
},
}),
]);
const client = makeClient(buffer);
client.contentParts = [
{
type: 'tool_call',
tool_call: {
id: 'call_sub',
name: Constants.SUBAGENT,
args: '{}',
output: 'final text',
progress: 1,
},
},
];
client.finalizeSubagentContent();
const attached = client.contentParts[0].tool_call.subagent_content;
expect(Array.isArray(attached)).toBe(true);
expect(attached).toHaveLength(2);
expect(attached[0].type).toBe('text');
expect(attached[0].text).toBe('Hello world!');
expect(attached[1].type).toBe('tool_call');
expect(attached[1].tool_call.name).toBe('calculator');
expect(attached[1].tool_call.output).toBe('4');
/** Buffer drained so a second call (e.g. resumable retry) doesn't
* double-append. */
expect(buffer.size).toBe(0);
});
it('ignores tool_call parts whose name is not SUBAGENT', async () => {
const buffer = await runSubagentEvents([
event(
'run_step',
{
id: 'step_msg',
index: 0,
stepDetails: { type: 'message_creation' },
},
'call_regular',
),
event(
'message_delta',
{
id: 'step_msg',
delta: { content: [{ type: 'text', text: 'x' }] },
},
'call_regular',
),
]);
const client = makeClient(buffer);
client.contentParts = [
{
type: 'tool_call',
tool_call: { id: 'call_regular', name: 'calculator', args: '{}' },
},
];
client.finalizeSubagentContent();
expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined();
});
it('is a safe no-op when the aggregator map is empty or missing', () => {
const client = makeClient(undefined);
client.contentParts = [
{
type: 'tool_call',
tool_call: { id: 'call_sub', name: Constants.SUBAGENT, args: '{}' },
},
];
expect(() => client.finalizeSubagentContent()).not.toThrow();
expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined();
});
it('discards aggregators keyed by a tool_call_id not present in contentParts', async () => {
const buffer = await runSubagentEvents([
event(
'run_step',
{
id: 'step_msg',
index: 0,
stepDetails: { type: 'message_creation' },
},
'call_missing',
),
event(
'message_delta',
{
id: 'step_msg',
delta: { content: [{ type: 'text', text: 'x' }] },
},
'call_missing',
),
]);
const client = makeClient(buffer);
client.contentParts = [
{
type: 'tool_call',
tool_call: { id: 'call_other', name: Constants.SUBAGENT, args: '{}' },
},
];
client.finalizeSubagentContent();
expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined();
});
it('keeps per-parent tool_call aggregators isolated for parallel subagents', async () => {
const buffer = await runSubagentEvents([
event(
'run_step',
{
id: 'step_a',
index: 0,
stepDetails: { type: 'message_creation' },
},
'call_a',
),
event(
'message_delta',
{ id: 'step_a', delta: { content: [{ type: 'text', text: 'A' }] } },
'call_a',
),
event(
'run_step',
{
id: 'step_b',
index: 0,
stepDetails: { type: 'message_creation' },
},
'call_b',
),
event(
'message_delta',
{ id: 'step_b', delta: { content: [{ type: 'text', text: 'B' }] } },
'call_b',
),
]);
const client = makeClient(buffer);
client.contentParts = [
{ type: 'tool_call', tool_call: { id: 'call_a', name: Constants.SUBAGENT, args: '{}' } },
{ type: 'tool_call', tool_call: { id: 'call_b', name: Constants.SUBAGENT, args: '{}' } },
];
client.finalizeSubagentContent();
expect(client.contentParts[0].tool_call.subagent_content).toEqual([
expect.objectContaining({ type: 'text', text: 'A' }),
]);
expect(client.contentParts[1].tool_call.subagent_content).toEqual([
expect.objectContaining({ type: 'text', text: 'B' }),
]);
});
});
describe('AgentClient - resumeCompletion content protection', () => {
const makeContext = (filters) => ({
options: {
req: {
user: { id: 'user-123' },
body: { files: [] },
config: {
endpoints: { [EModelEndpoint.agents]: { checkpointer: {} } },
filters,
},
},
agent: {
id: 'agent-123',
hide_sequential_outputs: false,
model_parameters: { model: 'gpt-4' },
tools: [],
},
},
user: 'user-123',
conversationId: 'conversation-123',
responseMessageId: 'response-123',
parentMessageId: 'parent-123',
agentConfigs: new Map(),
contentParts: [],
collectedUsage: [],
pendingSubagentEmits: [],
getEncoding: jest.fn(() => 'o200k_base'),
buildSteerWiring: jest.fn(),
buildActivityLabelWiring: jest.fn(() => null),
buildActivityPhaseWiring: jest.fn(() => null),
buildReasoningLabelWiring: jest.fn(() => null),
buildSubagentUsageEmitter: jest.fn(),
buildDetachedSubagentUsageRecorder: jest.fn(),
handleRunInterrupt: jest.fn().mockResolvedValue(undefined),
completeActivityPhase: jest.fn(),
applyHideSequentialOutputsFilter: jest.fn(),
rebaseActivityPhaseBounds: jest.fn(),
finalizeSubagentContent: jest.fn(),
settleActivityLabels: jest.fn().mockResolvedValue(undefined),
recordCollectedUsage: jest.fn().mockResolvedValue(undefined),
});
beforeEach(() => {
jest.clearAllMocks();
mockCreateRun.mockReset();
mockGetAgentCheckpointer.mockReset();
require('~/models')
.getMessages.mockReset()
.mockResolvedValue([
{
messageId: 'parent-123',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
role: 'user',
text: 'safe',
},
]);
require('~/models').getFiles.mockReset().mockResolvedValue([]);
});
it('blocks checkpoint user content before rebuilding the run', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: {
channel_values: {
messages: [{ _getType: () => 'human', content: 'PRIVATE-RESUME-CONTENT' }],
},
},
}),
});
const context = makeContext({
messages: {
pii: {
fields: ['text'],
starterPatterns: [],
customPatterns: [
{
id: 'private-resume',
label: 'private resume content',
regex: 'PRIVATE-RESUME-CONTENT',
},
],
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'message', field: 'text' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('blocks a path-marked assistant fragment restored from the exact persisted branch', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
require('~/models').getMessages.mockResolvedValue([
{
messageId: 'root',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
role: 'user',
text: 'safe',
},
{
messageId: 'assistant-1',
parentMessageId: 'root',
isCreatedByUser: false,
role: 'assistant',
content: [
{ type: ContentTypes.TEXT, text: 'ordinary model prose' },
{ type: ContentTypes.TEXT, text: 'PRIVATE-RESUME-EDIT' },
],
userSubmittedPaths: ['/content/1/text'],
},
{
messageId: 'parent-123',
parentMessageId: 'assistant-1',
isCreatedByUser: true,
role: 'user',
text: 'continue',
},
]);
const context = makeContext({
messages: {
pii: {
fields: ['content_part'],
starterPatterns: [],
customPatterns: [
{
id: 'private-resume-edit',
label: 'private resume edit',
regex: 'PRIVATE-RESUME-EDIT',
},
],
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'message', field: 'content_part' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('fails closed when the persisted resume branch has a missing ancestor', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
require('~/models').getMessages.mockResolvedValue([
{
messageId: 'parent-123',
parentMessageId: 'missing-ancestor',
isCreatedByUser: true,
role: 'user',
text: 'continue',
},
]);
const context = makeContext({
messages: {
pii: {
fields: ['text'],
starterPatterns: ['email'],
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_uninspectable',
body: { source: 'message', field: 'content_part' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('allows unmarked model prose and ignores a marked sibling branch on resume', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
require('~/models').getMessages.mockResolvedValue([
{
messageId: 'root',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
role: 'user',
text: 'safe',
},
{
messageId: 'target-assistant',
parentMessageId: 'root',
isCreatedByUser: false,
role: 'assistant',
text: 'PRIVATE-MODEL-PROSE',
},
{
messageId: 'parent-123',
parentMessageId: 'target-assistant',
isCreatedByUser: true,
role: 'user',
text: 'continue target branch',
},
{
messageId: 'sibling-assistant',
parentMessageId: 'root',
isCreatedByUser: false,
role: 'assistant',
content: [{ type: ContentTypes.TEXT, text: 'PRIVATE-MODEL-PROSE' }],
userSubmittedPaths: ['/content/0/text'],
},
{
messageId: 'sibling-user',
parentMessageId: 'sibling-assistant',
isCreatedByUser: true,
role: 'user',
text: 'continue sibling branch',
},
]);
const resume = jest.fn().mockResolvedValue(undefined);
mockCreateRun.mockResolvedValue({
resume,
getCalibrationRatio: jest.fn(() => 0),
});
const context = makeContext({
messages: {
pii: {
fields: ['text', 'content_part'],
starterPatterns: [],
customPatterns: [
{
id: 'private-model-prose',
label: 'private model prose',
regex: 'PRIVATE-MODEL-PROSE',
},
],
},
},
});
await AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} });
expect(mockCreateRun).toHaveBeenCalledTimes(1);
expect(mockCreateRun.mock.calls[0][0]).toEqual(
expect.objectContaining({
modelCallbacks: [expect.objectContaining({ name: 'librechat-model-bound-content-filter' })],
}),
);
expect(resume).toHaveBeenCalledTimes(1);
expect(resume.mock.calls[0][1]).not.toHaveProperty('callbacks');
});
it('blocks handoff dynamic tool context before rebuilding a resumed run', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
const context = makeContext({
files: {
pii: {
fields: ['content'],
starterPatterns: [],
customPatterns: [
{
id: 'private-resume-dynamic-context',
label: 'private resume dynamic context',
regex: 'PRIVATE-RESUME-DYNAMIC-CONTEXT',
},
],
},
},
});
context.agentConfigs = new Map([
[
'handoff-agent',
{
id: 'handoff-agent',
model_parameters: { model: 'gpt-4' },
dynamicToolContextMap: {
ignored: 42,
web_search: 'PRIVATE-RESUME-DYNAMIC-CONTEXT',
},
},
],
]);
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'file', field: 'content' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('blocks a nested pure subagent tool definition before rebuilding a resumed run', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
const context = makeContext({
agentInstructions: {
pii: {
fields: ['description'],
starterPatterns: [],
customPatterns: [
{
id: 'private-resume-subagent-tool',
label: 'private resume subagent tool',
regex: 'PRIVATE-RESUME-SUBAGENT-TOOL',
},
],
},
},
});
context.options.agent.subagentAgentConfigs = [
{
id: 'pure-subagent',
model_parameters: { model: 'gpt-4' },
subagentAgentConfigs: [
{
id: 'nested-pure-subagent',
model_parameters: { model: 'gpt-4' },
toolDefinitions: [
{
name: 'resume_lookup',
description: 'PRIVATE-RESUME-SUBAGENT-TOOL',
parameters: { type: 'object' },
},
],
},
],
},
];
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'agent_instruction', field: 'description' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('re-inspects current extracted file text when policy tightens while paused', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
require('~/models').getMessages.mockResolvedValue([
{
messageId: 'parent-123',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
role: 'user',
text: 'inspect the attached file',
files: [{ file_id: 'file-paused', filename: 'report.txt' }],
},
]);
require('~/models').getFiles.mockResolvedValue([
{
file_id: 'file-paused',
filename: 'report.txt',
text: 'PRIVATE-EXTRACTED-TEXT',
},
]);
const context = makeContext({
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
customPatterns: [
{
id: 'private-extracted-text',
label: 'private extracted text',
regex: 'PRIVATE-EXTRACTED-TEXT',
},
],
uninspectable: 'block',
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'file', field: 'extracted_text' },
});
expect(require('~/models').getFiles).toHaveBeenCalledWith(
{
file_id: { $in: ['file-paused'] },
user: 'user-123',
},
{},
{},
);
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('freezes owner-hydrated resume files into the final model callback', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
const storedMessage = {
messageId: 'parent-123',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
role: 'user',
text: 'inspect the attached file',
files: [{ file_id: 'file-paused', filename: 'report.txt' }],
};
require('~/models').getMessages.mockResolvedValue([storedMessage]);
require('~/models').getFiles.mockResolvedValue([
{
file_id: 'file-paused',
filename: 'report.txt',
text: 'Safe extracted text',
},
]);
const resume = jest.fn().mockResolvedValue(undefined);
mockCreateRun.mockResolvedValue({ resume, getCalibrationRatio: jest.fn(() => 0) });
const context = makeContext({
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
});
await AgentClient.prototype.resumeCompletion.call(context, {
resumeValue: {},
storedMessages: [storedMessage],
});
const [modelBoundCallback] = mockCreateRun.mock.calls[0][0].modelCallbacks;
expect(() =>
modelBoundCallback.handleChatModelStart(undefined, [
[
{
role: 'human',
content: [{ type: 'input_file', file_id: 'file-paused' }],
additional_kwargs: { sourceMessageId: 'parent-123' },
},
],
]),
).not.toThrow();
});
it('fails closed when a paused file reference cannot be rehydrated', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
require('~/models').getMessages.mockResolvedValue([
{
messageId: 'parent-123',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
role: 'user',
text: 'inspect the attached file',
files: [{ file_id: 'missing-paused-file' }],
},
]);
const context = makeContext({
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_uninspectable',
body: { source: 'file', field: 'extracted_text' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('fails closed when resume file-reference traversal exceeds its depth bound', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
let nested = { file_id: 'deep-paused-file' };
for (let depth = 0; depth < 30; depth++) {
nested = { nested };
}
require('~/models').getMessages.mockResolvedValue([
{
messageId: 'parent-123',
parentMessageId: Constants.NO_PARENT,
isCreatedByUser: true,
role: 'user',
text: 'inspect the nested file',
content: [{ type: ContentTypes.TEXT, nested }],
},
]);
const context = makeContext({
files: {
pii: {
fields: ['extracted_text'],
starterPatterns: [],
uninspectable: 'block',
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} }),
).rejects.toMatchObject({
code: 'content_filter_uninspectable',
body: { source: 'file', field: 'extracted_text' },
});
expect(require('~/models').getFiles).not.toHaveBeenCalled();
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('blocks seeded tool arguments before rebuilding the run', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
const context = makeContext({
toolArguments: {
pii: {
fields: ['arguments'],
starterPatterns: [],
customPatterns: [
{
id: 'private-tool-input',
label: 'private tool input',
regex: 'PRIVATE-TOOL-INPUT',
},
],
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, {
resumeValue: {},
seedContent: [
{
type: ContentTypes.TOOL_CALL,
tool_call: {
name: 'example_tool',
arguments: { value: 'PRIVATE-TOOL-INPUT' },
},
},
],
}),
).rejects.toMatchObject({
code: 'content_filter_block',
body: { source: 'tool_argument', field: 'arguments' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('fails closed when selected seeded tool arguments cannot be fully traversed', async () => {
mockGetAgentCheckpointer.mockResolvedValue({
getTuple: jest.fn().mockResolvedValue({
checkpoint: { channel_values: { messages: [] } },
}),
});
const deepArguments = {};
let current = deepArguments;
for (let depth = 0; depth < 30; depth++) {
current.nested = {};
current = current.nested;
}
Object.defineProperty(deepArguments, 'toJSON', {
value: () => {
throw new Error('cannot serialize');
},
});
const context = makeContext({
toolArguments: {
pii: {
fields: ['arguments'],
starterPatterns: ['sk_prefix'],
},
},
});
await expect(
AgentClient.prototype.resumeCompletion.call(context, {
resumeValue: {},
seedContent: [
{
type: ContentTypes.TOOL_CALL,
tool_call: {
name: 'example_tool',
arguments: deepArguments,
},
},
],
}),
).rejects.toMatchObject({
code: 'content_filter_uninspectable',
body: { source: 'tool_argument', field: 'arguments' },
});
expect(mockCreateRun).not.toHaveBeenCalled();
});
it('does not log or persist provider error content while resuming', async () => {
const { logger } = require('@librechat/data-schemas');
const privateValue = 'PRIVATE-RESUME-PROVIDER-CONTENT';
const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
mockCreateRun.mockRejectedValue(
Object.assign(new Error(`Provider echoed ${privateValue}`), {
code: 'ERR_REMOTE',
response: { status: 422, data: { prompt: privateValue } },
}),
);
const context = makeContext({
messages: {
pii: {
fields: ['text'],
starterPatterns: ['email'],
},
},
});
await AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} });
expect(context.contentParts).toContainEqual({
type: ContentTypes.ERROR,
[ContentTypes.ERROR]: 'An error occurred while resuming the request',
});
expect(JSON.stringify(context.contentParts)).not.toContain(privateValue);
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(privateValue);
expect(errorSpy).toHaveBeenCalledWith(
'[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error',
expect.objectContaining({ type: 'Error' }),
);
errorSpy.mockRestore();
});
it('preserves provider error detail when content protection is disabled', async () => {
const providerMessage = 'Legacy provider detail';
mockCreateRun.mockRejectedValue(new Error(providerMessage));
const context = makeContext(undefined);
await AgentClient.prototype.resumeCompletion.call(context, { resumeValue: {} });
expect(context.contentParts).toContainEqual({
type: ContentTypes.ERROR,
[ContentTypes.ERROR]: `An error occurred while resuming the request: ${providerMessage}`,
});
});
});