mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-02 20:17:17 +00:00
* 📄 feat: Auto-render Text-Based Code Execution Artifacts Inline Eagerly extract text content from non-image artifacts produced by code execution tools and render it inline in the message instead of behind a click-to-download file card. Reuses the SkillFiles binary-detection helper and the existing parseDocument dispatcher so docx, xlsx, csv, html, code, and other text-renderable formats land directly under the tool call. PPTX is intentionally classified but not yet extracted — follow-up. * 🌐 chore: Remove unused com_download_expires locale key Removed in en/translation.json so the detect-unused-i18n-keys CI check passes. The only reference was a commented-out localize() call in LogContent.tsx that was deleted in the previous commit. * 🩹 fix: Address PR review on code artifact text extraction - extract.ts: build the temp document path from a randomUUID and pass path.basename(name) as originalname so a malicious artifact name cannot escape os.tmpdir() (P1 traversal flagged by codex/Copilot). - process.js: classify and extract using safeName, not the raw name — defense in depth alongside the temp-path fix. - classify.ts: add a bare-name lookup so extensionless text artifacts (Makefile, Dockerfile, …) classify as utf8-text instead of falling through to other. - Attachment.tsx: wire aria-expanded / aria-controls on the show-all toggle for screen reader support. - LogContent.tsx: restore a download chip (LogLink) on inline-text attachments so users can still pull down the underlying file. - Tests: cover extensionless filenames and the temp-path traversal invariant. * 🩹 fix: Address comprehensive PR review on code artifact extraction - extract.ts: walk back to a UTF-8 code-point boundary before truncating so cuts cannot land mid-multibyte and emit U+FFFD (CJK/emoji concern). truncate() now accepts the original buffer to skip a redundant encode. - extract.ts: add an 8s timeout around parseDocument via Promise.race so a pathological docx/xlsx cannot stall the response path. - process.js: always set `text` (string or null) on the file payload — createFile uses findOneAndUpdate with $set semantics, so omitting the field leaves a stale value behind when an artifact's content changes. - Attachment.tsx: switch the show-all toggle from char-count threshold to a useLayoutEffect ref measurement on scrollHeight, and use overflow-hidden when collapsed (overflow-auto when expanded) so the collapsed box has a single clear interaction model. - Attachment.tsx + LogContent.tsx: lift `isImageAttachment` / `isTextAttachment` into a shared attachmentTypes module. LogContent keeps its looser image check (no width/height required) because the legacy log surface receives attachments without dimensions. - Tests: cover multi-byte boundary, the always-set-text contract on updates, and the new shared predicates. * 🧪 test: Component test for TextAttachment + direct withTimeout coverage - Attachment.tsx: re-order local imports longest-to-shortest per AGENTS.md (attachmentTypes ahead of FileContainer/Image). - extract.ts: export withTimeout so it can be unit-tested directly (it's also used internally — exporting carries no runtime cost). - extract.spec.ts: three small unit tests on withTimeout that cover resolve, propagated rejection, and timeout rejection paths with real timers. - TextAttachment.test.tsx: ten cases for the new React component — text rendering in <pre>, download chip presence/absence, ref-based collapse measurement (with scrollHeight stubbed via prototype), aria-expanded toggle, fall-through to FileAttachment for missing and empty text, and AttachmentGroup routing. * 🩹 fix: Canonicalize document MIME by extension before parseDocument When the classifier puts a file on the document path via its extension (.docx, .xlsx, …) but the buffer sniffer returned a generic value like application/zip or application/octet-stream, we previously forwarded that generic MIME to parseDocument, which dispatches strictly by MIME and silently rejected it — exactly defeating the extension-first classification this PR added. extractDocument now remaps the MIME from the extension (falling back to the original sniffed MIME if the extension is unrecognized, so files that reached the document branch via MIME detection still work). Adds a parameterized test across docx/xlsx/xls/ods/odt against zip/octet sniffs to guard the regression. * 🩹 fix: Reuse existing withTimeout from utils/promise The previous commit's local withTimeout export collided with the already-exported `withTimeout` from `~/utils/promise`, breaking the @librechat/api tsc job (TS2308 ambiguous re-export). Drops the duplicate, imports from `~/utils/promise`, and removes the now-redundant unit tests (the helper has its own coverage in utils/promise.spec.ts). The third argument shifts from a label to the fully-formed timeout error message that the existing helper expects. * 🧹 chore: TextAttachment test polish (NITs) - Use the conventional `import Attachment, { AttachmentGroup }` form rather than `default as Attachment`. - Save the original `scrollHeight` property descriptor and restore it in afterAll, so the prototype patch never leaks past this suite.
526 lines
18 KiB
JavaScript
526 lines
18 KiB
JavaScript
// 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);
|
|
jest.mock('@librechat/api', () => {
|
|
const http = require('http');
|
|
const https = require('https');
|
|
return {
|
|
logAxiosError: jest.fn(),
|
|
getBasePath: jest.fn(() => ''),
|
|
sanitizeFilename: jest.fn((name) => name),
|
|
createAxiosInstance: jest.fn(() => mockAxios),
|
|
classifyCodeArtifact: (...args) => mockClassifyCodeArtifact(...args),
|
|
extractCodeArtifactText: (...args) => mockExtractCodeArtifactText(...args),
|
|
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 } = require('@librechat/api');
|
|
|
|
const { processCodeOutput, getSessionInfo } = 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 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 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 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('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 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 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('should detect MIME type from buffer', async () => {
|
|
const smallBuffer = Buffer.alloc(100);
|
|
mockAxios.mockResolvedValue({ data: smallBuffer });
|
|
determineFileType.mockResolvedValue({ mime: 'application/pdf' });
|
|
|
|
const 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 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 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 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();
|
|
});
|
|
});
|
|
|
|
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 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 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 result = await processCodeOutput(baseParams);
|
|
|
|
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 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 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 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 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 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 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('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);
|
|
});
|
|
});
|
|
});
|
|
});
|