fix message-tool-only telegram fallback (#76272)

This commit is contained in:
Tyler Nishida
2026-05-03 04:15:53 -10:00
committed by GitHub
parent 30018bddc6
commit 796c1e67c3
7 changed files with 117 additions and 14 deletions

View File

@@ -3006,6 +3006,40 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliveredReplies?.[0]?.text?.trim()).not.toBe("NO_REPLY");
});
it("does not add silent-reply fallback for message-tool-only turns", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 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,
},
},
},
} as unknown as OpenClawConfig,
});
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not add silent-reply fallback after visible block delivery", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -665,6 +665,7 @@ export const dispatchTelegramMessage = async ({
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
let queuedFinal = false;
let suppressSilentReplyFallback = false;
let hadErrorReplyFailureOrSkip = false;
let isFirstTurnInSession = false;
let dispatchError: unknown;
@@ -1171,6 +1172,8 @@ export const dispatchTelegramMessage = async ({
return;
}
({ queuedFinal } = turnResult.dispatchResult);
suppressSilentReplyFallback =
turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only";
} catch (err) {
dispatchError = err;
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
@@ -1300,7 +1303,13 @@ export const dispatchTelegramMessage = async ({
sentFallback = result.delivered;
}
if (!queuedFinal && !sentFallback && !dispatchError && !deliverySummary.delivered) {
if (
!queuedFinal &&
!sentFallback &&
!dispatchError &&
!deliverySummary.delivered &&
!suppressSilentReplyFallback
) {
const policySessionKey =
ctxPayload.CommandSource === "native"
? (ctxPayload.CommandTargetSessionKey ?? ctxPayload.SessionKey)
@@ -1332,7 +1341,7 @@ export const dispatchTelegramMessage = async ({
const hasFinalResponse = hasFinalInboundReplyDispatch(
{ queuedFinal },
{
fallbackDelivered: sentFallback,
fallbackDelivered: sentFallback || suppressSilentReplyFallback,
deliverySummaryDelivered: deliverySummary.delivered,
},
);

View File

@@ -113,6 +113,7 @@ function finalizeDispatchResult(
final: Math.max(0, result.counts.final - cancelledCounts.final),
};
return {
...result,
queuedFinal: result.queuedFinal && counts.final > 0,
counts,
};

View File

@@ -4158,6 +4158,8 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
);
hookMocks.runner.runReplyDispatch.mockResolvedValue(undefined);
hookMocks.runner.runBeforeDispatch.mockResolvedValue(undefined);
threadInfoMocks.parseSessionThreadInfo.mockReset();
threadInfoMocks.parseSessionThreadInfo.mockImplementation(parseGenericThreadSessionInfo);
});
it("still calls the replyResolver when sendPolicy is deny", async () => {
@@ -4539,6 +4541,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () =>
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result.queuedFinal).toBe(false);
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();

View File

@@ -754,6 +754,12 @@ export async function dispatchReplyFromConfig(
suppressHookUserDelivery,
suppressHookReplyLifecycle,
} = sourceReplyPolicy;
const attachSourceReplyDeliveryMode = (
result: DispatchFromConfigResult,
): DispatchFromConfigResult =>
sourceReplyDeliveryMode === "message_tool_only"
? { ...result, sourceReplyDeliveryMode }
: result;
let pluginFallbackReason:
| "plugin-bound-fallback-missing-plugin"
@@ -797,7 +803,10 @@ export async function dispatchReplyFromConfig(
markIdle("plugin_binding_dispatch");
recordProcessed("completed", { reason: "plugin-bound-handled" });
commitInboundDedupeIfClaimed();
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
return attachSourceReplyDeliveryMode({
queuedFinal: false,
counts: dispatcher.getQueuedCounts(),
});
}
case "missing_plugin":
case "no_handler": {
@@ -824,7 +833,10 @@ export async function dispatchReplyFromConfig(
markIdle("plugin_binding_declined");
recordProcessed("completed", { reason: "plugin-bound-declined" });
commitInboundDedupeIfClaimed();
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
return attachSourceReplyDeliveryMode({
queuedFinal: false,
counts: dispatcher.getQueuedCounts(),
});
}
case "error": {
logVerbose(
@@ -837,7 +849,10 @@ export async function dispatchReplyFromConfig(
markIdle("plugin_binding_error");
recordProcessed("completed", { reason: "plugin-bound-error" });
commitInboundDedupeIfClaimed();
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
return attachSourceReplyDeliveryMode({
queuedFinal: false,
counts: dispatcher.getQueuedCounts(),
});
}
}
}
@@ -910,7 +925,7 @@ export async function dispatchReplyFromConfig(
recordProcessed("completed", { reason: "fast_abort" });
markIdle("message_completed");
commitInboundDedupeIfClaimed();
return { queuedFinal, counts };
return attachSourceReplyDeliveryMode({ queuedFinal, counts });
}
const isSlackNonDirectSurface =
@@ -989,7 +1004,7 @@ export async function dispatchReplyFromConfig(
recordProcessed("completed", { reason: "before_dispatch_handled" });
markIdle("message_completed");
commitInboundDedupeIfClaimed();
return { queuedFinal, counts };
return attachSourceReplyDeliveryMode({ queuedFinal, counts });
}
}
@@ -1023,10 +1038,10 @@ export async function dispatchReplyFromConfig(
);
if (replyDispatchResult?.handled) {
commitInboundDedupeIfClaimed();
return {
return attachSourceReplyDeliveryMode({
queuedFinal: replyDispatchResult.queuedFinal,
counts: replyDispatchResult.counts,
};
});
}
}
@@ -1443,10 +1458,10 @@ export async function dispatchReplyFromConfig(
},
);
if (tailDispatchResult?.handled) {
return {
return attachSourceReplyDeliveryMode({
queuedFinal: tailDispatchResult.queuedFinal,
counts: tailDispatchResult.counts,
};
});
}
}
}
@@ -1535,7 +1550,7 @@ export async function dispatchReplyFromConfig(
pluginFallbackReason ? { reason: pluginFallbackReason } : undefined,
);
markIdle("message_completed");
return { queuedFinal, counts };
return attachSourceReplyDeliveryMode({ queuedFinal, counts });
} catch (err) {
if (inboundDedupeClaim.status === "claimed") {
if (inboundDedupeReplayUnsafe) {

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { GetReplyOptions } from "../get-reply-options.types.js";
import type { GetReplyOptions, SourceReplyDeliveryMode } from "../get-reply-options.types.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { FormatAbortReplyText, TryFastAbortFromMessage } from "./abort.runtime-types.js";
import type { GetReplyFromConfig } from "./get-reply.types.js";
@@ -8,6 +8,7 @@ import type { ReplyDispatchKind, ReplyDispatcher } from "./reply-dispatcher.type
export type DispatchFromConfigResult = {
queuedFinal: boolean;
counts: Record<ReplyDispatchKind, number>;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
};
export type DispatchFromConfigParams = {

View File

@@ -130,6 +130,7 @@ let runReplyAgent: typeof import("./agent-runner.runtime.js").runReplyAgent;
let routeReply: typeof import("./route-reply.runtime.js").routeReply;
let drainFormattedSystemEvents: typeof import("./session-system-events.js").drainFormattedSystemEvents;
let resolveTypingMode: typeof import("./typing-mode.js").resolveTypingMode;
let buildDirectChatContext: typeof import("./groups.js").buildDirectChatContext;
let buildGroupChatContext: typeof import("./groups.js").buildGroupChatContext;
let buildInboundUserContextPrefix: typeof import("./inbound-meta.js").buildInboundUserContextPrefix;
let getActiveReplyRunCount: typeof import("./reply-run-registry.js").getActiveReplyRunCount;
@@ -242,7 +243,7 @@ describe("runPreparedReply media-only handling", () => {
({ routeReply } = await import("./route-reply.runtime.js"));
({ drainFormattedSystemEvents } = await import("./session-system-events.js"));
({ resolveTypingMode } = await import("./typing-mode.js"));
({ buildGroupChatContext } = await import("./groups.js"));
({ buildDirectChatContext, buildGroupChatContext } = await import("./groups.js"));
({ buildInboundUserContextPrefix } = await import("./inbound-meta.js"));
({ __testing: replyRunTesting, getActiveReplyRunCount } =
await import("./reply-run-registry.js"));
@@ -331,6 +332,45 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false);
});
it("passes message-tool-only delivery into direct chat prompt context", async () => {
await runPreparedReply(
baseParams({
ctx: {
Body: "yo",
RawBody: "yo",
CommandBody: "yo",
ThreadHistoryBody: "Earlier direct message",
OriginatingChannel: "telegram",
OriginatingTo: "telegram-direct-test-id",
ChatType: "direct",
},
sessionCtx: {
Body: "yo",
BodyStripped: "yo",
ThreadHistoryBody: "Earlier direct message",
MediaPath: "/tmp/input.png",
Provider: "telegram",
ChatType: "direct",
OriginatingChannel: "telegram",
OriginatingTo: "telegram-direct-test-id",
},
opts: {
sourceReplyDeliveryMode: "message_tool_only",
},
}),
);
expect(buildDirectChatContext).toHaveBeenCalledWith(
expect.objectContaining({
sessionCtx: expect.objectContaining({
Provider: "telegram",
ChatType: "direct",
}),
sourceReplyDeliveryMode: "message_tool_only",
}),
);
});
it.each(["direct", "dm"] as const)(
"propagates empty-assistant silence for %s runs with explicit direct silent replies",
async (chatType) => {