diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index d9079ea8f94..e99617a35ce 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -4581,6 +4581,38 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => ); }); + it("preserves hook-blocked metadata when source delivery is message-tool-only", async () => { + setNoAbort(); + sessionStoreMocks.currentEntry = { + sessionId: "s1", + updatedAt: 0, + sendPolicy: "allow", + }; + const dispatcher = createDispatcher(); + const blockedReply = setReplyPayloadMetadata( + { text: "Your message could not be sent: blocked by policy-plugin", isError: true }, + { beforeAgentRunBlocked: true }, + ); + const replyResolver = vi.fn(async () => blockedReply satisfies ReplyPayload); + + const result = await dispatchReplyFromConfig({ + ctx: buildTestCtx({ SessionKey: "test:session" }), + cfg: emptyConfig, + dispatcher, + replyResolver, + replyOptions: { + sourceReplyDeliveryMode: "message_tool_only", + }, + }); + + expect(replyResolver).toHaveBeenCalledTimes(1); + expect(result.queuedFinal).toBe(false); + expect(result.beforeAgentRunBlocked).toBe(true); + expect(result.sourceReplyDeliveryMode).toBe("message_tool_only"); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + }); + it("delivers marked runtime failure notices in message-tool-only mode", async () => { setNoAbort(); sessionStoreMocks.currentEntry = { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index b7f5e592505..b6da85e9ed4 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -95,7 +95,10 @@ import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js"; import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js"; import { resolveOriginMessageProvider } from "./origin-routing.js"; import { resolveReplyRoutingDecision } from "./routing-policy.js"; -import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; +import { + isExplicitSourceReplyCommand, + resolveSourceReplyVisibilityPolicy, +} from "./source-reply-delivery-mode.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; const routeReplyRuntimeLoader = createLazyImportLoader(() => import("./route-reply.runtime.js")); @@ -711,7 +714,7 @@ export async function dispatchReplyFromConfig( const prefersMessageToolDelivery = params.replyOptions?.sourceReplyDeliveryMode === "message_tool_only" || (params.replyOptions?.sourceReplyDeliveryMode === undefined && - ctx.CommandSource !== "native" && + !isExplicitSourceReplyCommand(ctx) && (chatType === "group" || chatType === "channel" ? effectiveVisibleReplies !== "automatic" : effectiveVisibleReplies === "message_tool")); diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index eadae391a54..47b072805fa 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -97,13 +97,23 @@ describe("resolveSourceReplyDeliveryMode", () => { expect( resolveSourceReplyDeliveryMode({ cfg: emptyConfig, - ctx: { ChatType: "group", CommandSource: "text", CommandAuthorized: true }, + ctx: { + ChatType: "group", + CommandSource: "text", + CommandAuthorized: true, + CommandBody: "/status", + }, }), ).toBe("automatic"); expect( resolveSourceReplyDeliveryMode({ cfg: emptyConfig, - ctx: { ChatType: "group", CommandSource: "text" }, + ctx: { + ChatType: "group", + CommandSource: "text", + CommandAuthorized: false, + CommandBody: "/status", + }, }), ).toBe("message_tool_only"); }); @@ -192,7 +202,12 @@ describe("resolveSourceReplyVisibilityPolicy", () => { it("keeps native and authorized text command replies visible in groups", () => { for (const ctx of [ { ChatType: "group", CommandSource: "native" }, - { ChatType: "group", CommandSource: "text", CommandAuthorized: true }, + { + ChatType: "group", + CommandSource: "text", + CommandAuthorized: true, + CommandBody: "/status", + }, ] as const) { expect( resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index fbd45055e5b..90e5574b3ce 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -6,9 +6,17 @@ import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; export type SourceReplyDeliveryModeContext = { ChatType?: string; CommandAuthorized?: boolean; + CommandBody?: string; CommandSource?: "text" | "native"; }; +export function isExplicitSourceReplyCommand(ctx: SourceReplyDeliveryModeContext): boolean { + if (ctx.CommandSource === "native") { + return true; + } + return ctx.CommandSource === "text" && ctx.CommandAuthorized === true; +} + export function resolveSourceReplyDeliveryMode(params: { cfg: OpenClawConfig; ctx: SourceReplyDeliveryModeContext; @@ -21,10 +29,7 @@ export function resolveSourceReplyDeliveryMode(params: { ? "automatic" : params.requested; } - if ( - params.ctx.CommandSource === "native" || - (params.ctx.CommandSource === "text" && params.ctx.CommandAuthorized === true) - ) { + if (isExplicitSourceReplyCommand(params.ctx)) { return "automatic"; } const chatType = normalizeChatType(params.ctx.ChatType);