fix(telegram): retain transcript-backed truncated finals

This commit is contained in:
Peter Steinberger
2026-05-15 20:54:47 +01:00
parent 25a8f5f3f8
commit d0218d3e59
7 changed files with 414 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- Discord: validate message-read results before normalizing channel history and report unexpected payloads with a Discord boundary error instead of `map is not a function`. Fixes #82252. Thanks @jessewunderlich.
- Agents/runtime: apply `agents.defaults.models["provider/*"].agentRuntime` as provider-wide model runtime policy while preserving exact model runtime precedence. Fixes #82243. Thanks @rendrag-git.
- Agents/auto-reply: restrict `NO_REPLY` prompt guidance to automatic group/channel replies, remove legacy silent-reply rewrites, and suppress accidental direct-chat silent tokens instead of delivering fallback text. Fixes #82254. Thanks @absol89.
- Telegram: retain a longer partial-stream preview when a final callback only carries an ellipsis-truncated snapshot, preventing the visible answer and transcript mirror from being replaced by the short preview. Fixes #82239.
- Telegram/active-memory: run blocking memory recall through the Telegram provider for direct-message turns even when the hook context carries the raw chat id, preventing embedded recall from launching against an invalid numeric channel. Fixes #82177. Thanks @cslash-zz.
- Control UI/WebChat: keep optimistic image messages from embedding large inline `data:` previews and preserve image-only user turns in chat history, avoiding browser stack overflows when sending image attachments. Fixes #82182. Thanks @ExploreSheep.
- Agents/media: preserve message-tool-only delivery for generated music and video completion handoffs, so group/channel completions do not finish without posting the generated attachment.

View File

@@ -1,5 +1,6 @@
export {
loadSessionStore,
readLatestAssistantTextFromSessionTranscript,
resolveAndPersistSessionFile,
resolveSessionStoreEntry,
} from "openclaw/plugin-sdk/session-store-runtime";

View File

@@ -58,6 +58,7 @@ const appendSessionTranscriptMessage = vi.hoisted(() =>
);
const emitSessionTranscriptUpdate = vi.hoisted(() => vi.fn());
const loadSessionStore = vi.hoisted(() => vi.fn());
const readLatestAssistantTextFromSessionTranscript = vi.hoisted(() => vi.fn());
const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json"));
const resolveAndPersistSessionFile = vi.hoisted(() =>
vi.fn(async () => ({
@@ -131,6 +132,7 @@ vi.mock("./bot-message-dispatch.runtime.js", () => ({
generateTopicLabel,
getAgentScopedMediaLocalRoots,
loadSessionStore,
readLatestAssistantTextFromSessionTranscript,
resolveAndPersistSessionFile,
resolveAutoTopicLabelConfig: resolveAutoTopicLabelConfigRuntime,
resolveChunkMode,
@@ -219,6 +221,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
wasSentByBot.mockReset();
appendSessionTranscriptMessage.mockReset();
emitSessionTranscriptUpdate.mockReset();
readLatestAssistantTextFromSessionTranscript.mockReset();
loadSessionStore.mockReset();
resolveStorePath.mockReset();
resolveAndPersistSessionFile.mockReset();
@@ -962,6 +965,48 @@ describe("dispatchTelegramMessage draft streaming", () => {
});
});
it("mirrors the longer streamed preview when final text is truncated", async () => {
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
const truncatedFinal =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
const context = createContext();
context.ctxPayload.SessionKey = "agent:default:telegram:direct:123";
loadSessionStore.mockReturnValue({
"agent:default:telegram:direct:123": { sessionId: "s1" },
});
readLatestAssistantTextFromSessionTranscript.mockResolvedValue({
text: fullAnswer,
timestamp: Date.now() + 1_000,
});
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: fullAnswer });
await dispatcherOptions.deliver({ text: truncatedFinal }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({ context });
expect(answerDraftStream.update).toHaveBeenCalledWith(fullAnswer);
expect(answerDraftStream.update).not.toHaveBeenCalledWith(truncatedFinal);
expectRecordFields(mockCallArg(emitInternalMessageSentHook), {
content: fullAnswer,
messageId: 2001,
});
const transcriptCall = expectRecordFields(mockCallArg(appendSessionTranscriptMessage), {
transcriptPath: "/tmp/session.jsonl",
});
expectRecordFields(transcriptCall.message, {
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: fullAnswer }],
});
});
it("emits the redacted appended message in transcript updates", async () => {
setupDraftStreams({ answerMessageId: 2001 });
const context = createContext();

View File

@@ -66,6 +66,7 @@ import {
generateTopicLabel,
getAgentScopedMediaLocalRoots,
loadSessionStore,
readLatestAssistantTextFromSessionTranscript,
resolveAutoTopicLabelConfig,
resolveChunkMode,
resolveMarkdownTableMode,
@@ -442,6 +443,7 @@ export const dispatchTelegramMessage = async ({
telegramDeps: injectedTelegramDeps,
opts,
}: DispatchTelegramMessageParams) => {
const dispatchStartedAt = Date.now();
const telegramDeps =
injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps;
const {
@@ -962,6 +964,43 @@ export const dispatchTelegramMessage = async ({
);
const endTelegramInboundTurnDeliveryCorrelation = beginDeliveryCorrelation();
const sessionKey = ctxPayload.SessionKey;
const resolveCurrentTurnTranscriptFinalText = async (): Promise<string | undefined> => {
if (!sessionKey) {
return undefined;
}
try {
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
skipCache: true,
});
const sessionEntry = resolveSessionStoreEntry({
store,
sessionKey,
}).existing;
if (!sessionEntry?.sessionId) {
return undefined;
}
const { sessionFile } = await resolveAndPersistSessionFile({
sessionId: sessionEntry.sessionId,
sessionKey,
sessionStore: store,
storePath,
sessionEntry,
agentId: route.agentId,
sessionsDir: path.dirname(storePath),
});
const latest = await readLatestAssistantTextFromSessionTranscript(sessionFile);
if (!latest?.timestamp || latest.timestamp < dispatchStartedAt) {
return undefined;
}
return latest.text;
} catch (err) {
logVerbose(`telegram transcript final candidate lookup failed: ${formatErrorMessage(err)}`);
return undefined;
}
};
const deliveryBaseOptions = {
chatId: String(chatId),
accountId: route.accountId,
@@ -1201,6 +1240,7 @@ export const dispatchTelegramMessage = async ({
buttons,
});
},
resolveFinalTextCandidate: () => resolveCurrentTurnTranscriptFinalText(),
log: logVerbose,
markDelivered: () => {
deliveryState.markDelivered();

View File

@@ -52,6 +52,10 @@ type CreateLaneTextDelivererParams = {
text: string;
buttons?: TelegramInlineButtons;
}) => Promise<void>;
resolveFinalTextCandidate?: (params: {
finalText: string;
laneName: LaneName;
}) => Promise<string | undefined> | string | undefined;
log: (message: string) => void;
markDelivered: () => void;
};
@@ -101,6 +105,53 @@ function compactChunks(chunks: readonly string[]): string[] {
return out;
}
function stripTrailingEllipsis(text: string): string {
return text.replace(/(?:\s*(?:\.{3}|\u2026))+$/u, "").trimEnd();
}
const MIN_TRUNCATED_FINAL_PREFIX_CHARS = 48;
const MIN_TRUNCATED_FINAL_CONTINUATION_CHARS = 24;
function isPotentialTruncatedFinal(finalText: string): boolean {
const trimmedFinal = finalText.trimEnd();
const untruncatedFinal = stripTrailingEllipsis(trimmedFinal);
return (
untruncatedFinal.length >= MIN_TRUNCATED_FINAL_PREFIX_CHARS && untruncatedFinal !== trimmedFinal
);
}
function selectLongerPreviewForFinal(params: {
finalText: string;
candidateTexts: readonly (string | undefined)[];
}): string | undefined {
const finalText = params.finalText.trimEnd();
const untruncatedFinal = stripTrailingEllipsis(finalText);
if (
untruncatedFinal.length < MIN_TRUNCATED_FINAL_PREFIX_CHARS ||
untruncatedFinal === finalText
) {
return undefined;
}
for (const candidate of params.candidateTexts) {
const candidateText = candidate?.trimEnd();
if (
!candidateText ||
candidateText.length <= finalText.length ||
!candidateText.startsWith(untruncatedFinal)
) {
continue;
}
const continuation = candidateText.slice(untruncatedFinal.length).trimStart();
if (
continuation.length >= MIN_TRUNCATED_FINAL_CONTINUATION_CHARS &&
/^[\p{L}\p{N}]/u.test(continuation)
) {
return candidateText;
}
}
return undefined;
}
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
const followUpPayload = (payload: ReplyPayload, text: string) =>
params.applyTextToFollowUpPayload
@@ -138,6 +189,53 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
return undefined;
}
const retainedPreview =
isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(text)
? selectLongerPreviewForFinal({
finalText: text,
candidateTexts: [
await params.resolveFinalTextCandidate?.({ finalText: text, laneName }),
stream.lastDeliveredText?.(),
lane.lastPartialText,
],
})
: undefined;
if (retainedPreview && (!buttons || retainedPreview.length <= params.draftMaxChars)) {
const previewText = retainedPreview;
lane.lastPartialText = previewText;
lane.hasStreamedMessage = true;
await params.stopDraftLane(lane);
const messageId = stream.messageId();
if (typeof messageId !== "number") {
if (stream.sendMayHaveLanded?.()) {
lane.finalized = true;
params.markDelivered();
return result("preview-retained");
}
return undefined;
}
const deliveredStreamText = stream.lastDeliveredText?.();
if (deliveredStreamText !== undefined && deliveredStreamText !== previewText) {
return undefined;
}
if (buttons) {
try {
await params.editStreamMessage({ laneName, messageId, text: previewText, buttons });
} catch (err) {
params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`);
}
}
for (const chunk of remainingChunks) {
if (chunk.trim().length === 0) {
continue;
}
await params.sendPayload(followUpPayload(payload, chunk));
}
lane.finalized = true;
params.markDelivered();
return result("preview-finalized", { content: previewText, messageId });
}
lane.lastPartialText = firstChunk;
lane.hasStreamedMessage = true;
lane.finalized = false;

View File

@@ -15,6 +15,10 @@ function createHarness(params?: {
answerStream?: DraftLaneState["stream"] | null;
draftMaxChars?: number;
splitFinalTextForStream?: (text: string) => readonly string[];
resolveFinalTextCandidate?: (params: {
finalText: string;
laneName: LaneName;
}) => string | undefined;
}) {
const answer =
params?.answerStream === null
@@ -59,6 +63,7 @@ function createHarness(params?: {
stopDraftLane,
clearDraftLane,
editStreamMessage,
resolveFinalTextCandidate: params?.resolveFinalTextCandidate,
log,
markDelivered,
});
@@ -151,6 +156,229 @@ describe("createLaneTextDeliverer", () => {
expect(harness.lanes.answer.finalized).toBe(true);
});
it("keeps a longer partial preview when the final payload is an ellipsis-truncated snapshot", async () => {
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
const truncatedFinal =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue(fullAnswer);
const harness = createHarness({
answerStream: answer,
resolveFinalTextCandidate: () => fullAnswer,
});
const result = await deliverFinalAnswer(harness, truncatedFinal);
const delivery = expectPreviewFinalized(result);
expect(delivery.content).toBe(fullAnswer);
expect(delivery.messageId).toBe(999);
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
expect(harness.lanes.answer.finalized).toBe(true);
});
it("keeps a longer delivered stream preview when transcript lookup misses", async () => {
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
const truncatedFinal =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue(fullAnswer);
const harness = createHarness({ answerStream: answer });
harness.lanes.answer.lastPartialText = fullAnswer;
harness.lanes.answer.hasStreamedMessage = true;
const result = await deliverFinalAnswer(harness, truncatedFinal);
const delivery = expectPreviewFinalized(result);
expect(delivery.content).toBe(fullAnswer);
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("keeps a longer pending partial preview before it is delivered", async () => {
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
const truncatedFinal =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
let deliveredText = "";
const answer = createTestDraftStream({
messageId: 999,
onStop: () => {
deliveredText = fullAnswer;
},
});
answer.lastDeliveredText.mockImplementation(() => deliveredText);
const harness = createHarness({
answerStream: answer,
resolveFinalTextCandidate: () => fullAnswer,
});
answer.update(fullAnswer);
harness.lanes.answer.lastPartialText = fullAnswer;
harness.lanes.answer.hasStreamedMessage = true;
const result = await deliverFinalAnswer(harness, truncatedFinal);
const delivery = expectPreviewFinalized(result);
expect(delivery.content).toBe(fullAnswer);
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("materializes a pending retained preview before reading the message id", async () => {
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
const truncatedFinal =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
let answer: ReturnType<typeof createTestDraftStream>;
let deliveredText = "";
answer = createTestDraftStream({
onStop: () => {
answer.setMessageId(999);
deliveredText = fullAnswer;
},
});
answer.lastDeliveredText.mockImplementation(() => deliveredText);
const harness = createHarness({
answerStream: answer,
resolveFinalTextCandidate: () => fullAnswer,
});
answer.update(fullAnswer);
harness.lanes.answer.lastPartialText = fullAnswer;
harness.lanes.answer.hasStreamedMessage = true;
const result = await deliverFinalAnswer(harness, truncatedFinal);
const delivery = expectPreviewFinalized(result);
expect(delivery.content).toBe(fullAnswer);
expect(delivery.messageId).toBe(999);
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("falls back when the retained pending preview does not land", async () => {
const fullAnswer =
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console.";
const truncatedFinal = "Ja. Hier nochmal sauber Schritt fuer Schritt...";
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue("older preview");
const harness = createHarness({
answerStream: answer,
resolveFinalTextCandidate: () => fullAnswer,
});
harness.lanes.answer.lastPartialText = fullAnswer;
harness.lanes.answer.hasStreamedMessage = true;
const result = await deliverFinalAnswer(harness, truncatedFinal);
expect(result.kind).toBe("sent");
expect(harness.clearDraftLane).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith({ text: truncatedFinal }, { durable: true });
});
it("uses the canonical final when the shorter final has no truncation marker", async () => {
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue("Hello world");
const harness = createHarness({ answerStream: answer });
const result = await deliverFinalAnswer(harness, "Hello");
expect(result.kind).toBe("sent");
expect(answer.update).toHaveBeenCalledWith("Hello");
expect(harness.sendPayload).toHaveBeenCalledWith({ text: "Hello" }, { durable: true });
});
it("uses the canonical final when the shorter final intentionally ends with ellipsis", async () => {
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue("Let's leave it... and continue");
const harness = createHarness({ answerStream: answer });
const result = await deliverFinalAnswer(harness, "Let's leave it...");
expect(result.kind).toBe("sent");
expect(answer.update).toHaveBeenCalledWith("Let's leave it...");
expect(harness.sendPayload).toHaveBeenCalledWith(
{ text: "Let's leave it..." },
{ durable: true },
);
});
it("uses the canonical final when an intentional ellipsis replaces a longer draft", async () => {
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue("I don't know the answer");
const harness = createHarness({ answerStream: answer });
const result = await deliverFinalAnswer(harness, "I don't know...");
expect(result.kind).toBe("sent");
expect(answer.update).toHaveBeenCalledWith("I don't know...");
expect(harness.sendPayload).toHaveBeenCalledWith(
{ text: "I don't know..." },
{ durable: true },
);
});
it("uses the canonical split final when only the first chunk ends with ellipsis", async () => {
const buttons = [[{ text: "OK", callback_data: "ok" }]];
const answer = createTestDraftStream({ messageId: 999 });
const harness = createHarness({
answerStream: answer,
draftMaxChars: 10,
splitFinalTextForStream: () => ["Hello...", " world"],
});
harness.lanes.answer.lastPartialText = "Hello retained preview";
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello... world",
payload: { text: "Hello... world" },
infoKind: "final",
buttons,
});
const delivery = expectPreviewFinalized(result);
expect(delivery.content).toBe("Hello... world");
expect(answer.update).toHaveBeenCalledWith("Hello...");
expect(harness.editStreamMessage).toHaveBeenCalledWith({
laneName: "answer",
messageId: 999,
text: "Hello...",
buttons,
});
expect(harness.sendPayload).toHaveBeenCalledWith({ text: " world" });
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("uses normal final delivery when retained preview is too long for button edit", async () => {
const buttons = [[{ text: "OK", callback_data: "ok" }]];
const answer = createTestDraftStream({ messageId: 999 });
answer.lastDeliveredText.mockReturnValue("Hello retained preview");
const harness = createHarness({
answerStream: answer,
draftMaxChars: 10,
resolveFinalTextCandidate: () => "Hello retained preview",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello...",
payload: { text: "Hello..." },
infoKind: "final",
buttons,
});
expect(result.kind).toBe("sent");
expect(answer.update).toHaveBeenCalledWith("Hello...");
expect(harness.editStreamMessage).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith({ text: "Hello..." }, { durable: true });
});
it("falls back to normal delivery when no stream exists", async () => {
const harness = createHarness({ answerStream: null });

View File

@@ -4,6 +4,7 @@ export { loadSessionStore } from "../config/sessions/store-load.js";
export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js";
export { resolveSessionTranscriptPathInDir, resolveStorePath } from "../config/sessions/paths.js";
export { resolveAndPersistSessionFile } from "../config/sessions/session-file.js";
export { readLatestAssistantTextFromSessionTranscript } from "../config/sessions/transcript.js";
export { resolveSessionKey } from "../config/sessions/session-key.js";
export { resolveGroupSessionKey } from "../config/sessions/group.js";
export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";