mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
fix(auto-reply): tighten silent token semantics and prefix streaming
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user