Skip to content

Commit 1d0e9a9

Browse files
committed
fix(doctor): migrate legacy tts enabled toggles
1 parent eb7f305 commit 1d0e9a9

6 files changed

Lines changed: 275 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
### Fixes
1818

1919
- 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.
20+
- 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.
2021
- 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.
2122
- 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.
2223
- 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.

src/commands/doctor/shared/deprecation-compat.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ export const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
183183
docsPath: "/tools/tts",
184184
tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"],
185185
}),
186+
deprecatedCompatRecord({
187+
code: "doctor-tts-enabled-auto-mode",
188+
owner: "tts",
189+
introduced: "2026-04-29",
190+
source:
191+
"messages.tts.enabled, agents.*.tts.enabled, channels.*.tts.enabled, and voice-call plugin tts.enabled",
192+
migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts",
193+
replacement:
194+
'messages/agents/channels/plugins TTS auto mode, for example auto: "always" or auto: "off"',
195+
docsPath: "/tools/tts",
196+
tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"],
197+
}),
186198
deprecatedCompatRecord({
187199
code: "doctor-plugin-install-config-ledger",
188200
owner: "plugin",

src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,88 @@ describe("legacy migrate provider-shaped config", () => {
8383
});
8484
});
8585

86+
it("moves legacy tts enabled toggles to auto mode in known config locations", () => {
87+
const res = migrateLegacyConfig({
88+
messages: {
89+
tts: {
90+
enabled: true,
91+
},
92+
},
93+
agents: {
94+
defaults: {
95+
tts: {
96+
enabled: false,
97+
},
98+
},
99+
list: [
100+
{
101+
id: "voice-agent",
102+
tts: {
103+
enabled: true,
104+
auto: "tagged",
105+
},
106+
},
107+
],
108+
},
109+
channels: {
110+
discord: {
111+
tts: {
112+
enabled: true,
113+
},
114+
accounts: {
115+
primary: {
116+
tts: {
117+
enabled: false,
118+
},
119+
},
120+
},
121+
},
122+
},
123+
plugins: {
124+
entries: {
125+
"voice-call": {
126+
config: {
127+
tts: {
128+
enabled: true,
129+
},
130+
},
131+
},
132+
},
133+
},
134+
});
135+
136+
expect(res.changes).toEqual([
137+
'Moved messages.tts.enabled → messages.tts.auto "always".',
138+
'Moved agents.defaults.tts.enabled → agents.defaults.tts.auto "off".',
139+
"Removed agents.list[0].tts.enabled because agents.list[0].tts.auto is already set.",
140+
'Moved channels.discord.tts.enabled → channels.discord.tts.auto "always".',
141+
'Moved channels.discord.accounts.primary.tts.enabled → channels.discord.accounts.primary.tts.auto "off".',
142+
'Moved plugins.entries.voice-call.config.tts.enabled → plugins.entries.voice-call.config.tts.auto "always".',
143+
]);
144+
expect(res.config).toMatchObject({
145+
messages: { tts: { auto: "always" } },
146+
agents: {
147+
defaults: { tts: { auto: "off" } },
148+
list: [{ id: "voice-agent", tts: { auto: "tagged" } }],
149+
},
150+
channels: {
151+
discord: {
152+
tts: { auto: "always" },
153+
accounts: { primary: { tts: { auto: "off" } } },
154+
},
155+
},
156+
plugins: {
157+
entries: {
158+
"voice-call": {
159+
config: {
160+
tts: { auto: "always" },
161+
},
162+
},
163+
},
164+
},
165+
});
166+
});
167+
86168
it("moves plugins.entries.voice-call.config.tts.<provider> keys into providers", () => {
87169
const res = migrateLegacyConfig({
88170
plugins: {

src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,57 @@ function hasLegacyPluginEntryTtsProviderKeys(value: unknown): boolean {
4444
});
4545
}
4646

47+
function hasLegacyTtsEnabled(value: unknown): boolean {
48+
return typeof getRecord(value)?.enabled === "boolean";
49+
}
50+
51+
function hasLegacyTtsEnabledInAgentLocations(value: unknown): boolean {
52+
const agents = getRecord(value);
53+
if (hasLegacyTtsEnabled(getRecord(getRecord(agents?.defaults)?.tts))) {
54+
return true;
55+
}
56+
const agentList = Array.isArray(agents?.list) ? agents.list : [];
57+
return agentList.some((entry) => hasLegacyTtsEnabled(getRecord(getRecord(entry)?.tts)));
58+
}
59+
60+
function hasLegacyTtsEnabledInChannelLocations(value: unknown): boolean {
61+
const channels = getRecord(value);
62+
for (const [channelId, channelValue] of Object.entries(channels ?? {})) {
63+
if (isBlockedObjectKey(channelId)) {
64+
continue;
65+
}
66+
const channel = getRecord(channelValue);
67+
if (hasLegacyTtsEnabled(getRecord(channel?.tts))) {
68+
return true;
69+
}
70+
const accounts = getRecord(channel?.accounts);
71+
for (const [accountId, accountValue] of Object.entries(accounts ?? {})) {
72+
if (isBlockedObjectKey(accountId)) {
73+
continue;
74+
}
75+
if (hasLegacyTtsEnabled(getRecord(getRecord(accountValue)?.tts))) {
76+
return true;
77+
}
78+
}
79+
}
80+
return false;
81+
}
82+
83+
function hasLegacyTtsEnabledInPluginLocations(value: unknown): boolean {
84+
const entries = getRecord(value);
85+
if (!entries) {
86+
return false;
87+
}
88+
return Object.entries(entries).some(([pluginId, entryValue]) => {
89+
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
90+
return false;
91+
}
92+
const entry = getRecord(entryValue);
93+
const config = getRecord(entry?.config);
94+
return hasLegacyTtsEnabled(getRecord(config?.tts));
95+
});
96+
}
97+
4798
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
4899
const providers = getRecord(tts.providers) ?? {};
49100
tts.providers = providers;
@@ -121,7 +172,73 @@ function migrateLegacyTtsConfig(
121172
}
122173
}
123174

124-
const LEGACY_TTS_RULES: LegacyConfigRule[] = [
175+
function migrateLegacyTtsEnabled(
176+
tts: Record<string, unknown> | null | undefined,
177+
pathLabel: string,
178+
changes: string[],
179+
): void {
180+
if (!tts || typeof tts.enabled !== "boolean") {
181+
return;
182+
}
183+
const nextAuto = tts.enabled ? "always" : "off";
184+
delete tts.enabled;
185+
if (typeof tts.auto === "string" && tts.auto.trim()) {
186+
changes.push(`Removed ${pathLabel}.enabled because ${pathLabel}.auto is already set.`);
187+
return;
188+
}
189+
tts.auto = nextAuto;
190+
changes.push(`Moved ${pathLabel}.enabled → ${pathLabel}.auto "${nextAuto}".`);
191+
}
192+
193+
function visitKnownTtsConfigLocations(
194+
raw: Record<string, unknown>,
195+
visit: (tts: Record<string, unknown> | null | undefined, pathLabel: string) => void,
196+
): void {
197+
const messages = getRecord(raw.messages);
198+
visit(getRecord(messages?.tts), "messages.tts");
199+
200+
const agents = getRecord(raw.agents);
201+
const agentDefaults = getRecord(agents?.defaults);
202+
visit(getRecord(agentDefaults?.tts), "agents.defaults.tts");
203+
204+
const agentList = Array.isArray(agents?.list) ? agents.list : [];
205+
agentList.forEach((entry, index) => {
206+
const agent = getRecord(entry);
207+
visit(getRecord(agent?.tts), `agents.list[${index}].tts`);
208+
});
209+
210+
const channels = getRecord(raw.channels);
211+
for (const [channelId, channelValue] of Object.entries(channels ?? {})) {
212+
if (isBlockedObjectKey(channelId)) {
213+
continue;
214+
}
215+
const channel = getRecord(channelValue);
216+
visit(getRecord(channel?.tts), `channels.${channelId}.tts`);
217+
const accounts = getRecord(channel?.accounts);
218+
for (const [accountId, accountValue] of Object.entries(accounts ?? {})) {
219+
if (isBlockedObjectKey(accountId)) {
220+
continue;
221+
}
222+
visit(
223+
getRecord(getRecord(accountValue)?.tts),
224+
`channels.${channelId}.accounts.${accountId}.tts`,
225+
);
226+
}
227+
}
228+
229+
const plugins = getRecord(raw.plugins);
230+
const pluginEntries = getRecord(plugins?.entries);
231+
for (const [pluginId, entryValue] of Object.entries(pluginEntries ?? {})) {
232+
if (isBlockedObjectKey(pluginId) || !LEGACY_TTS_PLUGIN_IDS.has(pluginId)) {
233+
continue;
234+
}
235+
const entry = getRecord(entryValue);
236+
const config = getRecord(entry?.config);
237+
visit(getRecord(config?.tts), `plugins.entries.${pluginId}.config.tts`);
238+
}
239+
}
240+
241+
const LEGACY_TTS_PROVIDER_RULES: LegacyConfigRule[] = [
125242
{
126243
path: ["messages", "tts"],
127244
message:
@@ -136,11 +253,36 @@ const LEGACY_TTS_RULES: LegacyConfigRule[] = [
136253
},
137254
];
138255

256+
const LEGACY_TTS_ENABLED_RULES: LegacyConfigRule[] = [
257+
{
258+
path: ["messages", "tts"],
259+
message: 'messages.tts.enabled is legacy; use messages.tts.auto. Run "openclaw doctor --fix".',
260+
match: (value) => hasLegacyTtsEnabled(value),
261+
},
262+
{
263+
path: ["agents"],
264+
message: 'agents.*.tts.enabled is legacy; use agents.*.tts.auto. Run "openclaw doctor --fix".',
265+
match: (value) => hasLegacyTtsEnabledInAgentLocations(value),
266+
},
267+
{
268+
path: ["channels"],
269+
message:
270+
'channels.*.tts.enabled is legacy; use channels.*.tts.auto. Run "openclaw doctor --fix".',
271+
match: (value) => hasLegacyTtsEnabledInChannelLocations(value),
272+
},
273+
{
274+
path: ["plugins", "entries"],
275+
message:
276+
'plugins.entries.voice-call.config.tts.enabled is legacy; use plugins.entries.voice-call.config.tts.auto. Run "openclaw doctor --fix".',
277+
match: (value) => hasLegacyTtsEnabledInPluginLocations(value),
278+
},
279+
];
280+
139281
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] = [
140282
defineLegacyConfigMigration({
141283
id: "tts.providers-generic-shape",
142284
describe: "Move legacy bundled TTS config keys into messages.tts.providers",
143-
legacyRules: LEGACY_TTS_RULES,
285+
legacyRules: LEGACY_TTS_PROVIDER_RULES,
144286
apply: (raw, changes) => {
145287
const messages = getRecord(raw.messages);
146288
migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes);
@@ -164,4 +306,14 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] =
164306
}
165307
},
166308
}),
309+
defineLegacyConfigMigration({
310+
id: "tts.enabled-auto-mode",
311+
describe: "Move legacy TTS enabled toggles to auto mode",
312+
legacyRules: LEGACY_TTS_ENABLED_RULES,
313+
apply: (raw, changes) => {
314+
visitKnownTtsConfigLocations(raw, (tts, pathLabel) =>
315+
migrateLegacyTtsEnabled(tts, pathLabel, changes),
316+
);
317+
},
318+
}),
167319
];
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { CronJobStateSchema } from "../gateway/protocol/schema.js";
3+
4+
type SchemaLike = {
5+
properties?: Record<string, unknown>;
6+
deprecated?: boolean;
7+
};
8+
9+
describe("cron protocol schema", () => {
10+
it("marks the legacy lastStatus alias deprecated", () => {
11+
const properties = (CronJobStateSchema as SchemaLike).properties ?? {};
12+
const lastStatus = properties.lastStatus as SchemaLike | undefined;
13+
expect(lastStatus).toBeDefined();
14+
expect(lastStatus?.deprecated).toBe(true);
15+
});
16+
});

src/gateway/protocol/schema/cron.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ const CronSessionTargetSchema = Type.Union([
2525
Type.String({ pattern: "^session:.+" }),
2626
]);
2727
const CronWakeModeSchema = Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]);
28-
const CronRunStatusSchema = Type.Union([
29-
Type.Literal("ok"),
30-
Type.Literal("error"),
31-
Type.Literal("skipped"),
32-
]);
28+
function cronRunStatusSchema(options: Record<string, unknown> = {}) {
29+
return Type.Union([Type.Literal("ok"), Type.Literal("error"), Type.Literal("skipped")], options);
30+
}
31+
32+
const CronRunStatusSchema = cronRunStatusSchema();
33+
const DeprecatedCronRunStatusSchema = cronRunStatusSchema({
34+
deprecated: true,
35+
description: "Deprecated alias for lastRunStatus.",
36+
});
3337
const CronSortDirSchema = Type.Union([Type.Literal("asc"), Type.Literal("desc")]);
3438
const CronJobsEnabledFilterSchema = Type.Union([
3539
Type.Literal("all"),
@@ -239,7 +243,7 @@ export const CronJobStateSchema = Type.Object(
239243
runningAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
240244
lastRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
241245
lastRunStatus: Type.Optional(CronRunStatusSchema),
242-
lastStatus: Type.Optional(CronRunStatusSchema),
246+
lastStatus: Type.Optional(DeprecatedCronRunStatusSchema),
243247
lastError: Type.Optional(Type.String()),
244248
lastErrorReason: Type.Optional(CronFailoverReasonSchema),
245249
lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })),

0 commit comments

Comments
 (0)