From 1d0e9a907e6d98329f852409bce18a52332a9a22 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Apr 2026 00:28:35 -0700 Subject: [PATCH] fix(doctor): migrate legacy tts enabled toggles --- CHANGELOG.md | 1 + .../doctor/shared/deprecation-compat.ts | 12 ++ ...acy-config-migrate.provider-shapes.test.ts | 82 +++++++++ .../legacy-config-migrations.runtime.tts.ts | 156 +++++++++++++++++- src/cron/cron-protocol-schema.test.ts | 16 ++ src/gateway/protocol/schema/cron.ts | 16 +- 6 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 src/cron/cron-protocol-schema.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5965e9ddfc8..4772f195723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus. +- Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. Thanks @vincentkoc. - CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator. - MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc. - Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW. diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index 9067eeaf724..f5953907aeb 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -183,6 +183,18 @@ export const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ docsPath: "/tools/tts", tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }), + deprecatedCompatRecord({ + code: "doctor-tts-enabled-auto-mode", + owner: "tts", + introduced: "2026-04-29", + source: + "messages.tts.enabled, agents.*.tts.enabled, channels.*.tts.enabled, and voice-call plugin tts.enabled", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts", + replacement: + 'messages/agents/channels/plugins TTS auto mode, for example auto: "always" or auto: "off"', + docsPath: "/tools/tts", + tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"], + }), deprecatedCompatRecord({ code: "doctor-plugin-install-config-ledger", owner: "plugin", diff --git a/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts b/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts index a6999f1ae9b..fbe13c7c04d 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts @@ -83,6 +83,88 @@ describe("legacy migrate provider-shaped config", () => { }); }); + it("moves legacy tts enabled toggles to auto mode in known config locations", () => { + const res = migrateLegacyConfig({ + messages: { + tts: { + enabled: true, + }, + }, + agents: { + defaults: { + tts: { + enabled: false, + }, + }, + list: [ + { + id: "voice-agent", + tts: { + enabled: true, + auto: "tagged", + }, + }, + ], + }, + channels: { + discord: { + tts: { + enabled: true, + }, + accounts: { + primary: { + tts: { + enabled: false, + }, + }, + }, + }, + }, + plugins: { + entries: { + "voice-call": { + config: { + tts: { + enabled: true, + }, + }, + }, + }, + }, + }); + + expect(res.changes).toEqual([ + 'Moved messages.tts.enabled → messages.tts.auto "always".', + 'Moved agents.defaults.tts.enabled → agents.defaults.tts.auto "off".', + "Removed agents.list[0].tts.enabled because agents.list[0].tts.auto is already set.", + 'Moved channels.discord.tts.enabled → channels.discord.tts.auto "always".', + 'Moved channels.discord.accounts.primary.tts.enabled → channels.discord.accounts.primary.tts.auto "off".', + 'Moved plugins.entries.voice-call.config.tts.enabled → plugins.entries.voice-call.config.tts.auto "always".', + ]); + expect(res.config).toMatchObject({ + messages: { tts: { auto: "always" } }, + agents: { + defaults: { tts: { auto: "off" } }, + list: [{ id: "voice-agent", tts: { auto: "tagged" } }], + }, + channels: { + discord: { + tts: { auto: "always" }, + accounts: { primary: { tts: { auto: "off" } } }, + }, + }, + plugins: { + entries: { + "voice-call": { + config: { + tts: { auto: "always" }, + }, + }, + }, + }, + }); + }); + it("moves plugins.entries.voice-call.config.tts. keys into providers", () => { const res = migrateLegacyConfig({ plugins: { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts index d3531b34fc2..10a7cd8c538 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts @@ -44,6 +44,57 @@ function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean { }); } +function hasLegacyTtsEnabled(value: unknown): boolean { + return typeof getRecord(value)?.enabled === "boolean"; +} + +function hasLegacyTtsEnabledInAgentLocations(value: unknown): boolean { + const agents = getRecord(value); + if (hasLegacyTtsEnabled(getRecord(getRecord(agents?.defaults)?.tts))) { + return true; + } + const agentList = Array.isArray(agents?.list) ? agents.list : []; + return agentList.some((entry) => hasLegacyTtsEnabled(getRecord(getRecord(entry)?.tts))); +} + +function hasLegacyTtsEnabledInChannelLocations(value: unknown): boolean { + const channels = getRecord(value); + for (const [channelId, channelValue] of Object.entries(channels ?? {})) { + if (isBlockedObjectKey(channelId)) { + continue; + } + const channel = getRecord(channelValue); + if (hasLegacyTtsEnabled(getRecord(channel?.tts))) { + return true; + } + const accounts = getRecord(channel?.accounts); + for (const [accountId, accountValue] of Object.entries(accounts ?? {})) { + if (isBlockedObjectKey(accountId)) { + continue; + } + if (hasLegacyTtsEnabled(getRecord(getRecord(accountValue)?.tts))) { + return true; + } + } + } + return false; +} + +function hasLegacyTtsEnabledInPluginLocations(value: unknown): boolean { + const entries = getRecord(value); + if (!entries) { + return false; + } + return Object.entries(entries).some(([pluginId, entryValue]) => { + if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) { + return false; + } + const entry = getRecord(entryValue); + const config = getRecord(entry?.config); + return hasLegacyTtsEnabled(getRecord(config?.tts)); + }); +} + function getOrCreateTtsProviders(tts: Record): Record { const providers = getRecord(tts.providers) ?? {}; tts.providers = providers; @@ -121,7 +172,73 @@ function migrateLegacyTtsConfig( } } -const LEGACY_TTS_RULES: LegacyConfigRule[] = [ +function migrateLegacyTtsEnabled( + tts: Record | null | undefined, + pathLabel: string, + changes: string[], +): void { + if (!tts || typeof tts.enabled !== "boolean") { + return; + } + const nextAuto = tts.enabled ? "always" : "off"; + delete tts.enabled; + if (typeof tts.auto === "string" && tts.auto.trim()) { + changes.push(`Removed ${pathLabel}.enabled because ${pathLabel}.auto is already set.`); + return; + } + tts.auto = nextAuto; + changes.push(`Moved ${pathLabel}.enabled → ${pathLabel}.auto "${nextAuto}".`); +} + +function visitKnownTtsConfigLocations( + raw: Record, + visit: (tts: Record | null | undefined, pathLabel: string) => void, +): void { + const messages = getRecord(raw.messages); + visit(getRecord(messages?.tts), "messages.tts"); + + const agents = getRecord(raw.agents); + const agentDefaults = getRecord(agents?.defaults); + visit(getRecord(agentDefaults?.tts), "agents.defaults.tts"); + + const agentList = Array.isArray(agents?.list) ? agents.list : []; + agentList.forEach((entry, index) => { + const agent = getRecord(entry); + visit(getRecord(agent?.tts), `agents.list[${index}].tts`); + }); + + const channels = getRecord(raw.channels); + for (const [channelId, channelValue] of Object.entries(channels ?? {})) { + if (isBlockedObjectKey(channelId)) { + continue; + } + const channel = getRecord(channelValue); + visit(getRecord(channel?.tts), `channels.${channelId}.tts`); + const accounts = getRecord(channel?.accounts); + for (const [accountId, accountValue] of Object.entries(accounts ?? {})) { + if (isBlockedObjectKey(accountId)) { + continue; + } + visit( + getRecord(getRecord(accountValue)?.tts), + `channels.${channelId}.accounts.${accountId}.tts`, + ); + } + } + + const plugins = getRecord(raw.plugins); + const pluginEntries = getRecord(plugins?.entries); + for (const [pluginId, entryValue] of Object.entries(pluginEntries ?? {})) { + if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) { + continue; + } + const entry = getRecord(entryValue); + const config = getRecord(entry?.config); + visit(getRecord(config?.tts), `plugins.entries.${pluginId}.config.tts`); + } +} + +const LEGACY_TTS_PROVIDER_RULES: LegacyConfigRule[] = [ { path: ["messages", "tts"], message: @@ -136,11 +253,36 @@ const LEGACY_TTS_RULES: LegacyConfigRule[] = [ }, ]; +const LEGACY_TTS_ENABLED_RULES: LegacyConfigRule[] = [ + { + path: ["messages", "tts"], + message: 'messages.tts.enabled is legacy; use messages.tts.auto. Run "openclaw doctor --fix".', + match: (value) => hasLegacyTtsEnabled(value), + }, + { + path: ["agents"], + message: 'agents.*.tts.enabled is legacy; use agents.*.tts.auto. Run "openclaw doctor --fix".', + match: (value) => hasLegacyTtsEnabledInAgentLocations(value), + }, + { + path: ["channels"], + message: + 'channels.*.tts.enabled is legacy; use channels.*.tts.auto. Run "openclaw doctor --fix".', + match: (value) => hasLegacyTtsEnabledInChannelLocations(value), + }, + { + path: ["plugins", "entries"], + message: + 'plugins.entries.voice-call.config.tts.enabled is legacy; use plugins.entries.voice-call.config.tts.auto. Run "openclaw doctor --fix".', + match: (value) => hasLegacyTtsEnabledInPluginLocations(value), + }, +]; + export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "tts.providers-generic-shape", describe: "Move legacy bundled TTS config keys into messages.tts.providers", - legacyRules: LEGACY_TTS_RULES, + legacyRules: LEGACY_TTS_PROVIDER_RULES, apply: (raw, changes) => { const messages = getRecord(raw.messages); migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes); @@ -164,4 +306,14 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] = } }, }), + defineLegacyConfigMigration({ + id: "tts.enabled-auto-mode", + describe: "Move legacy TTS enabled toggles to auto mode", + legacyRules: LEGACY_TTS_ENABLED_RULES, + apply: (raw, changes) => { + visitKnownTtsConfigLocations(raw, (tts, pathLabel) => + migrateLegacyTtsEnabled(tts, pathLabel, changes), + ); + }, + }), ]; diff --git a/src/cron/cron-protocol-schema.test.ts b/src/cron/cron-protocol-schema.test.ts new file mode 100644 index 00000000000..b7ba98f8286 --- /dev/null +++ b/src/cron/cron-protocol-schema.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { CronJobStateSchema } from "../gateway/protocol/schema.js"; + +type SchemaLike = { + properties?: Record; + deprecated?: boolean; +}; + +describe("cron protocol schema", () => { + it("marks the legacy lastStatus alias deprecated", () => { + const properties = (CronJobStateSchema as SchemaLike).properties ?? {}; + const lastStatus = properties.lastStatus as SchemaLike | undefined; + expect(lastStatus).toBeDefined(); + expect(lastStatus?.deprecated).toBe(true); + }); +}); diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 9411f61f1ba..b76c7af96fb 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -25,11 +25,15 @@ const CronSessionTargetSchema = Type.Union([ Type.String({ pattern: "^session:.+" }), ]); const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]); -const CronRunStatusSchema = Type.Union([ - Type.Literal("ok"), - Type.Literal("error"), - Type.Literal("skipped"), -]); +function cronRunStatusSchema(options: Record = {}) { + return Type.Union([Type.Literal("ok"), Type.Literal("error"), Type.Literal("skipped")], options); +} + +const CronRunStatusSchema = cronRunStatusSchema(); +const DeprecatedCronRunStatusSchema = cronRunStatusSchema({ + deprecated: true, + description: "Deprecated alias for lastRunStatus.", +}); const CronSortDirSchema = Type.Union([Type.Literal("asc"), Type.Literal("desc")]); const CronJobsEnabledFilterSchema = Type.Union([ Type.Literal("all"), @@ -239,7 +243,7 @@ export const CronJobStateSchema = Type.Object( runningAtMs: Type.Optional(Type.Integer({ minimum: 0 })), lastRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })), lastRunStatus: Type.Optional(CronRunStatusSchema), - lastStatus: Type.Optional(CronRunStatusSchema), + lastStatus: Type.Optional(DeprecatedCronRunStatusSchema), lastError: Type.Optional(Type.String()), lastErrorReason: Type.Optional(CronFailoverReasonSchema), lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })),