diff --git a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts index f2732a9b03c1..3d2c0620dd83 100644 --- a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts @@ -425,6 +425,56 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => expect(result.noVisibleReplyFallbackEligible).toBeUndefined(); }); + it("delivers fallback for mentioned group turns even when group silence is allowed", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => undefined); + const ctx = buildTestCtx({ + ChatType: "group", + Surface: "telegram", + Provider: "telegram", + SessionKey: "agent:main:telegram:group:oc_group", + WasMentioned: true, + }); + + // No silentReply config: group default is "allow", but an explicit mention + // is a directed turn and must never end silently. + const result = await dispatchReplyFromConfig({ + ctx, + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ + text: NO_VISIBLE_REPLY_FALLBACK_TEXT, + }); + expect(result.noVisibleReplyFallbackDelivered).toBe(true); + }); + + it("keeps ambient group turns silent under the default group policy", async () => { + setNoAbort(); + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => undefined); + const ctx = buildTestCtx({ + ChatType: "group", + Surface: "telegram", + Provider: "telegram", + SessionKey: "agent:main:telegram:group:oc_group", + }); + + const result = await dispatchReplyFromConfig({ + ctx, + cfg: emptyConfig, + dispatcher, + replyResolver, + }); + + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + expect(result.noVisibleReplyFallbackDelivered).toBeUndefined(); + expect(result.noVisibleReplyFallbackEligible).toBeUndefined(); + }); + it("does not deliver no-visible fallback when silentReply allows empty finals", async () => { setNoAbort(); const dispatcher = createDispatcher(); diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts index a3451094dc97..7eaca7ddd086 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts @@ -224,7 +224,11 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli const chatType = normalizeChatType(ctx.ChatType); const silentReplyConversationType = resolveRoutedPolicyConversationType(ctx); const silentReplySurface = normalizeLowercaseStringOrEmpty(ctx.Surface ?? ctx.Provider); + // Group silent-reply policy sanctions silence for ambient chatter only. A turn + // that explicitly addressed the bot (mention) must never end silently, matching + // the hard-coded direct-chat rule in resolveSilentReplyPolicyFromPolicies. const emptyFinalAllowedAsSilent = + ctx.WasMentioned !== true && silentReplyConversationType !== undefined && resolveSilentReplyPolicyFromPolicies({ conversationType: silentReplyConversationType,