fix(channels): clarify unsupported image model errors

This commit is contained in:
xmanrui 2026-08-21 19:07:18 +08:00
parent c3b3a29f27
commit 5e5c7e46c5
12 changed files with 436 additions and 142 deletions

View file

@ -6,6 +6,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
import { normalizeSnapshot } from '../../../plugin-src/client/channels/weixin/api.js';
import {
AccountCard,
@ -57,6 +58,13 @@ test('Weixin client keeps only the public connection-test result', () => {
state: 'connected',
configured: true,
bot: { name: '微信机器人', accountIdMasked: 'account••••1234' },
lastMessageError: {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
providerDetail: 'must-not-cross-client-normalization',
},
}],
});
@ -64,6 +72,42 @@ test('Weixin client keeps only the public connection-test result', () => {
sent: false,
code: 'test-target-unavailable',
});
assert.deepEqual(snapshot.bots[0].lastMessageError, {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
});
assert.doesNotMatch(JSON.stringify(snapshot), /must-not-cross-client-normalization/);
});
test('Weixin account card shows the latest safe message-processing error', () => {
const props = {
account: {
...account('wx_image', '微信机器人'),
lastMessageError: {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
at: Date.now(),
},
},
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
};
const markup = renderToStaticMarkup(React.createElement(AccountCard, props));
assert.match(markup, /最近一条消息处理失败/);
assert.match(markup, /当前模型不支持图片/);
setImTranslator((key) => en[key] ?? key);
try {
const english = renderToStaticMarkup(React.createElement(AccountCard, props));
assert.match(english, /Latest message failed/);
assert.match(english, /current model does not support images/i);
assert.doesNotMatch(english, /[\p{Script=Han}]/u);
} finally {
setImTranslator(null);
}
});
test('Weixin card feedback stays visible without hiding connection errors', () => {

View file

@ -188,6 +188,53 @@ test('Weixin returns a specific retry message when encrypted image loading fails
assert.equal(fixture.seen.has('weixin-image-error'), true);
});
test('Weixin explains model image rejection and records only safe structured diagnostics', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-image');
const sent = [];
const status = createWeixinBridgeStatus();
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: () => [{ name: 'image', data: PNG_BYTES }],
sendText: async (request) => sent.push(request),
},
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async () => {
throw Object.assign(new Error('Model detail at /private/path with provider-token'), {
code: 'attachment-error',
details: {
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
providerDetail: 'must-not-cross-status-boundary',
},
});
},
},
state: fixture.state,
status,
logger: { error() {} },
});
await bridge.accept(message('weixin-model-image-error', '', {
item_list: [{ type: 2, image_item: { media: {} } }],
}));
assert.match(sent.at(-1).text, /当前模型不支持图片/);
assert.match(sent.at(-1).text, /\/models/);
assert.equal(fixture.seen.has('weixin-model-image-error'), true);
assert.deepEqual(status.lastMessageError, {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: sent.at(-1).text,
at: status.lastMessageError.at,
});
assert.equal(Number.isFinite(status.lastMessageError.at), true);
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private|provider-token|providerDetail/);
});
test('Weixin executes /compact for the bound Session without prompting the model', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-compact');
@ -1088,6 +1135,7 @@ test('bridge commands are local and internal failures return a generic message',
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'old-session');
const sent = [];
const status = createWeixinBridgeStatus();
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request.text) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
@ -1099,6 +1147,7 @@ test('bridge commands are local and internal failures return a generic message',
ask: async () => { throw new Error('private path /secret and token-shaped detail'); },
},
state: fixture.state,
status,
logger: { error() {} },
});
@ -1107,4 +1156,11 @@ test('bridge commands are local and internal failures return a generic message',
await bridge.accept(message('failure', '触发失败'));
assert.match(sent.at(-1), /消息处理失败/);
assert.doesNotMatch(sent.at(-1), /private path|secret|token-shaped/);
assert.deepEqual(status.lastMessageError, {
code: 'message-processing-failed',
reason: 'UNKNOWN',
message: '消息处理失败,请稍后重试。',
at: status.lastMessageError.at,
});
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private path|secret|token-shaped/);
});

View file

@ -47,7 +47,7 @@ function configFixture() {
};
}
function runtimeFactory({ failStart = false, startError } = {}) {
function runtimeFactory({ failStart = false, startError, lastMessageError = null } = {}) {
const runtimes = [];
const connectionTests = [];
const createRuntime = async ({ config, token }) => {
@ -61,6 +61,7 @@ function runtimeFactory({ failStart = false, startError } = {}) {
weixinConnectionState: ready ? 'connected' : 'idle',
harnessReachable: ready,
lastCheckedAt: ready ? 100 : null,
lastMessageError,
};
},
async start() {
@ -80,7 +81,15 @@ function runtimeFactory({ failStart = false, startError } = {}) {
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory();
const runtimes = runtimeFactory({
lastMessageError: {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
providerDetail: 'must-not-cross-controller-boundary',
},
});
const controller = new WeixinController({
api: {
beginLogin: async ({ localTokens }) => {
@ -116,6 +125,13 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
const publicJson = JSON.stringify(controller.status());
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
assert.equal(controller.status().totals.connected, 1);
assert.deepEqual(controller.status().bots[0].lastMessageError, {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
});
assert.doesNotMatch(publicJson, /must-not-cross-controller-boundary/);
await controller.sendConnectionTest(completed.botId);
assert.equal(runtimes.connectionTests[0].botId, completed.botId);

View file

@ -6,6 +6,8 @@ import {
ImagePromptError,
fetchImageBuffer,
hasInboundPrompt,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from '../src/channels/shared/image-prompt.mjs';
import { HarnessClient } from '../src/channels/shared/harness-client.mjs';
@ -100,6 +102,49 @@ test('unsupported image bytes receive a channel-safe error', async () => {
);
});
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) => {