From a0df8b6bcf64f5bea1f22d5aa885037a7671b998 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 8 Jul 2026 17:48:55 +0800 Subject: [PATCH] fix: repair streaming output broken by markdown rendering changes Two bugs were introduced when sendTextMessage was changed to auto-detect message format (text/post/interactive): 1. Message type mismatch: sendTextMessage could create a text or post message, but patchTextMessage only works on interactive cards. Fix: add sendInteractiveCardMessage for streaming create (always interactive, always patchable). 2. flushTextToSink state corruption: the text management after flush had a flawed appendedDuringFlush heuristic that could lose text or duplicate it. Fix: properly track sent vs unsent text by removing the sent prefix length, keeping only the last chunk for future patches. Also fix finish() to flush pending text regardless of currentMessageId. --- hub/src/feishu/client.ts | 18 ++++++++++++++++++ hub/src/feishu/textStream.ts | 19 +++++++++++++++---- hub/src/feishu/trigger.ts | 3 ++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 9038dac..dc75513 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -234,6 +234,24 @@ export async function sendText(rt: FeishuRuntime, chatId: string, text: string): await sendTextMessage(rt, chatId, text); } +/** Send an interactive card message (always patchable). Used for streaming. */ +export async function sendInteractiveCardMessage(rt: FeishuRuntime, chatId: string, text: string): Promise { + const payload = buildOutboundPayload(text); + const card = buildInteractiveCardFromOutboundPayload(payload); + const client = rt.client as unknown as { + im: { v1: { message: { create: (p: unknown) => Promise } } }; + }; + try { + const res = await client.im.v1.message.create({ + params: { receive_id_type: "chat_id" }, + data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) }, + }); + return messageIdFromResponse(res); + } catch { + return null; + } +} + /** Patch a text-as-card message in-place (streaming updates). */ export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise { const payload = buildOutboundPayload(text); diff --git a/hub/src/feishu/textStream.ts b/hub/src/feishu/textStream.ts index 3e440e5..7ffb9cf 100644 --- a/hub/src/feishu/textStream.ts +++ b/hub/src/feishu/textStream.ts @@ -144,10 +144,12 @@ export class PatchableTextStream { async finish(fallbackText: string): Promise { await this.flushChain; - if (this.currentMessageId !== null && this.text.length > 0) { + // If there's pending text to flush, do it now. + if (this.text.length > 0) { await this.flushTextToSink(); return; } + // No pending text. If we never sent anything, send the fallback. if (this.currentMessageId === null && fallbackText.length > 0) { this.text = fallbackText; await this.flushTextToSink(); @@ -184,16 +186,25 @@ export class PatchableTextStream { if (firstChunk === undefined) return; if (this.currentMessageId === null) { - for (const chunk of chunks) { + // First message: create it with the first chunk, then send overflow + // chunks as new messages. + this.currentMessageId = await this.sink.create(firstChunk); + for (const chunk of chunks.slice(1)) { this.currentMessageId = await this.sink.create(chunk); } + // Keep only the last chunk for future patches; remove already-sent prefix. + const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0); + this.text = this.text.slice(sentLength); } else { + // Patch the current message with the first chunk, then create new + // messages for overflow chunks. await this.sink.patch(this.currentMessageId, firstChunk); for (const chunk of chunks.slice(1)) { this.currentMessageId = await this.sink.create(chunk); } + // Keep only the last chunk for future patches; remove already-sent prefix. + const sentLength = chunks.slice(0, -1).reduce((sum, c) => sum + c.length, 0); + this.text = this.text.slice(sentLength); } - const appendedDuringFlush = this.text.startsWith(textToFlush) ? this.text.slice(textToFlush.length) : ""; - this.text = `${chunks.at(-1) ?? ""}${appendedDuringFlush}`; } } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index da5f9cd..a9916b5 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -14,6 +14,7 @@ import type { FastifyBaseLogger } from "fastify"; import { sendText, sendTextMessage, + sendInteractiveCardMessage, patchTextMessage, reactToMessage, addReaction, @@ -264,7 +265,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { // Stream agent response as text-as-card messages. Lazy-init: don't send // anything until the first text-delta arrives (avoids empty placeholder). const textStream = new PatchableTextStream({ - create: (text) => sendTextMessage(rt, chatId, text), + create: (text) => sendInteractiveCardMessage(rt, chatId, text), patch: (messageId, text) => patchTextMessage(rt, messageId, text), }); const deliveredFiles: string[] = [];