mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
feat: add structured message failure reporting
This commit is contained in:
parent
b42fc411c2
commit
ad3c08ace3
59 changed files with 2405 additions and 607 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}$/);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
149
test/client-last-message-error.test.mjs
Normal file
149
test/client-last-message-error.test.mjs
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
107
test/message-failure.test.mjs
Normal file
107
test/message-failure.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue