Compare commits

...

5 Commits

Author SHA1 Message Date
Pocket Clawd
0eb0364819 fix(webchat): suppress ack-only heartbeats + update changelog (#2396) (thanks @dlauer) 2026-01-26 14:52:07 -08:00
Pocket Clawd
1a172ba873 test(webchat): cover heartbeat visibility suppression 2026-01-26 14:48:59 -08:00
Pocket Clawd
1284c3d868 fix(webchat): suppress heartbeat ok broadcasts; stabilize audit/status tests 2026-01-26 14:48:59 -08:00
Pocket Clawd
cec996c812 fix(webchat): suppress heartbeat chat broadcast using agent runId
- Avoid per-event config loads by caching webchat heartbeat visibility\n- Use agent runId (not client runId) to read isHeartbeat context\n- Add regression test for clientRunId mismatch
2026-01-26 14:48:59 -08:00
Dave Lauer
822663001a feat: add heartbeat visibility filtering for webchat
- Add isHeartbeat to AgentRunContext to track heartbeat runs
- Pass isHeartbeat flag through agent runner execution
- Suppress webchat broadcast (deltas + final) for heartbeat runs when showOk is false
- Webchat uses channels.defaults.heartbeat settings (no per-channel config)
- Default behavior: hide HEARTBEAT_OK from webchat (matches other channels)

This allows users to control whether heartbeat responses appear in
the webchat UI via channels.defaults.heartbeat.showOk (defaults to false).
2026-01-26 14:48:59 -08:00
11 changed files with 344 additions and 12 deletions

View File

@ -30,6 +30,7 @@ Status: unreleased.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
- Web UI: honor heartbeat visibility (hide HEARTBEAT_OK acks by default) in WebChat. (#2396) Thanks @dlauer.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.

View File

@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
verboseLevel: params.resolvedVerboseLevel,
isHeartbeat: params.isHeartbeat,
});
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;

View File

@ -370,7 +370,7 @@ describe("channels command", () => {
});
expect(lines.join("\n")).toMatch(/Warnings:/);
expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i);
expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
expect(lines.join("\n")).toMatch(/Run: .*doctor/i);
});
it("surfaces Discord permission audit issues in channels status output", () => {

View File

@ -312,7 +312,7 @@ describe("statusCommand", () => {
expect(logs.some((l) => l.includes("FAQ:"))).toBe(true);
expect(logs.some((l) => l.includes("Troubleshooting:"))).toBe(true);
expect(logs.some((l) => l.includes("Next steps:"))).toBe(true);
expect(logs.some((l) => l.includes("clawdbot status --all"))).toBe(true);
expect(logs.some((l) => l.includes("status --all"))).toBe(true);
});
it("shows gateway auth when reachable", async () => {

View File

@ -0,0 +1,178 @@
import { describe, expect, it, vi } from "vitest";
describe("agent event handler (webchat heartbeat visibility)", () => {
it("suppresses HEARTBEAT_OK-only broadcasts to webchat when showOk is false (context on clientRunId)", async () => {
vi.resetModules();
vi.doMock("../config/config.js", () => {
return {
loadConfig: vi.fn(() => ({
agents: {
defaults: {
heartbeat: {
ackMaxChars: 30,
},
},
},
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
},
})),
};
});
const { registerAgentRunContext } = await import("../infra/agent-events.js");
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
const broadcast = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
// server-chat uses clientRunId for the broadcast payload.
registerAgentRunContext("client-1", { isHeartbeat: true });
const handler = createAgentEventHandler({
broadcast,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => undefined,
clearAgentRunContext: vi.fn(),
});
handler({
runId: "run-1",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "HEARTBEAT_OK" },
});
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
expect(chatCalls).toHaveLength(0);
const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
expect(sessionChatCalls).toHaveLength(1);
});
it("suppresses when heartbeat context is only registered under agentRunId (clientRunId differs)", async () => {
vi.resetModules();
vi.doMock("../config/config.js", () => {
return {
loadConfig: vi.fn(() => ({
agents: {
defaults: {
heartbeat: {
ackMaxChars: 30,
},
},
},
channels: {
defaults: {
heartbeat: {
showOk: false,
},
},
},
})),
};
});
const { registerAgentRunContext } = await import("../infra/agent-events.js");
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
const broadcast = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
// This matches how agent-runner registers context (runId), while webchat may use a separate clientRunId.
registerAgentRunContext("run-1", { isHeartbeat: true });
const handler = createAgentEventHandler({
broadcast,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => undefined,
clearAgentRunContext: vi.fn(),
});
handler({
runId: "run-1",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "HEARTBEAT_OK" },
});
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
expect(chatCalls).toHaveLength(0);
});
it("still broadcasts non-HEARTBEAT_OK heartbeat alerts to webchat when showOk is false", async () => {
vi.resetModules();
vi.doMock("../config/config.js", () => {
return {
loadConfig: vi.fn(() => ({
agents: {
defaults: {
heartbeat: {
ackMaxChars: 30,
},
},
},
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
},
})),
};
});
const { registerAgentRunContext } = await import("../infra/agent-events.js");
const { createAgentEventHandler, createChatRunState } = await import("./server-chat.js");
const broadcast = vi.fn();
const nodeSendToSession = vi.fn();
const agentRunSeq = new Map<string, number>();
const chatRunState = createChatRunState();
chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
registerAgentRunContext("client-1", { isHeartbeat: true });
const handler = createAgentEventHandler({
broadcast,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun: () => undefined,
clearAgentRunContext: vi.fn(),
});
handler({
runId: "run-1",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "ALERT: something happened" },
});
const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
expect(chatCalls).toHaveLength(1);
});
});

View File

@ -1,8 +1,67 @@
import { stripHeartbeatToken, DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../auto-reply/heartbeat.js";
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
type WebchatHeartbeatPolicy = {
showOk: boolean;
ackMaxChars: number;
};
let webchatHeartbeatPolicyCache: { policy: WebchatHeartbeatPolicy; loadedAtMs: number } | undefined;
function resolveWebchatHeartbeatPolicy(): WebchatHeartbeatPolicy {
// loadConfig() reads from disk + validates, so avoid doing it on every token stream event.
const now = Date.now();
const cached = webchatHeartbeatPolicyCache;
if (cached && now - cached.loadedAtMs < 5_000) return cached.policy;
let policy: WebchatHeartbeatPolicy;
try {
const cfg = loadConfig();
const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
const ackMaxChars = Math.max(
0,
cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
policy = { showOk: visibility.showOk, ackMaxChars };
} catch {
// Safe fallback: treat HEARTBEAT_OK as hidden, but don't suppress alerts.
policy = { showOk: false, ackMaxChars: DEFAULT_HEARTBEAT_ACK_MAX_CHARS };
}
webchatHeartbeatPolicyCache = { policy, loadedAtMs: now };
return policy;
}
function resolveHeartbeatContextIsHeartbeat(runId: string, clientRunId: string): boolean {
const clientContext = getAgentRunContext(clientRunId);
if (clientContext?.isHeartbeat !== undefined) return clientContext.isHeartbeat;
const runContext = getAgentRunContext(runId);
return Boolean(runContext?.isHeartbeat);
}
function shouldSuppressHeartbeatBroadcast(
runId: string,
clientRunId: string,
text: string | undefined,
): boolean {
if (!resolveHeartbeatContextIsHeartbeat(runId, clientRunId)) return false;
const policy = resolveWebchatHeartbeatPolicy();
if (policy.showOk) return false;
const normalized = String(text ?? "").trim();
if (!normalized) return true;
// Only suppress if this looks like a heartbeat ack-only response.
return stripHeartbeatToken(normalized, { mode: "heartbeat", maxAckChars: policy.ackMaxChars })
.shouldSkip;
}
export type ChatRunEntry = {
sessionKey: string;
clientRunId: string;
@ -113,7 +172,13 @@ export function createAgentEventHandler({
resolveSessionKeyForRun,
clearAgentRunContext,
}: AgentEventHandlerOptions) {
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
const emitChatDelta = (
sessionKey: string,
clientRunId: string,
seq: number,
text: string,
agentRunId: string,
) => {
chatRunState.buffers.set(clientRunId, text);
const now = Date.now();
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
@ -130,7 +195,10 @@ export function createAgentEventHandler({
timestamp: now,
},
};
if (!shouldSuppressHeartbeatBroadcast(agentRunId, clientRunId, text)) {
broadcast("chat", payload, { dropIfSlow: true });
}
nodeSendToSession(sessionKey, "chat", payload);
};
@ -139,7 +207,8 @@ export function createAgentEventHandler({
clientRunId: string,
seq: number,
jobState: "done" | "error",
error?: unknown,
error: unknown,
agentRunId: string,
) => {
const text = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
chatRunState.buffers.delete(clientRunId);
@ -158,7 +227,10 @@ export function createAgentEventHandler({
}
: undefined,
};
if (!shouldSuppressHeartbeatBroadcast(agentRunId, clientRunId, text)) {
broadcast("chat", payload);
}
nodeSendToSession(sessionKey, "chat", payload);
return;
}
@ -224,7 +296,7 @@ export function createAgentEventHandler({
if (sessionKey) {
nodeSendToSession(sessionKey, "agent", agentPayload);
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text, evt.runId);
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
if (chatLink) {
const finished = chatRunState.registry.shift(evt.runId);
@ -238,6 +310,7 @@ export function createAgentEventHandler({
evt.seq,
lifecyclePhase === "error" ? "error" : "done",
evt.data?.error,
evt.runId,
);
} else {
emitChatFinal(
@ -246,6 +319,7 @@ export function createAgentEventHandler({
evt.seq,
lifecyclePhase === "error" ? "error" : "done",
evt.data?.error,
evt.runId,
);
}
} else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {

View File

@ -14,6 +14,7 @@ export type AgentEventPayload = {
export type AgentRunContext = {
sessionKey?: string;
verboseLevel?: VerboseLevel;
isHeartbeat?: boolean;
};
// Keep per-run counters so streams stay strictly monotonic per runId.
@ -34,6 +35,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext)
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
existing.verboseLevel = context.verboseLevel;
}
if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) {
existing.isHeartbeat = context.isHeartbeat;
}
}
export function getAgentRunContext(runId: string) {

View File

@ -247,4 +247,58 @@ describe("resolveHeartbeatVisibility", () => {
useIndicator: true,
});
});
it("webchat uses channel defaults only (no per-channel config)", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: true,
showAlerts: false,
useIndicator: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
expect(result).toEqual({
showOk: true,
showAlerts: false,
useIndicator: false,
});
});
it("webchat returns defaults when no channel defaults configured", () => {
const cfg = {} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" });
expect(result).toEqual({
showOk: false,
showAlerts: true,
useIndicator: true,
});
});
it("webchat ignores accountId (only uses defaults)", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: true,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "webchat",
accountId: "some-account",
});
expect(result.showOk).toBe(true);
});
});

View File

@ -1,6 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
import type { DeliverableMessageChannel } from "../utils/message-channel.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
export type ResolvedHeartbeatVisibility = {
showOk: boolean;
@ -14,13 +14,28 @@ const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = {
useIndicator: true, // Emit indicator events
};
/**
* Resolve heartbeat visibility settings for a channel.
* Supports both deliverable channels (telegram, signal, etc.) and webchat.
* For webchat, uses channels.defaults.heartbeat since webchat doesn't have per-channel config.
*/
export function resolveHeartbeatVisibility(params: {
cfg: ClawdbotConfig;
channel: DeliverableMessageChannel;
channel: GatewayMessageChannel;
accountId?: string;
}): ResolvedHeartbeatVisibility {
const { cfg, channel, accountId } = params;
// Webchat uses channel defaults only (no per-channel or per-account config)
if (channel === "webchat") {
const channelDefaults = cfg.channels?.defaults?.heartbeat;
return {
showOk: channelDefaults?.showOk ?? DEFAULT_VISIBILITY.showOk,
showAlerts: channelDefaults?.showAlerts ?? DEFAULT_VISIBILITY.showAlerts,
useIndicator: channelDefaults?.useIndicator ?? DEFAULT_VISIBILITY.useIndicator,
};
}
// Layer 1: Global channel defaults
const channelDefaults = cfg.channels?.defaults?.heartbeat;

View File

@ -44,6 +44,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
@ -88,6 +89,7 @@ describe("security audit", () => {
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});

View File

@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: {
return findings;
}
function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
function collectGatewayConfigFindings(
cfg: ClawdbotConfig,
env: NodeJS.ProcessEnv,
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode });
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...collectAttackSurfaceSummaryFindings(cfg));
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
findings.push(...collectGatewayConfigFindings(cfg));
findings.push(...collectGatewayConfigFindings(cfg, env));
findings.push(...collectBrowserControlFindings(cfg));
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));