diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 939fa92f0..3537972e4 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: { registerAgentRunContext(runId, { sessionKey: params.sessionKey, verboseLevel: params.resolvedVerboseLevel, + isHeartbeat: params.isHeartbeat, }); } let runResult: Awaited>; diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 9ef62e688..8c67767a6 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,8 +1,28 @@ 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"; +/** + * Check if webchat broadcasts should be suppressed for heartbeat runs. + * Returns true if the run is a heartbeat and showOk is false. + */ +function shouldSuppressHeartbeatBroadcast(runId: string): boolean { + const runContext = getAgentRunContext(runId); + if (!runContext?.isHeartbeat) return false; + + try { + const cfg = loadConfig(); + const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); + return !visibility.showOk; + } catch { + // Default to suppressing if we can't load config + return true; + } +} + export type ChatRunEntry = { sessionKey: string; clientRunId: string; @@ -130,7 +150,10 @@ export function createAgentEventHandler({ timestamp: now, }, }; - broadcast("chat", payload, { dropIfSlow: true }); + // Suppress webchat broadcast for heartbeat runs when showOk is false + if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + broadcast("chat", payload, { dropIfSlow: true }); + } nodeSendToSession(sessionKey, "chat", payload); }; @@ -158,7 +181,10 @@ export function createAgentEventHandler({ } : undefined, }; - broadcast("chat", payload); + // Suppress webchat broadcast for heartbeat runs when showOk is false + if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + broadcast("chat", payload); + } nodeSendToSession(sessionKey, "chat", payload); return; } diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index c11dff8ab..5c41c3c95 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -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) { diff --git a/src/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts index 17a7dc128..e98054bbb 100644 --- a/src/infra/heartbeat-visibility.test.ts +++ b/src/infra/heartbeat-visibility.test.ts @@ -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); + }); }); diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts index 75555b878..e4943464c 100644 --- a/src/infra/heartbeat-visibility.ts +++ b/src/infra/heartbeat-visibility.ts @@ -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 { DeliverableMessageChannel, 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;