From a26b844b88437406ea93d03dec151db207fa918c Mon Sep 17 00:00:00 2001 From: Evan Newman Date: Sat, 4 Apr 2026 10:37:10 -0230 Subject: [PATCH] fix(doctor): avoid repeat talk normalization changes from key order (#59911) Merged via squash. Prepared head SHA: a67bcaa11b22d94bf74a01962ce057356ce31fe3 Co-authored-by: ejames-dev <180847219+ejames-dev@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- CHANGELOG.md | 2 +- src/commands/doctor-config-flow.test.ts | 50 +++++++++++++++++++ .../doctor-legacy-config.migrations.test.ts | 22 ++++++++ src/commands/doctor-legacy-config.ts | 3 +- 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ee7687e34..be74d8b6168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,7 @@ Docs: https://docs.openclaw.ai - Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out. - Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation. - Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker. -- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147. +- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev. ## 2026.4.2 diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index d5c109fa08d..2fa4cf84731 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2017,4 +2017,54 @@ describe("doctor config flow", () => { expect(cfg.channels.googlechat.dm.allowFrom).toEqual(["*"]); expect(cfg.channels.googlechat.allowFrom).toEqual(["*"]); }); + + it("does not report repeat talk provider normalization on consecutive repair runs", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify( + { + talk: { + interruptOnSpeech: true, + silenceTimeoutMs: 1500, + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "secret-key", + voiceId: "voice-123", + modelId: "eleven_v3", + }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + noteSpy.mockClear(); + + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + const secondRunTalkNormalizationLines = noteSpy.mock.calls + .filter((call) => call[1] === "Doctor changes") + .map((call) => String(call[0])) + .filter((line) => line.includes("Normalized talk.provider/providers shape")); + expect(secondRunTalkNormalizationLines).toEqual([]); + } finally { + noteSpy.mockRestore(); + } + }); + }); }); diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 4acf83c5262..f5d0f0e30ff 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -759,6 +759,28 @@ describe("normalizeCompatibilityConfigValues", () => { ]); }); + it("does not report talk provider normalization for semantically identical key ordering differences", () => { + const input = { + talk: { + interruptOnSpeech: true, + silenceTimeoutMs: 1500, + providers: { + elevenlabs: { + apiKey: "secret-key", + voiceId: "voice-123", + modelId: "eleven_v3", + }, + }, + provider: "elevenlabs", + }, + }; + + const res = normalizeCompatibilityConfigValues(input); + + expect(res.config).toEqual(input); + expect(res.changes).toEqual([]); + }); + it("migrates tools.message.allowCrossContextSend to canonical crossContext settings", () => { const res = normalizeCompatibilityConfigValues({ tools: { diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index 0e5c89c47f7..d34cb54433f 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -1,5 +1,6 @@ import { migrateVoiceCallLegacyConfigInput } from "../../extensions/voice-call/config-api.js"; import { normalizeProviderId } from "../agents/model-selection.js"; +import { isDeepStrictEqual } from "node:util"; import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveNormalizedProviderModelMaxTokens } from "../config/defaults.js"; @@ -386,7 +387,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { return; } - const sameShape = JSON.stringify(normalizedTalk) === JSON.stringify(rawTalk); + const sameShape = isDeepStrictEqual(normalizedTalk, rawTalk); if (sameShape) { return; }