Skip to content

Commit 47056e8

Browse files
author
OpenClaw Assistant
committed
Warn on suppressed message-tool-only finals
1 parent 6252f2a commit 47056e8

2 files changed

Lines changed: 266 additions & 2 deletions

File tree

src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22
import { clearAgentHarnesses } from "../../agents/harness/registry.js";
3+
import {
4+
onInternalDiagnosticEvent,
5+
resetDiagnosticEventsForTest,
6+
type DiagnosticEventPayload,
7+
} from "../../infra/diagnostic-events.js";
38
import type { PluginHookReplyDispatchResult } from "../../plugins/hooks.js";
49
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
510
import {
@@ -33,6 +38,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
3338
clearAgentHarnesses();
3439
setDiscordTestRegistry();
3540
resetInboundDedupe();
41+
resetDiagnosticEventsForTest();
3642
mocks.routeReply.mockReset().mockResolvedValue({ ok: true, messageId: "mock" });
3743
mocks.tryFastAbortFromMessage.mockReset().mockResolvedValue({
3844
handled: false,
@@ -200,6 +206,199 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
200206
expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined();
201207
});
202208

209+
it("audits substantive finals suppressed by message-tool-only delivery", async () => {
210+
hookMocks.runner.hasHooks.mockReturnValue(false);
211+
const diagnosticEvents: DiagnosticEventPayload[] = [];
212+
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
213+
diagnosticEvents.push(event);
214+
});
215+
216+
try {
217+
const result = await dispatchReplyFromConfig({
218+
ctx: {
219+
...createHookCtx(),
220+
ChatType: "channel",
221+
Surface: "discord",
222+
Provider: "discord",
223+
},
224+
cfg: emptyConfig,
225+
dispatcher: createDispatcher(),
226+
replyResolver: async () => ({ text: "model reply" }),
227+
});
228+
229+
await new Promise<void>((resolve) => setImmediate(resolve));
230+
const auditEvent = diagnosticEvents.find(
231+
(event) =>
232+
event.type === "log.record" &&
233+
event.message.includes("Normal assistant final reply was suppressed"),
234+
);
235+
236+
expect(result.queuedFinal).toBe(false);
237+
expect(auditEvent?.type).toBe("log.record");
238+
if (auditEvent?.type === "log.record") {
239+
expect(auditEvent.level).toBe("warning");
240+
expect(auditEvent.attributes).toMatchObject({
241+
channel: "discord",
242+
sessionKey: "agent:test:session",
243+
sourceReplyDeliveryMode: "message_tool_only",
244+
suppressedFinalCount: 1,
245+
suppressedFinalTextChars: 11,
246+
sendPolicyDenied: false,
247+
});
248+
expect(auditEvent.message).not.toContain("model reply");
249+
}
250+
} finally {
251+
stopDiagnostics();
252+
}
253+
});
254+
255+
it("does not audit silent NO_REPLY finals suppressed by message-tool-only delivery", async () => {
256+
hookMocks.runner.hasHooks.mockReturnValue(false);
257+
const diagnosticEvents: DiagnosticEventPayload[] = [];
258+
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
259+
diagnosticEvents.push(event);
260+
});
261+
262+
try {
263+
await dispatchReplyFromConfig({
264+
ctx: {
265+
...createHookCtx(),
266+
ChatType: "channel",
267+
Surface: "discord",
268+
Provider: "discord",
269+
},
270+
cfg: emptyConfig,
271+
dispatcher: createDispatcher(),
272+
replyResolver: async () => ({ text: "NO_REPLY" }),
273+
});
274+
275+
await new Promise<void>((resolve) => setImmediate(resolve));
276+
expect(
277+
diagnosticEvents.some(
278+
(event) =>
279+
event.type === "log.record" &&
280+
event.message.includes("Normal assistant final reply was suppressed"),
281+
),
282+
).toBe(false);
283+
} finally {
284+
stopDiagnostics();
285+
}
286+
});
287+
288+
it("does not audit suppressed finals when send policy denies delivery", async () => {
289+
hookMocks.runner.hasHooks.mockReturnValue(false);
290+
const diagnosticEvents: DiagnosticEventPayload[] = [];
291+
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
292+
diagnosticEvents.push(event);
293+
});
294+
295+
try {
296+
await dispatchReplyFromConfig({
297+
ctx: {
298+
...createHookCtx(),
299+
ChatType: "channel",
300+
Surface: "discord",
301+
Provider: "discord",
302+
},
303+
cfg: {
304+
...emptyConfig,
305+
session: { sendPolicy: { default: "deny" } },
306+
},
307+
dispatcher: createDispatcher(),
308+
replyResolver: async () => ({ text: "model reply" }),
309+
});
310+
311+
await new Promise<void>((resolve) => setImmediate(resolve));
312+
expect(
313+
diagnosticEvents.some(
314+
(event) =>
315+
event.type === "log.record" &&
316+
event.message.includes("Normal assistant final reply was suppressed"),
317+
),
318+
).toBe(false);
319+
} finally {
320+
stopDiagnostics();
321+
}
322+
});
323+
324+
it("audits media-only finals suppressed by message-tool-only delivery", async () => {
325+
hookMocks.runner.hasHooks.mockReturnValue(false);
326+
const diagnosticEvents: DiagnosticEventPayload[] = [];
327+
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
328+
diagnosticEvents.push(event);
329+
});
330+
331+
try {
332+
await dispatchReplyFromConfig({
333+
ctx: {
334+
...createHookCtx(),
335+
ChatType: "channel",
336+
Surface: "discord",
337+
Provider: "discord",
338+
},
339+
cfg: emptyConfig,
340+
dispatcher: createDispatcher(),
341+
replyResolver: async () => ({ mediaUrls: ["file:///tmp/result.png"] }),
342+
});
343+
344+
await new Promise<void>((resolve) => setImmediate(resolve));
345+
const auditEvent = diagnosticEvents.find(
346+
(event) =>
347+
event.type === "log.record" &&
348+
event.message.includes("Normal assistant final reply was suppressed"),
349+
);
350+
expect(auditEvent?.type).toBe("log.record");
351+
if (auditEvent?.type === "log.record") {
352+
expect(auditEvent.attributes).toMatchObject({
353+
suppressedFinalCount: 1,
354+
suppressedFinalTextChars: 0,
355+
});
356+
}
357+
} finally {
358+
stopDiagnostics();
359+
}
360+
});
361+
362+
it("audits aggregate suppressed final counts without recording text", async () => {
363+
hookMocks.runner.hasHooks.mockReturnValue(false);
364+
const diagnosticEvents: DiagnosticEventPayload[] = [];
365+
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
366+
diagnosticEvents.push(event);
367+
});
368+
369+
try {
370+
await dispatchReplyFromConfig({
371+
ctx: {
372+
...createHookCtx(),
373+
ChatType: "channel",
374+
Surface: "discord",
375+
Provider: "discord",
376+
},
377+
cfg: emptyConfig,
378+
dispatcher: createDispatcher(),
379+
replyResolver: async () => [{ text: "first" }, { text: "second reply" }],
380+
});
381+
382+
await new Promise<void>((resolve) => setImmediate(resolve));
383+
const auditEvent = diagnosticEvents.find(
384+
(event) =>
385+
event.type === "log.record" &&
386+
event.message.includes("Normal assistant final reply was suppressed"),
387+
);
388+
expect(auditEvent?.type).toBe("log.record");
389+
if (auditEvent?.type === "log.record") {
390+
expect(auditEvent.attributes).toMatchObject({
391+
suppressedFinalCount: 2,
392+
suppressedFinalTextChars: 17,
393+
});
394+
expect(auditEvent.message).not.toContain("first");
395+
expect(auditEvent.message).not.toContain("second reply");
396+
}
397+
} finally {
398+
stopDiagnostics();
399+
}
400+
});
401+
203402
it("preserves pending final delivery when final dispatch fails", async () => {
204403
hookMocks.runner.hasHooks.mockReturnValue(false);
205404
sessionStoreMocks.currentEntry = {

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
toPluginMessageContext,
3939
toPluginMessageReceivedEvent,
4040
} from "../../hooks/message-hook-mappers.js";
41-
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
41+
import { emitTrustedDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
4242
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
4343
import { formatErrorMessage } from "../../infra/errors.js";
4444
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
@@ -78,6 +78,7 @@ import type { BlockReplyContext } from "../get-reply-options.types.js";
7878
import { getReplyPayloadMetadata, type ReplyPayload } from "../reply-payload.js";
7979
import type { FinalizedMsgContext } from "../templating.js";
8080
import { normalizeVerboseLevel } from "../thinking.js";
81+
import { isSilentReplyPayloadText } from "../tokens.js";
8182
import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js";
8283
import {
8384
createInternalHookEvent,
@@ -160,6 +161,48 @@ const AUDIO_HEADER_RE = /^\[Audio\b/i;
160161
const normalizeMediaType = (value: string): string =>
161162
normalizeOptionalLowercaseString(value.split(";")[0]) ?? "";
162163

164+
const isSubstantiveSuppressedFinalReply = (reply: ReplyPayload): boolean => {
165+
if (reply.isReasoning === true) {
166+
return false;
167+
}
168+
const parts = resolveSendableOutboundReplyParts(reply);
169+
if (!parts.hasContent) {
170+
return false;
171+
}
172+
if (!parts.hasMedia && isSilentReplyPayloadText(parts.text)) {
173+
return false;
174+
}
175+
return true;
176+
};
177+
178+
const emitSuppressedFinalReplyAudit = (params: {
179+
channel?: string;
180+
sessionKey?: string;
181+
sourceReplyDeliveryMode: string;
182+
suppressedFinalCount: number;
183+
suppressedFinalTextChars: number;
184+
sendPolicyDenied: boolean;
185+
}): void => {
186+
if (params.suppressedFinalCount <= 0) {
187+
return;
188+
}
189+
emitTrustedDiagnosticEvent({
190+
type: "log.record",
191+
level: "warning",
192+
loggerName: "auto-reply.dispatch",
193+
message:
194+
"Normal assistant final reply was suppressed by message-tool-only source delivery; visible output must be sent with the message tool, then the normal final should be NO_REPLY.",
195+
attributes: {
196+
channel: params.channel ?? "unknown",
197+
sessionKey: params.sessionKey ?? "unknown",
198+
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
199+
suppressedFinalCount: params.suppressedFinalCount,
200+
suppressedFinalTextChars: params.suppressedFinalTextChars,
201+
sendPolicyDenied: params.sendPolicyDenied,
202+
},
203+
});
204+
};
205+
163206
const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
164207
const rawTypes = [
165208
typeof ctx.MediaType === "string" ? ctx.MediaType : undefined,
@@ -1554,6 +1597,8 @@ export async function dispatchReplyFromConfig(
15541597
let routedFinalCount = 0;
15551598
let attemptedFinalDelivery = false;
15561599
let finalDeliveryFailed = false;
1600+
let suppressedFinalCount = 0;
1601+
let suppressedFinalTextChars = 0;
15571602
const shouldDeliverDespiteSourceReplySuppression = (reply: ReplyPayload) =>
15581603
suppressAutomaticSourceDelivery &&
15591604
!sendPolicyDenied &&
@@ -1564,7 +1609,16 @@ export async function dispatchReplyFromConfig(
15641609
if (reply.isReasoning === true) {
15651610
continue;
15661611
}
1567-
if (suppressDelivery && !shouldDeliverDespiteSourceReplySuppression(reply)) {
1612+
const deliverDespiteSourceSuppression = shouldDeliverDespiteSourceReplySuppression(reply);
1613+
if (suppressDelivery && !deliverDespiteSourceSuppression) {
1614+
if (
1615+
suppressAutomaticSourceDelivery &&
1616+
!sendPolicyDenied &&
1617+
isSubstantiveSuppressedFinalReply(reply)
1618+
) {
1619+
suppressedFinalCount += 1;
1620+
suppressedFinalTextChars += resolveSendableOutboundReplyParts(reply).trimmedText.length;
1621+
}
15681622
continue;
15691623
}
15701624
attemptedFinalDelivery = true;
@@ -1583,6 +1637,17 @@ export async function dispatchReplyFromConfig(
15831637
});
15841638
}
15851639

1640+
if (suppressedFinalCount > 0) {
1641+
emitSuppressedFinalReplyAudit({
1642+
channel: deliveryChannel,
1643+
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
1644+
sourceReplyDeliveryMode,
1645+
suppressedFinalCount,
1646+
suppressedFinalTextChars,
1647+
sendPolicyDenied,
1648+
});
1649+
}
1650+
15861651
if (!suppressDelivery) {
15871652
const ttsMode = resolveConfiguredTtsMode(cfg, {
15881653
agentId: sessionAgentId,

0 commit comments

Comments
 (0)