fix(telegram): preserve finalized previews on mixed text+voice turns

This commit is contained in:
Peter Steinberger
2026-02-26 03:41:58 +01:00
parent 03e689fc89
commit 53fcfdf794
3 changed files with 53 additions and 1 deletions

View File

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
- Telegram/Preview cleanup: keep finalized text previews when a later assistant message is media-only (for example mixed text plus voice turns) by skipping finalized preview archival at assistant-message boundaries, preventing cleanup from deleting already-visible final text messages. (#27042)
- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
- Slack/Inbound media fallback: deliver file-only messages even when Slack media downloads fail by adding a filename placeholder fallback, capping fallback names to the shared media-file limit, and normalizing empty filenames to `file` so attachment-only messages are not silently dropped. (#25181) Thanks @justinhuangcode.

View File

@@ -691,6 +691,52 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
});
it.each(["partial", "block"] as const)(
"keeps finalized text preview when the next assistant message is media-only (%s mode)",
async (streamMode) => {
let answerMessageId: number | undefined = 1001;
const answerDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => answerMessageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
forceNewMessage: vi.fn().mockImplementation(() => {
answerMessageId = undefined;
}),
};
const reasoningDraftStream = createDraftStream();
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "First message preview" });
await dispatcherOptions.deliver({ text: "First message final" }, { kind: "final" });
await replyOptions?.onAssistantMessageStart?.();
await dispatcherOptions.deliver({ mediaUrl: "file:///tmp/voice.ogg" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "1001" });
const bot = createBot();
await dispatchWithContext({ context: createContext(), streamMode, bot });
expect(editMessageTelegram).toHaveBeenCalledWith(
123,
1001,
"First message final",
expect.any(Object),
);
const deleteMessageCalls = (
bot.api as unknown as { deleteMessage: { mock: { calls: unknown[][] } } }
).deleteMessage.mock.calls;
expect(deleteMessageCalls).not.toContainEqual([123, 1001]);
},
);
it("maps finals correctly when archived preview id arrives during final flush", async () => {
let answerMessageId: number | undefined;
let answerDraftParams:

View File

@@ -567,7 +567,10 @@ export const dispatchTelegramMessage = async ({
reasoningStepState.resetForNextStep();
if (answerLane.hasStreamedMessage) {
const previewMessageId = answerLane.stream?.messageId();
if (typeof previewMessageId === "number") {
// Only archive previews that still need a matching final text update.
// Once a preview has already been finalized, archiving it here causes
// cleanup to delete a user-visible final message on later media-only turns.
if (typeof previewMessageId === "number" && !finalizedPreviewByLane.answer) {
archivedAnswerPreviews.push({
messageId: previewMessageId,
textSnapshot: answerLane.lastPartialText,
@@ -576,6 +579,8 @@ export const dispatchTelegramMessage = async ({
answerLane.stream?.forceNewMessage();
}
resetDraftLaneState(answerLane);
// New assistant message boundary: this lane now tracks a fresh preview lifecycle.
finalizedPreviewByLane.answer = false;
}
: undefined,
onReasoningEnd: reasoningLane.stream