From e64d72299e0613335ad0a9bd67d58f373be60ed2 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 26 Feb 2026 15:51:52 +0530 Subject: [PATCH] fix(auto-reply): tighten silent token semantics and prefix streaming --- .../reply/agent-runner-execution.ts | 13 ++++++++++- src/auto-reply/reply/reply-flow.test.ts | 12 ++++++---- src/auto-reply/reply/route-reply.test.ts | 8 +++++-- src/auto-reply/reply/streaming-directives.ts | 5 ++-- src/auto-reply/reply/typing.ts | 7 ++++-- src/auto-reply/tokens.test.ts | 23 ++++++++++++++++++- src/auto-reply/tokens.ts | 2 +- 7 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 32022f95453..a9bd537b527 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -28,7 +28,12 @@ import { import { stripHeartbeatToken } from "../heartbeat.js"; import type { TemplateContext } from "../templating.js"; import type { VerboseLevel } from "../thinking.js"; -import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { + HEARTBEAT_TOKEN, + isSilentReplyPrefixText, + isSilentReplyText, + SILENT_REPLY_TOKEN, +} from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { buildEmbeddedRunBaseParams, @@ -141,6 +146,12 @@ export async function runAgentTurnWithFallback(params: { if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) { return { skip: true }; } + if ( + isSilentReplyPrefixText(text, SILENT_REPLY_TOKEN) || + isSilentReplyPrefixText(text, HEARTBEAT_TOKEN) + ) { + return { skip: true }; + } if (!text) { // Allow media-only payloads (e.g. tool result screenshots) through. if ((payload.mediaUrls?.length ?? 0) > 0) { diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 03ff953be7c..3c697b445ec 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1099,18 +1099,20 @@ describe("followup queue collect routing", () => { const emptyCfg = {} as OpenClawConfig; describe("createReplyDispatcher", () => { - it("drops empty payloads and silent tokens without media", async () => { + it("drops empty payloads and exact silent tokens without media", async () => { const deliver = vi.fn().mockResolvedValue(undefined); const dispatcher = createReplyDispatcher({ deliver }); expect(dispatcher.sendFinalReply({})).toBe(false); expect(dispatcher.sendFinalReply({ text: " " })).toBe(false); expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false); - expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false); + expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(true); + expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(true); await dispatcher.waitForIdle(); - expect(deliver).not.toHaveBeenCalled(); + expect(deliver).toHaveBeenCalledTimes(2); + expect(deliver.mock.calls[0]?.[0]?.text).toBe(`${SILENT_REPLY_TOKEN} -- nope`); + expect(deliver.mock.calls[1]?.[0]?.text).toBe(`interject.${SILENT_REPLY_TOKEN}`); }); it("strips heartbeat tokens and applies responsePrefix", async () => { @@ -1162,7 +1164,7 @@ describe("createReplyDispatcher", () => { expect(deliver).toHaveBeenCalledTimes(3); expect(deliver.mock.calls[0][0].text).toBe("PFX already"); expect(deliver.mock.calls[1][0].text).toBe(""); - expect(deliver.mock.calls[2][0].text).toBe(""); + expect(deliver.mock.calls[2][0].text).toBe(`PFX ${SILENT_REPLY_TOKEN} -- explanation`); }); it("preserves ordering across tool, block, and final replies", async () => { diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index c6d726ebaf5..ca369375870 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -168,7 +168,7 @@ describe("routeReply", () => { expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); }); - it("drops payloads that start with the silent token", async () => { + it("does not drop payloads that merely start with the silent token", async () => { mocks.sendMessageSlack.mockClear(); const res = await routeReply({ payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` }, @@ -177,7 +177,11 @@ describe("routeReply", () => { cfg: {} as never, }); expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + expect(mocks.sendMessageSlack).toHaveBeenCalledWith( + "channel:C123", + `${SILENT_REPLY_TOKEN} -- (why am I here?)`, + expect.any(Object), + ); }); it("applies responsePrefix when routing", async () => { diff --git a/src/auto-reply/reply/streaming-directives.ts b/src/auto-reply/reply/streaming-directives.ts index 13c5047a9e1..e61499200e0 100644 --- a/src/auto-reply/reply/streaming-directives.ts +++ b/src/auto-reply/reply/streaming-directives.ts @@ -1,6 +1,6 @@ import { splitMediaFromOutput } from "../../media/parse.js"; import { parseInlineDirectives } from "../../utils/directive-tags.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyDirectiveParseResult } from "./reply-directives.js"; type PendingReplyState = { @@ -47,7 +47,8 @@ const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChun } const silentToken = options?.silentToken ?? SILENT_REPLY_TOKEN; - const isSilent = isSilentReplyText(text, silentToken); + const isSilent = + isSilentReplyText(text, silentToken) || isSilentReplyPrefixText(text, silentToken); if (isSilent) { text = ""; } diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index fee32418050..6f8ce6be50d 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -1,5 +1,5 @@ import { createTypingKeepaliveLoop } from "../../channels/typing-lifecycle.js"; -import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; +import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; export type TypingController = { onReplyStart: () => Promise; @@ -163,7 +163,10 @@ export function createTypingController(params: { if (!trimmed) { return; } - if (silentToken && isSilentReplyText(trimmed, silentToken)) { + if ( + silentToken && + (isSilentReplyText(trimmed, silentToken) || isSilentReplyPrefixText(trimmed, silentToken)) + ) { return; } refreshTypingTtl(); diff --git a/src/auto-reply/tokens.test.ts b/src/auto-reply/tokens.test.ts index c8cee251aa3..262932f82ca 100644 --- a/src/auto-reply/tokens.test.ts +++ b/src/auto-reply/tokens.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isSilentReplyText } from "./tokens.js"; +import { isSilentReplyPrefixText, isSilentReplyText } from "./tokens.js"; describe("isSilentReplyText", () => { it("returns true for exact token", () => { @@ -35,3 +35,24 @@ describe("isSilentReplyText", () => { expect(isSilentReplyText("Checked inbox. HEARTBEAT_OK", "HEARTBEAT_OK")).toBe(false); }); }); + +describe("isSilentReplyPrefixText", () => { + it("matches uppercase underscore prefixes", () => { + expect(isSilentReplyPrefixText("NO_")).toBe(true); + expect(isSilentReplyPrefixText("NO_RE")).toBe(true); + expect(isSilentReplyPrefixText("NO_REPLY")).toBe(true); + expect(isSilentReplyPrefixText(" HEARTBEAT_", "HEARTBEAT_OK")).toBe(true); + }); + + it("rejects ambiguous natural-language prefixes", () => { + expect(isSilentReplyPrefixText("N")).toBe(false); + expect(isSilentReplyPrefixText("No")).toBe(false); + expect(isSilentReplyPrefixText("Hello")).toBe(false); + }); + + it("rejects non-prefixes and mixed characters", () => { + expect(isSilentReplyPrefixText("NO_X")).toBe(false); + expect(isSilentReplyPrefixText("NO_REPLY more")).toBe(false); + expect(isSilentReplyPrefixText("NO-")).toBe(false); + }); +}); diff --git a/src/auto-reply/tokens.ts b/src/auto-reply/tokens.ts index 18cabceaad5..bb53b6727c3 100644 --- a/src/auto-reply/tokens.ts +++ b/src/auto-reply/tokens.ts @@ -12,7 +12,7 @@ export function isSilentReplyText( } const escaped = escapeRegExp(token); // Only match when the entire response (trimmed) is the silent token, - // optionally surrounded by whitespace/punctuation. This prevents + // optionally surrounded by whitespace. This prevents // substantive replies ending with NO_REPLY from being suppressed (#19537). return new RegExp(`^\\s*${escaped}\\s*$`).test(text); }