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>
This commit is contained in:
Tao Xie 2026-03-24 13:28:10 +08:00 committed by GitHub
parent 9082795b10
commit 0b54b64fe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 155 additions and 26 deletions

View File

@ -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

View File

@ -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 }) => {

View File

@ -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<string, any>();
const originalOrder = new Map<string, number>();
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<string>();
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<string>();
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++;