fix(doctor): migrate legacy tts enabled toggles

This commit is contained in:
Vincent Koc
2026-04-29 00:28:35 -07:00
parent eb7f305737
commit 1d0e9a907e
6 changed files with 275 additions and 8 deletions

View File

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

View File

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

View File

@@ -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.<provider> keys into providers", () => {
const res = migrateLegacyConfig({
plugins: {

View File

@@ -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<string, unknown>): Record<string, unknown> {
const providers = getRecord(tts.providers) ?? {};
tts.providers = providers;
@@ -121,7 +172,73 @@ function migrateLegacyTtsConfig(
}
}
const LEGACY_TTS_RULES: LegacyConfigRule[] = [
function migrateLegacyTtsEnabled(
tts: Record<string, unknown> | 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<string, unknown>,
visit: (tts: Record<string, unknown> | 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),
);
},
}),
];

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { CronJobStateSchema } from "../gateway/protocol/schema.js";
type SchemaLike = {
properties?: Record<string, unknown>;
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);
});
});

View File

@@ -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<string, unknown> = {}) {
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 })),