feat: add structured message failure reporting

This commit is contained in:
xmanrui 2026-08-25 13:42:43 +08:00
parent b42fc411c2
commit ad3c08ace3
59 changed files with 2405 additions and 607 deletions

View file

@ -147,6 +147,7 @@ test('a definitive native-image rejection falls back to file delivery', async (t
test('a failed file fallback produces one failure receipt and one safe notice', async (t) => {
const artifact = await committedArtifact(t, 'result.webp', Buffer.from([1]));
const calls = [];
const classifiedFailure = { referenceId: 'MF-ARTIFACT1' };
const delivery = await deliverOutboundArtifacts({
artifacts: [artifact],
@ -159,13 +160,22 @@ test('a failed file fallback produces one failure receipt and one safe notice',
calls.push('file');
throw rejected('artifact-provider-failed');
},
sendFailureNotice: async () => {
onFailure: (failedArtifact, error) => {
calls.push('classify');
assert.equal(failedArtifact, artifact);
assert.equal(error.code, 'artifact-provider-failed');
return classifiedFailure;
},
sendFailureNotice: async (failedArtifact, error, failure) => {
calls.push('notice');
assert.equal(failedArtifact, artifact);
assert.equal(error.code, 'artifact-provider-failed');
assert.equal(failure, classifiedFailure);
return { messageId: 'notice-after-fallback' };
},
});
assert.deepEqual(calls, ['image', 'file', 'notice']);
assert.deepEqual(calls, ['image', 'file', 'classify', 'notice']);
assert.deepEqual(delivery.receipt.providerMessageIds, ['notice-after-fallback']);
assert.deepEqual(delivery.receipt.artifacts, [{
artifactId: artifact.artifactId,

View file

@ -792,6 +792,11 @@ test('AI Card creation closes a delivered card with an independent signal after
assert.equal(bodies.some((body) => body.isError === true), true);
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
assert.equal(bodies.some((body) => (
body.isError === true
&& body.content === '卡片已结束,请查看后续消息。'
)), true);
assert.equal(JSON.stringify(bodies).includes('消息处理失败,请稍后重试。'), false);
});
test('text splitting prefers line boundaries and never produces oversized chunks', () => {

View file

@ -479,6 +479,8 @@ test('DingTalk still attempts a registered file when the final text transport fa
assert.deepEqual(files, ['dingtalk-text-failed.pdf']);
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
assert.equal(bridge.status.artifactsSent, 1);
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
assert.deepEqual(receipt.providerMessageIds, ['dingtalk-file-after-text-failure']);
assert.deepEqual(receipt.artifacts, [{
artifactId: 'dingtalk-artifact-one',
@ -537,7 +539,16 @@ test('DingTalk gives safe, actionable file delivery failure guidance', async (t)
robotCode: 'robot-code',
}));
assert.equal(sent.at(-1), scenario.expected);
const failure = bridge.status.lastMessageError;
assert.equal(sent.at(-1).startsWith(`${scenario.expected}\n\n`), true);
assert.equal(
failure.code,
scenario.code === 'artifact-delivery-uncertain'
? 'CHANNEL_DELIVERY_UNCERTAIN'
: 'CHANNEL_PERMISSION',
);
assert.equal(failure.reason, scenario.code.toUpperCase().replaceAll('-', '_'));
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
assert.doesNotMatch(sent.join('\n'), /private provider rejection detail/);
assert.equal(bridge.status.artifactSendErrors, 1);
assert.deepEqual(receipt.artifacts, [{
@ -691,7 +702,48 @@ test('DingTalk returns a specific retry message when picture download fails', as
robotCode: 'robot-from-callback',
}));
assert.equal(sent.at(-1).text, '图片下载失败,请重新发送后再试。');
assert.match(sent.at(-1).text, /^图片下载失败,请重新发送后再试。/);
assert.match(sent.at(-1).text, /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
});
test('DingTalk exposes a structured model rate limit without changing connection state', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-rate-limit');
const sent = [];
const status = {
...createDingtalkBridgeStatus(),
connected: true,
connectionState: 'connected',
};
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request.text) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => true,
ask: async () => {
const error = new Error('private DingTalk provider rate-limit detail');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: fixture.state,
status,
logger: { error() {} },
});
await bridge.accept(message('dingtalk-rate-limit', '触发模型限流'));
const failure = status.lastMessageError;
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
assert.doesNotMatch(sent.at(-1), /private DingTalk provider rate-limit detail/);
assert.equal(status.connected, true);
assert.equal(status.connectionState, 'connected');
});
test('DingTalk distinguishes download-address failures from temporary-file failures', async () => {
@ -727,7 +779,8 @@ test('DingTalk distinguishes download-address failures from temporary-file failu
robotCode: 'robot-from-callback',
}));
assert.equal(sent.at(-1).text, expected);
assert.equal(sent.at(-1).text.startsWith(expected), true);
assert.match(sent.at(-1).text, /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
}
});

View file

@ -194,6 +194,6 @@ test('a failed final frame closes the delivered card once and requests text fall
assert.equal(fixtureValue.calls.finish.length, 1);
assert.equal(fixtureValue.calls.fail.length, 1);
assert.equal(fixtureValue.calls.fail[0].text, '消息处理失败,请稍后重试。');
assert.equal(fixtureValue.calls.fail[0].text, '卡片已结束,请查看后续消息。');
assert.notEqual(fixtureValue.calls.fail[0].signal, fixtureValue.calls.finish[0].signal);
});

View file

@ -604,6 +604,7 @@ test('Feishu retains a batch after an ask failure and retries the same snapshot'
const sent = [];
const asked = [];
const finalReactions = [];
const status = bridgeStatus();
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => sent.push(text)),
channel: {
@ -617,12 +618,17 @@ test('Feishu retains a batch after an ask failure and retries the same snapshot'
sessionExists: async () => true,
ask: async (_sessionId, text) => {
asked.push(text);
if (asked.length === 1) throw new Error('transient batch failure');
if (asked.length === 1) {
const error = new Error('private transient batch rate-limit detail');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
}
return '重试成功';
},
},
state: fixture.state,
status: bridgeStatus(),
status,
allowedSenderOpenIds: new Set(['ou_user']),
logger: { info() {}, warn() {}, error() {} },
});
@ -630,7 +636,12 @@ test('Feishu retains a batch after an ask failure and retries the same snapshot'
await bridge.accept(event('batch-retry-open', '/batch'));
await bridge.accept(event('batch-retry-content', '需要重试'));
await bridge.accept(event('batch-retry-first-send', '/send'));
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
assert.match(sent.at(-1), /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
assert.match(sent.at(-1), /提交失败,已保留 1 条消息/);
assert.equal(status.lastMessageError.code, 'MODEL_RATE_LIMIT');
assert.equal(sent.at(-1).includes(`参考号:${status.lastMessageError.referenceId}`), true);
assert.doesNotMatch(sent.at(-1), /private transient batch rate-limit detail/);
assert.equal(finalReactions.at(-1), 'ERROR');
await bridge.accept(event('batch-retry-status', '/batch'));
@ -2544,6 +2555,9 @@ test('Feishu finalizes the answer card before delivering registered result files
assert.equal(status.artifactSendErrors, 1);
assert.equal(notices.length, 1);
assert.match(notices[0], /notes\.txt.*限流/);
assert.equal(status.lastMessageError.code, 'CHANNEL_RATE_LIMIT');
assert.equal(status.lastMessageError.reason, 'ARTIFACT_RATE_LIMITED');
assert.equal(notices[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
assert.doesNotMatch(notices[0], /provider detail/);
});
@ -2551,6 +2565,7 @@ test('Feishu tells users to check the chat before retrying an uncertain file del
const artifact = await committedArtifact(t, 'uncertain.txt', 'uncertain result', 'uncertain');
const fixture = stateFixture([['p2p:ou_user', 'session-uncertain-artifact']]);
const notices = [];
const status = bridgeStatus();
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => notices.push(text)),
channel: {
@ -2572,16 +2587,18 @@ test('Feishu tells users to check the chat before retrying an uncertain file del
},
},
state: fixture.state,
status: bridgeStatus(),
status,
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_uncertain_artifact', '生成并发送文件'));
await bridge.waitForIdle();
assert.deepEqual(notices, [
'结果文件「uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
]);
assert.equal(notices.length, 1);
assert.match(notices[0], /^结果文件「uncertain\.txt」发送结果未能确认/);
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.equal(status.lastMessageError.reason, 'ARTIFACT_DELIVERY_UNCERTAIN');
assert.equal(notices[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
});
test('Feishu delivers a file-only Turn with a neutral final card', async (t) => {
@ -2676,6 +2693,7 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
const fixture = stateFixture([['p2p:ou_user', 'session-text-failure']]);
const files = [];
let fallbackTextAttempts = 0;
const status = bridgeStatus();
const bridge = new FeishuHarnessBridge({
client: textClient(async () => {
fallbackTextAttempts += 1;
@ -2705,7 +2723,7 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
},
},
state: fixture.state,
status: bridgeStatus(),
status,
allowedSenderOpenIds: new Set(['ou_user']),
logger: { info() {}, warn() {}, error() {} },
});
@ -2715,6 +2733,8 @@ test('Feishu still delivers a file-only result when CardKit and fallback text bo
assert.deepEqual(files, ['survives-text-failure.txt']);
assert.equal(fallbackTextAttempts, 1, 'must not append a generic retry after file success');
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
});
test('Feishu returns the receipt after reaction finalization and one safe notice when text and file delivery fail', async (t) => {
@ -2811,7 +2831,8 @@ test('Feishu keeps the generic error when no answer or file failure notice is vi
await bridge.accept(event('om-no-visible-failure', '生成并发送文件'));
assert.equal(attemptedTexts.length, 3);
assert.match(attemptedTexts.at(-1), /^处理失败,请稍后重试/);
assert.match(attemptedTexts.at(-1), /^回复发送结果未能确认/);
assert.match(attemptedTexts.at(-1), /错误码:CHANNEL_DELIVERY_UNCERTAIN;参考号:MF-[A-F0-9]{8}$/);
});
test('Feishu does not repeat finalized card text when cancellation happens before file delivery', async (t) => {
@ -2938,11 +2959,54 @@ test('bridge does not expose internal error details in a Feishu failure reply',
await bridge.waitForIdle();
assert.equal(sent.length, 1);
assert.match(sent[0], /处理失败,请稍后重试/);
assert.match(sent[0], /任务未完成,暂时无法确定原因/);
assert.match(sent[0], /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}$/);
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
});
test('Feishu exposes a structured model rate limit without changing connection state', async () => {
const fixture = stateFixture([['p2p:ou_owner', 'session-rate-limit']]);
const sent = [];
const status = {
...bridgeStatus(),
connected: true,
connectionState: 'connected',
};
const bridge = new FeishuHarnessBridge({
client: textClient(async (outgoing) => sent.push(outgoing.text)),
channel: {},
harness: {
sessionExists: async () => true,
ask: async () => {
const error = new Error('private Feishu provider rate-limit detail');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: fixture.state,
status,
allowedSenderOpenIds: new Set(['ou_owner']),
logger: { error() {} },
});
await bridge.accept(event('om_rate_limit', '触发模型限流', {
senderOpenId: 'ou_owner',
}));
await bridge.waitForIdle();
const failure = status.lastMessageError;
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
assert.doesNotMatch(sent.at(-1), /private Feishu provider rate-limit detail/);
assert.equal(status.connected, true);
assert.equal(status.connectionState, 'connected');
});
// ── Interactive cards: menus, session lists, workspace lists ───────────────
function cardClient(onSend, onPatch = null) {
@ -3461,10 +3525,14 @@ test('compact card action contains session lookup failures', async () => {
const fixture = stateFixture();
const sent = [];
let failSessionLookup = false;
const status = bridgeStatus();
const sessionFor = fixture.state.sessionFor;
fixture.state.sessionFor = (key) => {
if (failSessionLookup) {
throw new Error('secret-shaped compact detail /private/path');
const error = new Error('secret-shaped compact detail /private/path');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
}
return sessionFor(key);
};
@ -3473,7 +3541,7 @@ test('compact card action contains session lookup failures', async () => {
channel: {},
harness: sessionsHarness(1),
state: fixture.state,
status: bridgeStatus(),
status,
allowedSenderOpenIds: new Set(['ou_owner']),
logger: { warn() {}, error() {} },
});
@ -3488,10 +3556,56 @@ test('compact card action contains session lookup failures', async () => {
.filter((message) => message.msgType === 'text')
.map((message) => JSON.parse(message.content).text);
assert.equal(replies.length, 1);
assert.match(replies[0], /上下文压缩失败,请稍后重试/);
assert.match(replies[0], /模型服务正在限流,本次任务未完成。请稍后重试。/);
assert.match(replies[0], /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
assert.equal(replies[0].endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
assert.doesNotMatch(replies[0], /secret-shaped|private\/path|compactCommand/);
});
test('Feishu list and status command failures share one safe classified format', async () => {
for (const command of ['/sessionlist', '/workspacelist', '/status']) {
const fixture = stateFixture();
const sent = [];
const status = bridgeStatus();
const providerFailure = () => {
const error = new Error(`private ${command} provider detail`);
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
return error;
};
const harness = {
currentWorkspace: () => tmpdir(),
listWorkspaces: async () => [tmpdir()],
listWorkspaceSessions: async () => ({ workspace: tmpdir(), sessions: [] }),
ensureRunning: async () => true,
};
if (command === '/sessionlist') harness.listWorkspaceSessions = async () => { throw providerFailure(); };
if (command === '/workspacelist') harness.listWorkspaces = async () => { throw providerFailure(); };
if (command === '/status') harness.ensureRunning = async () => { throw providerFailure(); };
const bridge = new FeishuHarnessBridge({
client: textClient(async ({ text }) => sent.push(text)),
channel: {},
harness,
state: fixture.state,
status,
allowedSenderOpenIds: new Set(['ou_user']),
logger: { warn() {}, error() {} },
});
await bridge.accept(event(`classified-${command.slice(1)}`, command));
await bridge.waitForIdle();
assert.equal(status.lastMessageError.code, 'MODEL_RATE_LIMIT', command);
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/, command);
assert.equal(
sent.at(-1).endsWith(`参考号:${status.lastMessageError.referenceId}`),
true,
command,
);
assert.doesNotMatch(sent.at(-1), /private .* provider detail/, command);
}
});
test('preset card selection does not expose internal update errors', async () => {
const fixture = stateFixture();
const sent = [];

View file

@ -5,6 +5,7 @@ import { join } from 'node:path';
import test from 'node:test';
import {
createQqBridgeStatus,
qqInboundMessage,
QqHarnessBridge,
sendQqImage,
@ -500,10 +501,95 @@ test('QQ rejects non-platform image URLs without fetching and returns a retryabl
}));
assert.equal(downloads, 0);
assert.deepEqual(sent, ['图片下载失败,请重新发送后再试。']);
assert.equal(sent.length, 1);
assert.match(sent[0], /^图片下载失败,请重新发送后再试。/);
assert.match(sent[0], /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
assert.equal(fixture.seen.has('qq-image-untrusted'), true);
});
test('QQ exposes a structured model rate limit without changing connection state', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-rate-limit']]);
const sent = [];
const status = {
...createQqBridgeStatus(),
connected: true,
connectionState: 'connected',
};
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => {
sent.push(text);
return { id: 'qq-rate-limit-reply' };
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async () => {
const error = new Error('private QQ provider rate-limit detail');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: fixture.state,
status,
logger: { error() {} },
});
await bridge.accept(message({
messageId: 'qq-rate-limit',
content: '触发模型限流',
replyTarget: {
scope: 'c2c',
targetId: 'owner-openid',
msgId: 'qq-rate-limit',
},
}));
const failure = status.lastMessageError;
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
assert.doesNotMatch(sent.at(-1), /private QQ provider rate-limit detail/);
assert.equal(status.connected, true);
assert.equal(status.connectionState, 'connected');
});
test('QQ does not submit a redelivered message twice when the safe failure reply cannot send', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-redelivery']]);
let asks = 0;
const bridge = new QqHarnessBridge({
bot: {
sendText: async () => {
throw new Error('QQ reply transport unavailable');
},
},
ownerUserOpenid: 'owner-openid',
harness: {
sessionExists: async () => true,
ask: async () => {
asks += 1;
const error = new Error('private provider failure');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: fixture.state,
logger: { warn() {}, error() {} },
});
const inbound = message({ messageId: 'qq-redelivered-after-failure' });
await bridge.accept(inbound);
await bridge.accept(inbound);
assert.equal(asks, 1);
assert.equal(fixture.seen.has(inbound.messageId), true);
});
test('QQ does not use an image caption as a pending Harness answer', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-question-image']]);
const sent = [];
@ -836,7 +922,7 @@ test('QQ group messages suppress every successful progress update', async () =>
assert.equal(streamCalls, 0);
});
test('QQ group messages append tool failures to one final answer', async () => {
test('QQ group messages append a stable tool failure notice without exposing provider details', async () => {
const sent = [];
const bridge = new QqHarnessBridge({
bot: {
@ -877,8 +963,9 @@ test('QQ group messages append tool failures to one final answer', async () => {
replyTarget: { scope: 'group', targetId: 'group-error', msgId: 'msg-tool-error' },
}));
assert.deepEqual(sent, [
'已存入两套记忆。\n\n---\n\nTool call add_observations\nError: Error calling add_observations. Status code: 404.',
'已存入两套记忆。\n\n---\n\n工具调用「add_observations」未成功,请检查工具配置或稍后重试。',
]);
assert.doesNotMatch(sent[0], /Error calling|Status code|404/);
});
test('QQ delivers final group answers as markdown messages', async () => {
@ -2005,6 +2092,12 @@ test('QQ sends registered files after text with the native SDK and continues aft
assert.equal(files[1].source.buffer.toString(), '<h1>second</h1>');
assert.equal(files[1].options.fileName, 'second.html');
assert.match(sentTexts[1].text, /first\.txt.*上传额度/);
assert.equal(status.lastMessageError.code, 'CHANNEL_RATE_LIMIT');
assert.equal(status.lastMessageError.reason, 'ARTIFACT_RATE_LIMITED');
assert.equal(
sentTexts[1].text.endsWith(`参考号:${status.lastMessageError.referenceId}`),
true,
);
assert.doesNotMatch(sentTexts[1].text, /private quota detail/);
assert.equal(status.artifactsSent, 1);
assert.equal(status.artifactSendErrors, 1);
@ -2041,6 +2134,8 @@ test('QQ still delivers registered files when every final text delivery attempt
assert.deepEqual(files, [{ bytes: Buffer.from('file bytes'), fileName: 'survives-text-failure.txt' }]);
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
});
test('QQ returns the authoritative receipt and sends one safe notice when text and file delivery fail', async (t) => {
@ -2122,7 +2217,8 @@ test('QQ keeps the generic error when neither the answer nor the file failure no
await bridge.accept(message({ messageId: 'qq-no-visible-failure' }));
assert.equal(attemptedTexts.length, 3);
assert.equal(attemptedTexts.at(-1), '消息处理失败,请稍后重试。');
assert.match(attemptedTexts.at(-1), /^回复发送结果未能确认/);
assert.match(attemptedTexts.at(-1), /错误码:CHANNEL_DELIVERY_UNCERTAIN;参考号:MF-[A-F0-9]{8}$/);
});
test('QQ reports an unacknowledged native file send as uncertain instead of inviting a blind retry', async (t) => {

View file

@ -187,7 +187,7 @@ test('QQ runtime aborts an in-flight Harness interaction when stopped', async ()
test('QQ runtime enables result-file delivery without per-bot configuration', async () => {
const bot = new FakeBot();
const finished = deferred();
const askObserved = deferred();
let onArtifact;
const runtime = new QqRuntime({
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' },
@ -197,12 +197,13 @@ test('QQ runtime enables result-file delivery without per-bot configuration', as
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
onArtifact = options.onArtifact;
askObserved.resolve();
return '完成';
},
},
state: {
hasSeen: () => false,
markSeen: async () => finished.resolve(),
markSeen: async () => {},
sessionFor: () => 'session-existing',
setSession: async () => {},
clearSession: async () => {},
@ -222,7 +223,7 @@ test('QQ runtime enables result-file delivery without per-bot configuration', as
messageId: 'qq-artifact-gate',
replyTarget: { scope: 'c2c', targetId: 'owner', msgId: 'qq-artifact-gate' },
});
await finished.promise;
await askObserved.promise;
assert.equal(typeof onArtifact, 'function');
await runtime.stop();
});

View file

@ -5,7 +5,10 @@ import { join } from 'node:path';
import test from 'node:test';
import { createBotWorkspaceScope } from '../../../src/channels/shared/bot-workspace-store.mjs';
import { HarnessClient } from '../../../src/channels/shared/harness-client.mjs';
import {
HarnessClient,
HarnessTurnError,
} from '../../../src/channels/shared/harness-client.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
createOutboundArtifactTool,
@ -313,6 +316,78 @@ function controlledTurn({ sessionId, initialEnd = false, controlExecutor } = {})
};
}
test('HarnessClient preserves structured turn failures for channel classification', async () => {
for (const [reason, expectedCode, expectedProviderCode] of [
[{ kind: 'error', error: { code: 'RATE_LIMIT', message: 'private provider detail' } },
'harness-turn-failed', 'RATE_LIMIT'],
[{ kind: 'max-tokens' }, 'model-max-tokens', undefined],
[{ kind: 'completed' }, 'model-empty-response', undefined],
['completed', 'model-empty-response', undefined],
['stopped', 'turn-interrupted', undefined],
]) {
const turn = controlledTurn();
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
await turn.admitted;
turn.finish({ reason });
await assert.rejects(asking, (error) => {
assert.ok(error instanceof HarnessTurnError);
assert.equal(error.code, expectedCode);
assert.equal(error.providerCode, expectedProviderCode);
assert.equal(error.promptAccepted, true);
assert.doesNotMatch(error.message, /private provider detail/);
return true;
});
}
});
test('HarnessClient does not treat partial text from a failed turn as success', async () => {
for (const [reason, expectedCode, expectedProviderCode] of [
[{ kind: 'error', error: { code: 'RATE_LIMIT' } },
'harness-turn-failed', 'RATE_LIMIT'],
[{ kind: 'error', error: { code: 'CONTENT_FILTER' } },
'harness-turn-failed', 'CONTENT_FILTER'],
[{ kind: 'max-tokens' }, 'model-max-tokens', undefined],
[{ kind: 'blocked' }, 'turn-blocked', undefined],
['interrupted', 'turn-interrupted', undefined],
['stopped', 'turn-interrupted', undefined],
['cancelled', 'turn-interrupted', undefined],
['aborted', 'turn-aborted', undefined],
]) {
const turn = controlledTurn();
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
await turn.admitted;
turn.finish({ text: 'partial result must not escape', reason });
await assert.rejects(asking, (error) => {
assert.ok(error instanceof HarnessTurnError);
assert.equal(error.code, expectedCode);
assert.equal(error.providerCode, expectedProviderCode);
return true;
});
}
});
test('HarnessClient accepts partial text only for completed or omitted end reasons', async () => {
for (const reason of ['completed', { kind: 'completed' }, null]) {
const turn = controlledTurn();
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 2_000 });
await turn.admitted;
turn.finish({ text: 'valid partial result', reason });
assert.equal(await asking, 'valid partial result');
}
});
test('HarnessClient marks a reply timeout after prompt admission', async () => {
const turn = controlledTurn();
const asking = turn.client.ask(turn.id, 'work', { timeoutMs: 1 });
await turn.admitted;
await assert.rejects(asking, (error) => {
assert.ok(error instanceof HarnessTurnError);
assert.equal(error.code, 'harness-reply-timeout');
assert.equal(error.promptAccepted, true);
return true;
});
});
test('control methods require exact owner identity, key, and Session before any RPC', async () => {
const turn = controlledTurn();
const owner = {};

View file

@ -48,6 +48,14 @@ test('t() translates known keys and fills placeholders in English mode', () => {
t('示例:先发 /models,再发 /model 2 [推理等级ID]'),
'Example: send /models first, then /model 2 [reasoning effort ID]',
);
assert.equal(
t('卡片已结束,请查看后续消息。'),
'This card has ended. Please check the next message.',
);
assert.equal(
t('工具调用「{name}」未成功,请检查工具配置或稍后重试。', { name: 'search' }),
'Tool call "search" did not succeed. Check the tool configuration or try again later.',
);
assert.equal(t(42), 42);
});

View file

@ -14,7 +14,10 @@ import {
createOutboundArtifactTool,
releaseOutboundArtifact,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
import {
createTextBridgeStatus,
TextHarnessBridge,
} from '../../../src/channels/shared/text-harness-bridge.mjs';
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
@ -216,6 +219,123 @@ test('all four shared text channels execute /compact outside the model prompt pa
}
});
test('all four shared text channels expose structured model rate limits without changing connection state', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
['whatsapp', WhatsappHarnessBridge],
]) {
const fixture = stateFixture({ 'direct:chat-a': `session-${name}` });
const sent = [];
const status = {
...createTextBridgeStatus(),
connected: true,
connectionState: 'connected',
};
const bridge = new Bridge({
bot: { sendText: async (_target, text) => sent.push(text) },
state: fixture.state,
status,
harness: {
sessionExists: async () => true,
ask: async () => {
const error = new Error('private provider rate-limit detail');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
logger: { error() {} },
});
await bridge.accept(message(`rate-limit-${name}`, '触发模型限流'));
const failure = status.lastMessageError;
assert.equal(failure.code, 'MODEL_RATE_LIMIT', name);
assert.equal(failure.reason, 'MODEL_RATE_LIMIT', name);
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/, name);
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/, name);
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true, name);
assert.doesNotMatch(sent.at(-1), /private provider rate-limit detail/, name);
assert.equal(status.connected, true, name);
assert.equal(status.connectionState, 'connected', name);
}
});
test('all four shared text channels retain a traceable artifact failure until a clean turn succeeds', async (t) => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
['whatsapp', WhatsappHarnessBridge],
]) {
const failedArtifact = await committedArtifact(
t,
`${name}-blocked.txt`,
'blocked',
`${name}-blocked`,
);
const sentArtifact = await committedArtifact(
t,
`${name}-sent.txt`,
'sent',
`${name}-sent`,
);
const fixture = stateFixture();
const sent = [];
const status = {
...createTextBridgeStatus(),
connected: true,
connectionState: 'connected',
};
let askCount = 0;
const bridge = new Bridge({
bot: {
sendText: async (_target, text) => {
sent.push(text);
return { id: `${name}-text-${sent.length}` };
},
sendFile: async (_target, file) => {
if (file.fileName.endsWith('-blocked.txt')) {
const error = new Error('private permission detail');
error.code = 'artifact-permission-required';
throw error;
}
return { id: `${name}-file` };
},
},
state: fixture.state,
status,
harness: {
createSession: async () => `session-${name}`,
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
const artifact = askCount === 0 ? failedArtifact : sentArtifact;
askCount += 1;
await options.onArtifact(artifact);
return '文件已生成。';
},
},
logger: { warn() {}, error() {} },
});
await bridge.accept(message(`artifact-failed-${name}`, '生成文件'));
const failure = status.lastMessageError;
assert.equal(failure.code, 'CHANNEL_PERMISSION', name);
assert.equal(failure.reason, 'ARTIFACT_PERMISSION_REQUIRED', name);
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/, name);
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true, name);
assert.doesNotMatch(sent.at(-1), /private permission detail/, name);
assert.equal(status.connected, true, name);
assert.equal(status.connectionState, 'connected', name);
await bridge.accept(message(`artifact-clean-${name}`, '再生成一个文件'));
assert.equal(status.lastMessageError, null, `${name} clears the prior failure after a clean turn`);
}
});
test('all four shared text channels collect a private batch and submit it in one ordered ask', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
@ -703,7 +823,9 @@ test('shared text bridge reports a safe native-file download failure', async ()
files: [{ name: 'failed.txt', load: async () => Buffer.from('unused') }],
}));
assert.deepEqual(sent, ['文件下载失败,请重新发送后再试。']);
assert.equal(sent.length, 1);
assert.match(sent[0], /^文件下载失败,请重新发送后再试。/);
assert.match(sent[0], /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
assert.doesNotMatch(sent[0], /secret|token|https:/i);
});
@ -898,6 +1020,93 @@ test('a stopped shared-channel turn closes an opened stream instead of leaving a
assert.deepEqual(sent, []);
});
test('a failed shared-channel turn finalizes an editable stream when fail is unavailable', async () => {
const fixture = stateFixture({ 'direct:chat-a': 'session-failed-stream' });
const finished = [];
const sent = [];
let cancelled = 0;
const bridge = createBridge({
state: fixture.state,
bot: {
sendText: async (_target, text) => sent.push(text),
openStream: async () => ({
update() {},
async finish(text) { finished.push(text); },
cancel() { cancelled += 1; },
}),
},
harness: {
workspaceSession: () => ({
sessionExists: async () => true,
ask: async () => {
const error = new Error('private provider rate-limit details');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
}),
},
});
await bridge.accept(message('failed-editable-stream', '执行任务'));
assert.equal(finished.length, 1);
assert.match(finished[0], /模型服务正在限流/);
assert.match(finished[0], /错误码:MODEL_RATE_LIMIT;参考号:MF-[A-F0-9]{8}/);
assert.doesNotMatch(finished[0], /private provider rate-limit details/);
assert.equal(cancelled, 0);
assert.deepEqual(sent, []);
});
test('an unknown final delivery receipt is reported without rerunning the prompt', async () => {
const fixture = stateFixture({ 'direct:chat-a': 'session-unknown-delivery' });
const notices = [];
let asks = 0;
const status = {
...createTextBridgeStatus(),
connected: true,
connectionState: 'connected',
};
const bridge = new TextHarnessBridge({
descriptor: { key: 'test', label: 'Test' },
state: fixture.state,
status,
bot: {
sendDelivery: async () => ({
presentation: 'test-final',
providerMessageIds: ['possibly-sent-answer'],
deliveryOutcome: 'unknown',
reason: 'provider-timeout',
}),
sendText: async (_target, text) => {
notices.push(text);
return { providerMessageIds: ['uncertain-notice'] };
},
},
harness: {
workspaceSession: () => ({
sessionExists: async () => true,
ask: async () => {
asks += 1;
return '可能已经送达的回答';
},
}),
},
logger: { warn() {}, error() {} },
});
const receipt = await bridge.accept(message('unknown-final-delivery', '回答问题'));
assert.equal(asks, 1);
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.match(notices.at(-1), /发送结果未能确认.*不要立即重复提交/);
assert.equal(notices.at(-1).endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
assert.deepEqual(receipt.providerMessageIds, ['possibly-sent-answer']);
assert.equal(receipt.deliveryOutcome, 'unknown');
assert.equal(status.connected, true);
assert.equal(status.connectionState, 'connected');
});
test('shared text artifact delivery sends text first and each materialized file in order', async (t) => {
const first = await committedArtifact(t, 'result.html', '<h1>result</h1>', 'success-html');
const second = await committedArtifact(t, 'notes.txt', 'notes', 'success-text');
@ -1011,6 +1220,8 @@ test('shared text delivery still attempts registered files when its final text c
assert.deepEqual(texts, ['文字回答']);
assert.equal(bridge.status.artifactsSent, 1);
assert.equal(bridge.status.messagesReplied, 1);
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
assert.deepEqual(receipt.providerMessageIds, ['file-after-text-failure']);
assert.deepEqual(receipt.artifacts, [{ artifactId: 'text-failed-1', outcome: 'sent' }]);
});

View file

@ -564,11 +564,13 @@ test('shared bridge surfaces a definite final delivery failure without rerunning
});
assert.equal(prompts, 1);
assert.deepEqual(safeReplies, ['消息处理失败,请稍后重试。']);
assert.equal(safeReplies.length, 1);
assert.match(safeReplies[0], /回复已经生成,但当前渠道暂时无法发送/);
assert.match(safeReplies[0], /错误码:CHANNEL_DELIVERY;参考号:MF-[A-F0-9]{8}/);
assert.equal(receipt.deliveryOutcome, 'failed');
assert.equal(receipt.reason, 'telegram-403');
assert.deepEqual(receipt.artifacts, []);
assert.match(bridge.status.lastError, /telegram-403/);
assert.equal(bridge.status.lastError, 'Channel message delivery failed');
});
}
});
@ -944,6 +946,7 @@ test('Telegram group processing failure replaces its placeholder instead of leav
assert.equal(sent[0].text, '正在处理…');
assert.equal(edits.length, 1);
assert.equal(edits[0].messageId, 1401);
assert.equal(edits[0].text, '消息处理失败,请稍后重试。');
assert.match(edits[0].text, /任务未完成,暂时无法确定原因/);
assert.match(edits[0].text, /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}/);
assert.equal(Object.hasOwn(edits[0], 'richMessage'), false);
});

View file

@ -5,6 +5,7 @@ import { join } from 'node:path';
import test from 'node:test';
import {
createWecomBridgeStatus,
sendWecomImage,
WecomHarnessBridge,
wecomInboundMessage,
@ -765,7 +766,8 @@ test('Enterprise WeChat bounds prefetched image memory while a conversation is q
assert.equal(downloads.length, 4);
assert.equal(prompts.length, 5);
assert.equal(transport.streamed.some(({ content }) => (
content === '当前待处理图片较多,请稍后重新发送。'
content.startsWith('当前待处理图片较多,请稍后重新发送。')
&& /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/.test(content)
)), true);
});
@ -816,13 +818,98 @@ test('Enterprise WeChat finalizes an existing progress stream when Harness fails
});
await bridge.accept(frame());
assert.deepEqual(replies, [
{ streamId: 'stream-failure', content: '正在思考中…', finish: false },
{ streamId: 'stream-failure', content: '消息处理失败,请稍后重试。', finish: true },
]);
assert.deepEqual(replies[0], {
streamId: 'stream-failure', content: '正在思考中…', finish: false,
});
assert.equal(replies[1].streamId, 'stream-failure');
assert.equal(replies[1].finish, true);
assert.match(replies[1].content, /任务未完成,暂时无法确定原因/);
assert.match(replies[1].content, /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}$/);
assert.equal(store.seen.has('msg-1'), true);
});
test('Enterprise WeChat exposes a structured model rate limit without changing connection state', async () => {
const transport = testClient();
const status = {
...createWecomBridgeStatus(),
connected: true,
connectionState: 'connected',
};
const bridge = new WecomHarnessBridge({
client: transport.client,
generateStreamId: () => 'rate-limit-stream',
harness: {
sessionExists: async () => true,
ask: async () => {
const error = new Error('private Enterprise WeChat provider rate-limit detail');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: state(),
status,
logger: { error() {} },
});
await bridge.accept(frame({
msgid: 'wecom-rate-limit',
text: { content: '触发模型限流' },
}));
const failure = status.lastMessageError;
const visibleError = transport.streamed.at(-1).content;
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
assert.match(visibleError, /模型服务正在限流,本次任务未完成。请稍后重试。/);
assert.equal(visibleError.endsWith(`参考号:${failure.referenceId}`), true);
assert.doesNotMatch(visibleError, /private Enterprise WeChat provider rate-limit detail/);
assert.equal(status.connected, true);
assert.equal(status.connectionState, 'connected');
});
test('Enterprise WeChat does not resubmit a recorded prompt when the safe error reply fails', async () => {
const store = state();
let asks = 0;
let safeReplyAttempts = 0;
const bridge = new WecomHarnessBridge({
client: {
replyStream: async (_frame, _streamId, _content, finish) => {
if (!finish) return;
safeReplyAttempts += 1;
throw new Error('safe reply unavailable');
},
replyStreamNonBlocking: async () => {},
sendMessage: async () => {},
},
generateStreamId: () => 'safe-error-replay-stream',
harness: {
sessionExists: async () => true,
ask: async () => {
asks += 1;
const error = new Error('private provider failure');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: store,
logger: { error() {}, warn() {} },
});
const inbound = frame({
msgid: 'wecom-safe-error-replay',
text: { content: '请执行一次' },
});
await bridge.accept(inbound);
await bridge.accept(inbound);
assert.equal(asks, 1);
assert.equal(safeReplyAttempts, 1);
assert.equal(store.seen.has('wecom-safe-error-replay'), true);
});
test('an Enterprise WeChat answer bypasses the original conversation queue', async () => {
const transport = testClient();
const answered = deferred();
@ -1600,6 +1687,9 @@ test('Enterprise WeChat sends registered files after the final text and continue
assert.equal(uploads[1].bytes.toString(), '<h1>second</h1>');
assert.equal(active.length, 1);
assert.match(active[0].content, /first\.txt.*暂时未能/);
assert.equal(status.lastMessageError.code, 'CHANNEL_DELIVERY');
assert.equal(status.lastMessageError.reason, 'ARTIFACT_PROVIDER_FAILED');
assert.equal(active[0].content.endsWith(`参考号:${status.lastMessageError.referenceId}`), true);
assert.doesNotMatch(active[0].content, /private provider detail/);
assert.equal(status.artifactsSent, 1);
assert.equal(status.artifactSendErrors, 1);
@ -1655,6 +1745,8 @@ test('Enterprise WeChat still delivers registered files when final text delivery
}]);
assert.equal(finalTextAttempts, 1, 'must not append a generic retry stream after file success');
assert.equal(activeTextAttempts, 1);
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
});
test('Enterprise WeChat returns the authoritative receipt and one safe notice when text and file delivery fail', async (t) => {
@ -1754,7 +1846,9 @@ test('Enterprise WeChat keeps the generic error when no answer or file failure n
await bridge.accept(frame({ msgid: 'wecom-no-visible-failure' }));
assert.equal(attemptedActiveTexts.length, 2);
assert.deepEqual(finalStreamTexts, ['文字结果', '消息处理失败,请稍后重试。']);
assert.equal(finalStreamTexts[0], '文字结果');
assert.match(finalStreamTexts[1], /^回复发送结果未能确认/);
assert.match(finalStreamTexts[1], /错误码:CHANNEL_DELIVERY_UNCERTAIN;参考号:MF-[A-F0-9]{8}$/);
});
test('Enterprise WeChat reports an unacknowledged file message as uncertain', async (t) => {
@ -1980,7 +2074,7 @@ test('Enterprise WeChat keeps a failed batch for retry and queues later ordinary
await bridge.accept(frame({ msgid: 'retry-start', text: { content: '/batch' } }));
await bridge.accept(frame({ msgid: 'retry-content', text: { content: '需要重试' } }));
await bridge.accept(frame({ msgid: 'retry-send-1', text: { content: '/send' } }));
assert.match(transport.streamed.at(-1).content, /消息处理失败.*已保留 1 条消息/s);
assert.match(transport.streamed.at(-1).content, /错误码:INTERNAL_UNKNOWN.*已保留 1 条消息/s);
const retry = bridge.accept(frame({ msgid: 'retry-send-2', text: { content: '/send' } }));
await eventually(() => prompts.length === 2);

View file

@ -160,7 +160,7 @@ test('Enterprise WeChat runtime aborts an in-flight Harness interaction when sto
test('Enterprise WeChat runtime enables result-file delivery without per-bot configuration', async () => {
const client = new FakeClient();
const finished = deferred();
const askObserved = deferred();
let onArtifact;
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
@ -170,12 +170,13 @@ test('Enterprise WeChat runtime enables result-file delivery without per-bot con
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
onArtifact = options.onArtifact;
askObserved.resolve();
return '完成';
},
},
state: {
hasSeen: () => false,
markSeen: async () => finished.resolve(),
markSeen: async () => {},
sessionFor: () => 'session-existing',
setSession: async () => {},
clearSession: async () => {},
@ -195,7 +196,7 @@ test('Enterprise WeChat runtime enables result-file delivery without per-bot con
text: { content: '生成文件' },
},
});
await finished.promise;
await askObserved.promise;
assert.equal(typeof onArtifact, 'function');
await runtime.stop();
});

View file

@ -62,6 +62,7 @@ test('Weixin client keeps only the public connection-test result', () => {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
referenceId: 'MF-1A2B3C4D',
at: 123,
providerDetail: 'must-not-cross-client-normalization',
},
@ -76,6 +77,7 @@ test('Weixin client keeps only the public connection-test result', () => {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
referenceId: 'MF-1A2B3C4D',
at: 123,
});
assert.doesNotMatch(JSON.stringify(snapshot), /must-not-cross-client-normalization/);
@ -89,6 +91,7 @@ test('Weixin account card shows the latest safe message-processing error', () =>
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
referenceId: 'MF-1A2B3C4D',
at: Date.now(),
},
},

View file

@ -477,6 +477,8 @@ test('Weixin still attempts a registered file when the final text transport fail
assert.deepEqual(files, ['weixin-text-failed.txt']);
assert.equal(textAttempts, 1, 'must not send a generic retry notice after the file succeeds');
assert.equal(bridge.status.artifactsSent, 1);
assert.equal(bridge.status.lastMessageError.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.match(bridge.status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
assert.deepEqual(receipt.providerMessageIds, ['weixin-file-after-text-failure']);
assert.deepEqual(receipt.artifacts, [{ artifactId: 'weixin-artifact-one', outcome: 'sent' }]);
});
@ -515,10 +517,11 @@ test('Weixin tells users to inspect the chat instead of retrying an uncertain fi
const receipt = await bridge.accept(message('weixin-artifact-uncertain', '生成文件'));
assert.equal(
sent.at(-1),
'结果文件「weixin-uncertain.txt」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。',
);
const failure = bridge.status.lastMessageError;
assert.match(sent.at(-1), /^结果文件「weixin-uncertain\.txt」发送结果未能确认/);
assert.equal(failure.code, 'CHANNEL_DELIVERY_UNCERTAIN');
assert.equal(failure.reason, 'ARTIFACT_DELIVERY_UNCERTAIN');
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
assert.doesNotMatch(sent.join('\n'), /private provider transport detail/);
assert.equal(bridge.status.artifactSendErrors, 1);
assert.deepEqual(receipt.artifacts, [{
@ -614,7 +617,8 @@ test('Weixin returns a specific retry message when encrypted image loading fails
item_list: [{ type: 2, image_item: { media: {} } }],
}));
assert.equal(sent.at(-1).text, '图片下载失败,请重新发送后再试。');
assert.match(sent.at(-1).text, /^图片下载失败,请重新发送后再试。/);
assert.match(sent.at(-1).text, /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
assert.equal(fixture.seen.has('weixin-image-error'), true);
});
@ -656,11 +660,13 @@ test('Weixin explains model image rejection and records only safe structured dia
assert.match(sent.at(-1).text, /\/models/);
assert.equal(fixture.seen.has('weixin-model-image-error'), true);
assert.deepEqual(status.lastMessageError, {
code: 'attachment-error',
code: 'INPUT_INVALID',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: sent.at(-1).text,
message: '当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
referenceId: status.lastMessageError.referenceId,
at: status.lastMessageError.at,
});
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
assert.equal(Number.isFinite(status.lastMessageError.at), true);
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private|provider-token|providerDetail/);
});
@ -1619,7 +1625,7 @@ test('bridge rejects every user except the account owner returned by QR login',
assert.equal(status.messagesRejected, 1);
});
test('bridge commands are local and internal failures return a generic message', async () => {
test('bridge commands are local and internal failures return a safe traceable message', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'old-session');
const sent = [];
@ -1642,17 +1648,99 @@ test('bridge commands are local and internal failures return a generic message',
await bridge.accept(message('new', '/new'));
assert.equal(fixture.sessions.has('p2p:owner-user'), false);
await bridge.accept(message('failure', '触发失败'));
assert.match(sent.at(-1), /消息处理失败/);
assert.match(sent.at(-1), /任务未完成,暂时无法确定原因/);
assert.match(sent.at(-1), /错误码:INTERNAL_UNKNOWN;参考号:MF-[A-F0-9]{8}$/);
assert.doesNotMatch(sent.at(-1), /private path|secret|token-shaped/);
assert.deepEqual(status.lastMessageError, {
code: 'message-processing-failed',
reason: 'UNKNOWN',
message: '消息处理失败,请稍后重试。',
code: 'INTERNAL_UNKNOWN',
reason: 'INTERNAL_UNKNOWN',
message: '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
referenceId: status.lastMessageError.referenceId,
at: status.lastMessageError.at,
});
assert.match(status.lastMessageError.referenceId, /^MF-[A-F0-9]{8}$/);
assert.doesNotMatch(JSON.stringify(status.lastMessageError), /private path|secret|token-shaped/);
});
test('Weixin exposes a structured model rate limit without changing connection state', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-rate-limit');
const sent = [];
const status = {
...createWeixinBridgeStatus(),
connected: true,
connectionState: 'connected',
};
const bridge = new WeixinHarnessBridge({
api: { sendText: async ({ text }) => sent.push(text) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async () => {
const error = new Error('private Weixin provider rate-limit detail');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: fixture.state,
status,
logger: { error() {} },
});
await bridge.accept(message('weixin-rate-limit', '触发模型限流'));
const failure = status.lastMessageError;
assert.equal(failure.code, 'MODEL_RATE_LIMIT');
assert.equal(failure.reason, 'MODEL_RATE_LIMIT');
assert.match(failure.referenceId, /^MF-[A-F0-9]{8}$/);
assert.match(sent.at(-1), /模型服务正在限流,本次任务未完成。请稍后重试。/);
assert.equal(sent.at(-1).endsWith(`参考号:${failure.referenceId}`), true);
assert.doesNotMatch(sent.at(-1), /private Weixin provider rate-limit detail/);
assert.equal(status.connected, true);
assert.equal(status.connectionState, 'connected');
});
test('Weixin does not resubmit a recorded prompt when the safe error reply fails', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-safe-error-replay');
let asks = 0;
let safeReplyAttempts = 0;
const bridge = new WeixinHarnessBridge({
api: {
sendText: async () => {
safeReplyAttempts += 1;
throw new Error('safe reply unavailable');
},
},
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async () => {
asks += 1;
const error = new Error('private provider failure');
error.code = 'harness-turn-failed';
error.providerCode = 'RATE_LIMIT';
throw error;
},
},
state: fixture.state,
logger: { error() {} },
});
const inbound = message('weixin-safe-error-replay', '请执行一次');
await bridge.accept(inbound);
await bridge.accept(inbound);
assert.equal(asks, 1);
assert.equal(safeReplyAttempts, 1);
assert.equal(fixture.seen.has('weixin-safe-error-replay'), true);
});
test('Weixin batch input collects up to ten native text messages and submits one ordered turn', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-batch');
@ -1729,7 +1817,7 @@ test('Weixin batch cancellation is local and a failed submission remains retryab
await bridge.accept(message('retry-start', '/batch'));
await bridge.accept(message('retry-content', '需要重试'));
await bridge.accept(message('retry-send-1', '/send'));
assert.match(sent.at(-1), /消息处理失败.*已保留 1 条消息/s);
assert.match(sent.at(-1), /错误码:INTERNAL_UNKNOWN.*已保留 1 条消息/s);
await bridge.accept(message('retry-send-2', '/send'));
assert.equal(attempts, 2);

View file

@ -86,6 +86,7 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
referenceId: 'MF-WX123456',
at: 123,
providerDetail: 'must-not-cross-controller-boundary',
},
@ -129,6 +130,7 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
referenceId: 'MF-WX123456',
at: 123,
});
assert.doesNotMatch(publicJson, /must-not-cross-controller-boundary/);

View file

@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { normalizeLastMessageError } from '../plugin-src/client/last-message-error.js';
import { normalizeBotsSnapshot as normalizeFeishu } from '../plugin-src/client/channels/feishu/api.js';
import { normalizeSnapshot as normalizeDingtalk } from '../plugin-src/client/channels/dingtalk/api.js';
import { normalizeSnapshot as normalizeWeixin } from '../plugin-src/client/channels/weixin/api.js';
import { normalizeSnapshot as normalizeWecom } from '../plugin-src/client/channels/wecom/api.js';
import { normalizeSnapshot as normalizeQq } from '../plugin-src/client/channels/qq/api.js';
import { normalizeSnapshot as normalizeSlack } from '../plugin-src/client/channels/slack/api.js';
import { normalizeSnapshot as normalizeTelegram } from '../plugin-src/client/channels/telegram/api.js';
import { normalizeSnapshot as normalizeDiscord } from '../plugin-src/client/channels/discord/api.js';
import { normalizeSnapshot as normalizeWhatsapp } from '../plugin-src/client/channels/whatsapp/api.js';
import { BotCard as FeishuBotCard } from '../plugin-src/client/channels/feishu/index.js';
import { AccountCard as DingtalkAccountCard } from '../plugin-src/client/channels/dingtalk/index.js';
import { AccountCard as WeixinAccountCard } from '../plugin-src/client/channels/weixin/index.js';
import { AccountCard as WecomAccountCard } from '../plugin-src/client/channels/wecom/index.js';
import { AccountCard as QqAccountCard } from '../plugin-src/client/channels/qq/index.js';
import { SlackAccountCard } from '../plugin-src/client/channels/slack/index.js';
import { TelegramAccountCard } from '../plugin-src/client/channels/telegram/index.js';
import { DiscordAccountCard } from '../plugin-src/client/channels/discord/index.js';
import { WhatsappAccountCard } from '../plugin-src/client/channels/whatsapp/index.js';
const publicFailure = Object.freeze({
code: 'MODEL_RATE_LIMIT',
reason: 'MODEL_RATE_LIMIT',
message: '模型服务正在限流,本次任务未完成。请稍后重试。',
referenceId: 'MF-12AB34CD',
at: Date.UTC(2026, 7, 25, 7, 30),
});
function rawBot() {
return {
botId: 'bot_safe',
connected: true,
configured: true,
state: 'connected',
workspace: '/workspace/current',
groupResponseMode: 'mention',
bot: {
name: 'Harness Bot',
username: 'harness_bot',
appIdMasked: 'app••••01',
clientIdMasked: 'client••••01',
accountIdMasked: 'account••••01',
idMasked: 'bot••••01',
},
health: {
status: 'healthy',
summary: '连接运行正常',
lastCheckedAt: publicFailure.at,
},
lastMessageError: {
...publicFailure,
providerDetail: 'must-not-cross-client-normalization',
stack: '/private/path/that/must/not/render',
},
};
}
test('last-message error normalizer keeps only the bounded public contract', () => {
assert.deepEqual(normalizeLastMessageError({
...publicFailure,
providerDetail: 'secret provider response',
token: 'secret token',
}), publicFailure);
const bounded = normalizeLastMessageError({
code: 'C'.repeat(80),
reason: 'R'.repeat(80),
message: 'M'.repeat(600),
referenceId: 'F'.repeat(60),
at: 1,
});
assert.deepEqual({
code: bounded.code.length,
reason: bounded.reason.length,
message: bounded.message.length,
referenceId: bounded.referenceId.length,
}, { code: 64, reason: 64, message: 500, referenceId: 40 });
for (const invalid of [
null,
[],
{ ...publicFailure, referenceId: '' },
{ ...publicFailure, at: '2026-08-25T07:30:00Z' },
{ ...publicFailure, at: Number.POSITIVE_INFINITY },
]) assert.equal(normalizeLastMessageError(invalid), null);
});
test('all channel snapshot normalizers retain the same safe message failure', () => {
const normalizers = [
['Feishu', normalizeFeishu],
['DingTalk', normalizeDingtalk],
['WeChat', normalizeWeixin],
['WeCom', normalizeWecom],
['QQ', normalizeQq],
['Slack', normalizeSlack],
['Telegram', normalizeTelegram],
['Discord', normalizeDiscord],
['WhatsApp', normalizeWhatsapp],
];
for (const [channel, normalize] of normalizers) {
const snapshot = normalize({ bots: [rawBot()] });
assert.deepEqual(snapshot.bots[0].lastMessageError, publicFailure, channel);
assert.doesNotMatch(
JSON.stringify(snapshot.bots[0].lastMessageError),
/providerDetail|private|token/i,
channel,
);
}
});
test('all channel cards show message failures independently from healthy connections', () => {
const account = rawBot();
const callbacks = {
onReconnect() {},
onRequestRemove() {},
onConfirmRemove() {},
onCancelRemove() {},
};
const cards = [
['Feishu', FeishuBotCard, { connection: account }],
['DingTalk', DingtalkAccountCard, { account }],
['WeChat', WeixinAccountCard, { account }],
['WeCom', WecomAccountCard, { account }],
['QQ', QqAccountCard, { account }],
['Slack', SlackAccountCard, { account }],
['Telegram', TelegramAccountCard, { account }],
['Discord', DiscordAccountCard, { account }],
['WhatsApp', WhatsappAccountCard, { account }],
];
for (const [channel, Card, props] of cards) {
const markup = renderToStaticMarkup(React.createElement(Card, { ...callbacks, ...props }));
assert.match(markup, /运行正常/, channel);
assert.match(markup, /最近一条消息处理失败/, channel);
assert.match(markup, /模型服务正在限流/, channel);
assert.match(markup, /MODEL_RATE_LIMIT/, channel);
assert.match(markup, /MF-12AB34CD/, channel);
assert.match(markup, /role="status"/, channel);
assert.match(markup, /<time dateTime="2026-08-25T07:30:00.000Z">/, channel);
assert.doesNotMatch(markup, /providerDetail|must-not-cross|private\/path/, channel);
}
});

View file

@ -93,7 +93,9 @@ test('image validation failures receive a specific safe reply', async () => {
});
assert.deepEqual(prompts, []);
assert.deepEqual(sent, ['暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。']);
assert.equal(sent.length, 1);
assert.match(sent[0], /^暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。/);
assert.match(sent[0], /错误码:INPUT_INVALID;参考号:MF-[A-F0-9]{8}$/);
});
test('an image caption cannot answer a pending Harness question', async () => {

View file

@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
classifyMessageFailure,
messageFailureText,
publicMessageFailure,
} from '../src/channels/shared/message-failure.mjs';
const options = { referenceId: 'MF-TEST01', at: 123 };
test('message failures distinguish stable Harness transport errors', () => {
assert.equal(classifyMessageFailure({
code: 'harness-connect-failed', method: 'host.describe',
}, options).code, 'HARNESS_CONNECT');
assert.equal(classifyMessageFailure({
code: 'harness-connect-failed', method: 'session.history',
}, options).code, 'HARNESS_RESULT_UNCERTAIN');
assert.equal(classifyMessageFailure({
code: 'harness-timeout', method: 'session.prompt',
}, options).code, 'HARNESS_RESULT_UNCERTAIN');
assert.equal(classifyMessageFailure({
code: 'harness-auth-required', method: 'host.describe',
}, options).code, 'HARNESS_ACCESS');
assert.equal(classifyMessageFailure({
code: 'harness-api-not-found', method: 'session.history',
}, options).code, 'HARNESS_PROTOCOL');
});
test('message failures use verified turn-end provider codes without exposing provider detail', () => {
for (const [providerCode, code] of [
['AUTH', 'MODEL_AUTH'],
['QUOTA', 'MODEL_QUOTA'],
['RATE_LIMIT', 'MODEL_RATE_LIMIT'],
['CONTEXT_WINDOW_EXCEEDED', 'MODEL_CONTEXT_LIMIT'],
['UNKNOWN_MODEL', 'MODEL_UNAVAILABLE'],
['TIMEOUT', 'MODEL_TIMEOUT'],
['TRANSPORT', 'MODEL_TRANSPORT'],
['SERVER', 'MODEL_SERVICE'],
['STREAM_CLOSED', 'MODEL_STREAM'],
['EMPTY_RESPONSE', 'MODEL_EMPTY_REPLY'],
['CONTENT_FILTER', 'MODEL_CONTENT_REJECTED'],
]) {
const failure = classifyMessageFailure({
code: 'harness-turn-failed',
providerCode,
message: 'provider-token /private/path',
reason: { error: { message: 'secret provider payload' } },
}, options);
assert.equal(failure.code, code);
assert.doesNotMatch(JSON.stringify(failure), /provider-token|private|secret provider/);
}
assert.equal(classifyMessageFailure({
code: 'channel-send-failed', providerCode: 'RATE_LIMIT', status: 429,
}, options).code, 'CHANNEL_RATE_LIMIT');
assert.equal(classifyMessageFailure({
code: 'harness-turn-failed', providerCode: 'PRIVATE_PROVIDER_CODE',
}, options).code, 'INTERNAL_UNKNOWN');
});
test('message failure text contains a safe code and traceable reference', () => {
const failure = classifyMessageFailure(new Error('secret-shaped internal detail'), options);
assert.deepEqual(failure, {
code: 'INTERNAL_UNKNOWN',
reason: 'INTERNAL_UNKNOWN',
message: '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
referenceId: 'MF-TEST01',
at: 123,
});
assert.match(messageFailureText(failure), /错误码:INTERNAL_UNKNOWN;参考号:MF-TEST01/);
assert.doesNotMatch(messageFailureText(failure), /secret-shaped/);
});
test('public message failure keeps only bounded safe fields', () => {
assert.deepEqual(publicMessageFailure({
...classifyMessageFailure({ code: 'agent-busy' }, options),
providerDetail: 'secret',
stack: '/private/path',
}), {
code: 'SESSION_BUSY',
reason: 'SESSION_BUSY',
message: '当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
referenceId: 'MF-TEST01',
at: 123,
});
});
test('artifact permission failures use the shared channel permission classification', () => {
const error = new Error('private provider permission detail');
error.code = 'artifact-permission-required';
const failure = classifyMessageFailure(error, {
userMessage: '结果文件已生成,但机器人没有文件发送权限。',
reason: error.code,
referenceId: 'MF-ART12345',
at: 123,
});
assert.deepEqual(failure, {
code: 'CHANNEL_PERMISSION',
reason: 'ARTIFACT_PERMISSION_REQUIRED',
message: '结果文件已生成,但机器人没有文件发送权限。',
referenceId: 'MF-ART12345',
at: 123,
});
});