From bef10149a94aaac6744ae7ec9cc0480ec080b3d3 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 8 Jul 2026 16:57:53 +0800 Subject: [PATCH] feat: improve outbound message chunking with code-block-aware splitting - Increase max message length from 4000 to 8000 chars - Add splitAtBoundary: paragraph > newline > space priority, never split inside fenced code blocks (move split to before opening fence) - Add sendLongText: sends multi-chunk long messages sequentially - Update PatchableTextStream.flush to use splitAtBoundary - Add unit tests (6) for splitting edge cases --- hub/src/feishu/client.ts | 13 +++ hub/src/feishu/textStream.ts | 133 ++++++++++++++++++++++++++-- hub/test/unit/feishu-client.test.ts | 17 +++- hub/test/unit/text-split.test.ts | 43 +++++++++ 4 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 hub/test/unit/text-split.test.ts diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index d49e4ea..974cac0 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -9,6 +9,7 @@ import * as lark from "@larksuiteoapi/node-sdk"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import type { FastifyBaseLogger } from "fastify"; +import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js"; export interface FeishuConfig { readonly appId: string; @@ -157,6 +158,18 @@ export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: s } } +/** Send long text as multiple Feishu messages. Returns the last successful message_id. */ +export async function sendLongText(rt: FeishuRuntime, chatId: string, text: string): Promise { + let lastMessageId: string | null = null; + for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) { + const messageId = await sendTextMessage(rt, chatId, chunk); + if (messageId !== null) { + lastMessageId = messageId; + } + } + return lastMessageId; +} + /** Send a plain text message (fire-and-forget). */ export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise { await sendTextMessage(rt, chatId, text); diff --git a/hub/src/feishu/textStream.ts b/hub/src/feishu/textStream.ts index 980e0fe..3e440e5 100644 --- a/hub/src/feishu/textStream.ts +++ b/hub/src/feishu/textStream.ts @@ -9,7 +9,107 @@ export interface PatchableTextStreamOptions { } const DEFAULT_PATCH_INTERVAL_MS = 400; -const DEFAULT_MAX_MESSAGE_LENGTH = 4000; +export const DEFAULT_MAX_MESSAGE_LENGTH = 8000; + +interface CodeBlockRange { + readonly start: number; + readonly end: number; +} + +export function splitAtBoundary(text: string, maxLength: number): string[] { + if (!Number.isInteger(maxLength) || maxLength <= 0) { + throw new RangeError("maxLength must be a positive integer"); + } + if (text.length <= maxLength) return [text]; + + const chunks: string[] = []; + let remaining = text; + while (remaining.length > maxLength) { + const splitPoint = chooseSplitPoint(remaining, maxLength); + if (splitPoint <= 0) { + const codeBlockEnd = codeBlockAtStartEnd(remaining); + const forcedSplitPoint = codeBlockEnd ?? maxLength; + chunks.push(remaining.slice(0, forcedSplitPoint)); + remaining = remaining.slice(forcedSplitPoint); + continue; + } + + chunks.push(remaining.slice(0, splitPoint)); + remaining = remaining.slice(splitPoint); + } + if (remaining.length > 0) { + chunks.push(remaining); + } + return chunks; +} + +function chooseSplitPoint(text: string, maxLength: number): number { + const codeBlocks = findFencedCodeBlocks(text); + const splitCodeBlock = codeBlocks.find((range) => maxLength > range.start && maxLength < range.end); + if (splitCodeBlock !== undefined) { + return splitCodeBlock.start; + } + + return ( + lastBoundary(text, maxLength, /\r?\n\r?\n/g, codeBlocks) ?? + lastBoundary(text, maxLength, /\r?\n/g, codeBlocks) ?? + lastBoundary(text, maxLength, / /g, codeBlocks) ?? + maxLength + ); +} + +function lastBoundary(text: string, maxLength: number, pattern: RegExp, codeBlocks: ReadonlyArray): number | undefined { + let boundary: number | undefined; + let match: RegExpExecArray | null; + pattern.lastIndex = 0; + while ((match = pattern.exec(text)) !== null) { + const splitPoint = match.index + match[0].length; + if (splitPoint > maxLength) break; + if (splitPoint > 0 && !isInsideCodeBlock(splitPoint, codeBlocks)) { + boundary = splitPoint; + } + } + return boundary; +} + +function isInsideCodeBlock(splitPoint: number, codeBlocks: ReadonlyArray): boolean { + return codeBlocks.some((range) => splitPoint > range.start && splitPoint < range.end); +} + +function codeBlockAtStartEnd(text: string): number | undefined { + const firstCodeBlock = findFencedCodeBlocks(text)[0]; + return firstCodeBlock?.start === 0 ? firstCodeBlock.end : undefined; +} + +function findFencedCodeBlocks(text: string): CodeBlockRange[] { + const ranges: CodeBlockRange[] = []; + let openStart: number | null = null; + let lineStart = 0; + + while (lineStart < text.length) { + const newlineIndex = text.indexOf("\n", lineStart); + const lineEnd = newlineIndex === -1 ? text.length : newlineIndex; + const nextLineStart = newlineIndex === -1 ? text.length : newlineIndex + 1; + const line = text.slice(lineStart, lineEnd); + + if (/^ {0,3}```/.test(line)) { + if (openStart === null) { + openStart = lineStart; + } else { + ranges.push({ start: openStart, end: nextLineStart }); + openStart = null; + } + } + + lineStart = nextLineStart; + } + + if (openStart !== null) { + ranges.push({ start: openStart, end: text.length }); + } + + return ranges; +} /** * Serializes text-card creation/patching for streaming output. @@ -45,11 +145,12 @@ export class PatchableTextStream { async finish(fallbackText: string): Promise { await this.flushChain; if (this.currentMessageId !== null && this.text.length > 0) { - await this.sink.patch(this.currentMessageId, this.text); + await this.flushTextToSink(); return; } if (this.currentMessageId === null && fallbackText.length > 0) { - this.currentMessageId = await this.sink.create(fallbackText); + this.text = fallbackText; + await this.flushTextToSink(); } } @@ -64,7 +165,7 @@ export class PatchableTextStream { private async flush(): Promise { if (this.currentMessageId === null) { - this.currentMessageId = await this.sink.create(this.text); + await this.flushTextToSink(); this.lastPatchAt = Date.now(); return; } @@ -73,12 +174,26 @@ export class PatchableTextStream { if (now - this.lastPatchAt < this.patchIntervalMs) return; this.lastPatchAt = now; - if (this.text.length > this.maxMessageLength) { - await this.sink.patch(this.currentMessageId, this.text.slice(0, this.maxMessageLength)); - this.text = this.text.slice(this.maxMessageLength); - this.currentMessageId = await this.sink.create(this.text); + await this.flushTextToSink(); + } + + private async flushTextToSink(): Promise { + const textToFlush = this.text; + const chunks = splitAtBoundary(textToFlush, this.maxMessageLength); + const firstChunk = chunks[0]; + if (firstChunk === undefined) return; + + if (this.currentMessageId === null) { + for (const chunk of chunks) { + this.currentMessageId = await this.sink.create(chunk); + } } else { - await this.sink.patch(this.currentMessageId, this.text); + await this.sink.patch(this.currentMessageId, firstChunk); + for (const chunk of chunks.slice(1)) { + this.currentMessageId = await this.sink.create(chunk); + } } + const appendedDuringFlush = this.text.startsWith(textToFlush) ? this.text.slice(textToFlush.length) : ""; + this.text = `${chunks.at(-1) ?? ""}${appendedDuringFlush}`; } } diff --git a/hub/test/unit/feishu-client.test.ts b/hub/test/unit/feishu-client.test.ts index 562c500..75e94b1 100644 --- a/hub/test/unit/feishu-client.test.ts +++ b/hub/test/unit/feishu-client.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { sendFile, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js"; +import { sendFile, sendLongText, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js"; describe("Feishu client helpers", () => { it("accepts top-level file_key from the Lark SDK file upload response", async () => { @@ -32,6 +32,21 @@ describe("Feishu client helpers", () => { await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1"); }); + + it("sends long text in chunks and returns the last successful message id", async () => { + let messageNumber = 0; + const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } })); + const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate }); + + await expect(sendLongText(rt, "chat-1", "x".repeat(16_005))).resolves.toBe("message-3"); + + expect(messageCreate).toHaveBeenCalledTimes(3); + const sentTexts = messageCreate.mock.calls.map(([payload]) => { + const content = (payload as { data: { content: string } }).data.content; + return (JSON.parse(content) as { text: string }).text; + }); + expect(sentTexts.map((text) => text.length)).toEqual([8000, 8000, 5]); + }); }); function mockRuntime(options: { diff --git a/hub/test/unit/text-split.test.ts b/hub/test/unit/text-split.test.ts new file mode 100644 index 0000000..d3f38b4 --- /dev/null +++ b/hub/test/unit/text-split.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { splitAtBoundary } from "../../src/feishu/textStream.js"; + +describe("splitAtBoundary", () => { + it("keeps short text in a single chunk", () => { + expect(splitAtBoundary("short text", 20)).toEqual(["short text"]); + }); + + it("keeps text at exactly maxLength in a single chunk", () => { + const text = "x".repeat(20); + + expect(splitAtBoundary(text, 20)).toEqual([text]); + }); + + it("splits text exceeding maxLength into chunks at or below maxLength", () => { + const chunks = splitAtBoundary("x".repeat(45), 20); + + expect(chunks).toHaveLength(3); + expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true); + expect(chunks.join("")).toBe("x".repeat(45)); + }); + + it("moves a split before a fenced code block when the boundary lands inside it", () => { + const prefix = "Before code starts\n"; + const codeBlock = "```ts\nx\n```\n"; + const text = `${prefix}${codeBlock}After`; + const chunks = splitAtBoundary(text, prefix.length + 4); + + expect(chunks).toEqual([prefix, `${codeBlock}After`]); + }); + + it("prefers paragraph boundaries over newline boundaries", () => { + const text = "para one\n\nline two\nline three"; + + expect(splitAtBoundary(text, 22)).toEqual(["para one\n\n", "line two\nline three"]); + }); + + it("prefers newline boundaries over space boundaries", () => { + const text = "alpha beta\ngamma delta epsilon"; + + expect(splitAtBoundary(text, 24)).toEqual(["alpha beta\n", "gamma delta epsilon"]); + }); +});