267 lines
8.8 KiB
TypeScript
267 lines
8.8 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 AionUi (aionui.com)
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { getAcpImageFileName, getAcpImagePath, sanitizeAcpToolCallContent } from '@/common/chat/acpToolCallOutput';
|
|
import { composeMessage, mergeAcpToolCallContent } from '@/common/chat/chatLib';
|
|
import type { IMessageAcpToolCall, TMessage } from '@/common/chat/chatLib';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const createAcpToolCall = (
|
|
rawOutput: IMessageAcpToolCall['content']['update']['rawOutput'],
|
|
id = 'ig_test_image'
|
|
): IMessageAcpToolCall => ({
|
|
id,
|
|
msg_id: id,
|
|
conversation_id: 'conv-1',
|
|
type: 'acp_tool_call',
|
|
content: {
|
|
sessionId: 'sess-1',
|
|
update: {
|
|
sessionUpdate: 'tool_call_update',
|
|
tool_call_id: id,
|
|
status: 'completed',
|
|
title: 'Image generation',
|
|
kind: 'execute',
|
|
rawOutput,
|
|
},
|
|
},
|
|
});
|
|
|
|
describe('ACP tool call image output', () => {
|
|
it('omits oversized inline image results and keeps a local image reference', () => {
|
|
const content = createAcpToolCall({
|
|
saved_path: '/Users/test/.codex/generated_images/session/ig_test_image.png',
|
|
result: `iVBORw0KGgo${'A'.repeat(128 * 1024)}`,
|
|
}).content;
|
|
|
|
const sanitized = sanitizeAcpToolCallContent(content);
|
|
|
|
expect(sanitized.update.rawOutput?.result).toBeUndefined();
|
|
expect(sanitized.update.rawOutput?.result_omitted).toBe(true);
|
|
expect(sanitized.update.rawOutput?.result_omitted_reason).toBe('image_base64');
|
|
expect(sanitized.update.rawOutput?.image).toEqual({
|
|
path: '/Users/test/.codex/generated_images/session/ig_test_image.png',
|
|
mime_type: 'image/png',
|
|
source: 'codex_image_generation',
|
|
});
|
|
});
|
|
|
|
it('preserves small raw outputs that are safe to render inline', () => {
|
|
const content = createAcpToolCall({
|
|
saved_path: '/tmp/result.txt',
|
|
result: 'short result',
|
|
}).content;
|
|
|
|
const sanitized = sanitizeAcpToolCallContent(content);
|
|
|
|
expect(sanitized.update.rawOutput?.result).toBe('short result');
|
|
expect(sanitized.update.rawOutput?.image).toBeUndefined();
|
|
});
|
|
|
|
it('preserves oversized non-image results even when saved_path is present', () => {
|
|
const content = createAcpToolCall({
|
|
saved_path: '/tmp/result.txt',
|
|
result: `long text output ${'A'.repeat(128 * 1024)}`,
|
|
}).content;
|
|
|
|
const sanitized = sanitizeAcpToolCallContent(content);
|
|
|
|
expect(sanitized.update.rawOutput?.result).toContain('long text output');
|
|
expect(sanitized.update.rawOutput?.result_omitted).toBeUndefined();
|
|
expect(sanitized.update.rawOutput?.image).toBeUndefined();
|
|
});
|
|
|
|
it('omits oversized inline image results even without saved_path', () => {
|
|
const content = createAcpToolCall({
|
|
result: `iVBORw0KGgo${'A'.repeat(128 * 1024)}`,
|
|
}).content;
|
|
|
|
const sanitized = sanitizeAcpToolCallContent(content);
|
|
|
|
expect(sanitized.update.rawOutput?.result).toBeUndefined();
|
|
expect(sanitized.update.rawOutput?.result_omitted).toBe(true);
|
|
expect(sanitized.update.rawOutput?.image).toBeUndefined();
|
|
});
|
|
|
|
it('preserves missing and non-string raw outputs', () => {
|
|
const withoutRawOutput = sanitizeAcpToolCallContent(createAcpToolCall(undefined).content);
|
|
expect(withoutRawOutput.update.rawOutput).toBeUndefined();
|
|
|
|
const nonStringResult = sanitizeAcpToolCallContent(
|
|
createAcpToolCall({
|
|
saved_path: '/tmp/result.png',
|
|
result: { value: 'not a base64 string' },
|
|
}).content
|
|
);
|
|
expect(nonStringResult.update.rawOutput?.result).toEqual({ value: 'not a base64 string' });
|
|
expect(nonStringResult.update.rawOutput?.image).toBeUndefined();
|
|
});
|
|
|
|
it('sanitizes snake_case raw_output and preserves existing image metadata', () => {
|
|
const content = createAcpToolCall(undefined).content;
|
|
content.update.raw_output = {
|
|
saved_path: '/Users/test/.codex/generated_images/session/ig_test_image.gif',
|
|
image: {
|
|
path: '/custom/preview.gif',
|
|
mime_type: 'image/custom',
|
|
source: 'existing',
|
|
},
|
|
result: `data:image/gif;base64,${'A'.repeat(128 * 1024)}`,
|
|
result_omitted_reason: 'existing_reason',
|
|
result_bytes: 42,
|
|
};
|
|
|
|
const sanitized = sanitizeAcpToolCallContent(content);
|
|
|
|
expect(sanitized.update.raw_output?.result).toBeUndefined();
|
|
expect(sanitized.update.raw_output?.image).toEqual({
|
|
path: '/custom/preview.gif',
|
|
mime_type: 'image/custom',
|
|
source: 'existing',
|
|
});
|
|
expect(sanitized.update.raw_output?.result_omitted_reason).toBe('existing_reason');
|
|
expect(sanitized.update.raw_output?.result_bytes).toBe(42);
|
|
});
|
|
|
|
it('detects jpeg and gif mime types for sanitized image outputs', () => {
|
|
const jpeg = sanitizeAcpToolCallContent(
|
|
createAcpToolCall({
|
|
saved_path: '/Users/test/.codex/generated_images/session/ig_test_image.jpeg',
|
|
result: `/9j/${'A'.repeat(128 * 1024)}`,
|
|
}).content
|
|
);
|
|
const gif = sanitizeAcpToolCallContent(
|
|
createAcpToolCall({
|
|
saved_path: '/Users/test/.codex/generated_images/session/ig_test_image.gif',
|
|
result: `data:image/gif;base64,${'A'.repeat(128 * 1024)}`,
|
|
}).content
|
|
);
|
|
|
|
expect(jpeg.update.rawOutput?.image?.mime_type).toBe('image/jpeg');
|
|
expect(gif.update.rawOutput?.image?.mime_type).toBe('image/gif');
|
|
});
|
|
|
|
it('sanitizes incoming updates when merging an existing ACP tool call', () => {
|
|
const existing = createAcpToolCall(undefined).content;
|
|
const incoming = createAcpToolCall({
|
|
saved_path: '/Users/test/.codex/generated_images/session/ig_test_image.webp',
|
|
result: `UklGR${'A'.repeat(128 * 1024)}`,
|
|
}).content;
|
|
|
|
const merged = mergeAcpToolCallContent(existing, incoming);
|
|
|
|
expect(merged.update.rawOutput?.result).toBeUndefined();
|
|
expect(merged.update.rawOutput?.image?.mime_type).toBe('image/webp');
|
|
});
|
|
|
|
it('sanitizes newly inserted ACP tool call messages', () => {
|
|
const message = createAcpToolCall({
|
|
saved_path: '/Users/test/.codex/generated_images/session/ig_test_image.jpg',
|
|
result: `/9j/${'A'.repeat(128 * 1024)}`,
|
|
});
|
|
|
|
const list = composeMessage(message, []);
|
|
|
|
expect(list).toHaveLength(1);
|
|
const inserted = list[0] as IMessageAcpToolCall;
|
|
expect(inserted.content.update.rawOutput?.result).toBeUndefined();
|
|
expect(inserted.content.update.rawOutput?.image?.mime_type).toBe('image/jpeg');
|
|
});
|
|
|
|
it('sanitizes ACP tool call messages appended to a non-empty compose list', () => {
|
|
const textMessage: TMessage = {
|
|
id: 'text-1',
|
|
msg_id: 'text-1',
|
|
conversation_id: 'conv-1',
|
|
type: 'text',
|
|
position: 'left',
|
|
content: {
|
|
content: 'hello',
|
|
},
|
|
};
|
|
const message = createAcpToolCall({
|
|
saved_path: '/Users/test/.codex/generated_images/session/ig_appended.webp',
|
|
result: `UklGR${'A'.repeat(128 * 1024)}`,
|
|
});
|
|
|
|
const list = composeMessage(message, [textMessage]);
|
|
|
|
expect(list).toHaveLength(2);
|
|
const inserted = list[1] as IMessageAcpToolCall;
|
|
expect(inserted.content.update.rawOutput?.result).toBeUndefined();
|
|
expect(inserted.content.update.rawOutput?.image?.mime_type).toBe('image/webp');
|
|
});
|
|
|
|
it('leaves non-ACP compose messages unchanged', () => {
|
|
const message: TMessage = {
|
|
id: 'text-1',
|
|
msg_id: 'text-1',
|
|
conversation_id: 'conv-1',
|
|
type: 'text',
|
|
position: 'left',
|
|
content: {
|
|
content: 'hello',
|
|
},
|
|
};
|
|
const messageHandler = vi.fn();
|
|
|
|
const list = composeMessage(message, [], messageHandler);
|
|
|
|
expect(list).toEqual([message]);
|
|
expect(messageHandler).toHaveBeenCalledWith('insert', message);
|
|
});
|
|
|
|
it('resolves image preview paths with image.path preferred over saved_path', () => {
|
|
expect(
|
|
getAcpImagePath({
|
|
...createAcpToolCall(undefined).content.update,
|
|
rawOutput: {
|
|
saved_path: '/fallback.png',
|
|
image: {
|
|
path: '/preview.png',
|
|
},
|
|
},
|
|
})
|
|
).toBe('/preview.png');
|
|
|
|
expect(
|
|
getAcpImagePath({
|
|
...createAcpToolCall(undefined).content.update,
|
|
raw_output: {
|
|
saved_path: '/persisted.png',
|
|
},
|
|
})
|
|
).toBe('/persisted.png');
|
|
|
|
expect(getAcpImagePath(createAcpToolCall(undefined).content.update)).toBeUndefined();
|
|
});
|
|
|
|
it('does not resolve non-image saved_path as an image preview path', () => {
|
|
expect(
|
|
getAcpImagePath({
|
|
...createAcpToolCall(undefined).content.update,
|
|
rawOutput: {
|
|
saved_path: '/tmp/result.txt',
|
|
},
|
|
})
|
|
).toBeUndefined();
|
|
|
|
expect(
|
|
getAcpImagePath({
|
|
...createAcpToolCall(undefined).content.update,
|
|
rawOutput: {
|
|
saved_path: '/tmp/result.txt',
|
|
result_omitted_reason: 'image_base64',
|
|
},
|
|
})
|
|
).toBe('/tmp/result.txt');
|
|
});
|
|
|
|
it('falls back to a generated image file name when the path has no file name', () => {
|
|
expect(getAcpImageFileName('/')).toBe('generated-image.png');
|
|
});
|
|
});
|