Skip to content

Commit daf8afc

Browse files
authored
fix(telegram): clear stale retain before transient final fallback (#41763)
Merged via squash. Prepared head SHA: c094083 Co-authored-by: obviyus <[email protected]> Co-authored-by: obviyus <[email protected]> Reviewed-by: @obviyus
1 parent 87876a3 commit daf8afc

File tree

3 files changed

+82
-0
lines changed

3 files changed

+82
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai
101101
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
102102
- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`.
103103
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
104+
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
104105

105106
## 2026.3.8
106107

src/telegram/bot-message-dispatch.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,81 @@ describe("dispatchTelegramMessage draft streaming", () => {
10311031
expect(deliverReplies).not.toHaveBeenCalled();
10321032
});
10331033

1034+
it("clears the active preview when a later final falls back after archived retain", async () => {
1035+
let answerMessageId: number | undefined;
1036+
let answerDraftParams:
1037+
| {
1038+
onSupersededPreview?: (preview: { messageId: number; textSnapshot: string }) => void;
1039+
}
1040+
| undefined;
1041+
const answerDraftStream = {
1042+
update: vi.fn().mockImplementation((text: string) => {
1043+
if (text.includes("Message B")) {
1044+
answerMessageId = 1002;
1045+
}
1046+
}),
1047+
flush: vi.fn().mockResolvedValue(undefined),
1048+
messageId: vi.fn().mockImplementation(() => answerMessageId),
1049+
clear: vi.fn().mockResolvedValue(undefined),
1050+
stop: vi.fn().mockResolvedValue(undefined),
1051+
forceNewMessage: vi.fn().mockImplementation(() => {
1052+
answerMessageId = undefined;
1053+
}),
1054+
};
1055+
const reasoningDraftStream = createDraftStream();
1056+
createTelegramDraftStream
1057+
.mockImplementationOnce((params) => {
1058+
answerDraftParams = params as typeof answerDraftParams;
1059+
return answerDraftStream;
1060+
})
1061+
.mockImplementationOnce(() => reasoningDraftStream);
1062+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
1063+
async ({ dispatcherOptions, replyOptions }) => {
1064+
await replyOptions?.onPartialReply?.({ text: "Message A partial" });
1065+
await replyOptions?.onAssistantMessageStart?.();
1066+
await replyOptions?.onPartialReply?.({ text: "Message B partial" });
1067+
answerDraftParams?.onSupersededPreview?.({
1068+
messageId: 1001,
1069+
textSnapshot: "Message A partial",
1070+
});
1071+
1072+
await dispatcherOptions.deliver({ text: "Message A final" }, { kind: "final" });
1073+
await dispatcherOptions.deliver({ text: "Message B final" }, { kind: "final" });
1074+
return { queuedFinal: true };
1075+
},
1076+
);
1077+
deliverReplies.mockResolvedValue({ delivered: true });
1078+
const preConnectErr = new Error("connect ECONNREFUSED 149.154.167.220:443");
1079+
(preConnectErr as NodeJS.ErrnoException).code = "ECONNREFUSED";
1080+
editMessageTelegram
1081+
.mockRejectedValueOnce(new Error("400: Bad Request: message to edit not found"))
1082+
.mockRejectedValueOnce(preConnectErr);
1083+
1084+
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
1085+
1086+
expect(editMessageTelegram).toHaveBeenNthCalledWith(
1087+
1,
1088+
123,
1089+
1001,
1090+
"Message A final",
1091+
expect.any(Object),
1092+
);
1093+
expect(editMessageTelegram).toHaveBeenNthCalledWith(
1094+
2,
1095+
123,
1096+
1002,
1097+
"Message B final",
1098+
expect.any(Object),
1099+
);
1100+
const finalTextSentViaDeliverReplies = deliverReplies.mock.calls.some((call: unknown[]) =>
1101+
(call[0] as { replies?: Array<{ text?: string }> })?.replies?.some(
1102+
(r: { text?: string }) => r.text === "Message B final",
1103+
),
1104+
);
1105+
expect(finalTextSentViaDeliverReplies).toBe(true);
1106+
expect(answerDraftStream.clear).toHaveBeenCalledTimes(1);
1107+
});
1108+
10341109
it.each(["partial", "block"] as const)(
10351110
"keeps finalized text preview when the next assistant message is media-only (%s mode)",
10361111
async (streamMode) => {

src/telegram/lane-delivery-text-deliverer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
464464
!hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError;
465465

466466
if (infoKind === "final") {
467+
// Transient previews must decide cleanup retention per final attempt.
468+
// Completed previews intentionally stay retained so later extra payloads
469+
// do not clear the already-finalized message.
470+
if (params.activePreviewLifecycleByLane[laneName] === "transient") {
471+
params.retainPreviewOnCleanupByLane[laneName] = false;
472+
}
467473
if (laneName === "answer") {
468474
const archivedResult = await consumeArchivedAnswerPreviewForFinal({
469475
lane,

0 commit comments

Comments
 (0)