Compare commits
5 Commits
main
...
feat/webch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eb0364819 | ||
|
|
1a172ba873 | ||
|
|
1284c3d868 | ||
|
|
cec996c812 | ||
|
|
822663001a |
@ -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.
|
||||
|
||||
@ -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>>;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
178
src/gateway/server-chat.heartbeat-visibility.test.ts
Normal file
178
src/gateway/server-chat.heartbeat-visibility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
broadcast("chat", payload, { dropIfSlow: true });
|
||||
|
||||
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,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
|
||||
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")) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user