fix(cli): differentiate gateway-restart hint for hot-loadable agent config sets (#80722) (#80823)

* fix(cli): narrow config hint branch

* Plan config hints from actual changes

* Plan direct unset hints from actual changes

* Expand broad unset hint paths

* fix(cli): respect reload mode in config hints

---------

Co-authored-by: Kiran Magic <kiran@Alices-Laptop.local>
Co-authored-by: kiranmagic7 <262980978+kiranmagic7@users.noreply.github.com>
This commit is contained in:
kiranmagic7
2026-06-29 02:50:54 +05:30
committed by GitHub
parent 91297bf420
commit a54919dcbc
3 changed files with 465 additions and 7 deletions
+322
View File
@@ -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<typeof import("../plugins/plugin-metadata-snapshot.js")>();
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 } };
+129 -7
View File
@@ -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<string>();
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 === "<root>" ? [] : 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<string, unknown>;
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<string, unknown>;
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 });
}
+14
View File
@@ -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: {