From 0b54b64fe7f95a9f622d9537058e5dd9ecb63e17 Mon Sep 17 00:00:00 2001 From: Tao Xie Date: Tue, 24 Mar 2026 13:28:10 +0800 Subject: [PATCH] fix(feishu): preserve docx block tree order (openclaw#40524) Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm vitest run extensions/feishu/src/docx.test.ts Co-authored-by: Tao Xie <7379039+TaoXieSZ@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/docx.test.ts | 44 ++++++++++ extensions/feishu/src/docx.ts | 136 +++++++++++++++++++++++------ 3 files changed, 155 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e13b3d144d..a3b195d0118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ. - Agents/cron: suppress the default heartbeat system prompt for cron-triggered embedded runs even when they target non-cron session keys, so cron tasks stop reading `HEARTBEAT.md` and polluting unrelated threads. (#53152) Thanks @Protocol-zero-0. ## 2026.3.23 diff --git a/extensions/feishu/src/docx.test.ts b/extensions/feishu/src/docx.test.ts index ed04bff8b0a..394dde66f2e 100644 --- a/extensions/feishu/src/docx.test.ts +++ b/extensions/feishu/src/docx.test.ts @@ -174,6 +174,50 @@ describe("feishu_doc image fetch hardening", () => { expect(result.details.blocks_added).toBe(3); }); + it("reorders convert output by document tree instead of raw block array order", async () => { + const blocks = [ + { block_type: 13, block_id: "li2", parent_id: "list1" }, + { block_type: 4, block_id: "h2" }, + { block_type: 13, block_id: "li1", parent_id: "list1" }, + { block_type: 3, block_id: "h1" }, + { block_type: 12, block_id: "list1", children: ["li1", "li2"] }, + { block_type: 2, block_id: "p1" }, + ]; + convertMock.mockResolvedValue({ + code: 0, + data: { + blocks, + first_level_block_ids: ["h1", "p1", "h2", "list1"], + }, + }); + + blockDescendantCreateMock.mockImplementationOnce(async ({ data }) => ({ + code: 0, + data: { + children: (data.children_id as string[]).map((id) => ({ block_id: id })), + }, + })); + + const feishuDocTool = resolveFeishuDocTool(); + + await feishuDocTool.execute("tool-call", { + action: "append", + doc_token: "doc_1", + content: "tree reorder", + }); + + const call = blockDescendantCreateMock.mock.calls[0]?.[0]; + expect(call?.data.children_id).toEqual(["h1", "p1", "h2", "list1"]); + expect((call?.data.descendants as Array<{ block_id: string }>).map((b) => b.block_id)).toEqual([ + "h1", + "p1", + "h2", + "list1", + "li1", + "li2", + ]); + }); + it("falls back to size-based convert chunking for long no-heading markdown", async () => { let successChunkCount = 0; convertMock.mockImplementation(async ({ data }) => { diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 7debd446a14..3d948067711 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -113,12 +113,90 @@ async function convertMarkdown(client: Lark.Client, markdown: string) { }; } -function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] { - if (!firstLevelIds || firstLevelIds.length === 0) return blocks; - const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean); - const sortedIds = new Set(firstLevelIds); - const remaining = blocks.filter((b) => !sortedIds.has(b.block_id)); - return [...sorted, ...remaining]; +function normalizeChildIds(children: unknown): string[] { + if (Array.isArray(children)) { + return children.filter((child): child is string => typeof child === "string"); + } + if (typeof children === "string") { + return [children]; + } + return []; +} + +// Convert API may return `blocks` in a non-render order. +// Reconstruct the document tree using first_level_block_ids plus children/parent links, +// then emit blocks in pre-order so Descendant/Children APIs receive one normalized tree contract. +function normalizeConvertedBlockTree( + blocks: any[], + firstLevelIds: string[], +): { orderedBlocks: any[]; rootIds: string[] } { + if (blocks.length <= 1) { + const rootIds = + blocks.length === 1 && typeof blocks[0]?.block_id === "string" ? [blocks[0].block_id] : []; + return { orderedBlocks: blocks, rootIds }; + } + + const byId = new Map(); + const originalOrder = new Map(); + for (const [index, block] of blocks.entries()) { + if (typeof block?.block_id === "string") { + byId.set(block.block_id, block); + originalOrder.set(block.block_id, index); + } + } + + const childIds = new Set(); + for (const block of blocks) { + for (const childId of normalizeChildIds(block?.children)) { + childIds.add(childId); + } + } + + const inferredTopLevelIds = blocks + .filter((block) => { + const blockId = block?.block_id; + if (typeof blockId !== "string") { + return false; + } + const parentId = typeof block?.parent_id === "string" ? block.parent_id : ""; + return !childIds.has(blockId) && (!parentId || !byId.has(parentId)); + }) + .sort((a, b) => (originalOrder.get(a.block_id) ?? 0) - (originalOrder.get(b.block_id) ?? 0)) + .map((block) => block.block_id); + + const rootIds = ( + firstLevelIds && firstLevelIds.length > 0 ? firstLevelIds : inferredTopLevelIds + ).filter((id, index, arr) => typeof id === "string" && byId.has(id) && arr.indexOf(id) === index); + + const orderedBlocks: any[] = []; + const visited = new Set(); + + const visit = (blockId: string) => { + if (!byId.has(blockId) || visited.has(blockId)) { + return; + } + visited.add(blockId); + const block = byId.get(blockId); + orderedBlocks.push(block); + for (const childId of normalizeChildIds(block?.children)) { + visit(childId); + } + }; + + for (const rootId of rootIds) { + visit(rootId); + } + + // Fallback for malformed/partial trees from Convert API: keep any leftovers in original order. + for (const block of blocks) { + if (typeof block?.block_id === "string") { + visit(block.block_id); + } else { + orderedBlocks.push(block); + } + } + + return { orderedBlocks, rootIds }; } /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */ @@ -259,14 +337,14 @@ async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) { const chunks = splitMarkdownByHeadings(markdown); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types const allBlocks: any[] = []; - const allFirstLevelBlockIds: string[] = []; + const allRootIds: string[] = []; for (const chunk of chunks) { const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk); - const sorted = sortBlocksByFirstLevel(blocks, firstLevelBlockIds); - allBlocks.push(...sorted); - allFirstLevelBlockIds.push(...firstLevelBlockIds); + const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); + allBlocks.push(...orderedBlocks); + allRootIds.push(...rootIds); } - return { blocks: allBlocks, firstLevelBlockIds: allFirstLevelBlockIds }; + return { blocks: allBlocks, firstLevelBlockIds: allRootIds }; } /** Insert blocks in batches of MAX_BLOCKS_PER_INSERT to avoid API 400 errors */ @@ -644,8 +722,11 @@ async function uploadFileBlock( // Create a placeholder text block first const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`; const converted = await convertMarkdown(client, placeholderMd); - const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds); - const { children: inserted } = await insertBlocks(client, docToken, sorted, blockId); + const { orderedBlocks } = normalizeConvertedBlockTree( + converted.blocks, + converted.firstLevelBlockIds, + ); + const { children: inserted } = await insertBlocks(client, docToken, orderedBlocks, blockId); // Get the first inserted block - we'll delete it and create the file in its place // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape @@ -824,11 +905,11 @@ async function writeDoc( } logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`); - const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds); + const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); const { children: inserted } = blocks.length > BATCH_SIZE - ? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger) - : await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds); + ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger) + : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds); const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); @@ -854,11 +935,11 @@ async function appendDoc( } logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`); - const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds); + const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); const { children: inserted } = blocks.length > BATCH_SIZE - ? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger) - : await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds); + ? await insertBlocksInBatches(client, docToken, orderedBlocks, rootIds, logger) + : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds); const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes); logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`); @@ -914,7 +995,7 @@ async function insertDoc( logger?.info?.("feishu_doc: Converting markdown..."); const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown); if (blocks.length === 0) throw new Error("Content is empty"); - const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds); + const { orderedBlocks, rootIds } = normalizeConvertedBlockTree(blocks, firstLevelBlockIds); logger?.info?.( `feishu_doc: Converted to ${blocks.length} blocks, inserting at index ${insertIndex}...`, @@ -924,13 +1005,13 @@ async function insertDoc( ? await insertBlocksInBatches( client, docToken, - sortedBlocks, - firstLevelBlockIds, + orderedBlocks, + rootIds, logger, parentId, insertIndex, ) - : await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds, { + : await insertBlocksWithDescendant(client, docToken, orderedBlocks, rootIds, { parentBlockId: parentId, index: insertIndex, }); @@ -1067,10 +1148,13 @@ async function writeTableCells( const text = rowValues[c] ?? ""; const converted = await convertMarkdown(client, text); - const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds); + const { orderedBlocks } = normalizeConvertedBlockTree( + converted.blocks, + converted.firstLevelBlockIds, + ); - if (sorted.length > 0) { - await insertBlocks(client, docToken, sorted, cellId); + if (orderedBlocks.length > 0) { + await insertBlocks(client, docToken, orderedBlocks, cellId); } written++;