dsh-im-ops/test/image-prompt.test.mjs

278 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_IMAGE_PROMPT,
ImagePromptError,
fetchImageBuffer,
hasInboundPrompt,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../src/channels/shared/image-prompt.mjs';
import { HarnessClient } from '../src/channels/shared/harness-client.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
test('text-only messages retain the existing Harness content shape', async () => {
assert.equal(hasInboundPrompt({ content: ' hello ' }), true);
assert.deepEqual(await promptContentForMessage({ content: ' hello ' }), [
{ type: 'text', text: 'hello' },
]);
});
test('image-only messages lazily load bytes and receive a useful default instruction', async () => {
let loadOptions;
const content = await promptContentForMessage({
content: '',
images: [{
name: '../photo.png',
async load(options) {
loadOptions = options;
return PNG_1X1;
},
}],
});
assert.equal(loadOptions.maxBytes, 5 * 1024 * 1024);
assert.deepEqual(content, [
{ type: 'text', text: DEFAULT_IMAGE_PROMPT },
{
type: 'image',
mediaType: 'image/png',
data: PNG_1X1.toString('base64'),
name: 'photo.png',
},
]);
});
test('captions and multiple image parts preserve their input order', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
const gif = Buffer.from('GIF89a payload');
assert.deepEqual(await promptContentForMessage({
content: 'compare these',
images: [{ data: jpeg }, { data: gif }],
}), [
{ type: 'text', text: 'compare these' },
{ type: 'image', mediaType: 'image/jpeg', data: jpeg.toString('base64') },
{ type: 'image', mediaType: 'image/gif', data: gif.toString('base64') },
]);
});
test('declared oversized images are rejected without downloading them', async () => {
let loaded = false;
await assert.rejects(
promptContentForMessage({
images: [{
size: 5 * 1024 * 1024 + 1,
async load() { loaded = true; return PNG_1X1; },
}],
}),
(error) => error instanceof ImagePromptError && error.code === 'image-too-large',
);
assert.equal(loaded, false);
});
test('multiple images share an aggregate byte limit', async () => {
let secondLoaded = false;
await assert.rejects(
promptContentForMessage({
images: [
{ size: PNG_1X1.length, async load() { return PNG_1X1; } },
{
size: PNG_1X1.length,
async load() { secondLoaded = true; return PNG_1X1; },
},
],
}, { maxTotalImageBytes: PNG_1X1.length + 1 }),
(error) => error instanceof ImagePromptError && error.code === 'images-too-large',
);
assert.equal(secondLoaded, false);
});
test('unsupported image bytes receive a channel-safe error', async () => {
await assert.rejects(
promptContentForMessage({ images: [{ data: Buffer.from('not an image') }] }),
(error) => error instanceof ImagePromptError
&& error.code === 'unsupported-image-type'
&& /JPEG/.test(error.userMessage),
);
});
test('Harness attachment failures map only allowlisted reasons to safe channel messages', () => {
const modelError = Object.assign(new Error('private provider detail'), {
code: 'attachment-error',
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES', privatePath: '/secret/model' },
});
assert.deepEqual(imagePromptDiagnostic(modelError), {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
userMessage: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
});
assert.doesNotMatch(imagePromptUserMessage(modelError), /private|secret/);
for (const reason of [
'IMAGE_TOO_LARGE',
'IMAGE_TOO_MANY_PIXELS',
'INVALID_IMAGE',
'INVALID_IMAGE_BASE64',
'IMAGE_TYPE_MISMATCH',
'TOO_MANY_IMAGES',
'IMAGES_TOO_LARGE',
]) {
assert.equal(typeof imagePromptUserMessage({
code: 'attachment-error', details: { reason },
}), 'string', reason);
}
});
test('unknown Harness failures remain generic and cannot access inherited map properties', () => {
for (const reason of ['FUTURE_PRIVATE_REASON', 'toString']) {
const error = {
code: 'attachment-error',
details: { reason },
message: 'provider token and /private/path',
};
assert.equal(imagePromptDiagnostic(error), null);
assert.equal(imagePromptUserMessage(error), null);
}
assert.equal(imagePromptUserMessage({
code: 'agent-busy',
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
}), null);
});
test('bounded HTTPS downloads enforce response size before buffering', async () => {
let cancelled = false;
const fetchImpl = async (url, options) => {
assert.equal(url.href, 'https://files.example/image.png');
assert.equal(options.redirect, 'manual');
return {
ok: true,
status: 200,
headers: { get: (name) => (name === 'content-length' ? '9' : null) },
body: { async cancel() { cancelled = true; } },
async arrayBuffer() { throw new Error('must not buffer'); },
};
};
await assert.rejects(
fetchImageBuffer('https://files.example/image.png', { fetchImpl, maxBytes: 8 }),
(error) => error instanceof ImagePromptError && error.code === 'image-too-large',
);
assert.equal(cancelled, true);
});
test('image downloads reject insecure platform URLs', async () => {
await assert.rejects(
fetchImageBuffer('http://files.example/image.png'),
/must use HTTPS/,
);
});
test('image downloads enforce messaging-platform host allowlists', async () => {
await assert.rejects(
fetchImageBuffer('https://cdn.attacker.example/image.png', {
allowedHosts: ['cdn.discordapp.com', '.slack.com'],
}),
/not hosted by the messaging platform/,
);
const data = await fetchImageBuffer('https://files.slack.com/image.png', {
allowedHosts: ['.slack.com'],
fetchImpl: async () => ({
ok: true,
headers: { get: () => null },
async arrayBuffer() { return PNG_1X1; },
}),
});
assert.deepEqual(data, PNG_1X1);
});
test('image downloads report redirects without following or exposing the target', async () => {
let options;
let cancelled = false;
await assert.rejects(
fetchImageBuffer('https://files.example.test/image', {
fetchImpl: async (_url, requestOptions) => {
options = requestOptions;
return {
ok: false,
status: 302,
headers: { get: () => null },
body: { async cancel() { cancelled = true; } },
};
},
}),
(error) => {
assert.equal(error.code, 'image-redirect-blocked');
assert.doesNotMatch(error.message, /private|token|hidden/);
return true;
},
);
assert.equal(options.redirect, 'manual');
assert.equal(cancelled, true);
});
test('image downloads report the safe HTTP status for non-success responses', async () => {
let cancelled = false;
await assert.rejects(
fetchImageBuffer('https://files.example.test/image', {
fetchImpl: async () => ({
ok: false,
status: 403,
headers: { get: () => null },
body: { async cancel() { cancelled = true; } },
}),
}),
(error) => error.code === 'image-http-error'
&& error.userMessage === '图片下载失败(HTTP 403),请重新发送后再试。',
);
assert.equal(cancelled, true);
});
test('HarnessClient sends structured image content without rewriting it', async () => {
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/image-prompt-test',
});
client.ensureRunning = async () => true;
let promptPayload;
let promptRpcId;
let historyCalls = 0;
client.rpc = async (method, payload, _timeoutMs, options) => {
if (method === 'session.prompt') {
promptPayload = payload;
promptRpcId = options.rpcId;
return {};
}
assert.equal(method, 'session.history');
historyCalls += 1;
if (historyCalls === 1) return { events: [] };
return {
events: [
{ event: { seq: 1, type: 'turn/start', data: { turn: 1 } } },
{ event: {
seq: 2,
type: 'user/message',
data: { turn: 1, source: { rpcId: promptRpcId } },
} },
{ event: {
seq: 3,
type: 'assistant/message',
data: { turn: 1, message: { content: [{ type: 'text', text: 'a cat' }] } },
} },
{ event: { seq: 4, type: 'turn/end', data: { turn: 1, reason: 'completed' } } },
],
};
};
const content = [
{ type: 'text', text: 'what is this?' },
{ type: 'image', mediaType: 'image/png', data: PNG_1X1.toString('base64') },
];
assert.equal(await client.ask('session-image', content, { timeoutMs: 2_000 }), 'a cat');
assert.deepEqual(promptPayload.content, content);
assert.equal(promptPayload.sessionId, 'session-image');
});