diff --git a/docs/channels/group-messages.md b/docs/channels/group-messages.md index 14fbf017bda..b5ca8c515b6 100644 --- a/docs/channels/group-messages.md +++ b/docs/channels/group-messages.md @@ -81,7 +81,7 @@ Only the owner number (from `channels.whatsapp.allowFrom`, or the bot’s own E. - Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. - Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. - Session store entries will appear as `agent::whatsapp:group:` in the session store (`~/.openclaw/agents//sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. -- Typing indicators in groups follow `agents.defaults.typingMode` (default: `message` when unmentioned). +- Typing indicators in groups follow `agents.defaults.typingMode`. When visible replies use the default message-tool-only mode, typing starts immediately by default so group members can see the agent is working even if no automatic final reply is posted. Explicit typing-mode config still wins. ## Related diff --git a/docs/channels/groups.md b/docs/channels/groups.md index d533b69d507..5d8129a958b 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -45,6 +45,8 @@ That means the agent still processes the turn and can update memory/session stat This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool. +Typing indicators are still sent while the agent works in tool-only mode. The default group typing mode is upgraded from "message" to "instant" for these turns because there may never be normal assistant message text before the agent decides whether to call the message tool. Explicit typing-mode config still wins. + To restore legacy automatic final replies for group/channel rooms: ```json5 diff --git a/src/auto-reply/reply/dispatch-acp-delivery.test.ts b/src/auto-reply/reply/dispatch-acp-delivery.test.ts index 796bfb96018..796e5526047 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.test.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.test.ts @@ -376,7 +376,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => { expect(onReplyStart).not.toHaveBeenCalled(); }); - it("does not fire onReplyStart when user delivery is suppressed", async () => { + it("does not fire onReplyStart when reply lifecycle is suppressed", async () => { const onReplyStart = vi.fn(async () => {}); const dispatcher = createDispatcher(); const coordinator = createAcpDispatchDeliveryCoordinator({ @@ -389,6 +389,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => { dispatcher, inboundAudio: false, suppressUserDelivery: true, + suppressReplyLifecycle: true, shouldRouteToOriginating: false, onReplyStart, }); @@ -403,6 +404,32 @@ describe("createAcpDispatchDeliveryCoordinator", () => { expect(onReplyStart).not.toHaveBeenCalled(); }); + it("can start reply lifecycle while user delivery is suppressed", async () => { + const onReplyStart = vi.fn(async () => {}); + const dispatcher = createDispatcher(); + const coordinator = createAcpDispatchDeliveryCoordinator({ + cfg: createAcpTestConfig(), + ctx: buildTestCtx({ + Provider: "visiblechat", + Surface: "visiblechat", + SessionKey: "agent:codex-acp:session-1", + }), + dispatcher, + inboundAudio: false, + suppressUserDelivery: true, + suppressReplyLifecycle: false, + shouldRouteToOriginating: false, + onReplyStart, + }); + + await coordinator.startReplyLifecycle(); + const delivered = await coordinator.deliver("final", { text: "hello" }); + + expect(delivered).toBe(false); + expect(onReplyStart).toHaveBeenCalledTimes(1); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + }); + it("keeps parent-owned background ACP child delivery silent while preserving accumulated output", async () => { const dispatcher = createDispatcher(); const coordinator = createAcpDispatchDeliveryCoordinator({ diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index 016bb626f43..1c7c8f3f2a4 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -181,6 +181,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { sessionTtsAuto?: TtsAutoMode; ttsChannel?: string; suppressUserDelivery?: boolean; + suppressReplyLifecycle?: boolean; shouldRouteToOriginating: boolean; originatingChannel?: string; originatingTo?: string; @@ -245,11 +246,9 @@ export function createAcpDispatchDeliveryCoordinator(params: { return; } state.startedReplyLifecycle = true; - // When delivery is suppressed (e.g. sendPolicy: "deny"), do not fire the - // onReplyStart callback — channels wire it to typing indicators / lifecycle - // notifications that should not leak outbound events while the session is - // under a deny policy. See #53328. - if (params.suppressUserDelivery) { + // Delivery and lifecycle suppression are separate: message-tool-only turns + // suppress automatic user delivery but still need typing/lifecycle signals. + if (params.suppressReplyLifecycle) { return; } void Promise.resolve(params.onReplyStart?.()).catch((error) => { diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 790ee757c03..6991d7d1814 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -227,6 +227,8 @@ async function runDispatch(params: { images?: Array<{ data: string; mimeType: string }>; ctxOverrides?: Record; sessionKeyOverride?: string; + suppressUserDelivery?: boolean; + suppressReplyLifecycle?: boolean; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; }) { const targetSessionKey = params.sessionKeyOverride ?? sessionKey; @@ -243,6 +245,8 @@ async function runDispatch(params: { sessionKey: targetSessionKey, images: params.images, inboundAudio: false, + suppressUserDelivery: params.suppressUserDelivery, + suppressReplyLifecycle: params.suppressReplyLifecycle, sourceReplyDeliveryMode: params.sourceReplyDeliveryMode, shouldRouteToOriginating: params.shouldRouteToOriginating ?? false, ...(params.shouldRouteToOriginating @@ -437,6 +441,27 @@ describe("tryDispatchAcpReply", () => { expect(call?.text).toContain("reply privately unless you send explicitly"); }); + it("starts reply lifecycle for tool-only ACP turns while suppressing automatic delivery", async () => { + setReadyAcpResolution(); + mockVisibleTextTurn("hidden final"); + const onReplyStart = vi.fn(); + const { dispatcher } = createDispatcher(); + + const result = await runDispatch({ + bodyForAgent: "reply via message tool if needed", + dispatcher, + onReplyStart, + suppressUserDelivery: true, + suppressReplyLifecycle: false, + sourceReplyDeliveryMode: "message_tool_only", + }); + + expect(result?.queuedFinal).toBe(false); + expect(onReplyStart).toHaveBeenCalledTimes(1); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); + }); + it("edits ACP tool lifecycle updates in place when supported", async () => { setReadyAcpResolution(); mockToolLifecycleTurn("call-1"); diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index be212e3d145..85ce1c06ce2 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -315,6 +315,7 @@ export async function tryDispatchAcpReply(params: { sessionTtsAuto?: TtsAutoMode; ttsChannel?: string; suppressUserDelivery?: boolean; + suppressReplyLifecycle?: boolean; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; shouldRouteToOriginating: boolean; originatingChannel?: string; @@ -353,6 +354,7 @@ export async function tryDispatchAcpReply(params: { sessionTtsAuto: params.sessionTtsAuto, ttsChannel: params.ttsChannel, suppressUserDelivery: params.suppressUserDelivery, + suppressReplyLifecycle: params.suppressReplyLifecycle, shouldRouteToOriginating: params.shouldRouteToOriginating, originatingChannel: params.originatingChannel, originatingTo: params.originatingTo, diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 6c9ca805f50..53b1edc5a53 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -490,7 +490,6 @@ const automaticGroupReplyConfig = { }, } as const satisfies OpenClawConfig; let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig; -let resolveSourceReplyDeliveryMode: typeof import("./source-reply-delivery-mode.js").resolveSourceReplyDeliveryMode; let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe; let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook; type DispatchReplyArgs = Parameters< @@ -499,7 +498,6 @@ type DispatchReplyArgs = Parameters< beforeAll(async () => { ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); - ({ resolveSourceReplyDeliveryMode } = await import("./source-reply-delivery-mode.js")); await import("./dispatch-acp.js"); await import("./dispatch-acp-command-bypass.js"); await import("./dispatch-acp-tts.runtime.js"); @@ -3869,31 +3867,6 @@ describe("before_dispatch hook", () => { }); describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => { - it("resolves group source delivery from shared core config", () => { - expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "channel" } })).toBe( - "message_tool_only", - ); - expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "group" } })).toBe( - "message_tool_only", - ); - expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "direct" } })).toBe( - "automatic", - ); - expect( - resolveSourceReplyDeliveryMode({ - cfg: automaticGroupReplyConfig, - ctx: { ChatType: "group" }, - }), - ).toBe("automatic"); - expect( - resolveSourceReplyDeliveryMode({ - cfg: emptyConfig, - ctx: { ChatType: "channel" }, - requested: "automatic", - }), - ).toBe("automatic"); - }); - beforeEach(() => { resetInboundDedupe(); sessionBindingMocks.resolveByConversation.mockReset(); @@ -3971,6 +3944,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => isTailDispatch: true, sendPolicy: "deny", suppressUserDelivery: true, + suppressReplyLifecycle: true, }), expect.any(Object), ); @@ -4301,6 +4275,7 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => expect(hookMocks.runner.runReplyDispatch).toHaveBeenCalledWith( expect.objectContaining({ suppressUserDelivery: true, + suppressReplyLifecycle: false, sourceReplyDeliveryMode: "message_tool_only", sendPolicy: "allow", }), diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index bdf45b6c750..c239fcdd316 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -78,7 +78,7 @@ import { resolveEffectiveReplyRoute } from "./effective-reply-route.js"; import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js"; import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js"; import { resolveReplyRoutingDecision } from "./routing-policy.js"; -import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js"; +import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; let routeReplyRuntimePromise: Promise | null = null; @@ -592,20 +592,23 @@ export async function dispatchReplyFromConfig( undefined, chatType: sessionStoreEntry.entry?.chatType, }); - const sendPolicyDenied = sendPolicy === "deny"; - const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({ + const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ cfg, ctx, requested: params.replyOptions?.sourceReplyDeliveryMode, + sendPolicy, + suppressAcpChildUserDelivery, + explicitSuppressTyping: params.replyOptions?.suppressTyping === true, + shouldSuppressTyping, }); - const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only"; - const suppressDelivery = sendPolicyDenied || suppressAutomaticSourceDelivery; - const deliverySuppressionReason = sendPolicyDenied - ? "sendPolicy: deny" - : suppressAutomaticSourceDelivery - ? "sourceReplyDeliveryMode: message_tool_only" - : ""; - const suppressHookUserDelivery = suppressAcpChildUserDelivery || suppressDelivery; + const { + sourceReplyDeliveryMode, + suppressAutomaticSourceDelivery, + suppressDelivery, + deliverySuppressionReason, + suppressHookUserDelivery, + suppressHookReplyLifecycle, + } = sourceReplyPolicy; let pluginFallbackReason: | "plugin-bound-fallback-missing-plugin" @@ -856,6 +859,7 @@ export async function dispatchReplyFromConfig( sessionTtsAuto, ttsChannel: deliveryChannel, suppressUserDelivery: suppressHookUserDelivery, + suppressReplyLifecycle: suppressHookReplyLifecycle, sourceReplyDeliveryMode, shouldRouteToOriginating, originatingChannel: routeReplyChannel, @@ -1037,8 +1041,7 @@ export async function dispatchReplyFromConfig( }; const typing = resolveRunTypingPolicy({ requestedPolicy: params.replyOptions?.typingPolicy, - suppressTyping: - sendPolicyDenied || params.replyOptions?.suppressTyping === true || shouldSuppressTyping, + suppressTyping: sourceReplyPolicy.suppressTyping, originatingChannel: routeReplyChannel, systemEvent: shouldRouteToOriginating, }); @@ -1272,6 +1275,7 @@ export async function dispatchReplyFromConfig( sessionTtsAuto, ttsChannel: deliveryChannel, suppressUserDelivery: suppressHookUserDelivery, + suppressReplyLifecycle: suppressHookReplyLifecycle, sourceReplyDeliveryMode, shouldRouteToOriginating, originatingChannel: routeReplyChannel, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 868c2a2981b..1067265a091 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -326,6 +326,7 @@ export async function runPreparedReply( isHeartbeat, typingPolicy, suppressTyping, + sourceReplyDeliveryMode: opts?.sourceReplyDeliveryMode, }); const shouldInjectGroupIntro = Boolean( isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 6c9f1fa82bc..7e7786eeb8c 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -371,6 +371,28 @@ describe("resolveTypingMode", () => { }, expected: "message", }, + { + name: "message-tool-only group chat starts typing immediately", + input: { + configured: undefined, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + sourceReplyDeliveryMode: "message_tool_only" as const, + }, + expected: "instant", + }, + { + name: "configured group typing mode wins over message-tool-only default", + input: { + configured: "message" as const, + isGroupChat: true, + wasMentioned: false, + isHeartbeat: false, + sourceReplyDeliveryMode: "message_tool_only" as const, + }, + expected: "message", + }, { name: "default mentioned group chat", input: { diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts new file mode 100644 index 00000000000..b23b9c33bed --- /dev/null +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + resolveSourceReplyDeliveryMode, + resolveSourceReplyVisibilityPolicy, +} from "./source-reply-delivery-mode.js"; + +const emptyConfig = {} as OpenClawConfig; +const automaticGroupReplyConfig = { + messages: { + groupChat: { + visibleReplies: "automatic", + }, + }, +} as const satisfies OpenClawConfig; + +describe("resolveSourceReplyDeliveryMode", () => { + it("defaults groups and channels to message-tool-only delivery", () => { + expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "channel" } })).toBe( + "message_tool_only", + ); + expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "group" } })).toBe( + "message_tool_only", + ); + expect(resolveSourceReplyDeliveryMode({ cfg: emptyConfig, ctx: { ChatType: "direct" } })).toBe( + "automatic", + ); + }); + + it("honors config and explicit requested mode", () => { + expect( + resolveSourceReplyDeliveryMode({ + cfg: automaticGroupReplyConfig, + ctx: { ChatType: "group" }, + }), + ).toBe("automatic"); + expect( + resolveSourceReplyDeliveryMode({ + cfg: emptyConfig, + ctx: { ChatType: "channel" }, + requested: "automatic", + }), + ).toBe("automatic"); + }); +}); + +describe("resolveSourceReplyVisibilityPolicy", () => { + it("allows direct automatic delivery without suppressing typing", () => { + expect( + resolveSourceReplyVisibilityPolicy({ + cfg: emptyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + }), + ).toMatchObject({ + sourceReplyDeliveryMode: "automatic", + sendPolicyDenied: false, + suppressAutomaticSourceDelivery: false, + suppressDelivery: false, + suppressHookUserDelivery: false, + suppressHookReplyLifecycle: false, + suppressTyping: false, + deliverySuppressionReason: "", + }); + }); + + it("suppresses automatic source delivery for default group turns without suppressing typing", () => { + expect( + resolveSourceReplyVisibilityPolicy({ + cfg: emptyConfig, + ctx: { ChatType: "group" }, + sendPolicy: "allow", + }), + ).toMatchObject({ + sourceReplyDeliveryMode: "message_tool_only", + sendPolicyDenied: false, + suppressAutomaticSourceDelivery: true, + suppressDelivery: true, + suppressHookUserDelivery: true, + suppressHookReplyLifecycle: false, + suppressTyping: false, + deliverySuppressionReason: "sourceReplyDeliveryMode: message_tool_only", + }); + }); + + it("keeps configured automatic group delivery visible", () => { + expect( + resolveSourceReplyVisibilityPolicy({ + cfg: automaticGroupReplyConfig, + ctx: { ChatType: "channel" }, + sendPolicy: "allow", + }), + ).toMatchObject({ + sourceReplyDeliveryMode: "automatic", + suppressAutomaticSourceDelivery: false, + suppressDelivery: false, + suppressHookReplyLifecycle: false, + suppressTyping: false, + }); + }); + + it("supports explicit message-tool-only delivery for direct chats without suppressing typing", () => { + expect( + resolveSourceReplyVisibilityPolicy({ + cfg: emptyConfig, + ctx: { ChatType: "direct" }, + requested: "message_tool_only", + sendPolicy: "allow", + }), + ).toMatchObject({ + sourceReplyDeliveryMode: "message_tool_only", + suppressAutomaticSourceDelivery: true, + suppressDelivery: true, + suppressHookReplyLifecycle: false, + suppressTyping: false, + deliverySuppressionReason: "sourceReplyDeliveryMode: message_tool_only", + }); + }); + + it("lets sendPolicy deny suppress delivery and typing", () => { + expect( + resolveSourceReplyVisibilityPolicy({ + cfg: emptyConfig, + ctx: { ChatType: "group" }, + sendPolicy: "deny", + }), + ).toMatchObject({ + sourceReplyDeliveryMode: "message_tool_only", + sendPolicyDenied: true, + suppressDelivery: true, + suppressHookUserDelivery: true, + suppressHookReplyLifecycle: true, + suppressTyping: true, + deliverySuppressionReason: "sendPolicy: deny", + }); + }); + + it("keeps explicit typing suppression separate from delivery suppression", () => { + expect( + resolveSourceReplyVisibilityPolicy({ + cfg: emptyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + explicitSuppressTyping: true, + }), + ).toMatchObject({ + sourceReplyDeliveryMode: "automatic", + suppressDelivery: false, + suppressHookUserDelivery: false, + suppressHookReplyLifecycle: true, + suppressTyping: true, + }); + }); + + it("keeps ACP child user delivery suppression separate from source delivery", () => { + expect( + resolveSourceReplyVisibilityPolicy({ + cfg: emptyConfig, + ctx: { ChatType: "direct" }, + sendPolicy: "allow", + suppressAcpChildUserDelivery: true, + }), + ).toMatchObject({ + sourceReplyDeliveryMode: "automatic", + suppressDelivery: false, + suppressHookUserDelivery: true, + suppressHookReplyLifecycle: true, + suppressTyping: false, + }); + }); +}); diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index c0d48be72d5..d7b35c7d1b7 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -1,5 +1,6 @@ import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { SessionSendPolicyDecision } from "../../sessions/send-policy.js"; import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; export type SourceReplyDeliveryModeContext = { @@ -22,3 +23,56 @@ export function resolveSourceReplyDeliveryMode(params: { } return "automatic"; } + +export type SourceReplyVisibilityPolicy = { + sourceReplyDeliveryMode: SourceReplyDeliveryMode; + sendPolicyDenied: boolean; + suppressAutomaticSourceDelivery: boolean; + suppressDelivery: boolean; + suppressHookUserDelivery: boolean; + suppressHookReplyLifecycle: boolean; + suppressTyping: boolean; + deliverySuppressionReason: string; +}; + +export function resolveSourceReplyVisibilityPolicy(params: { + cfg: OpenClawConfig; + ctx: SourceReplyDeliveryModeContext; + requested?: SourceReplyDeliveryMode; + sendPolicy: SessionSendPolicyDecision; + suppressAcpChildUserDelivery?: boolean; + explicitSuppressTyping?: boolean; + shouldSuppressTyping?: boolean; +}): SourceReplyVisibilityPolicy { + const sourceReplyDeliveryMode = resolveSourceReplyDeliveryMode({ + cfg: params.cfg, + ctx: params.ctx, + requested: params.requested, + }); + const sendPolicyDenied = params.sendPolicy === "deny"; + const suppressAutomaticSourceDelivery = sourceReplyDeliveryMode === "message_tool_only"; + const suppressDelivery = sendPolicyDenied || suppressAutomaticSourceDelivery; + const deliverySuppressionReason = sendPolicyDenied + ? "sendPolicy: deny" + : suppressAutomaticSourceDelivery + ? "sourceReplyDeliveryMode: message_tool_only" + : ""; + + return { + sourceReplyDeliveryMode, + sendPolicyDenied, + suppressAutomaticSourceDelivery, + suppressDelivery, + suppressHookUserDelivery: params.suppressAcpChildUserDelivery === true || suppressDelivery, + suppressHookReplyLifecycle: + sendPolicyDenied || + params.suppressAcpChildUserDelivery === true || + params.explicitSuppressTyping === true || + params.shouldSuppressTyping === true, + suppressTyping: + sendPolicyDenied || + params.explicitSuppressTyping === true || + params.shouldSuppressTyping === true, + deliverySuppressionReason, + }; +} diff --git a/src/auto-reply/reply/typing-mode.ts b/src/auto-reply/reply/typing-mode.ts index 42143bc5b85..8b90552f131 100644 --- a/src/auto-reply/reply/typing-mode.ts +++ b/src/auto-reply/reply/typing-mode.ts @@ -1,5 +1,6 @@ import type { TypingMode } from "../../config/types.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { TypingPolicy } from "../types.js"; import type { TypingController } from "./typing.js"; @@ -11,6 +12,7 @@ export type TypingModeContext = { isHeartbeat: boolean; typingPolicy?: TypingPolicy; suppressTyping?: boolean; + sourceReplyDeliveryMode?: SourceReplyDeliveryMode; }; export const DEFAULT_GROUP_TYPING_MODE: TypingMode = "message"; @@ -22,6 +24,7 @@ export function resolveTypingMode({ isHeartbeat, typingPolicy, suppressTyping, + sourceReplyDeliveryMode, }: TypingModeContext): TypingMode { if ( isHeartbeat || @@ -35,6 +38,9 @@ export function resolveTypingMode({ if (configured) { return configured; } + if (sourceReplyDeliveryMode === "message_tool_only") { + return "instant"; + } if (!isGroupChat || wasMentioned) { return "instant"; } diff --git a/src/plugin-sdk/acp-runtime-backend.ts b/src/plugin-sdk/acp-runtime-backend.ts index 276a7257156..24bfe25b053 100644 --- a/src/plugin-sdk/acp-runtime-backend.ts +++ b/src/plugin-sdk/acp-runtime-backend.ts @@ -90,6 +90,7 @@ export async function tryDispatchAcpReplyHook( sessionTtsAuto: event.sessionTtsAuto, ttsChannel: event.ttsChannel, suppressUserDelivery: event.suppressUserDelivery, + suppressReplyLifecycle: event.suppressReplyLifecycle === true || event.sendPolicy === "deny", sourceReplyDeliveryMode: event.sourceReplyDeliveryMode, shouldRouteToOriginating: event.shouldRouteToOriginating, originatingChannel: event.originatingChannel, diff --git a/src/plugin-sdk/acp-runtime.test.ts b/src/plugin-sdk/acp-runtime.test.ts index 4f25d35e09c..92953502215 100644 --- a/src/plugin-sdk/acp-runtime.test.ts +++ b/src/plugin-sdk/acp-runtime.test.ts @@ -145,6 +145,7 @@ describe("tryDispatchAcpReplyHook", () => { expect(dispatchMock).toHaveBeenCalledWith( expect.objectContaining({ suppressUserDelivery: true, + suppressReplyLifecycle: true, bypassForCommand: false, }), ); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 0dde76c27d5..a2aa8ff6ee5 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -357,6 +357,7 @@ export type PluginHookReplyDispatchEvent = { sessionTtsAuto?: TtsAutoMode; ttsChannel?: string; suppressUserDelivery?: boolean; + suppressReplyLifecycle?: boolean; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; shouldRouteToOriginating: boolean; originatingChannel?: string;