diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index 91a0a87bea65..747b8704b879 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 4a2d5dcf81b5..5f27ff777cde 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -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", () => { 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 fc399771a3f3..7063cbc7032a 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts @@ -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 || diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index ae1826f36de9..0225690e01ca 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -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; } diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index a81386720878..f919aa419947 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -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 ? { diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 5d7554044fae..bd6dc36af275 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -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([ diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index b779bb1e8340..33aee069db57 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -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, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index f127bec8d5dd..3073bec7dec2 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -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. */ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index e476f03b2b88..0b9a92b63c81 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -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);