Files
iofficeai--aionui/tests/unit/chat/acpToolCallOutput.test.ts
2026-07-13 13:20:22 +08:00

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');
});
});