diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 66f1ca6342e..24c3c9041a5 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -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); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 75543013506..09a78447f54 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -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, }, ); diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 737aa8804ca..0111adc57f6 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -113,6 +113,7 @@ function finalizeDispatchResult( final: Math.max(0, result.counts.final - cancelledCounts.final), }; return { + ...result, queuedFinal: result.queuedFinal && counts.final > 0, counts, }; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 4a9898e05be..fb08dad76de 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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(); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index b4dfff71f47..fccb38f84b6 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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) { diff --git a/src/auto-reply/reply/dispatch-from-config.types.ts b/src/auto-reply/reply/dispatch-from-config.types.ts index ecab40b4d68..81de6ef935d 100644 --- a/src/auto-reply/reply/dispatch-from-config.types.ts +++ b/src/auto-reply/reply/dispatch-from-config.types.ts @@ -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; + sourceReplyDeliveryMode?: SourceReplyDeliveryMode; }; export type DispatchFromConfigParams = { diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index f41b176a692..1d636ef65ba 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -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) => {