// Configurable file size limit for tests - use a getter so it can be changed per test const fileSizeLimitConfig = { value: 20 * 1024 * 1024 }; // Default 20MB // Mock librechat-data-provider with configurable file size limit jest.mock('librechat-data-provider', () => { const actual = jest.requireActual('librechat-data-provider'); return { ...actual, mergeFileConfig: jest.fn((config) => { const merged = actual.mergeFileConfig(config); // Override the serverFileSizeLimit with our test value return { ...merged, get serverFileSizeLimit() { return fileSizeLimitConfig.value; }, }; }), getEndpointFileConfig: jest.fn((options) => { const config = actual.getEndpointFileConfig(options); // Override fileSizeLimit with our test value return { ...config, get fileSizeLimit() { return fileSizeLimitConfig.value; }, }; }), }; }); const { FileContext } = require('librechat-data-provider'); // Mock uuid jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid-1234'), })); // Mock axios — process.js now uses createAxiosInstance() from @librechat/api const mockAxios = jest.fn(); mockAxios.post = jest.fn(); mockAxios.isAxiosError = jest.fn(() => false); const mockClassifyCodeArtifact = jest.fn(() => 'other'); const mockExtractCodeArtifactText = jest.fn(async () => null); const mockGetExtractedTextFormat = jest.fn((_name, _mime, text) => (text == null ? null : 'text')); /* `hasOfficeHtmlPath` gates the persist-then-render split: when true, processCodeOutput * returns `{ file, finalize }` with the file persisted at `status: 'pending'` * and `finalize` runs the background extraction. Default false here so the * legacy single-phase tests below (txt/png/etc) exercise the inline path * unchanged. The dedicated office/finalize describe block toggles it on. */ const mockHasOfficeHtmlPath = jest.fn(() => false); /* Pass-through `withTimeout`: tests don't drive timeouts here (those live * in promise.spec.ts and the finalizePreview unit tests below). */ const passthroughWithTimeout = async (promise) => promise; jest.mock('@librechat/api', () => { const http = require('http'); const https = require('https'); return { logAxiosError: jest.fn(), getBasePath: jest.fn(() => ''), sanitizeArtifactPath: jest.fn((name) => name), flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')), createAxiosInstance: jest.fn(() => mockAxios), withTimeout: (...args) => passthroughWithTimeout(...args), hasOfficeHtmlPath: (...args) => mockHasOfficeHtmlPath(...args), /** * Arrow-function indirection (vs. a direct `jest.fn()` reference) so * tests can per-case `mockReturnValueOnce` / `mockImplementationOnce` * on `mockClassifyCodeArtifact` / `mockExtractCodeArtifactText`. * `jest.mock(...)` is hoisted above the outer `const` declarations * at parse time, so a direct reference here would capture * `undefined`; the arrow defers the binding to call time. The * direct-`jest.fn()` mocks below stay constant per file. */ classifyCodeArtifact: (...args) => mockClassifyCodeArtifact(...args), extractCodeArtifactText: (...args) => mockExtractCodeArtifactText(...args), /* `processCodeOutput` derives the `textFormat` trust flag for * `IMongoFile` from this helper — Codex P1 review on PR #12934. * The mock returns 'text' for non-null extractor output and null * otherwise so the downstream `file.textFormat` field is set to * a believable shape without modeling the office-HTML branch * (the dispatcher under test isn't exercising that path). Per- * test overrides via `mockGetExtractedTextFormat.mockReturnValue` * if a case needs to assert the 'html' value. */ getExtractedTextFormat: (...args) => mockGetExtractedTextFormat(...args), getStorageMetadata: jest.fn(() => ({})), codeServerHttpAgent: new http.Agent({ keepAlive: false }), codeServerHttpsAgent: new https.Agent({ keepAlive: false }), }; }); jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), }, })); jest.mock('@librechat/agents', () => ({ getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'), })); // Mock models const mockClaimCodeFile = jest.fn(); jest.mock('~/models', () => ({ createFile: jest.fn().mockResolvedValue({}), getFiles: jest.fn(), updateFile: jest.fn(), claimCodeFile: (...args) => mockClaimCodeFile(...args), })); // Mock permissions (must be before process.js import) jest.mock('~/server/services/Files/permissions', () => ({ filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)), })); // Mock strategy functions jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn(), })); // Mock convertImage jest.mock('~/server/services/Files/images/convert', () => ({ convertImage: jest.fn(), })); // Mock determineFileType jest.mock('~/server/utils', () => ({ determineFileType: jest.fn(), })); const http = require('http'); const https = require('https'); const { createFile, getFiles } = require('~/models'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { determineFileType } = require('~/server/utils'); const { logger } = require('@librechat/data-schemas'); const { codeServerHttpAgent, codeServerHttpsAgent, getStorageMetadata } = require('@librechat/api'); const { processCodeOutput, getSessionInfo, readSandboxFile, primeFiles } = require('./process'); describe('Code Process', () => { const mockReq = { user: { id: 'user-123' }, config: { fileConfig: {}, fileStrategy: 'local', imageOutputType: 'webp', }, }; const baseParams = { req: mockReq, id: 'file-id-123', name: 'test-file.txt', apiKey: 'test-api-key', toolCallId: 'tool-call-123', conversationId: 'conv-123', messageId: 'msg-123', session_id: 'session-123', }; beforeEach(() => { jest.clearAllMocks(); // Default mock: atomic claim returns a new file record (no existing file) mockClaimCodeFile.mockResolvedValue({ file_id: 'mock-uuid-1234', user: 'user-123', }); getFiles.mockResolvedValue(null); createFile.mockResolvedValue({}); getStrategyFunctions.mockReturnValue({ saveBuffer: jest.fn().mockResolvedValue('/uploads/mock-file-path.txt'), }); determineFileType.mockResolvedValue({ mime: 'text/plain' }); }); describe('atomic file claim (via processCodeOutput)', () => { it('should reuse file_id from existing record via atomic claim', async () => { mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-file-id', filename: 'test-file.txt', usage: 2, createdAt: '2024-01-01T00:00:00.000Z', }); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(mockClaimCodeFile).toHaveBeenCalledWith({ filename: 'test-file.txt', conversationId: 'conv-123', file_id: 'mock-uuid-1234', user: 'user-123', }); expect(result.file_id).toBe('existing-file-id'); expect(result.usage).toBe(3); expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z'); }); it('should create new file when no existing file found', async () => { mockClaimCodeFile.mockResolvedValue({ file_id: 'mock-uuid-1234', user: 'user-123', }); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(result.file_id).toBe('mock-uuid-1234'); expect(result.usage).toBe(1); }); }); describe('processCodeOutput', () => { describe('image file processing', () => { it('should process image files using convertImage', async () => { const imageParams = { ...baseParams, name: 'chart.png' }; const imageBuffer = Buffer.alloc(500); mockAxios.mockResolvedValue({ data: imageBuffer }); const convertedFile = { filepath: '/uploads/converted-image.webp', bytes: 400, }; convertImage.mockResolvedValue(convertedFile); const { file: result } = await processCodeOutput(imageParams); expect(convertImage).toHaveBeenCalledWith( mockReq, imageBuffer, 'high', 'mock-uuid-1234.png', ); expect(result.type).toBe('image/webp'); expect(result.context).toBe(FileContext.execute_code); expect(result.filename).toBe('chart.png'); }); it('persists tenantId on image code output records when present', async () => { const tenantReq = { ...mockReq, user: { ...mockReq.user, tenantId: 'tenantA' } }; const imageBuffer = Buffer.alloc(500); mockAxios.mockResolvedValue({ data: imageBuffer }); convertImage.mockResolvedValue({ filepath: '/t/tenantA/images/user-123/mock-uuid-1234.webp', }); await processCodeOutput({ ...baseParams, req: tenantReq, name: 'chart.png', }); expect(mockClaimCodeFile).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), ); expect(createFile).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), true, ); }); it('should update existing image file with cache-busted filepath', async () => { const imageParams = { ...baseParams, name: 'chart.png' }; mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-img-id', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', }); const imageBuffer = Buffer.alloc(500); mockAxios.mockResolvedValue({ data: imageBuffer }); convertImage.mockResolvedValue({ filepath: '/images/user-123/existing-img-id.webp' }); const { file: result } = await processCodeOutput(imageParams); expect(convertImage).toHaveBeenCalledWith( mockReq, imageBuffer, 'high', 'existing-img-id.png', ); expect(result.file_id).toBe('existing-img-id'); expect(result.usage).toBe(2); expect(result.filepath).toMatch(/^\/images\/user-123\/existing-img-id\.webp\?v=\d+$/); expect(logger.debug).toHaveBeenCalledWith( expect.stringContaining('Updating existing file'), ); }); }); describe('non-image file processing', () => { it('should process non-image files using saveBuffer', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved-file.txt'); getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); determineFileType.mockResolvedValue({ mime: 'text/plain' }); const { file: result } = await processCodeOutput(baseParams); expect(mockSaveBuffer).toHaveBeenCalledWith({ userId: 'user-123', buffer: smallBuffer, fileName: 'mock-uuid-1234__test-file.txt', basePath: 'uploads', }); expect(result.type).toBe('text/plain'); expect(result.filepath).toBe('/uploads/saved-file.txt'); expect(result.bytes).toBe(100); }); it.each([ [ 'slides.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ], ['sheet.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], [ 'document.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ], ])('preserves stored metadata for code-generated office file %s', async (name, mime) => { const cloudfrontReq = { ...mockReq, user: { ...mockReq.user, tenantId: 'tenantA' }, config: { ...mockReq.config, fileStrategy: 'cloudfront' }, }; const smallBuffer = Buffer.alloc(100); const filepath = `https://cdn.example.com/r/us-east-2/t/tenantA/uploads/user-123/mock-uuid-1234__${name}`; const storageKey = `r/us-east-2/t/tenantA/uploads/user-123/mock-uuid-1234__${name}`; mockAxios.mockResolvedValue({ data: smallBuffer }); determineFileType.mockResolvedValue({ mime }); mockHasOfficeHtmlPath.mockReturnValueOnce(true); getStorageMetadata.mockReturnValueOnce({ storageKey, storageRegion: 'us-east-2' }); const mockSaveBuffer = jest.fn().mockResolvedValue(filepath); getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); const { file: result, finalize } = await processCodeOutput({ ...baseParams, req: cloudfrontReq, name, }); expect(mockSaveBuffer).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-123', basePath: 'uploads', tenantId: 'tenantA', }), ); expect(result).toMatchObject({ file_id: 'mock-uuid-1234', user: 'user-123', tenantId: 'tenantA', source: 'cloudfront', filename: name, filepath, storageKey, storageRegion: 'us-east-2', status: 'pending', }); expect(createFile).toHaveBeenCalledWith( expect.objectContaining({ file_id: 'mock-uuid-1234', user: 'user-123', tenantId: 'tenantA', source: 'cloudfront', storageKey, storageRegion: 'us-east-2', }), true, ); expect(typeof finalize).toBe('function'); }); it('passes and persists tenantId for non-image code output records', async () => { const tenantReq = { ...mockReq, user: { ...mockReq.user, tenantId: 'tenantA' } }; const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const mockSaveBuffer = jest .fn() .mockResolvedValue('/t/tenantA/uploads/user-123/mock-file-path.txt'); getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); await processCodeOutput({ ...baseParams, req: tenantReq, }); expect(mockClaimCodeFile).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), ); expect(mockSaveBuffer).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), ); expect(createFile).toHaveBeenCalledWith( expect.objectContaining({ tenantId: 'tenantA' }), true, ); }); it('preserves nested directory paths in the DB record while flattening the storage key', async () => { /* Regression test for the silent-data-loss path: when codeapi reports a * file with a nested name like "test_folder/test_file.txt", LibreChat * used to feed it through `sanitizeFilename` (basename-only), which * persisted "test_file.txt" to the DB and made the file un-locatable on * the next prime() (cat /mnt/data/test_folder/test_file.txt would * 404). The fix: keep the path on the DB record (so primeFiles can * place it back at the same nested location), but flatten it for the * storage key (saveBuffer strategies key by single component). */ const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.txt'); getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); const { file: result } = await processCodeOutput({ ...baseParams, name: 'test_folder/test_file.txt', }); // Storage key flattens `/` to `__` so on-disk strategies don't // accidentally create real subdirectories under uploads/. expect(mockSaveBuffer).toHaveBeenCalledWith( expect.objectContaining({ fileName: 'mock-uuid-1234__test_folder__test_file.txt', }), ); // DB row keeps the nested path verbatim — that's what primeFiles // ships back to the sandbox on the next turn. expect(result.filename).toBe('test_folder/test_file.txt'); // Claim is also keyed by the path-preserving name so the // (filename, conversationId) compound key stays consistent. expect(mockClaimCodeFile).toHaveBeenCalledWith( expect.objectContaining({ filename: 'test_folder/test_file.txt' }), ); }); it('passes a NAME_MAX-aware budget to flattenArtifactPath when composing the storage key', async () => { /* Codex review P1: per-segment caps on the path-preserving form * aren't enough — once the segments are joined with `__` for the * storage key, deeply-nested or moderately long paths can still * exceed filesystem NAME_MAX (255) and cause `ENAMETOOLONG` in * saveBuffer. processCodeOutput must pass a file_id-aware budget * to flattenArtifactPath so the cap holds end-to-end. The unit * tests in `packages/api/src/utils/files.spec.ts` cover the * truncation logic itself; this test covers the integration. */ const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.bin'); getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); const flattenSpy = require('@librechat/api').flattenArtifactPath; flattenSpy.mockClear(); await processCodeOutput({ ...baseParams, name: 'a/b/c.csv' }); // The handler should call flattenArtifactPath with both the // safeName AND a budget = NAME_MAX (255) minus the prefix // (`${file_id}__`). file_id mock is `mock-uuid-1234` (14 chars), // so the budget should be 255 - 14 - 2 = 239. expect(flattenSpy).toHaveBeenCalledWith(expect.any(String), 239); }); it('passes the basename (not the full nested path) to classifyCodeArtifact and extractCodeArtifactText', async () => { /* Codex review P2: with the path-preserving sanitizer, `safeName` * can be a nested string like `reports.v1/Makefile`. The * classifier reads `extensionOf` against the full string, which * sees `.v1/Makefile` after the dotted-dir's first dot and * misclassifies the file as `other` (so text extraction is * skipped). Pass `path.basename(safeName)` instead. */ const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.txt'); getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); await processCodeOutput({ ...baseParams, name: 'reports.v1/Makefile', }); expect(mockClassifyCodeArtifact).toHaveBeenCalledWith('Makefile', expect.any(String)); expect(mockExtractCodeArtifactText).toHaveBeenCalledWith( expect.any(Buffer), 'Makefile', expect.any(String), expect.any(String), ); }); it('should detect MIME type from buffer', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); determineFileType.mockResolvedValue({ mime: 'application/pdf' }); const { file: result } = await processCodeOutput({ ...baseParams, name: 'document.pdf' }); expect(determineFileType).toHaveBeenCalledWith(smallBuffer, true); expect(result.type).toBe('application/pdf'); }); it('should fallback to application/octet-stream for unknown types', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); determineFileType.mockResolvedValue(null); const { file: result } = await processCodeOutput({ ...baseParams, name: 'unknown.xyz' }); expect(result.type).toBe('application/octet-stream'); }); }); describe('inline text extraction', () => { it('should populate text on the file when extractor returns content', async () => { const buffer = Buffer.from('hello world\n', 'utf-8'); mockAxios.mockResolvedValue({ data: buffer }); determineFileType.mockResolvedValue({ mime: 'text/plain' }); mockClassifyCodeArtifact.mockReturnValueOnce('utf8-text'); mockExtractCodeArtifactText.mockResolvedValueOnce('hello world\n'); const { file: result } = await processCodeOutput({ ...baseParams, name: 'note.txt' }); expect(mockClassifyCodeArtifact).toHaveBeenCalledWith('note.txt', 'text/plain'); expect(mockExtractCodeArtifactText).toHaveBeenCalledWith( buffer, 'note.txt', 'text/plain', 'utf8-text', ); expect(result.text).toBe('hello world\n'); expect(createFile).toHaveBeenCalledWith( expect.objectContaining({ text: 'hello world\n' }), true, ); }); it('should set text to null when extractor returns null so updates clear stale values', async () => { const buffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: buffer }); determineFileType.mockResolvedValue({ mime: 'application/octet-stream' }); mockClassifyCodeArtifact.mockReturnValueOnce('other'); mockExtractCodeArtifactText.mockResolvedValueOnce(null); const { file: result } = await processCodeOutput({ ...baseParams, name: 'archive.zip' }); expect(result.text).toBeNull(); const createCall = createFile.mock.calls[0][0]; expect(createCall.text).toBeNull(); }); it('should overwrite a previously-stored text value when re-emitting a now-binary file', async () => { // Same filename + conversationId already has a stored text value; // claimCodeFile returns the existing record (isUpdate path). mockClaimCodeFile.mockResolvedValueOnce({ file_id: 'existing-id', filename: 'output.bin', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', }); const binaryBuffer = Buffer.from([0x00, 0xff, 0x00, 0xff]); mockAxios.mockResolvedValue({ data: binaryBuffer }); determineFileType.mockResolvedValue({ mime: 'application/octet-stream' }); mockClassifyCodeArtifact.mockReturnValueOnce('other'); mockExtractCodeArtifactText.mockResolvedValueOnce(null); await processCodeOutput({ ...baseParams, name: 'output.bin' }); // null (not omitted) so $set clears any prior `text` value. const createCall = createFile.mock.calls[0][0]; expect(createCall).toHaveProperty('text', null); }); it('should not invoke text extraction for image files', async () => { const imageBuffer = Buffer.alloc(500); mockAxios.mockResolvedValue({ data: imageBuffer }); convertImage.mockResolvedValue({ filepath: '/uploads/x.webp', bytes: 400 }); await processCodeOutput({ ...baseParams, name: 'chart.png' }); expect(mockClassifyCodeArtifact).not.toHaveBeenCalled(); expect(mockExtractCodeArtifactText).not.toHaveBeenCalled(); }); it('clears deferred-preview lifecycle fields so a prior office record at this file_id stops looking pending', async () => { /* Codex P2: same (filename, conversationId) was previously an * office artifact, leaving status/previewError/previewRevision * populated. The non-office update must reset them or the * client renders the wrong state for the now non-office file. */ mockClaimCodeFile.mockResolvedValueOnce({ file_id: 'reused-id', filename: 'output.txt', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', }); mockAxios.mockResolvedValue({ data: Buffer.from('hello') }); determineFileType.mockResolvedValue({ mime: 'text/plain' }); mockClassifyCodeArtifact.mockReturnValueOnce('text'); mockHasOfficeHtmlPath.mockReturnValueOnce(false); mockExtractCodeArtifactText.mockResolvedValueOnce('hello'); await processCodeOutput({ ...baseParams, name: 'output.txt' }); const createCall = createFile.mock.calls[0][0]; expect(createCall).toHaveProperty('status', null); expect(createCall).toHaveProperty('previewError', null); expect(createCall).toHaveProperty('previewRevision', null); }); }); describe('file size limit enforcement', () => { it('should fallback to download URL when file exceeds size limit', async () => { // Set a small file size limit for this test fileSizeLimitConfig.value = 1000; // 1KB limit const largeBuffer = Buffer.alloc(5000); // 5KB - exceeds 1KB limit mockAxios.mockResolvedValue({ data: largeBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('exceeds size limit')); expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123'); expect(result.expiresAt).toBeDefined(); // Should not call createFile for oversized files (fallback path) expect(createFile).not.toHaveBeenCalled(); // Reset to default for other tests fileSizeLimitConfig.value = 20 * 1024 * 1024; }); }); describe('fallback behavior', () => { it('should fallback to download URL when saveBuffer is not available', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); getStrategyFunctions.mockReturnValue({ saveBuffer: null }); const { file: result } = await processCodeOutput(baseParams); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('saveBuffer not available'), ); expect(result.filepath).toContain('/api/files/code/download/'); expect(result.filename).toBe('test-file.txt'); }); it('should fallback to download URL on axios error', async () => { mockAxios.mockRejectedValue(new Error('Network error')); const { file: result } = await processCodeOutput(baseParams); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('Falling back to Code API download URL for strategy local'), ); expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123'); expect(result.conversationId).toBe('conv-123'); expect(result.messageId).toBe('msg-123'); expect(result.toolCallId).toBe('tool-call-123'); }); }); describe('usage counter increment', () => { it('should set usage to 1 for new files', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(result.usage).toBe(1); }); it('should increment usage for existing files', async () => { mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-id', usage: 5, createdAt: '2024-01-01', }); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(result.usage).toBe(6); }); it('should handle existing file with undefined usage', async () => { mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-id', createdAt: '2024-01-01', }); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(result.usage).toBe(1); }); }); describe('metadata and file properties', () => { it('should include fileIdentifier in metadata', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(result.metadata).toEqual({ fileIdentifier: 'session-123/file-id-123', }); }); it('should set correct context for code-generated files', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(result.context).toBe(FileContext.execute_code); }); it('should include toolCallId and messageId in result', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput(baseParams); expect(result.toolCallId).toBe('tool-call-123'); expect(result.messageId).toBe('msg-123'); }); it('should call createFile with upsert enabled', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); await processCodeOutput(baseParams); expect(createFile).toHaveBeenCalledWith( expect.objectContaining({ file_id: 'mock-uuid-1234', context: FileContext.execute_code, }), true, // upsert flag ); }); }); describe('persistedMessageId (regression for cross-turn priming)', () => { /** * `getCodeGeneratedFiles` filters by `messageId IN ` * to scope files to the current branch. If `processCodeOutput` overwrote * the file's `messageId` with the current run's id on every update, a * file re-touched by a later turn (e.g. a failed read attempt that * re-shells the same filename) would lose its link to the assistant * message that originally produced it. Subsequent turns then can't find * it via `getCodeGeneratedFiles`, the priming chain has nothing to seed, * and the model thinks its own prior-turn artifact disappeared. * * Contract: * - On UPDATE (claimCodeFile returned an existing record): the persisted * `messageId` is `claimed.messageId` (preserved). Falls back to the * current run's `messageId` when the existing record predates the * `messageId` field (legacy data). * - On CREATE (new file): the persisted `messageId` is the current run's. * - The runtime return value ALWAYS uses the current run's `messageId` * via `Object.assign(file, { messageId, toolCallId })` so the artifact * attaches to the correct tool_call in the live response. */ /** * `processCodeOutput` mutates the file object after `createFile` returns * (`Object.assign(file, { messageId, toolCallId })`) so the runtime * caller sees the live messageId on the response. Reading * `createFile.mock.calls[0][0]` directly would therefore reflect the * post-mutation state because JS captures by reference. To assert * what was actually PERSISTED, snapshot the args at call time. */ function snapshotCreateFileArgs() { const snapshots = []; createFile.mockImplementation(async (file) => { snapshots.push({ ...file }); return {}; }); return snapshots; } it('preserves the original messageId in the persisted record on UPDATE', async () => { mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-id', filename: 'sentinel.txt', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', messageId: 'turn-1-original-msg', }); const persisted = snapshotCreateFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); await processCodeOutput({ ...baseParams, name: 'sentinel.txt', messageId: 'turn-2-current-run-msg', }); expect(persisted[0].messageId).toBe('turn-1-original-msg'); }); it('falls back to current run messageId on UPDATE when claimed.messageId is undefined (legacy record)', async () => { // Legacy record predates the persistedMessageId tracking. mockClaimCodeFile.mockResolvedValue({ file_id: 'legacy-id', filename: 'legacy.txt', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', // messageId intentionally absent }); const persisted = snapshotCreateFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); await processCodeOutput({ ...baseParams, name: 'legacy.txt', messageId: 'turn-N-current-run-msg', }); expect(persisted[0].messageId).toBe('turn-N-current-run-msg'); }); it('uses the current run messageId on CREATE (no claimed record)', async () => { mockClaimCodeFile.mockResolvedValue({ file_id: 'mock-uuid-1234', user: 'user-123', }); const persisted = snapshotCreateFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); await processCodeOutput({ ...baseParams, messageId: 'turn-1-create-msg', }); expect(persisted[0].messageId).toBe('turn-1-create-msg'); }); it('returns the CURRENT run messageId in the runtime response even on UPDATE (artifact attribution)', async () => { // The persisted DB record keeps the original messageId, but the // returned object surfaces the live messageId so the artifact lands // on the correct tool_call in this run's response. mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-id', filename: 'sentinel.txt', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', messageId: 'turn-1-original-msg', }); const persisted = snapshotCreateFileArgs(); const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); const { file: result } = await processCodeOutput({ ...baseParams, name: 'sentinel.txt', messageId: 'turn-2-current-run-msg', }); // DB preserves original expect(persisted[0].messageId).toBe('turn-1-original-msg'); // Runtime return surfaces the live (current) messageId expect(result.messageId).toBe('turn-2-current-run-msg'); }); it('preserves the original messageId on UPDATE for image files too', async () => { // Same contract as text files; the image branch builds its own file // record and would silently regress if the ternary diverged there. mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-img', filename: 'chart.png', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', messageId: 'turn-1-image-msg', }); const persisted = snapshotCreateFileArgs(); const imageBuffer = Buffer.alloc(500); mockAxios.mockResolvedValue({ data: imageBuffer }); convertImage.mockResolvedValue({ filepath: '/uploads/chart.webp', bytes: 400, }); await processCodeOutput({ ...baseParams, name: 'chart.png', messageId: 'turn-2-current-img-msg', }); expect(persisted[0].messageId).toBe('turn-1-image-msg'); }); }); describe('socket pool isolation', () => { it('should pass dedicated keepAlive:false agents to axios for processCodeOutput', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); await processCodeOutput(baseParams); const callConfig = mockAxios.mock.calls[0][0]; expect(callConfig.httpAgent).toBe(codeServerHttpAgent); expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent); expect(callConfig.httpAgent).toBeInstanceOf(http.Agent); expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent); expect(callConfig.httpAgent.keepAlive).toBe(false); expect(callConfig.httpsAgent.keepAlive).toBe(false); }); it('should pass dedicated keepAlive:false agents to axios for getSessionInfo', async () => { mockAxios.mockResolvedValue({ data: [{ name: 'sess/fid', lastModified: new Date().toISOString() }], }); await getSessionInfo('sess/fid', 'api-key'); const callConfig = mockAxios.mock.calls[0][0]; expect(callConfig.httpAgent).toBe(codeServerHttpAgent); expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent); expect(callConfig.httpAgent.keepAlive).toBe(false); expect(callConfig.httpsAgent.keepAlive).toBe(false); }); }); describe('deferred-preview flow (office-bucket files)', () => { /* Office-bucket files (DOCX/XLSX/etc.) split into: * the initial emit (await): persist `text: null, status: 'pending'`, * return `{ file, finalize }` so the caller can ship the * attachment to the client immediately; * the deferred render (background): finalize() invokes the extractor and * transitions the record to 'ready' (with text/textFormat) or * 'failed' (with previewError). The agent's final response * never blocks on the deferred render. * * The `hasOfficeHtmlPath` mock is the gate. Other tests keep it * at `false` (legacy single-phase path); we flip it on here. */ const { updateFile } = require('~/models'); beforeEach(() => { mockHasOfficeHtmlPath.mockReturnValue(true); updateFile.mockResolvedValue({ file_id: 'mock-uuid-1234', status: 'ready' }); }); afterEach(() => { mockHasOfficeHtmlPath.mockReturnValue(false); }); it('persists the initial emit with status:pending and text:null, deferring extraction', async () => { mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); determineFileType.mockResolvedValue({ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const result = await processCodeOutput({ ...baseParams, name: 'data.xlsx' }); expect(result.file).toMatchObject({ file_id: 'mock-uuid-1234', filename: 'data.xlsx', status: 'pending', text: null, textFormat: null, }); expect(typeof result.finalize).toBe('function'); // Extractor MUST NOT have been called yet — that's deferred preview work. expect(mockExtractCodeArtifactText).not.toHaveBeenCalled(); // Persisted record with the pending status. expect(createFile).toHaveBeenCalledWith( expect.objectContaining({ status: 'pending', text: null, textFormat: null }), true, ); }); it('finalize() runs the extractor, transitions to ready with text+textFormat on success', async () => { mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); determineFileType.mockResolvedValue({ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); mockExtractCodeArtifactText.mockResolvedValueOnce('
1
'); mockGetExtractedTextFormat.mockReturnValueOnce('html'); const { finalize } = await processCodeOutput({ ...baseParams, name: 'data.xlsx' }); await finalize(); expect(mockExtractCodeArtifactText).toHaveBeenCalledTimes(1); /* Update is conditional on `previewRevision` so an older render * can't overwrite a newer turn's record on cross-turn filename * reuse. The uuid mock returns the same value for every v4() * call, so file_id and previewRevision happen to coincide here * — what matters is the second arg carries the revision filter. */ expect(updateFile).toHaveBeenCalledWith( { file_id: 'mock-uuid-1234', text: '
1
', textFormat: 'html', status: 'ready', previewError: null, }, { previewRevision: 'mock-uuid-1234' }, ); }); it('finalize() transitions to failed with previewError when extractor returns null (HTML-or-null contract)', async () => { mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); determineFileType.mockResolvedValue({ mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); mockExtractCodeArtifactText.mockResolvedValueOnce(null); // Office bucket + null text → must be 'failed', NEVER raw text fallback // (PR #12934 SEC fix: prevents