diff --git a/src/community/feishu/messaging/message-renderer.ts b/src/community/feishu/messaging/message-renderer.ts index 9761a1b..39a4c06 100644 --- a/src/community/feishu/messaging/message-renderer.ts +++ b/src/community/feishu/messaging/message-renderer.ts @@ -23,6 +23,9 @@ import type { MarkdownElement, } from "./types"; +/** Maximum elements or components in one Feishu card. */ +const MAX_FEISHU_CARD_ELEMENTS = 200; + /** * Render assistant message content as a Feishu interactive card. * @param messageContent - Array of content blocks (thinking, tool_use, text). @@ -107,10 +110,10 @@ export async function renderMessageCard( } } - const stepCount = stepPanel.elements.length; - if (stepCount > 0) { + const totalStepCount = stepPanel.elements.length; + if (totalStepCount > 0) { const stepCountText = - stepCount + " " + (stepCount === 1 ? "step" : "steps"); + totalStepCount + " " + (totalStepCount === 1 ? "step" : "steps"); if (streaming) { stepPanel.header.title.content = `Working on it (${stepCountText})`; card.config!.summary.content = `Working on it (${stepCountText})`; @@ -142,9 +145,46 @@ export async function renderMessageCard( }, }); } + _trimCardElements(card); return card; } +/** Trim old step elements until the card fits Feishu's card limit. */ +function _trimCardElements(card: Card) { + let elementCount = _countElements(card); + const stepPanel = card.body.elements.find( + (element): element is CollapsiblePanel => element.tag === "collapsible_panel", + ); + if (!stepPanel || elementCount <= MAX_FEISHU_CARD_ELEMENTS) { + return; + } + + while ( + stepPanel.elements.length > 0 && + elementCount > MAX_FEISHU_CARD_ELEMENTS + ) { + const removedElement = stepPanel.elements.shift(); + elementCount -= _countElements(removedElement); + } +} + +function _countElements(value: unknown): number { + if (!value || typeof value !== "object") { + return 0; + } + + const item = value as Record; + const self = typeof item.tag === "string" ? 1 : 0; + return Object.values(item).reduce( + (count, child) => + count + + (Array.isArray(child) + ? child.reduce((sum, entry) => sum + _countElements(entry), 0) + : _countElements(child)), + self, + ); +} + async function _uploadMessageResource( text: string, { diff --git a/tests/community/feishu/messaging/message-renderer.test.ts b/tests/community/feishu/messaging/message-renderer.test.ts new file mode 100644 index 0000000..013b005 --- /dev/null +++ b/tests/community/feishu/messaging/message-renderer.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { renderMessageCard } from "@/community/feishu/messaging/message-renderer"; + +function countElements(value: unknown): number { + if (!value || typeof value !== "object") { + return 0; + } + + const item = value as Record; + const self = typeof item.tag === "string" ? 1 : 0; + return Object.values(item).reduce( + (count, child) => + count + + (Array.isArray(child) + ? child.reduce((sum, entry) => sum + countElements(entry), 0) + : countElements(child)), + self, + ); +} + +describe("renderMessageCard", () => { + test("keeps final cards within Feishu's official 200 element limit", async () => { + const card = await renderMessageCard( + [ + ...Array.from({ length: 100 }, (_, i) => ({ + type: "tool_use" as const, + id: `tool-${i}`, + name: "Bash", + input: { command: `echo ${i}` }, + })), + { type: "text", text: "done" } as const, + ], + { + streaming: false, + uploadImage: async () => "image-key", + }, + ); + + const stepPanel = card.body.elements.find( + (element) => element.tag === "collapsible_panel", + ); + + expect(countElements(card)).toBeLessThanOrEqual(200); + expect(stepPanel?.tag).toBe("collapsible_panel"); + expect(stepPanel?.elements.length).toBe(65); + }); +});