fix(delivery): surface failed semantic finals

This commit is contained in:
xmanrui 2026-08-24 03:41:53 +08:00
parent d7f4cf03ab
commit fde088e381
3 changed files with 139 additions and 3 deletions

File diff suppressed because one or more lines are too long

View file

@ -558,10 +558,18 @@ export class TextHarnessBridge {
textDeliveryError = error;
}
}
if (textReceipt?.deliveryOutcome === 'failed') {
const reason = textReceipt.reason ?? 'text-delivery-failed';
textDeliveryError = new Error(`Final text delivery failed (${reason})`);
textDeliveryError.code = reason;
}
// 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) throw textDeliveryError;
if (textDeliveryError && !delivery.userVisible) {
textDeliveryError.deliveryReceipt = delivery.receipt;
throw textDeliveryError;
}
if (delivery.userVisible) {
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
@ -626,7 +634,9 @@ export class TextHarnessBridge {
return;
}
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
if (await presentStreamFailure('消息处理失败,请稍后重试。')) return;
if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
return error.deliveryReceipt;
}
stream?.cancel?.();
try {
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
@ -636,6 +646,7 @@ export class TextHarnessBridge {
sendError,
);
}
return error.deliveryReceipt;
} finally {
await Promise.allSettled([
this.#cancelPendingInteraction(conversationKey),

View file

@ -510,6 +510,131 @@ test('Telegram uncertain final delivery records unknown and never sends a fallba
assert.deepEqual(groupResult.providerMessageIds, ['1202']);
});
test('shared bridge surfaces a definite final delivery failure without rerunning Prompt or artifacts', async (t) => {
for (const safeReplyFails of [false, true]) {
await t.test(safeReplyFails ? 'safe reply also fails' : 'safe reply succeeds', async () => {
const safeReplies = [];
let prompts = 0;
const stream = {
providerMessageIds: ['failed-placeholder'],
presentation: 'telegram-regular',
async update() {},
async finish() {
return {
presentation: 'telegram-rich-final',
providerMessageIds: ['failed-placeholder'],
deliveryOutcome: 'failed',
reason: 'telegram-403',
};
},
async fail() {
return undefined;
},
cancel() {},
};
const bridge = new TelegramHarnessBridge({
bot: {
sendTyping: async () => {},
openDeliveryStream: async () => stream,
sendText: async (_target, text) => {
safeReplies.push(text);
if (safeReplyFails) throw rejected(403);
return { message_id: 1261 };
},
},
state: memoryState(),
logger: { warn() {}, error() {} },
harness: {
createSession: async () => 'session-rich-artifact',
ask: async () => {
prompts += 1;
return '## final';
},
},
});
const receipt = await bridge.accept({
messageId: `definite-failure-${safeReplyFails}`,
senderId: 'rich-user',
kind: 'direct',
conversationId: `definite-failure-${safeReplyFails}`,
content: 'return a file',
addressed: true,
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
});
assert.equal(prompts, 1);
assert.deepEqual(safeReplies, ['消息处理失败,请稍后重试。']);
assert.equal(receipt.deliveryOutcome, 'failed');
assert.equal(receipt.reason, 'telegram-403');
assert.deepEqual(receipt.artifacts, []);
assert.match(bridge.status.lastError, /telegram-403/);
});
}
});
test('shared bridge settles each artifact once before containing a definite text failure', async (t) => {
const artifact = await committedArtifact(t);
const sentFiles = [];
let prompts = 0;
let safeReplies = 0;
const bridge = new TelegramHarnessBridge({
bot: {
sendTyping: async () => {},
openDeliveryStream: async () => ({
providerMessageIds: ['failed-placeholder'],
presentation: 'telegram-regular',
async update() {},
async finish() {
return {
presentation: 'telegram-rich-final',
providerMessageIds: ['failed-placeholder'],
deliveryOutcome: 'failed',
reason: 'telegram-403',
};
},
cancel() {},
}),
sendText: async () => {
safeReplies += 1;
return { message_id: 1261 };
},
sendFile: async (_target, file) => {
sentFiles.push(file.fileName);
return { message_id: 1262 };
},
},
state: memoryState(),
logger: { warn() {}, error() {} },
harness: {
createSession: async () => 'session-rich-artifact',
ask: async (_sessionId, _text, options) => {
prompts += 1;
await options.onArtifact(artifact);
return '## final with file';
},
},
});
const receipt = await bridge.accept({
messageId: 'definite-failure-with-file',
senderId: 'rich-user',
kind: 'direct',
conversationId: 'definite-failure-with-file',
content: 'return a file',
addressed: true,
replyTarget: { chatId: 42, chatType: 'private', replyToMessageId: 44 },
});
assert.equal(prompts, 1);
assert.equal(safeReplies, 0);
assert.deepEqual(sentFiles, ['result.txt']);
assert.equal(receipt.deliveryOutcome, 'failed');
assert.deepEqual(receipt.artifacts, [{
artifactId: 'telegram-rich-artifact', outcome: 'sent',
}]);
});
test('Telegram distinguishes pre-dispatch abort from an uncertain in-flight abort', async () => {
const before = new AbortController();
const beforeReason = new DOMException('stopped before dispatch', 'AbortError');