LibreChat/api/server/controllers/agents/callbacks.background.spec.js
Danny Avila 67b7b441b2
🛂 feat: Filter Model-Bound Content by Source (#14425)
* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
2026-08-21 22:43:32 -04:00

225 lines
8.2 KiB
JavaScript

jest.mock('~/server/services/Files/Code/process', () => ({
processCodeOutput: jest.fn(),
runPreviewFinalize: jest.fn(),
}));
jest.mock('~/server/services/Files/Code/preflight', () => ({
preflightCodeOutputBatch: jest.fn(async ({ artifact }) =>
(artifact.files ?? [])
.filter((file) => file.inherited !== true)
.map((file) => ({
file,
sessionId: file.storage_session_id ?? artifact.session_id,
})),
),
}));
jest.mock('~/server/services/Files/Citations', () => ({ processFileCitations: jest.fn() }));
jest.mock('~/server/services/Files/process', () => ({ saveBase64Image: jest.fn() }));
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
const { preflightCodeOutputBatch } = require('~/server/services/Files/Code/preflight');
const { createBackgroundCodeResultHandler } = require('./callbacks');
const req = { user: { id: 'user-1' } };
const baseParams = {
toolName: 'execute_code',
toolCallId: 'call_code',
messageId: 'msg-dispatch',
conversationId: 'convo-1',
agentId: 'agent_a',
output: 'stdout:\nhello',
artifact: {
session_id: 'exec-sess',
files: [
{ id: 'f1', name: 'plot.png', storage_session_id: 'store-1' },
{ id: 'f2', name: 'input.csv', inherited: true },
],
},
};
describe('createBackgroundCodeResultHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists non-inherited files with the original identity and patches the message row', async () => {
processCodeOutput.mockResolvedValue({
file: { file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' },
finalize: undefined,
});
const updateToolCallResult = jest.fn().mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const result = await handler(baseParams);
expect(processCodeOutput).toHaveBeenCalledTimes(1);
expect(processCodeOutput).toHaveBeenCalledWith(
expect.objectContaining({
req,
id: 'f1',
name: 'plot.png',
messageId: 'msg-dispatch',
toolCallId: 'call_code',
conversationId: 'convo-1',
agentId: 'agent_a',
session_id: 'store-1',
freshClaimAfter: expect.any(Number),
}),
);
expect(updateToolCallResult).toHaveBeenCalledTimes(1);
expect(updateToolCallResult).toHaveBeenCalledWith({
userId: 'user-1',
messageId: 'msg-dispatch',
conversationId: 'convo-1',
toolCallId: 'call_code',
agentId: 'agent_a',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' }],
markBackgrounded: true,
});
expect(result).toEqual({
attachments: [{ file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' }],
});
});
it('anchors the stale-output guard to dispatch time when provided', async () => {
processCodeOutput.mockResolvedValue({ file: { file_id: 'f1' } });
const handler = createBackgroundCodeResultHandler({
req,
updateToolCallResult: jest.fn().mockResolvedValue({ matched: true, unfinished: false }),
});
await handler({ ...baseParams, dispatchedAt: 12345 });
expect(processCodeOutput).toHaveBeenCalledWith(
expect.objectContaining({ freshClaimAfter: 12345 }),
);
});
it('runs deferred preview finalization without a live stream callback', async () => {
const finalize = jest.fn();
processCodeOutput.mockResolvedValue({
file: { file_id: 'f1' },
finalize,
previewRevision: 3,
});
const handler = createBackgroundCodeResultHandler({
req,
updateToolCallResult: jest.fn().mockResolvedValue({ matched: true, unfinished: false }),
});
await handler(baseParams);
expect(runPreviewFinalize).toHaveBeenCalledWith({ finalize, fileId: 'f1', previewRevision: 3 });
});
it('retries the row patch until the dispatch turn persists', async () => {
jest.useFakeTimers();
try {
processCodeOutput.mockResolvedValue({ file: { file_id: 'f1' } });
const updateToolCallResult = jest
.fn()
.mockResolvedValueOnce({ matched: false, unfinished: false })
.mockResolvedValueOnce({ matched: false, unfinished: false })
.mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const promise = handler(baseParams);
await jest.advanceTimersByTimeAsync(250);
await jest.advanceTimersByTimeAsync(500);
const result = await promise;
expect(updateToolCallResult).toHaveBeenCalledTimes(3);
expect(result?.attachments).toHaveLength(1);
} finally {
jest.useRealTimers();
}
});
it('keeps re-applying past unfinished partial rows until a finalized row is patched', async () => {
jest.useFakeTimers();
try {
processCodeOutput.mockResolvedValue({ file: { file_id: 'f1' } });
/* A disconnect mid-turn persists an unfinished partial row; the later
* finalize save overwrites it with the in-memory handle JSON, so a
* patch that settled on the partial row must not stop the loop. */
const updateToolCallResult = jest
.fn()
.mockResolvedValueOnce({ matched: true, unfinished: true })
.mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const promise = handler(baseParams);
await jest.advanceTimersByTimeAsync(250);
const result = await promise;
expect(updateToolCallResult).toHaveBeenCalledTimes(2);
expect(result?.attachments).toHaveLength(1);
} finally {
jest.useRealTimers();
}
});
it('still patches output when a file download fails (files are best-effort)', async () => {
processCodeOutput.mockRejectedValue(new Error('download failed'));
const updateToolCallResult = jest.fn().mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const result = await handler(baseParams);
expect(updateToolCallResult).toHaveBeenCalledWith(
expect.objectContaining({
output: 'stdout:\nhello',
attachments: [],
markBackgrounded: true,
}),
);
expect(result).toEqual({ attachments: [] });
});
it('rejects a blocked generated-file batch before persistence or tool-result update', async () => {
const blocked = new Error('Generated file content blocked');
preflightCodeOutputBatch.mockRejectedValueOnce(blocked);
const updateToolCallResult = jest.fn();
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
await expect(handler(baseParams)).rejects.toBe(blocked);
expect(processCodeOutput).not.toHaveBeenCalled();
expect(updateToolCallResult).not.toHaveBeenCalled();
});
it('reapply mode re-applies the row patch without reprocessing files', async () => {
const updateToolCallResult = jest.fn().mockResolvedValue({ matched: true, unfinished: false });
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
const result = await handler({
...baseParams,
artifact: undefined,
attachments: [{ file_id: 'f1' }],
reapply: true,
});
expect(processCodeOutput).not.toHaveBeenCalled();
expect(updateToolCallResult).toHaveBeenCalledTimes(1);
expect(updateToolCallResult).toHaveBeenCalledWith(
expect.objectContaining({
messageId: 'msg-dispatch',
toolCallId: 'call_code',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1' }],
/** The heal path must re-stamp the marker: the full-row save it
* repairs reverted the whole patched part, marker included. */
markBackgrounded: true,
}),
);
expect(result).toEqual({ attachments: [{ file_id: 'f1' }] });
});
it('returns null without identity to anchor to', async () => {
const updateToolCallResult = jest.fn();
const handler = createBackgroundCodeResultHandler({ req, updateToolCallResult });
expect(await handler({ ...baseParams, messageId: undefined })).toBeNull();
expect(updateToolCallResult).not.toHaveBeenCalled();
});
});