From 9623c33fb2ec96896b6b35a169f5260c8b6252bd Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:25:15 -0500 Subject: [PATCH] fix(feishu): preserve rejected reply delivery outcomes --- extensions/feishu/src/bot.broadcast.test.ts | 4 +- extensions/feishu/src/bot.test.ts | 4 +- extensions/feishu/src/bot.ts | 4 +- extensions/feishu/src/comment-shared.ts | 5 + extensions/feishu/src/media.test.ts | 12 +- .../feishu/src/reply-dispatcher.test.ts | 39 ++++ extensions/feishu/src/reply-dispatcher.ts | 2 +- extensions/feishu/src/send-result.test.ts | 42 ++++- extensions/feishu/src/send-result.ts | 50 +++++- extensions/feishu/src/send.retry.test.ts | 34 ++++ .../src/streaming-card.error-release.test.ts | 4 +- extensions/feishu/src/streaming-card.test.ts | 169 +++++++++++++++++- extensions/feishu/src/streaming-card.ts | 41 ++--- 13 files changed, 364 insertions(+), 46 deletions(-) diff --git a/extensions/feishu/src/bot.broadcast.test.ts b/extensions/feishu/src/bot.broadcast.test.ts index ddd6427533df..d6f7ec483943 100644 --- a/extensions/feishu/src/bot.broadcast.test.ts +++ b/extensions/feishu/src/bot.broadcast.test.ts @@ -445,8 +445,8 @@ describe("broadcast dispatch", () => { mockDispatchReply .mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } }) .mockResolvedValueOnce({ - queuedFinal: true, - counts: { final: 1 }, + queuedFinal: false, + counts: { final: 0 }, failedCounts: { tool: 0, block: 0, final: 1 }, }); const ensureNoVisibleReplyFallback = vi.fn(); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 32dd598254b6..b86001df064d 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1175,8 +1175,8 @@ describe("handleFeishuMessage command authorization", () => { it("sends no-visible fallback when queued final delivery fails", async () => { mockDispatchReplyFromConfig.mockResolvedValueOnce({ - queuedFinal: true, - counts: { tool: 0, block: 0, final: 1 }, + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, failedCounts: { tool: 0, block: 0, final: 1 }, }); const ensureNoVisibleReplyFallback = vi.fn(); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 5aad10783163..6c1ce50750ab 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -114,11 +114,11 @@ function shouldSendNoVisibleReplyFallback(dispatchResult: { dispatchResult.noVisibleReplyFallbackEligible === true && dispatchResult.queuedFinal !== true && finalCount === 0; - const queuedFinalFailed = dispatchResult.queuedFinal === true && failedFinalCount > 0; + const finalDeliveryFailed = failedFinalCount > 0; return ( dispatchResult.sendPolicyDenied !== true && dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" && - (emptyEligibleDispatch || queuedFinalFailed) + (emptyEligibleDispatch || finalDeliveryFailed) ); } diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index ca5de3eaae5d..af443758b932 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -11,6 +11,7 @@ import { getFeishuSendRateLimitCode, getFeishuSendRateLimitCodeFromResponse, } from "./send-rate-limit.js"; +import { createFeishuRejectedMessageApiError } from "./send-result.js"; export function encodeQuery(params: Record): string { const query = new URLSearchParams(); @@ -128,6 +129,10 @@ export async function requestFeishuApi( }, ); } catch (error) { + const rejection = createFeishuRejectedMessageApiError(error, errorPrefix); + if (rejection && getFeishuSendRateLimitCode(error) === undefined) { + throw rejection; + } throw createFeishuApiError(error, errorPrefix, options); } } diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 951a135d46be..0dbedf66487d 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; +import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime"; import { withTempDir } from "openclaw/plugin-sdk/test-env"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../runtime-api.js"; @@ -430,7 +431,7 @@ describe("sendMediaFeishu msg_type routing", () => { expect(callData<{ msg_type?: string }>(messageCreateMock).msg_type).toBe("image"); }); - it("preserves Feishu diagnostics when media sends reject before response checks", async () => { + it("classifies a structured media message rejection with bounded diagnostics", async () => { messageCreateMock.mockRejectedValueOnce( Object.assign(new Error("Request failed with status code 400"), { response: { @@ -447,15 +448,16 @@ describe("sendMediaFeishu msg_type routing", () => { }), ); - const send = sendMediaFeishu({ + const error = await sendMediaFeishu({ cfg: emptyConfig, to: "user:ou_target", mediaBuffer: validPngImage, fileName: "photo.png", - }); + }).catch((caught: unknown) => caught); - await expect(send).rejects.toThrow(/Feishu image send failed: .*"feishu_code":9499/); - await expect(send).rejects.toThrow(/"feishu_log_id":"20260429124731MEDIA"/); + expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError); + expect(error).toMatchObject({ retryable: false }); + expect((error as Error).message).toBe("Feishu image send failed: Bad Request (code=9499)"); }); it("uses msg_type=media when replying with mp4", async () => { diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 385870eba357..c1463f19b6cf 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -1799,6 +1799,45 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); }); + it("settles an identity-less streaming final exactly once without static fallback", async () => { + const { result, options } = createDispatcherHarness(); + const delivery = await options.deliver({ text: "accepted no-id final" }, { kind: "final" }); + requireStreamingInstance(0).closeWithResult.mockResolvedValueOnce({ + visibleReplySent: true, + content: "accepted no-id final", + }); + + await options.onIdle?.(); + + await expect(delivery?.finalization).resolves.toEqual({ + visibleReplySent: true, + content: "accepted no-id final", + }); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + await expect(result.ensureNoVisibleReplyFallback("accepted-no-id-stream")).resolves.toBe(false); + expect(requireStreamingInstance(0).closeWithResult).toHaveBeenCalledTimes(1); + }); + + it("keeps identity-less accepted-card custody when final content update fails", async () => { + const { result, options } = createDispatcherHarness(); + const delivery = await options.deliver({ text: "unaccepted final text" }, { kind: "final" }); + requireStreamingInstance(0).closeWithResult.mockRejectedValueOnce( + new FeishuStreamingFinalizationError(new Error("final update failed"), { + visibleReplySent: true, + }), + ); + + await expect(options.onIdle?.()).rejects.toThrow("final update failed"); + await expect(delivery?.finalization).rejects.toMatchObject({ + code: "CHANNEL_PARTIAL_DELIVERY", + deliveryResult: { visibleReplySent: true, content: "" }, + }); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + await expect(result.ensureNoVisibleReplyFallback("accepted-no-id-stream")).resolves.toBe(false); + }); + it("allows recovery after a final rewrite leaves only an earlier preview visible", async () => { const { result, options } = createDispatcherHarness(); result.replyOptions.onPartialReply?.({ text: "accepted preview" }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 00123f35f993..3932f18826e5 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1048,7 +1048,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP [...(providerFinalized ? [providerFinalized] : []), completion.result], deliveryError === undefined ? (completion.result.content ?? providerFinalized?.content) - : (providerFinalized?.content ?? completion.result.content), + : (providerFinalized?.content ?? ""), ); if (deliveryError !== undefined) { completion.reject( diff --git a/extensions/feishu/src/send-result.test.ts b/extensions/feishu/src/send-result.test.ts index df7df3c13c3d..8aa095148205 100644 --- a/extensions/feishu/src/send-result.test.ts +++ b/extensions/feishu/src/send-result.test.ts @@ -1,6 +1,46 @@ import { isChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; +import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime"; import { describe, expect, it } from "vitest"; -import { toFeishuSendResult } from "./send-result.js"; +import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; + +describe("assertFeishuMessageApiSuccess", () => { + it("classifies a fulfilled permanent provider rejection without exposing raw response data", () => { + const secretBearingResponse = { + code: 230099, + msg: "card table number over limit", + requestHeaders: { authorization: "secret" }, + card: { body: "private" }, + }; + + let caught: unknown; + try { + assertFeishuMessageApiSuccess(secretBearingResponse, "Feishu card send failed"); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(PlatformMessageNotDispatchedError); + expect(caught).toMatchObject({ retryable: false, cause: secretBearingResponse }); + expect((caught as Error).message).toBe( + "Feishu card send failed: card table number over limit (code=230099)", + ); + expect((caught as Error).message).not.toContain("secret"); + expect((caught as Error).message).not.toContain("private"); + }); + + it("keeps a code-less fulfilled response ambiguous instead of accepting it", () => { + let caught: unknown; + try { + assertFeishuMessageApiSuccess({ msg: "malformed gateway response" }, "Feishu send failed"); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(PlatformMessageNotDispatchedError); + expect((caught as Error).message).toBe("Feishu send failed: malformed gateway response"); + }); +}); describe("toFeishuSendResult", () => { it.each([undefined, "", " "])( diff --git a/extensions/feishu/src/send-result.ts b/extensions/feishu/src/send-result.ts index 477c6e087486..9d84e9116e6b 100644 --- a/extensions/feishu/src/send-result.ts +++ b/extensions/feishu/src/send-result.ts @@ -5,6 +5,9 @@ import { type MessageReceipt, type MessageReceiptPartKind, } from "openclaw/plugin-sdk/channel-outbound"; +import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; type FeishuMessageApiResponse = { code?: number; @@ -14,6 +17,44 @@ type FeishuMessageApiResponse = { }; }; +const FEISHU_PROVIDER_ERROR_MESSAGE_MAX_CHARS = 500; + +function createFeishuMessageRejection(params: { + response: unknown; + errorPrefix: string; + cause: unknown; +}): PlatformMessageNotDispatchedError | undefined { + const response = isRecord(params.response) ? params.response : undefined; + if (!response) { + return undefined; + } + if (typeof response.code !== "number" || response.code === 0) { + return undefined; + } + const providerMessage = normalizeOptionalString(response.msg); + const detail = providerMessage + ? `${sliceUtf16Safe(providerMessage, 0, FEISHU_PROVIDER_ERROR_MESSAGE_MAX_CHARS)} (code=${response.code})` + : `code ${response.code}`; + return new PlatformMessageNotDispatchedError(`${params.errorPrefix}: ${detail}`, { + cause: params.cause, + retryable: false, + }); +} + +export function createFeishuRejectedMessageApiError( + error: unknown, + errorPrefix: string, +): PlatformMessageNotDispatchedError | undefined { + if (!isRecord(error)) { + return undefined; + } + const response = isRecord(error.response) ? error.response : undefined; + const data = isRecord(response?.data) ? response.data : undefined; + return data + ? createFeishuMessageRejection({ response: data, errorPrefix, cause: error }) + : undefined; +} + export function resolveFeishuReceiptKind(msgType?: string): MessageReceiptPartKind { switch (msgType) { case "audio": @@ -59,9 +100,14 @@ export function assertFeishuMessageApiSuccess( response: FeishuMessageApiResponse, errorPrefix: string, ) { - if (response.code !== 0) { - throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`); + if (response.code === 0) { + return; } + const rejection = createFeishuMessageRejection({ response, errorPrefix, cause: response }); + if (rejection) { + throw rejection; + } + throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`); } export function toFeishuSendResult( diff --git a/extensions/feishu/src/send.retry.test.ts b/extensions/feishu/src/send.retry.test.ts index 4c04fff27599..479cf02a9017 100644 --- a/extensions/feishu/src/send.retry.test.ts +++ b/extensions/feishu/src/send.retry.test.ts @@ -5,6 +5,7 @@ * so no fake timers are needed. Related: issue #70879. */ +import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime"; import { describe, expect, it, vi } from "vitest"; import { requestFeishuApi } from "./comment-shared.js"; import { @@ -124,6 +125,23 @@ describe("requestFeishuApi — retry on rate-limit", () => { }); describe("requestFeishuApi — no retry for non-rate-limit errors", () => { + it("classifies a structured rejected message response as a permanent non-dispatch", async () => { + const rejected = axiosError(230099); + rejected.response.data.msg = "card table number over limit"; + const request = vi.fn().mockRejectedValue(rejected); + + const error = await requestFeishuApi(request, "Feishu card send failed", NO_DELAY).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError); + expect(error).toMatchObject({ retryable: false, cause: rejected }); + expect((error as Error).message).toBe( + "Feishu card send failed: card table number over limit (code=230099)", + ); + expect(request).toHaveBeenCalledTimes(1); + }); + it("throws immediately without retry for a non-rate-limit Feishu code", async () => { const request = vi.fn().mockRejectedValue(axiosError(230001)); @@ -137,6 +155,22 @@ describe("requestFeishuApi — no retry for non-rate-limit errors", () => { await expect(requestFeishuApi(request, "prefix", NO_DELAY)).rejects.toThrow(/network failure/); expect(request).toHaveBeenCalledTimes(1); }); + + it("keeps code-less transport failures ambiguous", async () => { + const request = vi.fn().mockRejectedValue( + Object.assign(new Error("socket closed"), { + response: { status: 502, data: { msg: "gateway unavailable" } }, + }), + ); + + const error = await requestFeishuApi(request, "prefix", NO_DELAY).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(PlatformMessageNotDispatchedError); + expect(request).toHaveBeenCalledTimes(1); + }); }); describe("getFeishuSendRateLimitCode — expanded rate-limit signals", () => { diff --git a/extensions/feishu/src/streaming-card.error-release.test.ts b/extensions/feishu/src/streaming-card.error-release.test.ts index b540899387ca..0b541e4adbc3 100644 --- a/extensions/feishu/src/streaming-card.error-release.test.ts +++ b/extensions/feishu/src/streaming-card.error-release.test.ts @@ -140,7 +140,9 @@ describe("feishu streaming card error-path body release", () => { await session.start("chat_id", "open_id"); loopback.settingsStatus = 500; - await expect(session.close()).resolves.toBe(false); + await expect(session.closeWithResult()).rejects.toMatchObject({ + result: { visibleReplySent: false }, + }); expect(loopback.releases).toEqual([ { bodyIsNull: false, bodyUsed: true }, diff --git a/extensions/feishu/src/streaming-card.test.ts b/extensions/feishu/src/streaming-card.test.ts index cb9a5985521b..325102d530d1 100644 --- a/extensions/feishu/src/streaming-card.test.ts +++ b/extensions/feishu/src/streaming-card.test.ts @@ -1,5 +1,6 @@ // Feishu tests cover streaming card plugin behavior. import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime"; import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime"; import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -15,7 +16,7 @@ type FeishuStreamingFetch = typeof fetch; type StreamingSessionState = { cardId: string; - messageId: string; + messageId?: string; sequence: number; currentText: string; sentText: string; @@ -287,6 +288,154 @@ describe("FeishuStreamingSession", () => { return { authTokens, client, deps }; } + function createStartHarness( + response: { code: number; msg: string; messageId?: string }, + rejectCardKitPath?: "content" | "settings", + ) { + const sendResponse = async () => ({ + code: response.code, + msg: response.msg, + data: response.messageId ? { message_id: response.messageId } : {}, + }); + const messageCreate = vi.fn(sendResponse); + const messageReply = vi.fn(sendResponse); + const messageDelete = vi.fn(async () => ({ code: 0, msg: "ok" })); + const client = { + im: { message: { create: messageCreate, reply: messageReply, delete: messageDelete } }, + } as unknown as ConstructorParameters[0]; + const requests: Array<{ path: string; body: string }> = []; + const deps = createMemoryFetch((url, body) => { + requests.push({ path: url.pathname, body }); + if (url.pathname.includes("/auth/")) { + return jsonResponse({ + code: 0, + msg: "ok", + tenant_access_token: "token", + expire: 7200, + }); + } + if (url.pathname.endsWith("/cardkit/v1/cards")) { + return jsonResponse({ code: 0, msg: "ok", data: { card_id: "card_start" } }); + } + if ( + (rejectCardKitPath === "content" && url.pathname.includes("/elements/content/content")) || + (rejectCardKitPath === "settings" && url.pathname.endsWith("/settings")) + ) { + return jsonResponse({ code: 19001, msg: `${rejectCardKitPath} rejected` }); + } + return jsonResponse({ code: 0, msg: "ok" }); + }); + return { client, deps, messageCreate, messageReply, messageDelete, requests }; + } + + it.each([ + { mode: "reply", options: { replyToMessageId: "om_parent" } }, + { mode: "root-create", options: { rootId: "om_root" } }, + { mode: "create", options: undefined }, + ])("classifies fulfilled permanent rejection in $mode mode", async ({ mode, options }) => { + const harness = createStartHarness({ + code: 230099, + msg: "card table number over limit", + }); + const session = new FeishuStreamingSession( + harness.client, + { appId: `app_rejected_${mode}`, appSecret: "secret" }, + undefined, + harness.deps, + ); + + const error = await session + .start("oc_chat", "chat_id", options) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(PlatformMessageNotDispatchedError); + expect(error).toMatchObject({ retryable: false }); + expect((error as Error).message).toBe( + "Send card failed: card table number over limit (code=230099)", + ); + expect(session.isActive()).toBe(false); + expect(harness.messageCreate.mock.calls.length + harness.messageReply.mock.calls.length).toBe( + 1, + ); + }); + + it.each([ + { mode: "reply", options: { replyToMessageId: "om_parent" } }, + { mode: "root-create", options: { rootId: "om_root" } }, + { mode: "create", options: undefined }, + ])("finalizes an accepted no-identity card in place in $mode mode", async ({ mode, options }) => { + const harness = createStartHarness({ code: 0, msg: "ok" }); + const session = new FeishuStreamingSession( + harness.client, + { appId: `app_no_id_${mode}`, appSecret: "secret" }, + undefined, + harness.deps, + ); + + await session.start("oc_chat", "chat_id", options); + expect(session.isActive()).toBe(true); + const result = await session.closeWithResult("exact final answer"); + + expect(result).toEqual({ visibleReplySent: true, content: "exact final answer" }); + expect(session.isActive()).toBe(false); + expect(harness.messageCreate.mock.calls.length + harness.messageReply.mock.calls.length).toBe( + 1, + ); + expect( + harness.requests.filter(({ path }) => path.includes("/elements/content/content")), + ).toHaveLength(1); + expect(harness.requests.filter(({ path }) => path.endsWith("/settings"))).toHaveLength(1); + expect(harness.messageDelete).not.toHaveBeenCalled(); + }); + + it("disposes an accepted no-identity card in place without attempting message deletion", async () => { + const harness = createStartHarness({ code: 0, msg: "ok" }); + const session = new FeishuStreamingSession( + harness.client, + { appId: "app_no_id_discard", appSecret: "secret" }, + undefined, + harness.deps, + ); + + await session.start("oc_chat", "chat_id"); + await session.discard(); + await session.discard(); + + expect(harness.messageDelete).not.toHaveBeenCalled(); + expect(harness.requests.filter(({ path }) => path.endsWith("/settings"))).toHaveLength(1); + expect(session.isActive()).toBe(false); + }); + + it.each(["content", "settings"] as const)( + "reports accepted no-identity custody when %s finalization fails", + async (rejectedPath) => { + const harness = createStartHarness({ code: 0, msg: "ok" }, rejectedPath); + const session = new FeishuStreamingSession( + harness.client, + { appId: `app_no_id_failed_${rejectedPath}`, appSecret: "secret" }, + undefined, + harness.deps, + ); + + await session.start("oc_chat", "chat_id"); + const error = await session + .closeWithResult("final answer") + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(FeishuStreamingFinalizationError); + expect(error).toMatchObject({ + result: { + visibleReplySent: true, + ...(rejectedPath === "settings" ? { content: "final answer" } : {}), + }, + }); + expect((error as FeishuStreamingFinalizationError).result.messageId).toBeUndefined(); + expect(harness.messageDelete).not.toHaveBeenCalled(); + expect(harness.requests.filter(({ path }) => path.endsWith("/settings"))).toHaveLength(1); + expect(session.isActive()).toBe(false); + }, + ); + it("rejects oversized streaming tenant-token JSON before buffering the full body", async () => { let streamState: | { @@ -637,7 +786,7 @@ describe("FeishuStreamingSession", () => { expect(updateBodies).toHaveLength(0); expect(replaceBodies).toHaveLength(0); - await session.close(); + await session.closeWithResult(); expect(updateBodies).toHaveLength(0); expect(replaceBodies).toHaveLength(1); @@ -693,8 +842,8 @@ describe("FeishuStreamingSession", () => { }); await session.update(next); - // close() reports whether any accepted content remains visible, even when the final rewrite fails. - await expect(session.close()).resolves.toBe(true); + const error = await session.closeWithResult().catch((caught: unknown) => caught); + expect(error).toMatchObject({ result: { visibleReplySent: true, content: previous } }); expect(replaceBodies).toHaveLength(1); expect(settingsBodies).toHaveLength(1); @@ -887,7 +1036,7 @@ describe("FeishuStreamingSession", () => { lastUpdateTime: 3_000, }); - await session.close("final answer"); + await session.closeWithResult("final answer"); expect(updateBodies).toHaveLength(0); expect(replaceBodies).toHaveLength(1); @@ -955,7 +1104,7 @@ describe("FeishuStreamingSession", () => { lastUpdateTime: 3_000, }); - await session.close(finalText); + await session.closeWithResult(finalText); expect(settingsBodies).toHaveLength(1); const settingsPayload = JSON.parse(settingsBodies[0] ?? "{}") as { settings?: string }; @@ -1005,7 +1154,9 @@ describe("FeishuStreamingSession", () => { lastUpdateTime: 3_000, }); - await session.close("final answer"); + await expect(session.closeWithResult("final answer")).rejects.toBeInstanceOf( + FeishuStreamingFinalizationError, + ); expect(updateBodies).toHaveLength(0); expect(replaceBodies).toHaveLength(1); @@ -1043,7 +1194,9 @@ describe("FeishuStreamingSession", () => { lastUpdateTime: 3_000, }); - await expect(session.close("final answer")).resolves.toBe(false); + await expect(session.closeWithResult("final answer")).rejects.toMatchObject({ + result: { visibleReplySent: false }, + }); expect(updateBodies).toHaveLength(1); expect(replaceBodies).toHaveLength(0); diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 88c00f972b92..78a1cfc662a1 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -14,6 +14,7 @@ import { FEISHU_HTTP_TIMEOUT_MS } from "./client-timeout.js"; import { getFeishuUserAgent } from "./client.js"; import { requestFeishuApi } from "./comment-shared.js"; import { readFeishuJsonResponse } from "./json-response.js"; +import { assertFeishuMessageApiSuccess } from "./send-result.js"; import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js"; import { resolveStreamingCardSendMode } from "./streaming-card-send-mode.js"; import type { FeishuDomain } from "./types.js"; @@ -26,7 +27,7 @@ type Credentials = { }; type CardState = { cardId: string; - messageId: string; + messageId: string | undefined; sequence: number; currentText: string; sentText: string; @@ -399,19 +400,18 @@ export class FeishuStreamingSession { "Send card failed", ); } - if (sendRes.code !== 0 || !sendRes.data?.message_id) { - throw new Error(`Send card failed: ${sendRes.msg}`); - } + assertFeishuMessageApiSuccess(sendRes, "Send card failed"); + const messageId = sendRes.data?.message_id?.trim() || undefined; this.state = { cardId, - messageId: sendRes.data.message_id, + messageId, sequence: 1, currentText: "", sentText: "", hasNote: Boolean(options?.note), }; - this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`); + this.log?.(`Started streaming: cardId=${cardId}, messageId=${messageId ?? "unavailable"}`); } private async updateCardContent( @@ -640,7 +640,7 @@ export class FeishuStreamingSession { const apiBase = resolveApiBase(this.creds.domain); // A failed final rewrite does not erase previously accepted visible content. // sentText advances only for an accepted write; the return value reports any visible content. - let visibleContentSent = Boolean(this.state.sentText.trim()); + let visibleContentSent = !this.state.messageId || Boolean(this.state.sentText.trim()); let finalWriteError: unknown; // Only send final update if content differs from what's already displayed. @@ -722,8 +722,8 @@ export class FeishuStreamingSession { this.log?.(`Closed streaming: cardId=${finalState.cardId}`); const result: FeishuStreamingCloseResult = { visibleReplySent: visibleContentSent, - ...(visibleContentSent ? { content: finalState.sentText } : {}), - messageId: finalState.messageId, + ...(finalState.sentText.trim() ? { content: finalState.sentText } : {}), + ...(finalState.messageId ? { messageId: finalState.messageId } : {}), }; if (finalWriteError !== undefined || closeError !== undefined) { const cause = @@ -738,21 +738,18 @@ export class FeishuStreamingSession { return result; } - async close(finalText?: string, options?: { note?: string }): Promise { - try { - return (await this.closeWithResult(finalText, options)).visibleReplySent; - } catch (error: unknown) { - if (error instanceof FeishuStreamingFinalizationError) { - return error.result.visibleReplySent; - } - throw error; - } - } - async discard(): Promise { if (!this.state || this.closed) { return; } + const messageId = this.state.messageId; + if (!messageId) { + // CardKit accepted this single-send entity, but Feishu returned no message + // identity. Finalize it in place; retrying or deleting by a fabricated id + // would duplicate content or target an unrelated message. + await this.closeWithResult(""); + return; + } this.closed = true; this.clearFlushTimer(); await this.queue; @@ -760,7 +757,7 @@ export class FeishuStreamingSession { const currentState = this.state; try { const response = await this.client.im.message.delete({ - path: { message_id: currentState.messageId }, + path: { message_id: messageId }, }); if (response.code !== undefined && response.code !== 0) { throw new Error(`Delete streaming card message failed: ${response.msg ?? response.code}`); @@ -771,7 +768,7 @@ export class FeishuStreamingSession { } catch (error) { this.log?.(`Discard failed: ${String(error)}`); this.closed = false; - await this.close(""); + await this.closeWithResult(""); } }