diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 05f2767c5dd1..9c61b2bd443d 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -843,6 +843,40 @@ describe("createFollowupRunner reply-lane admission", () => { ); }); + it("suppresses preflight compaction failure notices for queued room events", async () => { + runPreflightCompactionIfNeededMock.mockRejectedValueOnce( + new Error("Preflight compaction required but failed: auth profile mismatch"), + ); + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + sessionKey: "main", + defaultModel: "anthropic/claude", + }); + + await runner( + createQueuedRun({ + currentInboundEventKind: "room_event", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "acct-1", + originatingThreadId: "thread-1", + originatingChatType: "group", + run: { + messageProvider: "discord", + provider: "anthropic", + model: "claude", + verboseLevel: "off", + sessionKey: "main", + sourceReplyDeliveryMode: "message_tool_only", + }, + }), + ); + + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); + expect(routeReplyMock).not.toHaveBeenCalled(); + }); + it("preserves non-compaction preflight failures for queued followup runs", async () => { runPreflightCompactionIfNeededMock.mockRejectedValueOnce(new Error("session load failed")); const runner = createFollowupRunner({ @@ -1424,6 +1458,7 @@ describe("createFollowupRunner runtime config", () => { }), ); + expect(runCliAgentMock).toHaveBeenCalledOnce(); expect(routeReplyMock).toHaveBeenCalledWith( expect.objectContaining({ payload: { text: "persisted CLI followup" }, @@ -2353,6 +2388,179 @@ describe("createFollowupRunner progress forwarding", () => { ); }); + it("keeps queued room-event verbose tool summaries suppressed", async () => { + const queued = createQueuedRun({ + currentInboundEventKind: "room_event", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "acct-1", + originatingThreadId: "thread-1", + run: { + messageProvider: "discord", + sourceReplyDeliveryMode: "message_tool_only", + verboseLevel: "on", + }, + }); + + runEmbeddedAgentMock.mockImplementationOnce( + async (args: { + onToolResult?: (payload: { text: string }) => Promise; + shouldEmitToolResult?: () => boolean; + }) => { + expect(args.shouldEmitToolResult?.()).toBe(true); + await args.onToolResult?.({ text: "🛠️ Exec: echo ambient-progress" }); + return { payloads: [], meta: { agentMeta: {} } }; + }, + ); + + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + defaultModel: "claude", + }); + + await runner(queued); + + expect(routeReplyMock).not.toHaveBeenCalled(); + }); + + it("delivers queued fast auto progress for non-room-event message-tool-only turns", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const realAgentEvents = await vi.importActual( + "../../infra/agent-events.js", + ); + const runtimeConfig: OpenClawConfig = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { command: "claude" }, + }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }; + runCliAgentMock.mockImplementationOnce((params: { runId?: string }) => { + realAgentEvents.emitAgentEvent({ + runId: params.runId ?? "run-fast-followup", + stream: "tool", + data: { phase: "start", name: "bash", toolCallId: "call-1" }, + }); + vi.setSystemTime(7_100); + realAgentEvents.emitAgentEvent({ + runId: params.runId ?? "run-fast-followup", + stream: "tool", + data: { phase: "result", name: "bash", toolCallId: "call-1" }, + }); + return { payloads: [], meta: { agentMeta: {} } }; + }); + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-7", + }); + + await runner( + createQueuedRun({ + currentInboundEventKind: "user_request", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "acct-1", + originatingThreadId: "thread-1", + run: { + config: runtimeConfig, + messageProvider: "discord", + provider: "anthropic", + model: "claude-opus-4-7", + sourceReplyDeliveryMode: "message_tool_only", + fastMode: "auto", + fastModeOverride: true, + fastModeAutoOnSeconds: 5, + fastModeAutoOnSecondsOverride: true, + }, + }), + ); + + expect(routeReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "discord", + to: "channel:C1", + accountId: "acct-1", + threadId: "thread-1", + mirror: false, + replyKind: "tool", + payload: expect.objectContaining({ + text: "💨Fast: auto-off(6s>=5s)", + channelData: { openclawProgressKind: "fast-mode-auto" }, + }), + }), + ); + }); + + it("suppresses queued fast auto progress for room-event message-tool-only turns", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const realAgentEvents = await vi.importActual( + "../../infra/agent-events.js", + ); + const runtimeConfig: OpenClawConfig = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { command: "claude" }, + }, + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }; + runCliAgentMock.mockImplementationOnce((params: { runId?: string }) => { + realAgentEvents.emitAgentEvent({ + runId: params.runId ?? "run-fast-followup", + stream: "tool", + data: { phase: "start", name: "bash", toolCallId: "call-1" }, + }); + vi.setSystemTime(7_100); + realAgentEvents.emitAgentEvent({ + runId: params.runId ?? "run-fast-followup", + stream: "tool", + data: { phase: "result", name: "bash", toolCallId: "call-1" }, + }); + return { payloads: [], meta: { agentMeta: {} } }; + }); + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + defaultModel: "anthropic/claude-opus-4-7", + }); + + await runner( + createQueuedRun({ + currentInboundEventKind: "room_event", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "acct-1", + originatingThreadId: "thread-1", + run: { + config: runtimeConfig, + messageProvider: "discord", + provider: "anthropic", + model: "claude-opus-4-7", + sourceReplyDeliveryMode: "message_tool_only", + fastMode: "auto", + fastModeOverride: true, + fastModeAutoOnSeconds: 5, + fastModeAutoOnSecondsOverride: true, + }, + }), + ); + + expect(routeReplyMock).not.toHaveBeenCalled(); + }); + it("drains fire-and-forget queued tool progress before final delivery", async () => { const queued = createQueuedRun({ originatingChannel: "discord", @@ -4091,6 +4299,24 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { expectNoBlockReplyText(onBlockReply, "second payload"); }); + it("suppresses cross-channel route-failure notices for room events", async () => { + routeReplyMock.mockResolvedValue({ + ok: false, + error: "forced route failure", + }); + const queued = baseQueuedRun("webchat"); + queued.currentInboundEventKind = "room_event"; + queued.originatingChannel = "discord"; + queued.originatingTo = "channel:C1"; + const { onBlockReply } = await runMessagingCase({ + agentResult: { payloads: [{ text: "hello world!" }, { text: "second payload" }] }, + queued, + }); + + expect(routeReplyMock).toHaveBeenCalledTimes(2); + expect(onBlockReply).not.toHaveBeenCalled(); + }); + it("does not emit cross-channel route-failure notice when a later payload routes", async () => { routeReplyMock .mockResolvedValueOnce({ @@ -4433,6 +4659,47 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { }); }); + it("suppresses queued compaction notices for room events", async () => { + runPreflightCompactionIfNeededMock.mockImplementationOnce( + async (params: { + onCompactionNotice?: (phase: "start" | "end") => Promise | void; + sessionEntry?: SessionEntry; + }) => { + await params.onCompactionNotice?.("start"); + await params.onCompactionNotice?.("end"); + return params.sessionEntry; + }, + ); + runEmbeddedAgentMock.mockResolvedValueOnce({ + payloads: [], + meta: {}, + }); + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + defaultModel: "openai/gpt-5.5", + }); + + await runner( + createQueuedRun({ + currentInboundEventKind: "room_event", + originatingChannel: "discord", + originatingTo: "channel:C1", + messageId: "current-msg-1", + run: { + config: { + channels: { discord: { replyToMode: "all" } }, + agents: { defaults: { compaction: { notifyUser: true } } }, + }, + messageProvider: "discord", + sourceReplyDeliveryMode: "message_tool_only", + }, + }), + ); + + expect(routeReplyMock).not.toHaveBeenCalled(); + }); + it("routes queued compaction hook messages alongside notifyUser notices (#90185)", async () => { runEmbeddedAgentMock.mockImplementationOnce( async (args: { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index afe62c2b2ffb..00ce28f58116 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -482,6 +482,10 @@ export function createFollowupRunner(params: { !routedAnyCrossChannelPayloadToOrigin && opts?.onBlockReply ) { + if (queued.currentInboundEventKind === "room_event") { + logVerbose("followup queue: cross-channel failure notice suppressed for room_event"); + return; + } await sendDispatcherPayload({ text: "Follow-up completed, but OpenClaw could not deliver it to the originating " + @@ -560,6 +564,7 @@ export function createFollowupRunner(params: { shouldEmitVerboseProgress() && !shouldSuppressDefaultToolProgressMessages(); const shouldEmitToolOutputProgress = () => resolveCurrentVerboseLevel() === "full" && !shouldSuppressDefaultToolProgressMessages(); + const isRoomEventFollowup = () => queued.currentInboundEventKind === "room_event"; let observedVisibleToolErrorProgress = false; const markVisibleToolErrorProgress = () => { if (resolveCurrentVerboseLevel() === "on" && shouldEmitToolResultProgress()) { @@ -653,6 +658,10 @@ export function createFollowupRunner(params: { modelId: fallbackModel, }, ) => { + if (isRoomEventFollowup()) { + logVerbose("followup queue: compaction notice suppressed for room_event"); + return; + } const noticePayloads = resolveFollowupDeliveryPayloads({ cfg: runtimeConfig, payloads: [payload], @@ -719,6 +728,12 @@ export function createFollowupRunner(params: { includeDetails: run.verboseLevel === "on" || run.verboseLevel === "full", }); if (preflightCompactionFailureText) { + if (isRoomEventFollowup()) { + logVerbose( + "followup queue: preflight compaction failure notice suppressed for room_event", + ); + return; + } await sendFollowupPayloads( [ markReplyPayloadForSourceSuppressionDelivery({ @@ -932,6 +947,11 @@ export function createFollowupRunner(params: { // summary tracker so both runners deliver identical durable summaries. const deliverFollowupToolSummary = (payload: ReplyPayload) => enqueueProgressDelivery(async () => { + // room_event turns are ambient; only an explicit message tool call + // may post back into the source chat. + if (isRoomEventFollowup()) { + return; + } if ( run.sourceReplyDeliveryMode === "message_tool_only" && !shouldEmitToolResultProgress() @@ -1013,6 +1033,11 @@ export function createFollowupRunner(params: { : undefined, onFastModeAutoProgress: async (payload) => { await enqueueProgressDelivery(async () => { + // Mirrors direct dispatch progress suppression: ambient + // room events never get automatic fast-mode notices. + if (isRoomEventFollowup()) { + return; + } await sendFollowupPayloads( [payload], effectiveQueued,