fix(telegram): preserve tool-only duplicate suppression

This commit is contained in:
Ayaan Zaidi
2026-05-06 17:35:38 +05:30
parent a973e3199d
commit 21c33bed3b
4 changed files with 112 additions and 10 deletions

View File

@@ -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.

View File

@@ -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 () => {}),

View File

@@ -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 = {

View File

@@ -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"