From 67593a81082ced0f8c8fdc8bb1888851ce70d93c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 13 Apr 2026 16:08:24 +0100 Subject: [PATCH] fix(feishu): make card action retries explicit --- extensions/feishu/src/bot.card-action.test.ts | 22 ++++++++++++++++++- extensions/feishu/src/card-action.ts | 13 ++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 5ee4f060ed8..e470b897d0a 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js"; import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; import { + FeishuRetryableCardActionError, handleFeishuCardAction, resetProcessedFeishuCardActionTokensForTests, type FeishuCardActionEvent, @@ -88,6 +89,9 @@ describe("Feishu Card Action Handler", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(handleFeishuMessage) + .mockReset() + .mockResolvedValue(undefined as never); resetProcessedFeishuCardActionTokensForTests(); }); @@ -363,7 +367,7 @@ describe("Feishu Card Action Handler", () => { expect(handleFeishuMessage).toHaveBeenCalledTimes(1); }); - it("releases a claimed token when dispatch fails so retries can succeed", async () => { + it("keeps a claimed token completed after a non-retryable dispatch failure", async () => { const event = createStructuredQuickActionEvent({ token: "tok11", action: "feishu.quick_actions.help", @@ -376,6 +380,22 @@ describe("Feishu Card Action Handler", () => { await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient"); await handleFeishuCardAction({ cfg, event, runtime }); + expect(handleFeishuMessage).toHaveBeenCalledTimes(1); + }); + + it("releases a claimed token for explicit retryable dispatch failures", async () => { + const event = createStructuredQuickActionEvent({ + token: "tok11-retryable", + action: "feishu.quick_actions.help", + command: "/help", + }); + vi.mocked(handleFeishuMessage) + .mockRejectedValueOnce(new FeishuRetryableCardActionError("retry me")) + .mockResolvedValueOnce(undefined as never); + + await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("retry me"); + await handleFeishuCardAction({ cfg, event, runtime }); + expect(handleFeishuMessage).toHaveBeenCalledTimes(2); }); diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index f01a8a5b906..2d46bb8c069 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -35,6 +35,13 @@ const processedCardActionTokens = new Map< { status: "inflight" | "completed"; expiresAt: number } >(); +export class FeishuRetryableCardActionError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "FeishuRetryableCardActionError"; + } +} + export function resetProcessedFeishuCardActionTokensForTests(): void { processedCardActionTokens.clear(); } @@ -304,7 +311,11 @@ export async function handleFeishuCardAction(params: { }); completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); } catch (err) { - releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + if (err instanceof FeishuRetryableCardActionError) { + releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + } else { + completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); + } throw err; } }