diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 274358fc1ac8..0bdc2b30b67e 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -1638,7 +1638,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { .deliver({ text }, { kind: "final" }) .catch((caught: unknown) => caught); - expect(isChannelPartialDeliveryError(error)).toBe(true); + expect(error).toMatchObject({ + code: "CHANNEL_PARTIAL_DELIVERY", + deliveryResult: { + content: text, + messageIds: [], + visibleReplySent: true, + }, + }); expect(provider).toHaveBeenCalledOnce(); await Promise.resolve(options.onError?.(error, { kind: "final" })); expect(result.getVisibleReplyState().visibleReplySent).toBe(true); @@ -1650,6 +1657,50 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }, ); + it.each([ + { kind: "text", provider: sendMessageFeishuMock, acceptedBeforeReceiptLoss: 0 }, + { kind: "text", provider: sendMessageFeishuMock, acceptedBeforeReceiptLoss: 1 }, + { kind: "card", provider: sendStructuredCardFeishuMock, acceptedBeforeReceiptLoss: 0 }, + { kind: "card", provider: sendStructuredCardFeishuMock, acceptedBeforeReceiptLoss: 1 }, + ])( + "retains accepted $kind chunk content after receipt loss with $acceptedBeforeReceiptLoss prior receipts", + async ({ kind, provider, acceptedBeforeReceiptLoss }) => { + useNonStreamingAutoAccount(); + const runtime = getFeishuRuntimeMock(); + runtime.channel.text.resolveTextChunkLimit.mockReturnValue(6); + runtime.channel.text.chunkMarkdownTextWithMode.mockReturnValue(["first", "second", "third"]); + + if (acceptedBeforeReceiptLoss > 0) { + provider.mockResolvedValueOnce({ messageId: "om-first" }); + } + provider.mockRejectedValueOnce( + createChannelPartialDeliveryError( + new Error("Feishu reply failed: no message_id returned"), + { + messageIds: [], + visibleReplySent: true, + }, + ), + ); + const { options } = createDispatcherHarness(); + const text = kind === "card" ? "| first | second |\n| - | - |" : "firstsecondthird"; + + const error = await options + .deliver({ text }, { kind: "final" }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: "CHANNEL_PARTIAL_DELIVERY", + deliveryResult: { + content: acceptedBeforeReceiptLoss > 0 ? "firstsecond" : "first", + messageIds: acceptedBeforeReceiptLoss > 0 ? ["om-first"] : [], + visibleReplySent: true, + }, + }); + expect(provider).toHaveBeenCalledTimes(acceptedBeforeReceiptLoss + 1); + }, + ); + it("retains the finalized streaming card when companion media never dispatches", async () => { const marker = Object.assign( new Error("media load failed", { cause: new Error("blocked local load") }), diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 994adcfb3d76..b1bc01e76f24 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -763,18 +763,22 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP acceptedChunks.push(chunk); markVisibleReplySent(); } catch (error: unknown) { - if (isChannelPartialDeliveryError(error)) { + const acceptedChunk = isChannelPartialDeliveryError(error) + ? error.deliveryResult + : undefined; + if (acceptedChunk) { + acceptedChunks.push(acceptedChunk.content ?? chunk); markVisibleReplySent(); } - throw createFeishuPartialReplyDeliveryError( - error, - createFeishuReplyDeliveryResult({ + throw createFeishuPartialReplyDeliveryError(error, { + ...acceptedChunk, + ...createFeishuReplyDeliveryResult({ results, - visibleReplySent: results.length > 0, + visibleReplySent: results.length > 0 || acceptedChunk !== undefined, content: acceptedChunks.join(""), kind: paramsLocal.useCard ? "card" : "text", }), - ); + }); } } if (paramsLocal.infoKind === "final") {