From 885e690f3e6c7ec14ce784319002e762e5bb0ca9 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 27 Jul 2026 21:07:12 +0900 Subject: [PATCH] fix(auto-reply): never allow silent replies for mentioned group turns The default group silentReply policy sanctions silence for ambient chatter, which also swallowed empty completions on turns that explicitly mentioned the bot - the reported production failure. A mention is a directed turn: treat it like a direct chat and let the no-visible-reply fallback fire. Proven live: group mention + empty completion delivers exactly one fallback message; ambient group turns stay silent; normal replies produce no fallback. --- ...config.hooks-and-send-policy.test-utils.ts | 50 +++++++++++++++++++ .../dispatch-from-config.prepare-context.ts | 4 ++ 2 files changed, 54 insertions(+) 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,