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

@ -532,6 +532,41 @@ export class HarnessInteractionError extends Error {
}
}
export class HarnessTurnError extends Error {
constructor(code, { reason, providerCode } = {}) {
super(`Harness turn failed (${code})`);
this.name = 'HarnessTurnError';
this.code = code;
this.promptAccepted = true;
if (reason && typeof reason === 'object') this.reason = reason;
if (typeof providerCode === 'string' && providerCode) this.providerCode = providerCode;
}
}
function harnessTurnError(reason) {
const kind = nonEmptyText(reason?.kind) ?? nonEmptyText(reason);
if (kind === 'error') {
const failure = reason?.error ?? reason?.failure;
return new HarnessTurnError('harness-turn-failed', {
reason,
providerCode: nonEmptyText(failure?.code) ?? undefined,
});
}
if (kind === 'max-tokens') return new HarnessTurnError('model-max-tokens', { reason });
if (kind === 'blocked') return new HarnessTurnError('turn-blocked', { reason });
if (['interrupted', 'stopped', 'cancelled', 'canceled'].includes(kind)) {
return new HarnessTurnError('turn-interrupted', { reason });
}
if (kind === 'aborted') return new HarnessTurnError('turn-aborted', { reason });
if (kind === 'completed') return new HarnessTurnError('model-empty-response', { reason });
return new HarnessTurnError('harness-turn-failed', { reason });
}
function harnessTurnSucceeded(reason) {
if (reason === null || reason === undefined) return true;
return (nonEmptyText(reason?.kind) ?? nonEmptyText(reason)) === 'completed';
}
export class HarnessClient {
#baseUrl;
#workspace;
@ -1282,6 +1317,9 @@ export class HarnessClient {
}
if (!tracker.finished) continue;
turnFinished = true;
if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
throw harnessTurnError(tracker.reason);
}
// An accepted /stop revokes attachment delivery even when Harness
// preserved a useful partial text answer for the existing UX.
const artifactCount = ownership?.stopRequested
@ -1292,11 +1330,9 @@ export class HarnessClient {
}
if (artifactCount > 0) return '';
if (ownership?.stopRequested) throw turnStoppedError();
throw new Error(
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
);
throw harnessTurnError(tracker.reason);
}
throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
throw new HarnessTurnError('harness-reply-timeout');
} catch (error) {
// Once cancellation was accepted, transport/poll failures and timeouts
// describe the convergence of that stop, not an unrelated ask failure.

View file

@ -27,6 +27,83 @@ export default {
'结果文件「{name}」已生成,但当前渠道暂时未能发送,请稍后重试。':
'The result file "{name}" was generated, but this channel could not send it right now. Please try again later.',
'消息处理失败,请稍后重试。': 'Failed to process the message. Please try again later.',
'卡片已结束,请查看后续消息。': 'This card has ended. Please check the next message.',
'工具调用「{name}」未成功,请检查工具配置或稍后重试。': 'Tool call "{name}" did not succeed. Check the tool configuration or try again later.',
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。':
'Could not connect to the processing service, so the message was not submitted. Make sure DeepSeek Harness is running, then try again.',
'处理服务响应超时,消息尚未开始处理。请稍后重试。':
'The processing service timed out before the message started. Please try again later.',
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。':
'The task status could not be confirmed and the task may have started. Wait or send /stop before submitting it again.',
'消息提交结果未能确认,任务可能已经开始。请先等待或发送 /status 查看状态,不要立即重复提交。':
'The message submission could not be confirmed and the task may have started. Wait or send /status before submitting it again.',
'暂时无法读取任务进度,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
'Task progress is temporarily unavailable and the task may still be running. Wait or send /stop before submitting it again.',
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。':
'The processing service rejected the bot connection. Ask an administrator to check the Harness address, proxy, or access settings.',
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。':
'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.',
'DeepSeek Harness 暂时无法完成请求,请稍后重试。':
'DeepSeek Harness could not complete the request. Please try again later.',
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.',
'模型凭据缺失或已失效。请管理员检查模型配置后重试。':
'The model credentials are missing or invalid. Ask an administrator to check the model settings.',
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。':
'The model quota or balance is insufficient. Ask an administrator to add quota or switch models, then try again.',
'模型服务正在限流,本次任务未完成。请稍后重试。':
'The model service is rate-limiting requests. Please try again later.',
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。':
'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
'当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。':
'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.',
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
'当前模型不支持所选配置。请切换模型或推理等级后重试。':
'The current model does not support the selected settings. Switch the model or reasoning effort, then try again.',
'模型服务响应超时,本次任务未完成。请稍后重试。':
'The model service timed out and the task did not finish. Please try again later.',
'暂时无法连接模型服务,本次任务未完成。请稍后重试。':
'The model service is temporarily unreachable and the task did not finish. Please try again later.',
'模型服务暂时异常,本次任务未完成。请稍后重试。':
'The model service is temporarily unavailable and the task did not finish. Please try again later.',
'模型回复中断或格式异常,本次任务未完成。请重试。':
'The model reply was interrupted or malformed, so the task did not finish. Please try again.',
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。':
'The model returned no displayable content. Try again, or switch models if this keeps happening.',
'模型拒绝处理当前内容。请调整问题内容后重试。':
'The model rejected the current content. Revise the request and try again.',
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
'任务被意外中断,本次未完成。请重试。':
'The task was unexpectedly interrupted and did not finish. Please try again.',
'当前会话已不存在。请发送 /new 创建新会话后重试。':
'The current Session no longer exists. Send /new to create one, then try again.',
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。':
'The current Session is still processing the previous task. Wait for it to finish, or send /stop before trying again.',
'工作区或会话状态刚刚发生变化。请重新发送这条消息。':
'The Workspace or Session state just changed. Please send this message again.',
'当前工作区不存在或暂不可用。请重新选择工作区后重试。':
'The current Workspace does not exist or is unavailable. Select another Workspace and try again.',
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。':
'The current Agent Preset does not exist or is unavailable. Send /presetlist and select another one.',
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。':
'The reply was generated, but the bot cannot send it. Ask an administrator to check channel permissions or reconnect the bot.',
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。':
'The reply was generated, but this channel is rate-limiting messages. Please try again later.',
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。':
'Reply delivery could not be confirmed. Check whether it already arrived before submitting the task again.',
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。':
'The reply was generated, but this channel could not send it. Please try again later.',
'当前消息包含无法处理的图片或文件。请调整后重新发送。':
'This message contains an image or file that cannot be processed. Adjust it and send it again.',
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
'错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
'{label}机器人': '{label} bot',
'目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
'目前支持文字、图片和文件消息。':

View file

@ -0,0 +1,244 @@
import { randomUUID } from 'node:crypto';
import { t } from './i18n.mjs';
const PROVIDER_FAILURES = Object.freeze({
AUTH: 'MODEL_AUTH',
MISSING_CREDENTIAL: 'MODEL_AUTH',
INVALID_CREDENTIAL: 'MODEL_AUTH',
QUOTA: 'MODEL_QUOTA',
RATE_LIMIT: 'MODEL_RATE_LIMIT',
CONTEXT_WINDOW_EXCEEDED: 'MODEL_CONTEXT_LIMIT',
UNKNOWN_MODEL: 'MODEL_UNAVAILABLE',
NO_ADAPTER: 'MODEL_UNAVAILABLE',
UNSUPPORTED_OPTION: 'MODEL_CONFIG',
UNSUPPORTED_REASONING_EFFORT: 'MODEL_CONFIG',
TIMEOUT: 'MODEL_TIMEOUT',
TRANSPORT: 'MODEL_TRANSPORT',
SERVER: 'MODEL_SERVICE',
STREAM_CLOSED: 'MODEL_STREAM',
MALFORMED_RESPONSE: 'MODEL_STREAM',
EMPTY_RESPONSE: 'MODEL_EMPTY_REPLY',
CONTENT_FILTER: 'MODEL_CONTENT_REJECTED',
UNSUPPORTED_CONTENT: 'MODEL_CONFIG',
});
const FAILURE_MESSAGES = Object.freeze({
HARNESS_CONNECT:
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
HARNESS_TIMEOUT:
'处理服务响应超时,消息尚未开始处理。请稍后重试。',
HARNESS_RESULT_UNCERTAIN:
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。',
HARNESS_ACCESS:
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。',
HARNESS_PROTOCOL:
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
HARNESS_SERVICE:
'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
MODEL_REPLY_TIMEOUT:
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
MODEL_AUTH:
'模型凭据缺失或已失效。请管理员检查模型配置后重试。',
MODEL_QUOTA:
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。',
MODEL_RATE_LIMIT:
'模型服务正在限流,本次任务未完成。请稍后重试。',
MODEL_CONTEXT_LIMIT:
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
MODEL_UNAVAILABLE:
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
MODEL_CONFIG:
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
MODEL_TIMEOUT:
'模型服务响应超时,本次任务未完成。请稍后重试。',
MODEL_TRANSPORT:
'暂时无法连接模型服务,本次任务未完成。请稍后重试。',
MODEL_SERVICE:
'模型服务暂时异常,本次任务未完成。请稍后重试。',
MODEL_STREAM:
'模型回复中断或格式异常,本次任务未完成。请重试。',
MODEL_EMPTY_REPLY:
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。',
MODEL_CONTENT_REJECTED:
'模型拒绝处理当前内容。请调整问题内容后重试。',
MODEL_OUTPUT_LIMIT:
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
TURN_BLOCKED:
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
TURN_INTERRUPTED:
'任务被意外中断,本次未完成。请重试。',
SESSION_NOT_FOUND:
'当前会话已不存在。请发送 /new 创建新会话后重试。',
SESSION_BUSY:
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
SESSION_STALE:
'工作区或会话状态刚刚发生变化。请重新发送这条消息。',
WORKSPACE_UNAVAILABLE:
'当前工作区不存在或暂不可用。请重新选择工作区后重试。',
PRESET_UNAVAILABLE:
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。',
CHANNEL_PERMISSION:
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。',
CHANNEL_RATE_LIMIT:
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。',
CHANNEL_DELIVERY_UNCERTAIN:
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。',
CHANNEL_DELIVERY:
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
INPUT_INVALID:
'当前消息包含无法处理的图片或文件。请调整后重新发送。',
INTERNAL_UNKNOWN:
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
});
function providerFailureCode(error) {
if (error?.code !== 'harness-turn-failed') return null;
const value = error?.providerCode ?? error?.details?.providerCode;
if (typeof value !== 'string') return null;
return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
}
function failureCode(error) {
const code = typeof error?.code === 'string' ? error.code : '';
const providerCode = providerFailureCode(error);
if (providerCode) return providerCode;
if (code === 'harness-connect-failed') {
return ['session.prompt', 'session.history'].includes(error?.method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_CONNECT';
}
if (code === 'harness-timeout') {
return ['session.prompt', 'session.history'].includes(error?.method)
? 'HARNESS_RESULT_UNCERTAIN'
: 'HARNESS_TIMEOUT';
}
if (code === 'harness-reply-timeout') return 'MODEL_REPLY_TIMEOUT';
if ([
'harness-auth-required', 'harness-proxy-auth-required',
'harness-loopback-forbidden', 'harness-host-untrusted',
'harness-request-forbidden',
].includes(code)) return 'HARNESS_ACCESS';
if (['harness-api-not-found', 'harness-response-invalid'].includes(code)) {
return 'HARNESS_PROTOCOL';
}
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
if (code === 'turn-blocked') return 'TURN_BLOCKED';
if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
if (code === 'agent-busy') return 'SESSION_BUSY';
if (code === 'workspace-session-stale') return 'SESSION_STALE';
if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
if (code.startsWith('image-') || code.startsWith('inbound-file-')
|| code === 'attachment-error') return 'INPUT_INVALID';
const status = Number(error?.status ?? error?.httpStatus);
if (status === 401 || status === 403
|| [
'channel-permission',
'permission-required',
'artifact-permission-required',
'forbidden',
'stale-token',
].includes(code)) {
return 'CHANNEL_PERMISSION';
}
if (status === 429
|| ['channel-rate-limit', 'rate-limited', 'artifact-rate-limited'].includes(code)) {
return 'CHANNEL_RATE_LIMIT';
}
if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
.includes(code)) {
return 'CHANNEL_DELIVERY_UNCERTAIN';
}
if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
|| ['network-error', 'timeout'].includes(code)) {
return 'CHANNEL_DELIVERY';
}
return 'INTERNAL_UNKNOWN';
}
function safeReferenceId(value) {
return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
? value
: `MF-${randomUUID().slice(0, 8).toUpperCase()}`;
}
function safeFailureReason(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(value)) return null;
return value.toUpperCase().replaceAll('-', '_');
}
export function classifyMessageFailure(error, {
userMessage,
reason,
referenceId,
at = Date.now(),
} = {}) {
const safeReason = safeFailureReason(reason);
const classifiedCode = failureCode(error);
const code = classifiedCode === 'INTERNAL_UNKNOWN'
&& safeReason
&& typeof userMessage === 'string'
&& userMessage.trim()
? 'INPUT_INVALID'
: classifiedCode;
return Object.freeze({
code,
reason: safeReason ?? code,
message: typeof userMessage === 'string' && userMessage.trim()
? userMessage.trim()
: t(FAILURE_MESSAGES[code]),
referenceId: safeReferenceId(referenceId),
at: Number.isFinite(at) ? at : Date.now(),
});
}
export function messageFailureText(failure) {
return `${failure.message}\n\n${t('错误码:{code};参考号:{referenceId}', failure)}`;
}
export function setLastMessageFailure(status, error, options) {
const failure = classifyMessageFailure(error, options);
status.lastMessageError = failure;
return failure;
}
export function clearLastMessageFailure(status) {
status.lastMessageError = null;
}
export function channelDeliveryFailure(error, { uncertain = true } = {}) {
const wrapped = new Error('Channel message delivery failed', { cause: error });
const status = Number(error?.status ?? error?.httpStatus);
wrapped.code = status === 401 || status === 403
? 'channel-permission'
: status === 429
? 'channel-rate-limit'
: uncertain
? 'channel-delivery-uncertain'
: 'channel-delivery-failed';
if (Number.isInteger(status)) wrapped.status = status;
return wrapped;
}
export function publicMessageFailure(value) {
if (!value || typeof value !== 'object'
|| typeof value.code !== 'string' || !value.code
|| typeof value.reason !== 'string' || !value.reason
|| typeof value.message !== 'string' || !value.message
|| typeof value.referenceId !== 'string' || !value.referenceId
|| !Number.isFinite(value.at)) return null;
return {
code: value.code.slice(0, 64),
reason: value.reason.slice(0, 64),
message: value.message.slice(0, 500),
referenceId: value.referenceId.slice(0, 40),
at: value.at,
};
}

View file

@ -73,6 +73,7 @@ export async function deliverOutboundArtifacts({
sendFile,
sendImage,
sendFailureNotice,
onFailure,
logger,
}) {
const receipts = baseReceipt ? [baseReceipt] : [];
@ -107,14 +108,20 @@ export async function deliverOutboundArtifacts({
} catch (error) {
if (isAbort(error, signal)) throw error;
artifactSendErrors += 1;
const failure = typeof onFailure === 'function'
? await onFailure(artifact, error)
: null;
const reference = typeof failure?.referenceId === 'string'
? ` [${failure.referenceId}]`
: '';
logger?.warn?.(
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
`[dsh-im:${channelKey}] result artifact delivery failed${reference} (${error?.code ?? 'unknown'})`,
);
let messageIds = [];
if (typeof sendFailureNotice === 'function') {
try {
signal?.throwIfAborted();
const notice = await sendFailureNotice(artifact, error);
const notice = await sendFailureNotice(artifact, error, failure);
signal?.throwIfAborted();
messageIds = providerIds(notice);
failureNoticeVisible = true;

View file

@ -27,6 +27,7 @@ import {
} from './batch-input.mjs';
import {
hasInboundImages,
imagePromptDiagnostic,
imagePromptUserMessage,
promptContentForMessage,
} from './image-prompt.mjs';
@ -45,6 +46,12 @@ import {
createTextDeliveryBlock,
providerMessageIdsFor,
} from './semantic/delivery.mjs';
import {
channelDeliveryFailure,
clearLastMessageFailure,
messageFailureText,
setLastMessageFailure,
} from './message-failure.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
@ -106,6 +113,7 @@ export function createTextBridgeStatus() {
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
lastMessageError: null,
};
}
@ -320,8 +328,9 @@ export class TextHarnessBridge {
})().catch(async (error) => {
if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
error,
);
}).finally(() => {
@ -399,8 +408,12 @@ export class TextHarnessBridge {
} catch (error) {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。')).catch(() => undefined);
const failure = setLastMessageFailure(this.#status, error);
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
error,
);
await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
}
}
@ -426,9 +439,13 @@ export class TextHarnessBridge {
sendFile: typeof this.#bot.sendFile === 'function'
? (file) => this.#bot.sendFile(target, file)
: undefined,
sendFailureNotice: (artifact, error) => this.#bot.sendText(
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
userMessage: artifactFailureText(artifact?.fileName, error, this.#descriptor),
reason: error?.code,
}),
sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error, this.#descriptor),
messageFailureText(failure),
),
logger: this.#logger,
});
@ -436,7 +453,11 @@ export class TextHarnessBridge {
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
return {
receipt: delivery.receipt,
userVisible: delivery.userVisible,
artifactSendErrors: delivery.artifactSendErrors,
};
}
async #process(message, messageId, senderId, conversationKey, {
@ -643,25 +664,37 @@ export class TextHarnessBridge {
reason: result?.reason,
});
} catch (error) {
textDeliveryError = error;
textDeliveryError = channelDeliveryFailure(error);
}
}
const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
if (textReceipt?.deliveryOutcome === 'failed') {
const reason = textReceipt.reason ?? 'text-delivery-failed';
textDeliveryError = new Error(`Final text delivery failed (${reason})`);
textDeliveryError.code = reason;
textDeliveryError = channelDeliveryFailure(
new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
{ uncertain: false },
);
} else if (finalDeliveryUnknown) {
textDeliveryError = channelDeliveryFailure(
new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
);
}
// A failed final text must not discard an already registered result file.
// Settle the independent attachment path before surfacing the text error.
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
if (textDeliveryError && !delivery.userVisible) {
if (textDeliveryError && (!delivery.userVisible || finalDeliveryUnknown)) {
textDeliveryError.deliveryReceipt = delivery.receipt;
throw textDeliveryError;
}
if (textDeliveryError && delivery.artifactSendErrors === 0) {
setLastMessageFailure(this.#status, textDeliveryError);
}
if (delivery.userVisible) {
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
clearLastMessageFailure(this.#status);
}
}
return delivery.receipt;
} catch (error) {
@ -688,10 +721,15 @@ export class TextHarnessBridge {
}
this.#status.lastError = error?.message ?? String(error);
const presentStreamFailure = async (text) => {
if (typeof stream?.fail !== 'function') return false;
const method = typeof stream?.fail === 'function'
? 'fail'
: (typeof stream?.finish === 'function' ? 'finish' : null);
if (!method) return false;
try {
const result = await stream.fail(text);
return Boolean(result) && result.deliveryOutcome !== 'failed';
const result = await stream[method](text);
return method === 'fail'
? Boolean(result) && result.deliveryOutcome !== 'failed'
: result !== false && result?.deliveryOutcome !== 'failed';
} catch (streamError) {
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] unable to finalize the failed stream:`,
@ -700,58 +738,25 @@ export class TextHarnessBridge {
return false;
}
};
if (failedBatch?.retained) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
error,
);
if (await presentStreamFailure(failedBatch.message)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, failedBatch.message);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
sendError,
);
}
return;
}
const imageErrorMessage = imagePromptUserMessage(error);
if (imageErrorMessage) {
if (await presentStreamFailure(imageErrorMessage)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, imageErrorMessage);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
sendError,
);
}
return;
}
const fileErrorMessage = inboundFileUserMessage(error);
if (fileErrorMessage) {
if (await presentStreamFailure(fileErrorMessage)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, fileErrorMessage);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
sendError,
);
}
return;
}
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
const failure = setLastMessageFailure(this.#status, error, {
userMessage: fileErrorMessage ?? imageErrorMessage,
reason: imagePromptDiagnostic(error)?.reason,
});
const failureText = failedBatch?.retained
? `${messageFailureText(failure)}\n\n${failedBatch.message}`
: messageFailureText(failure);
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
error,
);
if (await presentStreamFailure(failureText)) {
return error.deliveryReceipt;
}
stream?.cancel?.();
try {
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。'));
await this.#bot.sendText(target, failureText);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,

View file

@ -1,5 +1,6 @@
import { connectionTestMessage } from './connection-test.mjs';
import { t } from './i18n.mjs';
import { publicMessageFailure } from './message-failure.mjs';
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -291,6 +292,7 @@ export class TokenBotController {
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
},
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
error: structuredClone(this.#errors.get(config.botId) ?? null),
};
});