fix: surface actionable channel message errors

This commit is contained in:
xmanrui 2026-08-25 14:54:50 +08:00
parent b42fc411c2
commit 7cbed3aefb
59 changed files with 2405 additions and 607 deletions

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