forked from bai/curriculum-project-hub
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.
This commit is contained in:
@@ -234,6 +234,24 @@ export async function sendText(rt: FeishuRuntime, chatId: string, text: string):
|
|||||||
await sendTextMessage(rt, chatId, text);
|
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<string | null> {
|
||||||
|
const payload = buildOutboundPayload(text);
|
||||||
|
const card = buildInteractiveCardFromOutboundPayload(payload);
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
|
||||||
|
};
|
||||||
|
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). */
|
/** Patch a text-as-card message in-place (streaming updates). */
|
||||||
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
|
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
|
||||||
const payload = buildOutboundPayload(text);
|
const payload = buildOutboundPayload(text);
|
||||||
|
|||||||
@@ -144,10 +144,12 @@ export class PatchableTextStream {
|
|||||||
|
|
||||||
async finish(fallbackText: string): Promise<void> {
|
async finish(fallbackText: string): Promise<void> {
|
||||||
await this.flushChain;
|
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();
|
await this.flushTextToSink();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// No pending text. If we never sent anything, send the fallback.
|
||||||
if (this.currentMessageId === null && fallbackText.length > 0) {
|
if (this.currentMessageId === null && fallbackText.length > 0) {
|
||||||
this.text = fallbackText;
|
this.text = fallbackText;
|
||||||
await this.flushTextToSink();
|
await this.flushTextToSink();
|
||||||
@@ -184,16 +186,25 @@ export class PatchableTextStream {
|
|||||||
if (firstChunk === undefined) return;
|
if (firstChunk === undefined) return;
|
||||||
|
|
||||||
if (this.currentMessageId === null) {
|
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);
|
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 {
|
} else {
|
||||||
|
// Patch the current message with the first chunk, then create new
|
||||||
|
// messages for overflow chunks.
|
||||||
await this.sink.patch(this.currentMessageId, firstChunk);
|
await this.sink.patch(this.currentMessageId, firstChunk);
|
||||||
for (const chunk of chunks.slice(1)) {
|
for (const chunk of chunks.slice(1)) {
|
||||||
this.currentMessageId = await this.sink.create(chunk);
|
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}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { FastifyBaseLogger } from "fastify";
|
|||||||
import {
|
import {
|
||||||
sendText,
|
sendText,
|
||||||
sendTextMessage,
|
sendTextMessage,
|
||||||
|
sendInteractiveCardMessage,
|
||||||
patchTextMessage,
|
patchTextMessage,
|
||||||
reactToMessage,
|
reactToMessage,
|
||||||
addReaction,
|
addReaction,
|
||||||
@@ -264,7 +265,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
// Stream agent response as text-as-card messages. Lazy-init: don't send
|
// Stream agent response as text-as-card messages. Lazy-init: don't send
|
||||||
// anything until the first text-delta arrives (avoids empty placeholder).
|
// anything until the first text-delta arrives (avoids empty placeholder).
|
||||||
const textStream = new PatchableTextStream({
|
const textStream = new PatchableTextStream({
|
||||||
create: (text) => sendTextMessage(rt, chatId, text),
|
create: (text) => sendInteractiveCardMessage(rt, chatId, text),
|
||||||
patch: (messageId, text) => patchTextMessage(rt, messageId, text),
|
patch: (messageId, text) => patchTextMessage(rt, messageId, text),
|
||||||
});
|
});
|
||||||
const deliveredFiles: string[] = [];
|
const deliveredFiles: string[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user