fix(auto-reply): suppress room-event followup notices

This commit is contained in:
Ayaan Zaidi
2026-07-01 22:21:15 -07:00
parent dbccddbf34
commit 519158c576
2 changed files with 292 additions and 0 deletions
@@ -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<void>;
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<typeof import("../../infra/agent-events.js")>(
"../../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<typeof import("../../infra/agent-events.js")>(
"../../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> | 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: {
+25
View File
@@ -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,