Skip to content

Commit a54919d

Browse files
kiranmagic7Kiran Magic
andauthored
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 <[email protected]> Co-authored-by: kiranmagic7 <[email protected]>
1 parent 91297bf commit a54919d

3 files changed

Lines changed: 465 additions & 7 deletions

File tree

src/cli/config-cli.test.ts

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,43 @@ vi.mock("../config/runtime-schema.js", () => ({
5252
readBestEffortRuntimeConfigSchema: () => mockReadBestEffortRuntimeConfigSchema(),
5353
}));
5454

55+
vi.mock("../gateway/config-reload-plan.js", () => ({
56+
buildGatewayReloadPlan: (changedPaths: string[]) => {
57+
const restartReasons = changedPaths.filter(
58+
(changedPath) =>
59+
changedPath.startsWith("models.pricing.") || changedPath.startsWith("plugins.load."),
60+
);
61+
const hotReasons = changedPaths.filter(
62+
(changedPath) =>
63+
!restartReasons.includes(changedPath) &&
64+
(changedPath.startsWith("agents.list.") ||
65+
changedPath.startsWith("agents.defaults.models.") ||
66+
changedPath.startsWith("models.") ||
67+
changedPath.startsWith("plugins.")),
68+
);
69+
restartReasons.push(
70+
...changedPaths.filter(
71+
(changedPath) => !hotReasons.includes(changedPath) && !restartReasons.includes(changedPath),
72+
),
73+
);
74+
return {
75+
changedPaths,
76+
restartGateway: restartReasons.length > 0,
77+
restartReasons,
78+
hotReasons,
79+
reloadHooks: false,
80+
restartGmailWatcher: false,
81+
restartCron: false,
82+
restartHeartbeat: hotReasons.length > 0,
83+
restartHealthMonitor: false,
84+
reloadPlugins: false,
85+
restartChannels: new Set(),
86+
disposeMcpRuntimes: false,
87+
noopPaths: [],
88+
};
89+
},
90+
}));
91+
5592
vi.mock("../plugins/plugin-metadata-snapshot.js", async (importOriginal) => {
5693
const actual = await importOriginal<typeof import("../plugins/plugin-metadata-snapshot.js")>();
5794
return {
@@ -3425,6 +3462,291 @@ describe("config cli", () => {
34253462
});
34263463
});
34273464

3465+
describe("config apply hints - issue #80722", () => {
3466+
it("prints a hot-reload hint for agents.list model changes", async () => {
3467+
const resolved: OpenClawConfig = {
3468+
agents: {
3469+
list: [
3470+
{ id: "main" },
3471+
{ id: "mason-vale", model: { primary: "ollama/qwen3-coder-next" } },
3472+
],
3473+
},
3474+
};
3475+
setSnapshot(resolved, withRuntimeDefaults(resolved));
3476+
3477+
await runConfigCommand([
3478+
"config",
3479+
"set",
3480+
"agents.list[1].model.primary",
3481+
'"ollama/kimi-k2.6"',
3482+
"--strict-json",
3483+
]);
3484+
3485+
expectLogIncludes("Updated agents.list.1.model.primary");
3486+
expectLogIncludes("Change will apply without restarting the gateway.");
3487+
expectLogExcludes("Restart the gateway to apply.");
3488+
});
3489+
3490+
it("does not treat legacy per-agent agentRuntime as restart-required", async () => {
3491+
const resolved: OpenClawConfig = {
3492+
agents: {
3493+
list: [
3494+
{
3495+
id: "codex-legacy",
3496+
agentRuntime: { id: "codex" },
3497+
model: { primary: "openai/gpt-5.5" },
3498+
},
3499+
],
3500+
},
3501+
} as unknown as OpenClawConfig;
3502+
setSnapshot(resolved, withRuntimeDefaults(resolved));
3503+
3504+
await runConfigCommand([
3505+
"config",
3506+
"set",
3507+
"agents.list[0].model.primary",
3508+
'"openai/gpt-5.4-mini"',
3509+
"--strict-json",
3510+
]);
3511+
3512+
expectLogIncludes("Change will apply without restarting the gateway.");
3513+
expectLogExcludes("Restart the gateway to apply.");
3514+
});
3515+
3516+
it("keeps the restart hint for hot-path edits when reload mode is off", async () => {
3517+
const resolved: OpenClawConfig = {
3518+
agents: {
3519+
list: [{ id: "main", model: { primary: "openai/gpt-5.4" } }],
3520+
},
3521+
gateway: {
3522+
reload: { mode: "off" },
3523+
},
3524+
};
3525+
setSnapshot(resolved, withRuntimeDefaults(resolved));
3526+
3527+
await runConfigCommand([
3528+
"config",
3529+
"set",
3530+
"agents.list[0].model.primary",
3531+
'"openai/gpt-5.5"',
3532+
"--strict-json",
3533+
]);
3534+
3535+
expectLogIncludes("Updated agents.list.0.model.primary");
3536+
expectLogIncludes("Restart the gateway to apply.");
3537+
expectLogExcludes("Change will apply without restarting the gateway.");
3538+
});
3539+
3540+
it("keeps the restart hint for hot-path edits when reload mode is restart", async () => {
3541+
const resolved: OpenClawConfig = {
3542+
agents: {
3543+
list: [{ id: "main", model: { primary: "openai/gpt-5.4" } }],
3544+
},
3545+
gateway: {
3546+
reload: { mode: "restart" },
3547+
},
3548+
};
3549+
setSnapshot(resolved, withRuntimeDefaults(resolved));
3550+
3551+
await runConfigCommand([
3552+
"config",
3553+
"set",
3554+
"agents.list[0].model.primary",
3555+
'"openai/gpt-5.5"',
3556+
"--strict-json",
3557+
]);
3558+
3559+
expectLogIncludes("Updated agents.list.0.model.primary");
3560+
expectLogIncludes("Restart the gateway to apply.");
3561+
expectLogExcludes("Change will apply without restarting the gateway.");
3562+
});
3563+
3564+
it("prints a hot-reload hint when removing legacy per-agent agentRuntime", async () => {
3565+
const resolved: OpenClawConfig = {
3566+
agents: {
3567+
list: [
3568+
{
3569+
id: "codex-legacy",
3570+
agentRuntime: { id: "codex" },
3571+
},
3572+
],
3573+
},
3574+
} as unknown as OpenClawConfig;
3575+
setSnapshot(resolved, withRuntimeDefaults(resolved));
3576+
3577+
await runConfigCommand(["config", "unset", "agents.list[0].agentRuntime"]);
3578+
3579+
expectLogIncludes("Removed agents.list[0].agentRuntime");
3580+
expectLogIncludes("Change will apply without restarting the gateway.");
3581+
expectLogExcludes("Restart the gateway to apply.");
3582+
});
3583+
3584+
it("prints a hot-reload hint for provider runtime policy changes", async () => {
3585+
const resolved: OpenClawConfig = {
3586+
models: {
3587+
providers: {
3588+
openai: {},
3589+
},
3590+
},
3591+
} as unknown as OpenClawConfig;
3592+
setSnapshot(resolved, resolved);
3593+
3594+
await runConfigCommand([
3595+
"config",
3596+
"set",
3597+
"models.providers.openai.agentRuntime.id",
3598+
'"pi"',
3599+
"--strict-json",
3600+
]);
3601+
3602+
expectLogIncludes("Updated models.providers.openai.agentRuntime.id");
3603+
expectLogIncludes("Change will apply without restarting the gateway.");
3604+
expectLogExcludes("Restart the gateway to apply.");
3605+
});
3606+
3607+
it("keeps the restart hint for broad models writes that change pricing bootstrap", async () => {
3608+
const resolved: OpenClawConfig = {
3609+
models: {
3610+
pricing: {
3611+
enabled: false,
3612+
},
3613+
providers: {
3614+
openai: {
3615+
agentRuntime: { id: "node" },
3616+
},
3617+
},
3618+
},
3619+
} as unknown as OpenClawConfig;
3620+
setSnapshot(resolved, resolved);
3621+
3622+
await runConfigCommand([
3623+
"config",
3624+
"set",
3625+
"models",
3626+
'{"pricing":{"enabled":true},"providers":{"openai":{"agentRuntime":{"id":"node"}}}}',
3627+
"--strict-json",
3628+
"--replace",
3629+
]);
3630+
3631+
expectLogIncludes("Updated models. Restart the gateway to apply.");
3632+
expectLogExcludes("Change will apply without restarting the gateway.");
3633+
});
3634+
3635+
it("keeps the restart hint for broad plugins writes that change load paths", async () => {
3636+
const resolved: OpenClawConfig = {
3637+
plugins: {
3638+
load: {
3639+
paths: ["/tmp/openclaw-plugins-a"],
3640+
},
3641+
entries: {
3642+
canvas: { enabled: true },
3643+
},
3644+
},
3645+
} as unknown as OpenClawConfig;
3646+
setSnapshot(resolved, resolved);
3647+
3648+
await runConfigCommand([
3649+
"config",
3650+
"set",
3651+
"plugins",
3652+
'{"load":{"paths":["/tmp/openclaw-plugins-b"]},"entries":{"canvas":{"enabled":true}}}',
3653+
"--strict-json",
3654+
"--replace",
3655+
]);
3656+
3657+
expectLogIncludes("Updated plugins. Restart the gateway to apply.");
3658+
expectLogExcludes("Change will apply without restarting the gateway.");
3659+
});
3660+
3661+
it("keeps the restart hint for broad models unsets that remove pricing bootstrap", async () => {
3662+
const resolved: OpenClawConfig = {
3663+
models: {
3664+
pricing: {
3665+
enabled: false,
3666+
},
3667+
providers: {
3668+
openai: {
3669+
agentRuntime: { id: "node" },
3670+
},
3671+
},
3672+
},
3673+
} as unknown as OpenClawConfig;
3674+
setSnapshot(resolved, resolved);
3675+
3676+
await runConfigCommand(["config", "unset", "models"]);
3677+
3678+
expectLogIncludes("Removed models. Restart the gateway to apply.");
3679+
expectLogExcludes("Change will apply without restarting the gateway.");
3680+
});
3681+
3682+
it("keeps the restart hint for broad plugins unsets that remove load paths", async () => {
3683+
const resolved: OpenClawConfig = {
3684+
plugins: {
3685+
load: {
3686+
paths: ["/tmp/openclaw-plugins-a"],
3687+
},
3688+
entries: {
3689+
canvas: { enabled: true },
3690+
},
3691+
},
3692+
} as unknown as OpenClawConfig;
3693+
setSnapshot(resolved, resolved);
3694+
3695+
await runConfigCommand(["config", "unset", "plugins"]);
3696+
3697+
expectLogIncludes("Removed plugins. Restart the gateway to apply.");
3698+
expectLogExcludes("Change will apply without restarting the gateway.");
3699+
});
3700+
3701+
it("keeps the restart hint for restart-required config paths", async () => {
3702+
const resolved: OpenClawConfig = {
3703+
agents: { list: [{ id: "main" }] },
3704+
gateway: { port: 18789 },
3705+
};
3706+
setSnapshot(resolved, withRuntimeDefaults(resolved));
3707+
3708+
await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);
3709+
3710+
expectLogIncludes("Restart the gateway to apply.");
3711+
expectLogExcludes("Change will apply without restarting the gateway.");
3712+
});
3713+
3714+
it("keeps plugin entry config writes restart-backed when reload metadata is absent", async () => {
3715+
const resolved: OpenClawConfig = {
3716+
plugins: {
3717+
entries: {
3718+
canvas: { enabled: true },
3719+
},
3720+
},
3721+
} as unknown as OpenClawConfig;
3722+
setSnapshot(resolved, resolved);
3723+
3724+
await runConfigCommand(["config", "set", "plugins.entries.canvas.enabled", "false"]);
3725+
3726+
expectLogIncludes("Updated plugins.entries.canvas.enabled");
3727+
expectLogIncludes("Restart the gateway to apply.");
3728+
expectLogExcludes("Change will apply without restarting the gateway.");
3729+
});
3730+
3731+
it("keeps the restart hint for mixed hot and restart batch updates", async () => {
3732+
const resolved: OpenClawConfig = {
3733+
agents: { list: [{ id: "main", model: { primary: "openai/gpt-5.4" } }] },
3734+
gateway: { port: 18789 },
3735+
};
3736+
setSnapshot(resolved, withRuntimeDefaults(resolved));
3737+
3738+
await runConfigCommand([
3739+
"config",
3740+
"set",
3741+
"--batch-json",
3742+
'[{"path":"agents.list[0].model.primary","value":"openai/gpt-5.5"},{"path":"gateway.auth.mode","value":"token"}]',
3743+
]);
3744+
3745+
expectLogIncludes("Updated 2 config paths. Restart the gateway to apply.");
3746+
expectLogExcludes("Change will apply without restarting the gateway.");
3747+
});
3748+
});
3749+
34283750
describe("config file", () => {
34293751
it("prints the active config file path", async () => {
34303752
const resolved: OpenClawConfig = { gateway: { port: 18789 } };

0 commit comments

Comments
 (0)