diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index eaa83f7e30be..a98bf069c3bc 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -52,6 +52,43 @@ vi.mock("../config/runtime-schema.js", () => ({ readBestEffortRuntimeConfigSchema: () => mockReadBestEffortRuntimeConfigSchema(), })); +vi.mock("../gateway/config-reload-plan.js", () => ({ + buildGatewayReloadPlan: (changedPaths: string[]) => { + const restartReasons = changedPaths.filter( + (changedPath) => + changedPath.startsWith("models.pricing.") || changedPath.startsWith("plugins.load."), + ); + const hotReasons = changedPaths.filter( + (changedPath) => + !restartReasons.includes(changedPath) && + (changedPath.startsWith("agents.list.") || + changedPath.startsWith("agents.defaults.models.") || + changedPath.startsWith("models.") || + changedPath.startsWith("plugins.")), + ); + restartReasons.push( + ...changedPaths.filter( + (changedPath) => !hotReasons.includes(changedPath) && !restartReasons.includes(changedPath), + ), + ); + return { + changedPaths, + restartGateway: restartReasons.length > 0, + restartReasons, + hotReasons, + reloadHooks: false, + restartGmailWatcher: false, + restartCron: false, + restartHeartbeat: hotReasons.length > 0, + restartHealthMonitor: false, + reloadPlugins: false, + restartChannels: new Set(), + disposeMcpRuntimes: false, + noopPaths: [], + }; + }, +})); + vi.mock("../plugins/plugin-metadata-snapshot.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -3425,6 +3462,291 @@ describe("config cli", () => { }); }); + describe("config apply hints - issue #80722", () => { + it("prints a hot-reload hint for agents.list model changes", async () => { + const resolved: OpenClawConfig = { + agents: { + list: [ + { id: "main" }, + { id: "mason-vale", model: { primary: "ollama/qwen3-coder-next" } }, + ], + }, + }; + setSnapshot(resolved, withRuntimeDefaults(resolved)); + + await runConfigCommand([ + "config", + "set", + "agents.list[1].model.primary", + '"ollama/kimi-k2.6"', + "--strict-json", + ]); + + expectLogIncludes("Updated agents.list.1.model.primary"); + expectLogIncludes("Change will apply without restarting the gateway."); + expectLogExcludes("Restart the gateway to apply."); + }); + + it("does not treat legacy per-agent agentRuntime as restart-required", async () => { + const resolved: OpenClawConfig = { + agents: { + list: [ + { + id: "codex-legacy", + agentRuntime: { id: "codex" }, + model: { primary: "openai/gpt-5.5" }, + }, + ], + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, withRuntimeDefaults(resolved)); + + await runConfigCommand([ + "config", + "set", + "agents.list[0].model.primary", + '"openai/gpt-5.4-mini"', + "--strict-json", + ]); + + expectLogIncludes("Change will apply without restarting the gateway."); + expectLogExcludes("Restart the gateway to apply."); + }); + + it("keeps the restart hint for hot-path edits when reload mode is off", async () => { + const resolved: OpenClawConfig = { + agents: { + list: [{ id: "main", model: { primary: "openai/gpt-5.4" } }], + }, + gateway: { + reload: { mode: "off" }, + }, + }; + setSnapshot(resolved, withRuntimeDefaults(resolved)); + + await runConfigCommand([ + "config", + "set", + "agents.list[0].model.primary", + '"openai/gpt-5.5"', + "--strict-json", + ]); + + expectLogIncludes("Updated agents.list.0.model.primary"); + expectLogIncludes("Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("keeps the restart hint for hot-path edits when reload mode is restart", async () => { + const resolved: OpenClawConfig = { + agents: { + list: [{ id: "main", model: { primary: "openai/gpt-5.4" } }], + }, + gateway: { + reload: { mode: "restart" }, + }, + }; + setSnapshot(resolved, withRuntimeDefaults(resolved)); + + await runConfigCommand([ + "config", + "set", + "agents.list[0].model.primary", + '"openai/gpt-5.5"', + "--strict-json", + ]); + + expectLogIncludes("Updated agents.list.0.model.primary"); + expectLogIncludes("Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("prints a hot-reload hint when removing legacy per-agent agentRuntime", async () => { + const resolved: OpenClawConfig = { + agents: { + list: [ + { + id: "codex-legacy", + agentRuntime: { id: "codex" }, + }, + ], + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, withRuntimeDefaults(resolved)); + + await runConfigCommand(["config", "unset", "agents.list[0].agentRuntime"]); + + expectLogIncludes("Removed agents.list[0].agentRuntime"); + expectLogIncludes("Change will apply without restarting the gateway."); + expectLogExcludes("Restart the gateway to apply."); + }); + + it("prints a hot-reload hint for provider runtime policy changes", async () => { + const resolved: OpenClawConfig = { + models: { + providers: { + openai: {}, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "models.providers.openai.agentRuntime.id", + '"pi"', + "--strict-json", + ]); + + expectLogIncludes("Updated models.providers.openai.agentRuntime.id"); + expectLogIncludes("Change will apply without restarting the gateway."); + expectLogExcludes("Restart the gateway to apply."); + }); + + it("keeps the restart hint for broad models writes that change pricing bootstrap", async () => { + const resolved: OpenClawConfig = { + models: { + pricing: { + enabled: false, + }, + providers: { + openai: { + agentRuntime: { id: "node" }, + }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "models", + '{"pricing":{"enabled":true},"providers":{"openai":{"agentRuntime":{"id":"node"}}}}', + "--strict-json", + "--replace", + ]); + + expectLogIncludes("Updated models. Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("keeps the restart hint for broad plugins writes that change load paths", async () => { + const resolved: OpenClawConfig = { + plugins: { + load: { + paths: ["/tmp/openclaw-plugins-a"], + }, + entries: { + canvas: { enabled: true }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand([ + "config", + "set", + "plugins", + '{"load":{"paths":["/tmp/openclaw-plugins-b"]},"entries":{"canvas":{"enabled":true}}}', + "--strict-json", + "--replace", + ]); + + expectLogIncludes("Updated plugins. Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("keeps the restart hint for broad models unsets that remove pricing bootstrap", async () => { + const resolved: OpenClawConfig = { + models: { + pricing: { + enabled: false, + }, + providers: { + openai: { + agentRuntime: { id: "node" }, + }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "unset", "models"]); + + expectLogIncludes("Removed models. Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("keeps the restart hint for broad plugins unsets that remove load paths", async () => { + const resolved: OpenClawConfig = { + plugins: { + load: { + paths: ["/tmp/openclaw-plugins-a"], + }, + entries: { + canvas: { enabled: true }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "unset", "plugins"]); + + expectLogIncludes("Removed plugins. Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("keeps the restart hint for restart-required config paths", async () => { + const resolved: OpenClawConfig = { + agents: { list: [{ id: "main" }] }, + gateway: { port: 18789 }, + }; + setSnapshot(resolved, withRuntimeDefaults(resolved)); + + await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]); + + expectLogIncludes("Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("keeps plugin entry config writes restart-backed when reload metadata is absent", async () => { + const resolved: OpenClawConfig = { + plugins: { + entries: { + canvas: { enabled: true }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "plugins.entries.canvas.enabled", "false"]); + + expectLogIncludes("Updated plugins.entries.canvas.enabled"); + expectLogIncludes("Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + + it("keeps the restart hint for mixed hot and restart batch updates", async () => { + const resolved: OpenClawConfig = { + agents: { list: [{ id: "main", model: { primary: "openai/gpt-5.4" } }] }, + gateway: { port: 18789 }, + }; + setSnapshot(resolved, withRuntimeDefaults(resolved)); + + await runConfigCommand([ + "config", + "set", + "--batch-json", + '[{"path":"agents.list[0].model.primary","value":"openai/gpt-5.5"},{"path":"gateway.auth.mode","value":"token"}]', + ]); + + expectLogIncludes("Updated 2 config paths. Restart the gateway to apply."); + expectLogExcludes("Change will apply without restarting the gateway."); + }); + }); + describe("config file", () => { it("prints the active config file path", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 } }; diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 020d5d720df8..c4e31a844322 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -23,7 +23,6 @@ import { normalizeAgentModelRefForConfig, } from "../config/model-input.js"; import { CONFIG_PATH } from "../config/paths.js"; -import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js"; @@ -42,8 +41,12 @@ import { validateConfigObjectRawWithPlugins, } from "../config/validation.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; +import { diffConfigPaths } from "../gateway/config-diff.js"; +import { buildGatewayReloadPlan } from "../gateway/config-reload-plan.js"; +import { resolveGatewayReloadSettings } from "../gateway/config-reload-settings.js"; import { danger, info, success, warn } from "../globals.js"; import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -1001,6 +1004,114 @@ function toDotPath(path: PathSegment[]): string { return path.join("."); } +const RESTART_HINT = "Restart the gateway to apply."; +const HOT_RELOAD_HINT = "Change will apply without restarting the gateway."; +const NO_RELOAD_HINT = "No gateway restart needed."; + +function isPluginEntryConfigPath(path: string): boolean { + // CLI hints are operator guidance. Keep plugin entry writes conservative + // because the CLI cannot prove every plugin's reload metadata is loaded. + return path === "plugins.entries" || path.startsWith("plugins.entries."); +} + +function configApplyHintForPaths(paths: string[], afterConfig: OpenClawConfig): string { + if (paths.length === 0) { + return RESTART_HINT; + } + if (paths.some(isPluginEntryConfigPath)) { + return RESTART_HINT; + } + const plan = buildGatewayReloadPlan(paths); + if (plan.restartGateway) { + return RESTART_HINT; + } + if (plan.hotReasons.length > 0) { + const { mode } = resolveGatewayReloadSettings(afterConfig); + if (mode === "off" || mode === "restart") { + return RESTART_HINT; + } + return HOT_RELOAD_HINT; + } + return NO_RELOAD_HINT; +} + +function configApplyHintForOperations( + operations: ReadonlyArray<{ requestedPath?: PathSegment[] }>, + beforeConfig: OpenClawConfig, + afterConfig: OpenClawConfig, +): string { + const requestedPaths: string[] = []; + for (const operation of operations) { + if (!operation.requestedPath) { + return RESTART_HINT; + } + requestedPaths.push(toDotPath(operation.requestedPath)); + } + return configApplyHintForPaths( + expandActualChangedPathsWithRequestedDescendants( + diffConfigPaths(beforeConfig, afterConfig), + requestedPaths, + beforeConfig, + afterConfig, + ), + afterConfig, + ); +} + +function expandActualChangedPathsWithRequestedDescendants( + actualChangedPaths: string[], + requestedPaths: string[], + beforeConfig: OpenClawConfig, + afterConfig: OpenClawConfig, +): string[] { + const expanded = new Set(); + for (const actualPath of actualChangedPaths) { + const requestedDescendants = requestedPaths.filter( + (requestedPath) => requestedPath !== actualPath && requestedPath.startsWith(`${actualPath}.`), + ); + if (requestedDescendants.length > 0) { + for (const requestedPath of requestedDescendants) { + expanded.add(requestedPath); + } + continue; + } + for (const expandedPath of expandWholeValueChangePath(actualPath, beforeConfig, afterConfig)) { + expanded.add(expandedPath); + } + } + return [...expanded]; +} + +function expandWholeValueChangePath( + actualPath: string, + beforeConfig: OpenClawConfig, + afterConfig: OpenClawConfig, +): string[] { + const path = actualPath === "" ? [] : actualPath.split("."); + const before = getAtPath(beforeConfig, path); + const after = getAtPath(afterConfig, path); + if (before.found && !after.found) { + return collectChangedLeafPaths(before.value, actualPath); + } + if (!before.found && after.found) { + return collectChangedLeafPaths(after.value, actualPath); + } + return [actualPath]; +} + +function collectChangedLeafPaths(value: unknown, prefix: string): string[] { + if (!isPlainRecord(value)) { + return [prefix]; + } + const entries = Object.entries(value); + if (entries.length === 0) { + return [prefix]; + } + return entries.flatMap(([key, child]) => + collectChangedLeafPaths(child, prefix ? `${prefix}.${key}` : key), + ); +} + function parseSecretRefSource(raw: string, label: string): SecretRefSource { const source = raw.trim(); if (source === "env" || source === "file" || source === "exec") { @@ -2011,6 +2122,9 @@ async function runConfigOperations(params: { // instead of snapshot.config (runtime-merged with defaults). // This prevents runtime defaults from leaking into the written config file (issue #6070) const next = structuredClone(snapshot.resolved) as Record; + const currentConfigForApplyHint = normalizeConfigMutationModelRefs( + structuredClone(snapshot.resolved) as OpenClawConfig, + ); const mutationSchema = await loadConfigMutationSchema(); const unsetPaths: PathSegment[][] = []; const explicitSetPaths: PathSegment[][] = []; @@ -2197,16 +2311,16 @@ async function runConfigOperations(params: { if (params.successMode === "set" && operations.length === 1) { const operation = operations[0]; const action = operation?.mutation === "delete" ? "Removed" : "Updated"; - runtime.log( - info(`${action} ${toDotPath(operation?.requestedPath ?? [])}. Restart the gateway to apply.`), - ); + const hint = configApplyHintForOperations(operations, currentConfigForApplyHint, nextConfig); + runtime.log(info(`${action} ${toDotPath(operation?.requestedPath ?? [])}. ${hint}`)); return; } + const hint = configApplyHintForOperations(operations, currentConfigForApplyHint, nextConfig); if (params.successMode === "set") { - runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`)); + runtime.log(info(`Updated ${operations.length} config paths. ${hint}`)); return; } - runtime.log(info(`Applied ${operations.length} config update(s). Restart the gateway to apply.`)); + runtime.log(info(`Applied ${operations.length} config update(s). ${hint}`)); } function handleConfigMutationError(params: { @@ -2367,6 +2481,9 @@ export async function runConfigUnset(opts: { // instead of snapshot.config (runtime-merged with defaults). // This prevents runtime defaults from leaking into the written config file (issue #6070) const next = structuredClone(snapshot.resolved) as Record; + const currentConfigForApplyHint = normalizeConfigMutationModelRefs( + structuredClone(snapshot.resolved) as OpenClawConfig, + ); const unsetResult = unsetAtPath(next, parsedPath); if (!unsetResult.removed) { if (cliOptions.dryRun && cliOptions.json) { @@ -2414,7 +2531,12 @@ export async function runConfigUnset(opts: { ? {} : { writeOptions: { unsetPaths: [parsedPath] } }), }); - runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`)); + const hint = configApplyHintForOperations( + [buildUnsetOperation(parsedPath)], + currentConfigForApplyHint, + normalizeConfigMutationModelRefs(structuredClone(next) as OpenClawConfig), + ); + runtime.log(info(`Removed ${opts.path}. ${hint}`)); } catch (err) { handleConfigMutationError({ err, runtime, options: cliOptions }); } diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 048a1d8efbb0..822b9475fc45 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -175,6 +175,12 @@ describe("buildGatewayReloadPlan", () => { registration: { restartPrefixes: ["browser"] }, source: "test", }, + { + pluginId: "canvas", + pluginName: "Canvas", + registration: { restartPrefixes: ["plugins.entries.canvas"] }, + source: "test", + }, ]; beforeEach(() => { @@ -374,6 +380,14 @@ describe("buildGatewayReloadPlan", () => { expect(plan.hotReasons).toContain("plugins.entries.lossless-claw.config.mode"); }); + it("keeps restart-owned plugin entry config changes restart-backed", () => { + const plan = buildGatewayReloadPlan(["plugins.entries.canvas.enabled"]); + + expect(plan.restartGateway).toBe(true); + expect(plan.restartReasons).toEqual(["plugins.entries.canvas.enabled"]); + expect(plan.hotReasons).toStrictEqual([]); + }); + it("lists plugin install metadata and whole-record paths structurally", () => { const prev = { plugins: {