Skip to content

Commit e64d722

Browse files
committed
fix(auto-reply): tighten silent token semantics and prefix streaming
1 parent 2f2110a commit e64d722

7 files changed

Lines changed: 56 additions & 14 deletions

File tree

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ import {
2828
import { stripHeartbeatToken } from "../heartbeat.js";
2929
import type { TemplateContext } from "../templating.js";
3030
import type { VerboseLevel } from "../thinking.js";
31-
import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
31+
import {
32+
HEARTBEAT_TOKEN,
33+
isSilentReplyPrefixText,
34+
isSilentReplyText,
35+
SILENT_REPLY_TOKEN,
36+
} from "../tokens.js";
3237
import type { GetReplyOptions, ReplyPayload } from "../types.js";
3338
import {
3439
buildEmbeddedRunBaseParams,
@@ -141,6 +146,12 @@ export async function runAgentTurnWithFallback(params: {
141146
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
142147
return { skip: true };
143148
}
149+
if (
150+
isSilentReplyPrefixText(text, SILENT_REPLY_TOKEN) ||
151+
isSilentReplyPrefixText(text, HEARTBEAT_TOKEN)
152+
) {
153+
return { skip: true };
154+
}
144155
if (!text) {
145156
// Allow media-only payloads (e.g. tool result screenshots) through.
146157
if ((payload.mediaUrls?.length ?? 0) > 0) {

src/auto-reply/reply/reply-flow.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,18 +1099,20 @@ describe("followup queue collect routing", () => {
10991099
const emptyCfg = {} as OpenClawConfig;
11001100

11011101
describe("createReplyDispatcher", () => {
1102-
it("drops empty payloads and silent tokens without media", async () => {
1102+
it("drops empty payloads and exact silent tokens without media", async () => {
11031103
const deliver = vi.fn().mockResolvedValue(undefined);
11041104
const dispatcher = createReplyDispatcher({ deliver });
11051105

11061106
expect(dispatcher.sendFinalReply({})).toBe(false);
11071107
expect(dispatcher.sendFinalReply({ text: " " })).toBe(false);
11081108
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
1109-
expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(false);
1110-
expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(false);
1109+
expect(dispatcher.sendFinalReply({ text: `${SILENT_REPLY_TOKEN} -- nope` })).toBe(true);
1110+
expect(dispatcher.sendFinalReply({ text: `interject.${SILENT_REPLY_TOKEN}` })).toBe(true);
11111111

11121112
await dispatcher.waitForIdle();
1113-
expect(deliver).not.toHaveBeenCalled();
1113+
expect(deliver).toHaveBeenCalledTimes(2);
1114+
expect(deliver.mock.calls[0]?.[0]?.text).toBe(`${SILENT_REPLY_TOKEN} -- nope`);
1115+
expect(deliver.mock.calls[1]?.[0]?.text).toBe(`interject.${SILENT_REPLY_TOKEN}`);
11141116
});
11151117

11161118
it("strips heartbeat tokens and applies responsePrefix", async () => {
@@ -1162,7 +1164,7 @@ describe("createReplyDispatcher", () => {
11621164
expect(deliver).toHaveBeenCalledTimes(3);
11631165
expect(deliver.mock.calls[0][0].text).toBe("PFX already");
11641166
expect(deliver.mock.calls[1][0].text).toBe("");
1165-
expect(deliver.mock.calls[2][0].text).toBe("");
1167+
expect(deliver.mock.calls[2][0].text).toBe(`PFX ${SILENT_REPLY_TOKEN} -- explanation`);
11661168
});
11671169

11681170
it("preserves ordering across tool, block, and final replies", async () => {

src/auto-reply/reply/route-reply.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ describe("routeReply", () => {
168168
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
169169
});
170170

171-
it("drops payloads that start with the silent token", async () => {
171+
it("does not drop payloads that merely start with the silent token", async () => {
172172
mocks.sendMessageSlack.mockClear();
173173
const res = await routeReply({
174174
payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` },
@@ -177,7 +177,11 @@ describe("routeReply", () => {
177177
cfg: {} as never,
178178
});
179179
expect(res.ok).toBe(true);
180-
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
180+
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
181+
"channel:C123",
182+
`${SILENT_REPLY_TOKEN} -- (why am I here?)`,
183+
expect.any(Object),
184+
);
181185
});
182186

183187
it("applies responsePrefix when routing", async () => {

src/auto-reply/reply/streaming-directives.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { splitMediaFromOutput } from "../../media/parse.js";
22
import { parseInlineDirectives } from "../../utils/directive-tags.js";
3-
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
3+
import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
44
import type { ReplyDirectiveParseResult } from "./reply-directives.js";
55

66
type PendingReplyState = {
@@ -47,7 +47,8 @@ const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChun
4747
}
4848

4949
const silentToken = options?.silentToken ?? SILENT_REPLY_TOKEN;
50-
const isSilent = isSilentReplyText(text, silentToken);
50+
const isSilent =
51+
isSilentReplyText(text, silentToken) || isSilentReplyPrefixText(text, silentToken);
5152
if (isSilent) {
5253
text = "";
5354
}

src/auto-reply/reply/typing.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createTypingKeepaliveLoop } from "../../channels/typing-lifecycle.js";
2-
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
2+
import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
33

44
export type TypingController = {
55
onReplyStart: () => Promise<void>;
@@ -163,7 +163,10 @@ export function createTypingController(params: {
163163
if (!trimmed) {
164164
return;
165165
}
166-
if (silentToken && isSilentReplyText(trimmed, silentToken)) {
166+
if (
167+
silentToken &&
168+
(isSilentReplyText(trimmed, silentToken) || isSilentReplyPrefixText(trimmed, silentToken))
169+
) {
167170
return;
168171
}
169172
refreshTypingTtl();

src/auto-reply/tokens.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { isSilentReplyText } from "./tokens.js";
2+
import { isSilentReplyPrefixText, isSilentReplyText } from "./tokens.js";
33

44
describe("isSilentReplyText", () => {
55
it("returns true for exact token", () => {
@@ -35,3 +35,24 @@ describe("isSilentReplyText", () => {
3535
expect(isSilentReplyText("Checked inbox. HEARTBEAT_OK", "HEARTBEAT_OK")).toBe(false);
3636
});
3737
});
38+
39+
describe("isSilentReplyPrefixText", () => {
40+
it("matches uppercase underscore prefixes", () => {
41+
expect(isSilentReplyPrefixText("NO_")).toBe(true);
42+
expect(isSilentReplyPrefixText("NO_RE")).toBe(true);
43+
expect(isSilentReplyPrefixText("NO_REPLY")).toBe(true);
44+
expect(isSilentReplyPrefixText(" HEARTBEAT_", "HEARTBEAT_OK")).toBe(true);
45+
});
46+
47+
it("rejects ambiguous natural-language prefixes", () => {
48+
expect(isSilentReplyPrefixText("N")).toBe(false);
49+
expect(isSilentReplyPrefixText("No")).toBe(false);
50+
expect(isSilentReplyPrefixText("Hello")).toBe(false);
51+
});
52+
53+
it("rejects non-prefixes and mixed characters", () => {
54+
expect(isSilentReplyPrefixText("NO_X")).toBe(false);
55+
expect(isSilentReplyPrefixText("NO_REPLY more")).toBe(false);
56+
expect(isSilentReplyPrefixText("NO-")).toBe(false);
57+
});
58+
});

src/auto-reply/tokens.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function isSilentReplyText(
1212
}
1313
const escaped = escapeRegExp(token);
1414
// Only match when the entire response (trimmed) is the silent token,
15-
// optionally surrounded by whitespace/punctuation. This prevents
15+
// optionally surrounded by whitespace. This prevents
1616
// substantive replies ending with NO_REPLY from being suppressed (#19537).
1717
return new RegExp(`^\\s*${escaped}\\s*$`).test(text);
1818
}

0 commit comments

Comments
 (0)