From 759c81ceb8ff89dcfeafdfb9b7662761863fff54 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:54:14 -0500 Subject: [PATCH] test: audit heartbeat config honor --- src/config/config-honor-audit.ts | 139 +++++++++++++++++ .../heartbeat-config-honor.inventory.test.ts | 44 ++++++ .../heartbeat-config-honor.inventory.ts | 142 ++++++++++++++++++ src/gateway/config-reload-plan.ts | 5 + src/gateway/config-reload.test.ts | 32 ++++ .../heartbeat-runner.model-override.test.ts | 45 ++++++ 6 files changed, 407 insertions(+) create mode 100644 src/config/config-honor-audit.ts create mode 100644 src/config/heartbeat-config-honor.inventory.test.ts create mode 100644 src/config/heartbeat-config-honor.inventory.ts diff --git a/src/config/config-honor-audit.ts b/src/config/config-honor-audit.ts new file mode 100644 index 00000000000..3690469d397 --- /dev/null +++ b/src/config/config-honor-audit.ts @@ -0,0 +1,139 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { GENERATED_BASE_CONFIG_SCHEMA } from "./schema.base.generated.js"; + +export type ConfigHonorInventoryRow = { + key: string; + schemaPaths: string[]; + typePaths: string[]; + mergePaths: string[]; + consumerPaths: string[]; + reloadPaths: string[]; + testPaths: string[]; + notes?: string[]; +}; + +type ConfigHonorProofKey = + | "schemaPaths" + | "typePaths" + | "mergePaths" + | "consumerPaths" + | "reloadPaths" + | "testPaths"; + +export type ConfigHonorAuditResult = { + schemaKeys: string[]; + missingKeys: string[]; + extraKeys: string[]; + missingSchemaPaths: string[]; + missingFiles: string[]; + missingProofs: Array<{ + key: string; + missing: ConfigHonorProofKey[]; + }>; +}; + +const REPO_ROOT = fileURLToPath(new URL("../../", import.meta.url)); + +function hasSchemaPath(schemaPath: string): boolean { + const segments = schemaPath.split("."); + let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema; + for (const segment of segments) { + if (!current || typeof current !== "object") { + return false; + } + if (segment === "*") { + const items = (current as { items?: unknown }).items; + if (!items || typeof items !== "object") { + return false; + } + current = items; + continue; + } + const properties = (current as { properties?: Record }).properties; + if (!properties || !Object.hasOwn(properties, segment)) { + return false; + } + current = properties[segment]; + } + return true; +} + +export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] { + const keys = new Set(); + for (const prefix of prefixes) { + const segments = prefix.split("."); + let current: unknown = GENERATED_BASE_CONFIG_SCHEMA.schema; + for (const segment of segments) { + if (!current || typeof current !== "object") { + current = null; + break; + } + if (segment === "*") { + current = (current as { items?: unknown }).items ?? null; + continue; + } + current = (current as { properties?: Record }).properties?.[segment] ?? null; + } + const properties = (current as { properties?: Record } | null)?.properties; + if (!properties) { + continue; + } + for (const key of Object.keys(properties)) { + keys.add(key); + } + } + return [...keys].toSorted(); +} + +export function auditConfigHonorInventory(params: { + prefixes: string[]; + rows: ConfigHonorInventoryRow[]; + expectedKeys?: string[]; + repoRoot?: string; +}): ConfigHonorAuditResult { + const repoRoot = params.repoRoot ?? REPO_ROOT; + const schemaKeys = listSchemaLeafKeysForPrefixes(params.prefixes); + const expectedKeys = new Set(params.expectedKeys ?? schemaKeys); + const rowKeys = new Set(params.rows.map((row) => row.key)); + const missingKeys = [...expectedKeys].filter((key) => !rowKeys.has(key)).toSorted(); + const extraKeys = params.rows + .map((row) => row.key) + .filter((key) => !expectedKeys.has(key)) + .toSorted(); + + const missingSchemaPaths = params.rows.flatMap((row) => + row.schemaPaths.filter((schemaPath) => !hasSchemaPath(schemaPath)), + ); + + const missingFiles = params.rows.flatMap((row) => { + const files = [...row.typePaths, ...row.mergePaths, ...row.consumerPaths, ...row.testPaths]; + return files + .filter((relativePath) => !fs.existsSync(path.join(repoRoot, relativePath))) + .map((relativePath) => `${row.key}:${relativePath}`); + }); + + const missingProofs = params.rows + .map((row) => { + const missing: ConfigHonorProofKey[] = [ + row.schemaPaths.length === 0 ? "schemaPaths" : null, + row.typePaths.length === 0 ? "typePaths" : null, + row.mergePaths.length === 0 ? "mergePaths" : null, + row.consumerPaths.length === 0 ? "consumerPaths" : null, + row.reloadPaths.length === 0 ? "reloadPaths" : null, + row.testPaths.length === 0 ? "testPaths" : null, + ].filter((value): value is ConfigHonorProofKey => value !== null); + return missing.length > 0 ? { key: row.key, missing } : null; + }) + .filter((row): row is NonNullable => row !== null); + + return { + schemaKeys, + missingKeys, + extraKeys, + missingSchemaPaths, + missingFiles, + missingProofs, + }; +} diff --git a/src/config/heartbeat-config-honor.inventory.test.ts b/src/config/heartbeat-config-honor.inventory.test.ts new file mode 100644 index 00000000000..749d711a5d2 --- /dev/null +++ b/src/config/heartbeat-config-honor.inventory.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { auditConfigHonorInventory, listSchemaLeafKeysForPrefixes } from "./config-honor-audit.js"; +import { + HEARTBEAT_CONFIG_HONOR_INVENTORY, + HEARTBEAT_CONFIG_PREFIXES, +} from "./heartbeat-config-honor.inventory.js"; + +const EXPECTED_HEARTBEAT_KEYS = [ + "every", + "model", + "prompt", + "ackMaxChars", + "suppressToolErrorWarnings", + "lightContext", + "isolatedSession", + "target", + "to", + "accountId", + "directPolicy", + "includeReasoning", +] as const; + +describe("heartbeat config-honor inventory", () => { + it("keeps the planned heartbeat audit slice aligned with schema leaf keys", () => { + const schemaKeys = listSchemaLeafKeysForPrefixes([...HEARTBEAT_CONFIG_PREFIXES]); + for (const key of EXPECTED_HEARTBEAT_KEYS) { + expect(schemaKeys).toContain(key); + } + }); + + it("covers the planned heartbeat keys with runtime, reload, and test proofs", () => { + const audit = auditConfigHonorInventory({ + prefixes: [...HEARTBEAT_CONFIG_PREFIXES], + expectedKeys: [...EXPECTED_HEARTBEAT_KEYS], + rows: HEARTBEAT_CONFIG_HONOR_INVENTORY, + }); + + expect(audit.missingKeys).toEqual([]); + expect(audit.extraKeys).toEqual([]); + expect(audit.missingSchemaPaths).toEqual([]); + expect(audit.missingFiles).toEqual([]); + expect(audit.missingProofs).toEqual([]); + }); +}); diff --git a/src/config/heartbeat-config-honor.inventory.ts b/src/config/heartbeat-config-honor.inventory.ts new file mode 100644 index 00000000000..953dd1e3482 --- /dev/null +++ b/src/config/heartbeat-config-honor.inventory.ts @@ -0,0 +1,142 @@ +import type { ConfigHonorInventoryRow } from "./config-honor-audit.js"; + +export const HEARTBEAT_CONFIG_PREFIXES = [ + "agents.defaults.heartbeat", + "agents.list.*.heartbeat", +] as const; + +export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ + { + key: "every", + schemaPaths: ["agents.defaults.heartbeat.every", "agents.list.*.heartbeat.every"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: [ + "src/infra/heartbeat-runner.returns-default-unset.test.ts", + "src/gateway/config-reload.test.ts", + ], + }, + { + key: "model", + schemaPaths: ["agents.defaults.heartbeat.model", "agents.list.*.heartbeat.model"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: [ + "src/infra/heartbeat-runner.model-override.test.ts", + "src/gateway/config-reload.test.ts", + ], + }, + { + key: "prompt", + schemaPaths: ["agents.defaults.heartbeat.prompt", "agents.list.*.heartbeat.prompt"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], + }, + { + key: "ackMaxChars", + schemaPaths: ["agents.defaults.heartbeat.ackMaxChars", "agents.list.*.heartbeat.ackMaxChars"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts"], + }, + { + key: "suppressToolErrorWarnings", + schemaPaths: [ + "agents.defaults.heartbeat.suppressToolErrorWarnings", + "agents.list.*.heartbeat.suppressToolErrorWarnings", + ], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"], + }, + { + key: "lightContext", + schemaPaths: ["agents.defaults.heartbeat.lightContext", "agents.list.*.heartbeat.lightContext"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/bootstrap-files.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: [ + "src/infra/heartbeat-runner.model-override.test.ts", + "src/agents/bootstrap-files.test.ts", + "src/gateway/config-reload.test.ts", + ], + }, + { + key: "isolatedSession", + schemaPaths: [ + "agents.defaults.heartbeat.isolatedSession", + "agents.list.*.heartbeat.isolatedSession", + ], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"], + }, + { + key: "target", + schemaPaths: ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], + consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: [ + "src/infra/heartbeat-runner.returns-default-unset.test.ts", + "src/cron/service.main-job-passes-heartbeat-target-last.test.ts", + ], + }, + { + key: "to", + schemaPaths: ["agents.defaults.heartbeat.to", "agents.list.*.heartbeat.to"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], + consumerPaths: ["src/infra/outbound/targets.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], + }, + { + key: "accountId", + schemaPaths: ["agents.defaults.heartbeat.accountId", "agents.list.*.heartbeat.accountId"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], + consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: [ + "src/infra/heartbeat-runner.returns-default-unset.test.ts", + "src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts", + ], + }, + { + key: "directPolicy", + schemaPaths: ["agents.defaults.heartbeat.directPolicy", "agents.list.*.heartbeat.directPolicy"], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], + consumerPaths: ["src/infra/outbound/targets.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], + }, + { + key: "includeReasoning", + schemaPaths: [ + "agents.defaults.heartbeat.includeReasoning", + "agents.list.*.heartbeat.includeReasoning", + ], + typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], + mergePaths: ["src/infra/heartbeat-runner.ts"], + consumerPaths: ["src/infra/heartbeat-runner.ts"], + reloadPaths: ["src/gateway/config-reload-plan.ts"], + testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], + }, +]; diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index eaae31c2743..0baa596fa38 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -73,6 +73,11 @@ const BASE_RELOAD_RULES: ReloadRule[] = [ kind: "hot", actions: ["restart-heartbeat"], }, + { + prefix: "agents.list", + kind: "hot", + actions: ["restart-heartbeat"], + }, { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, { prefix: "browser", kind: "restart" }, diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 12a17567728..97d0b52d400 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -68,6 +68,21 @@ describe("diffConfigPaths", () => { }; expect(diffConfigPaths(prev, next)).toContain("memory.qmd.paths"); }); + + it("collapses changed agents.list heartbeat entries to agents.list", () => { + const prev = { + agents: { + list: [{ id: "ops", heartbeat: { every: "5m", lightContext: false } }], + }, + }; + const next = { + agents: { + list: [{ id: "ops", heartbeat: { every: "5m", lightContext: true } }], + }, + }; + + expect(diffConfigPaths(prev, next)).toEqual(["agents.list"]); + }); }); describe("buildGatewayReloadPlan", () => { @@ -174,6 +189,14 @@ describe("buildGatewayReloadPlan", () => { expect(plan.noopPaths).toEqual([]); }); + it("restarts heartbeat when agents.list entries change", () => { + const plan = buildGatewayReloadPlan(["agents.list"]); + expect(plan.restartGateway).toBe(false); + expect(plan.restartHeartbeat).toBe(true); + expect(plan.hotReasons).toContain("agents.list"); + expect(plan.noopPaths).toEqual([]); + }); + it("hot-reloads health monitor when channelHealthCheckMinutes changes", () => { const plan = buildGatewayReloadPlan(["gateway.channelHealthCheckMinutes"]); expect(plan.restartGateway).toBe(false); @@ -235,6 +258,12 @@ describe("buildGatewayReloadPlan", () => { expectRestartGmailWatcher: true, expectReloadHooks: true, }, + { + path: "agents.list", + expectRestartGateway: false, + expectHotPath: "agents.list", + expectRestartHeartbeat: true, + }, { path: "gateway.remote.url", expectRestartGateway: false, @@ -271,6 +300,9 @@ describe("buildGatewayReloadPlan", () => { if (testCase.expectReloadHooks) { expect(plan.reloadHooks).toBe(true); } + if (testCase.expectRestartHeartbeat) { + expect(plan.restartHeartbeat).toBe(true); + } }); }); diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index e7259c6f2b8..5ec85bda601 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -235,6 +235,51 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }); }); + it("passes per-agent heartbeat lightContext override after merging defaults", async () => { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + heartbeat: { + every: "30m", + lightContext: false, + }, + }, + list: [ + { id: "main", default: true }, + { + id: "ops", + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "whatsapp", + lightContext: true, + }, + }, + ], + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" }); + const result = await runHeartbeatWithSeed({ + seedSession, + cfg, + agentId: "ops", + sessionKey, + }); + + expect(result.replySpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + isHeartbeat: true, + bootstrapContextMode: "lightweight", + }), + cfg, + ); + }); + }); + it("does not pass heartbeatModelOverride when no heartbeat model is configured", async () => { const replyOpts = await runDefaultsHeartbeat({ model: undefined }); expect(replyOpts).toEqual(