fix(auto-reply): restore ambient room event silence (#126527)

Restore intentional silence for undirected room events while retaining visible-answer recovery for mentions, commands, and ordinary requests.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-20 09:26:40 +05:30
committed by GitHub
parent 916eef4e99
commit ade0fb2189
9 changed files with 59 additions and 20 deletions
@@ -111,6 +111,7 @@ export function buildEmbeddedRunBaseParams(params: {
enforceFinalTag,
silentExpected: params.run.silentExpected,
allowEmptyAssistantReplyAsSilent: params.run.allowEmptyAssistantReplyAsSilent,
terminalReplyExpectation: params.run.terminalReplyExpectation,
silentReplyPromptMode: params.run.silentReplyPromptMode,
sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode,
clientCaps: params.run.clientCaps,
@@ -148,6 +148,7 @@ describe("agent-runner-utils", () => {
enforceFinalTag: true,
cwd: "/tmp/task-repo",
taskSuggestionDeliveryMode: "gateway",
terminalReplyExpectation: "optional",
trustedInternalHandoff: {
kind: "subagent-completion",
sourceSessionKey: "agent:child",
@@ -202,6 +203,7 @@ describe("agent-runner-utils", () => {
expect(resolved.runId).toBe("run-1");
expect(resolved.promptCacheKey).toBe("webchat-cache-key");
expect(resolved.taskSuggestionDeliveryMode).toBe("gateway");
expect(resolved.terminalReplyExpectation).toBe("optional");
});
it("threads prompt cache affinity through embedded execution params", () => {
@@ -48,6 +48,7 @@ import { waitForReplyDispatcherIdle } from "./reply-dispatcher.js";
import { isDuplicateRestartRecoverySource } from "./restart-recovery-claim.js";
import { resolveStableMessageToolAvailability } from "./session-stable-reply-mode.js";
import {
isDirectedSourceReplyTurn,
isExplicitSourceReplyCommand,
isUnauthorizedTextSlashCommand,
resolveSourceReplyVisibilityPolicy,
@@ -378,18 +379,7 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli
const explicitCommandTurnCtx = isExplicitSourceReplyCommand(ctx, cfg);
const unauthorizedTextSlashSourceReplyCtx =
(chatType === "group" || chatType === "channel") && isUnauthorizedTextSlashCommand(ctx);
// The no-visible-reply fallback exists for a user who asked and got nothing.
// Only positively directed turns qualify: direct chats, explicit mentions
// (channels fold reply-to-bot into WasMentioned), and command turns. Ambient
// group chatter, room events, and turns whose classification facts were lost
// upstream (queued followups, rebuilt contexts) can never draw a visible
// failure notice, even when silence policy is disallow. A command turn is the
// one directed room_event (mirrors the room_event source-reply suppression
// bypass below); every other room_event stays undirected regardless of a
// stray WasMentioned/direct classification.
const noVisibleReplyFallbackDirected =
explicitCommandTurnCtx ||
(ctx.InboundEventKind !== "room_event" && (chatType === "direct" || ctx.WasMentioned === true));
const noVisibleReplyFallbackDirected = isDirectedSourceReplyTurn(ctx, cfg, chatType === "direct");
const shouldDeliverPluginBindingReply =
!suppressAutomaticSourceDelivery ||
explicitCommandTurnCtx ||
+13 -5
View File
@@ -53,7 +53,7 @@ import {
} from "./session-reset-prompt.js";
import { resolveSessionStableReplyMode } from "./session-stable-reply-mode.js";
import {
isExplicitSourceReplyCommand,
isDirectedSourceReplyTurn,
isSyntheticSourceReplyTurn,
} from "./source-reply-delivery-mode.js";
import { shouldApplyStartupContext, buildSessionStartupContextPrelude } from "./startup-context.js";
@@ -223,11 +223,18 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
sourceConversationContextByMode[sessionPromptSourceReplyDeliveryMode ?? "automatic"];
// Claude CLI fixes the system prompt at session creation; group intro must stay session-stable.
const groupIntro = isGroupChat ? buildGroupIntro({ sessionEntry, defaultActivation }) : "";
const isDirectedTurn =
isExplicitSourceReplyCommand(ctx, cfg) ||
(inboundEventKind !== "room_event" && (isDirectChat || ctx.WasMentioned === true));
const isDirectedTurn = isDirectedSourceReplyTurn(ctx, cfg, isDirectChat, inboundEventKind);
const isAmbientRoomEvent = inboundEventKind === "room_event" && !isDirectedTurn;
const allowEmptyAssistantReplyAsSilent =
isGroupChat && !isDirectedTurn && silentReplySettings.policy === "allow";
isGroupChat &&
!isDirectedTurn &&
(isAmbientRoomEvent || silentReplySettings.policy === "allow");
// Heartbeats retain the embedded runner's trigger-owned optional default.
const terminalReplyExpectation = isHeartbeat
? undefined
: isAmbientRoomEvent
? "optional"
: "required";
const groupSystemPrompt = normalizeOptionalString(promptSessionCtx.GroupSystemPrompt) ?? "";
const inboundMetaPrompt = buildInboundMetaSystemPrompt(
isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined },
@@ -476,6 +483,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) {
getInboundContext: () => ({ activeGoalContext, inboundUserContext }),
refreshInboundContextAfterAdmissionWait,
allowEmptyAssistantReplyAsSilent,
terminalReplyExpectation,
} as const;
}
@@ -106,6 +106,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
shouldInjectGroupIntro,
typingMode,
allowEmptyAssistantReplyAsSilent,
terminalReplyExpectation,
} = context;
const {
ctx,
@@ -497,6 +498,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission)
cliSessionBindingFacts,
skipProviderRuntimeHints: useFastReplyRuntime,
allowEmptyAssistantReplyAsSilent,
terminalReplyExpectation,
suppressTranscriptOnlyAssistantPersistence: isRoomEvent,
...(opts?.skillWorkshopProposalRevision
? {
@@ -622,6 +622,7 @@ describe("runPreparedReply media-only handling", () => {
let call = requireLastRunReplyAgentCall();
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
expect(call?.followupRun.run.terminalReplyExpectation).toBe("required");
await runPrepared({
defaultActivation: "mention",
@@ -629,6 +630,7 @@ describe("runPreparedReply media-only handling", () => {
call = requireLastRunReplyAgentCall();
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
expect(call?.followupRun.run.terminalReplyExpectation).toBe("required");
});
it.each([
@@ -658,9 +660,10 @@ describe("runPreparedReply media-only handling", () => {
const call = requireLastRunReplyAgentCall();
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false);
expect(call?.followupRun.run.terminalReplyExpectation).toBe("required");
});
it("keeps empty-assistant silence available for ambient room events", async () => {
it("keeps empty-assistant silence optional for ambient room events", async () => {
const defaults = baseParams();
await runPrepared({
ctx: {
@@ -673,10 +676,20 @@ describe("runPreparedReply media-only handling", () => {
InboundEventKind: "room_event",
WasMentioned: true,
},
cfg: {
agents: {
defaults: {
silentReply: { group: "disallow" },
},
},
},
});
const call = requireLastRunReplyAgentCall();
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
expect(call.followupRun.run).toMatchObject({
allowEmptyAssistantReplyAsSilent: true,
terminalReplyExpectation: "optional",
});
});
it("hydrates runtime thinking metadata before trusting static provider support", async () => {
@@ -2420,6 +2433,7 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.shouldSteer).toBe(false);
expect(call?.shouldFollowup).toBe(true);
expect(call?.isActive).toBe(true);
expect(call?.followupRun.run.terminalReplyExpectation).toBeUndefined();
});
it.each([
+1
View File
@@ -280,6 +280,7 @@ export function resolveFollowupDeliveryContextKey(run: FollowupRun): string {
execution.skipProviderRuntimeHints === true,
execution.silentExpected === true,
execution.allowEmptyAssistantReplyAsSilent === true,
execution.terminalReplyExpectation ?? "",
execution.suppressNextUserMessagePersistence === true,
execution.suppressTranscriptOnlyAssistantPersistence === true,
execution.blockReplyBreak,
+5 -1
View File
@@ -4,7 +4,10 @@ import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema
import type { AutoFallbackPrimaryProbe } from "../../../agents/agent-scope.js";
import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
import type { CliSessionBindingFacts } from "../../../agents/cli-runner/types.js";
import type { CurrentInboundPromptContext } from "../../../agents/embedded-agent-runner/run/params.js";
import type {
CurrentInboundPromptContext,
RunEmbeddedAgentParams,
} from "../../../agents/embedded-agent-runner/run/params.js";
import type { ModelFallbackRouteResolution } from "../../../agents/model-fallback.types.js";
import type { ScheduledToolPolicyContext } from "../../../agents/scheduled-tool-policy.js";
import type { TrustedSubagentCompletionHandoff } from "../../../agents/subagents/announce/subagent-announce-handoff.js";
@@ -230,6 +233,7 @@ export type FollowupRun = {
skipProviderRuntimeHints?: boolean;
silentExpected?: boolean;
allowEmptyAssistantReplyAsSilent?: boolean;
terminalReplyExpectation?: RunEmbeddedAgentParams["terminalReplyExpectation"];
suppressNextUserMessagePersistence?: boolean;
suppressTranscriptOnlyAssistantPersistence?: boolean;
/** Gateway-private optimistic-concurrency constraint for an operator-requested proposal revision. */
@@ -21,6 +21,7 @@ export type SourceReplyDeliveryModeContext = {
CommandSource?: "text" | "native";
CommandTurn?: CommandTurnContext;
BotUsername?: string;
WasMentioned?: boolean;
InputProvenance?: InputProvenance;
};
@@ -43,6 +44,22 @@ export function isExplicitSourceReplyCommand(
return isExplicitCommandTurnContext(ctx, cfg);
}
/**
* Room events remain ambient despite stale mention/direct facts. Explicit commands stay directed
* because their parsed command context is authoritative.
*/
export function isDirectedSourceReplyTurn(
ctx: SourceReplyDeliveryModeContext,
cfg: OpenClawConfig,
isDirectChat: boolean,
inboundEventKind = ctx.InboundEventKind,
): boolean {
return (
isExplicitSourceReplyCommand(ctx, cfg) ||
(inboundEventKind !== "room_event" && (isDirectChat || ctx.WasMentioned === true))
);
}
/** Returns true for text slash commands that lack authorization metadata. */
export function isUnauthorizedTextSlashCommand(ctx: SourceReplyDeliveryModeContext): boolean {
const commandTurn = resolveCommandTurnContext(ctx);