From 06e379a2390b1dbb2420bdcfab10bfcc7fdc36c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 23:53:29 +0100 Subject: [PATCH] fix: suppress stray HEARTBEAT_OK replies --- CHANGELOG.md | 1 + src/auto-reply/reply.triggers.test.ts | 26 +++++++++++++++++ src/auto-reply/reply.ts | 40 +++++++++++++++++++++++++-- src/web/auto-reply.test.ts | 5 ++-- src/web/auto-reply.ts | 1 + 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3dec2512..3f070f0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### Fixes - Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag. +- Auto-reply: suppress stray `HEARTBEAT_OK` acks so they never get delivered as messages. - Skills: switch imsg installer to brew tap formula. - Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI. - Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages. diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index c0fecc8b9..bd8358b43 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -13,6 +13,7 @@ vi.mock("../agents/pi-embedded.js", () => ({ import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { getReplyFromConfig } from "./reply.js"; +import { HEARTBEAT_TOKEN } from "./tokens.js"; const webMocks = vi.hoisted(() => ({ webAuthExists: vi.fn().mockResolvedValue(true), @@ -160,6 +161,31 @@ describe("trigger handling", () => { }); }); + it("suppresses HEARTBEAT_OK replies outside heartbeat runs", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: HEARTBEAT_TOKEN }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "hello", + From: "+1002", + To: "+2000", + }, + {}, + makeCfg(home), + ); + + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + }); + }); + it("updates group activation when the owner sends /activation", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 91701392d..112c2605c 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -61,7 +61,7 @@ import { type ThinkLevel, type VerboseLevel, } from "./thinking.js"; -import { SILENT_REPLY_TOKEN } from "./tokens.js"; +import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "./tokens.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; @@ -1190,6 +1190,7 @@ export async function getReplyFromConfig( return undefined; } + let suppressedByHeartbeatAck = false; try { if (shouldEagerType) { await startTypingLoop(); @@ -1216,6 +1217,17 @@ export async function getReplyFromConfig( runId, onPartialReply: opts?.onPartialReply ? async (payload) => { + if ( + !opts?.isHeartbeat && + payload.text?.includes(HEARTBEAT_TOKEN) + ) { + suppressedByHeartbeatAck = true; + logVerbose( + "Suppressing partial reply: detected HEARTBEAT_OK token", + ); + return; + } + if (suppressedByHeartbeatAck) return; await startTypingOnText(payload.text); await opts.onPartialReply?.({ text: payload.text, @@ -1226,6 +1238,17 @@ export async function getReplyFromConfig( shouldEmitToolResult, onToolResult: opts?.onToolResult ? async (payload) => { + if ( + !opts?.isHeartbeat && + payload.text?.includes(HEARTBEAT_TOKEN) + ) { + suppressedByHeartbeatAck = true; + logVerbose( + "Suppressing tool result: detected HEARTBEAT_OK token", + ); + return; + } + if (suppressedByHeartbeatAck) return; await startTypingOnText(payload.text); await opts.onToolResult?.({ text: payload.text, @@ -1261,9 +1284,22 @@ export async function getReplyFromConfig( const payloadArray = runResult.payloads ?? []; if (payloadArray.length === 0) return undefined; + if ( + suppressedByHeartbeatAck || + (!opts?.isHeartbeat && + payloadArray.some((payload) => payload.text?.includes(HEARTBEAT_TOKEN))) + ) { + logVerbose("Suppressing reply: detected HEARTBEAT_OK token"); + return undefined; + } const shouldSignalTyping = payloadArray.some((payload) => { const trimmed = payload.text?.trim(); - if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true; + if ( + trimmed && + trimmed !== SILENT_REPLY_TOKEN && + !trimmed.includes(HEARTBEAT_TOKEN) + ) + return true; if (payload.mediaUrl) return true; if (payload.mediaUrls && payload.mediaUrls.length > 0) return true; return false; diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index c0ca2bf1f..11657ab91 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1415,7 +1415,7 @@ describe("web auto-reply", () => { resetLoadConfigMock(); }); - it("skips responsePrefix for HEARTBEAT_OK responses", async () => { + it("does not deliver HEARTBEAT_OK responses", async () => { setLoadConfigMock(() => ({ routing: { allowFrom: ["*"], @@ -1456,8 +1456,7 @@ describe("web auto-reply", () => { sendMedia: vi.fn(), }); - // HEARTBEAT_OK should NOT have prefix - clawdis needs exact match - expect(reply).toHaveBeenCalledWith(HEARTBEAT_TOKEN); + expect(reply).not.toHaveBeenCalled(); resetLoadConfigMock(); }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 9fbd43caf..c844625af 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -185,6 +185,7 @@ export { stripHeartbeatToken }; function isSilentReply(payload?: ReplyPayload): boolean { if (!payload) return false; const text = payload.text?.trim(); + if (text?.includes(HEARTBEAT_TOKEN)) return true; if (!text || text !== SILENT_REPLY_TOKEN) return false; if (payload.mediaUrl || payload.mediaUrls?.length) return false; return true;