fix(doctor): avoid repeat talk normalization changes from key order (#59911)

Merged via squash.

Prepared head SHA: a67bcaa11b
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
This commit is contained in:
Evan Newman
2026-04-04 10:37:10 -02:30
committed by GitHub
parent 022618e887
commit a26b844b88
4 changed files with 75 additions and 2 deletions

View File

@@ -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

View File

@@ -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();
}
});
});
});

View File

@@ -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: {

View File

@@ -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;
}