From 21c33bed3b54dad2c7299096a43cf794e8d0f0d4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 6 May 2026 17:35:38 +0530 Subject: [PATCH] fix(telegram): preserve tool-only duplicate suppression --- CHANGELOG.md | 1 + .../telegram/src/bot-message-dispatch.test.ts | 35 +++++++++++ .../reply/dispatch-from-config.test.ts | 62 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 24 ++++--- 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf15c68dbb3..225767eb45a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -308,6 +308,7 @@ Docs: https://docs.openclaw.ai - Memory Wiki: skip empty and whitespace-only source pages when refreshing generated Related blocks, preventing blank pages from being rewritten into Related-only stubs. Fixes #78121. Thanks @amknight. - LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316. - Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949) Thanks @keshavbotagent. +- Telegram: keep duplicate message-tool-only Codex turns from posting generic silent-reply fallback text, so private finals stay private after inbound dedupe. Thanks @rubencu. - Telegram/sessions: gap-fill delivered embedded final replies into the session JSONL even when the runner trace is missing, so Telegram answers after tool calls do not vanish from the durable transcript. Fixes #77814. (#78426) Thanks @obviyus, @ChushulSuri, and @DougButdorf. - Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`. - Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models. diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index b634ec9cb8f..5f1e22d75af 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1162,6 +1162,41 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(editMessageTelegram).not.toHaveBeenCalled(); }); + it("does not add silent fallback when source delivery is message-tool-only", async () => { + setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 }); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + sourceReplyDeliveryMode: "message_tool_only", + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: "agent:main:telegram:direct:123", + } as unknown as TelegramMessageContext["ctxPayload"], + }), + cfg: { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + }, + }); + + expect(deliverReplies).not.toHaveBeenCalled(); + expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(sendMessageTelegram).not.toHaveBeenCalled(); + }); + it("shows compacting reaction during auto-compaction and resumes thinking", async () => { const statusReactionController = { setThinking: vi.fn(async () => {}), diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 7f9b4d9397c..0666fa655ae 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -2727,6 +2727,68 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + it("keeps message-tool-only delivery mode on duplicate inbound returns", async () => { + setNoAbort(); + const cfg = emptyConfig; + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + ChatType: "channel", + To: "telegram:chat:123", + MessageSid: "msg-tool-only-duplicate", + SessionKey: "agent:main:telegram:channel:123", + }); + const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); + + const first = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + const duplicate = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(first.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(duplicate.sourceReplyDeliveryMode).toBe("message_tool_only"); + }); + + it("does not mark duplicate inbound returns as tool-only when message is unavailable", async () => { + setNoAbort(); + const cfg = { tools: { allow: ["read"] } } as OpenClawConfig; + const ctx = buildTestCtx({ + Provider: "telegram", + Surface: "telegram", + ChatType: "channel", + To: "telegram:chat:123", + MessageSid: "msg-tool-unavailable-duplicate", + SessionKey: "agent:main:telegram:channel:123", + }); + const replyResolver = vi.fn(async () => ({ text: "visible fallback" }) as ReplyPayload); + + const first = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + const duplicate = await dispatchReplyFromConfig({ + ctx, + cfg, + dispatcher: createDispatcher(), + replyResolver, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(first.sourceReplyDeliveryMode).toBeUndefined(); + expect(duplicate.sourceReplyDeliveryMode).toBeUndefined(); + }); + it("keeps local discord exec approval tool prompts when the native runtime is inactive", async () => { setNoAbort(); const cfg = { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index f348181522b..bf8c2f1ae9d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -430,20 +430,10 @@ export async function dispatchReplyFromConfig( }); }; - const inboundDedupeClaim = claimInboundDedupe(ctx); - if (inboundDedupeClaim.status === "duplicate" || inboundDedupeClaim.status === "inflight") { - recordProcessed("skipped", { reason: "duplicate" }); - return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; - } let inboundDedupeReplayUnsafe = false; const markInboundDedupeReplayUnsafe = () => { inboundDedupeReplayUnsafe = true; }; - const commitInboundDedupeIfClaimed = () => { - if (inboundDedupeClaim.status === "claimed") { - commitInboundDedupe(inboundDedupeClaim.key); - } - }; const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg }); @@ -807,6 +797,20 @@ export async function dispatchReplyFromConfig( ? { ...result, sourceReplyDeliveryMode } : result; + const inboundDedupeClaim = claimInboundDedupe(ctx); + if (inboundDedupeClaim.status === "duplicate" || inboundDedupeClaim.status === "inflight") { + recordProcessed("skipped", { reason: "duplicate" }); + return attachSourceReplyDeliveryMode({ + queuedFinal: false, + counts: dispatcher.getQueuedCounts(), + }); + } + const commitInboundDedupeIfClaimed = () => { + if (inboundDedupeClaim.status === "claimed") { + commitInboundDedupe(inboundDedupeClaim.key); + } + }; + let pluginFallbackReason: | "plugin-bound-fallback-missing-plugin" | "plugin-bound-fallback-no-handler"