refactor(voice-call): retire runtime config compatibility (#105476)

* refactor(voice-call): rename config migration module

* refactor(voice-call): retire runtime config compatibility
This commit is contained in:
Vincent Koc
2026-07-12 16:47:40 +02:00
committed by GitHub
parent 551e484598
commit 3d1f5ffd54
8 changed files with 185 additions and 397 deletions
+1 -2
View File
@@ -101,7 +101,7 @@ Notes:
- Twilio defaults to US1. For a non-US Region, set `twilio.region` to `ie1` or `au1` and use credentials created in that Region; see [Twilio's regional REST API guide](https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region).
- `mock` is a local dev provider (no network calls).
- Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
- If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
- Runtime accepts canonical config only. If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
- advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
- `responseModel` is optional. When unset, voice responses use the runtime default model.
- `sessionScope` defaults to `per-phone`, preserving caller memory across calls. Use `per-call` for reception, booking, IVR, and bridge flows where each carrier call should start fresh.
@@ -163,4 +163,3 @@ Actions:
- Outbound conversation calls suppress barge-in only while the initial greeting is actively speaking, then re-enable normal interruption.
- Twilio stream disconnect auto-end uses a short grace window so quick reconnects do not end the call.
- Realtime provider selection is generic. Configure `streaming.provider` / `realtime.provider` and put provider-owned options under `providers.<id>`.
- Runtime fallback still accepts the old voice-call keys for now, but migration is a doctor step and the compat shim is scheduled to go away in a future release.
-12
View File
@@ -1,12 +0,0 @@
// Narrow barrel for config compatibility helpers consumed outside the plugin.
// Keep this separate from api.ts so config migration code does not pull in the
// full runtime-oriented voice-call surface.
export {
VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION,
collectVoiceCallLegacyConfigIssues,
formatVoiceCallLegacyConfigWarnings,
migrateVoiceCallLegacyConfigInput,
normalizeVoiceCallLegacyConfigInput,
parseVoiceCallPluginConfig,
} from "./src/config-compat.js";
+27 -34
View File
@@ -261,6 +261,33 @@ describe("voice-call plugin", () => {
];
});
it("defaults canonical plugin config to an enabled mock runtime", async () => {
const { service } = setup({});
await service?.start(createServiceContext());
expect(createVoiceCallRuntime).toHaveBeenCalledTimes(1);
expect(firstRuntimeConfig()).toMatchObject({
enabled: true,
provider: "mock",
});
});
it.each([
["provider log", { enabled: true, provider: "log" }],
[
"twilio.from",
{
enabled: true,
provider: "mock",
twilio: { from: "+15550001234" },
},
],
])("rejects legacy %s config instead of normalizing it at runtime", (_label, config) => {
expect(() => setup(config)).toThrow();
expect(createVoiceCallRuntime).not.toHaveBeenCalled();
});
it("reuses a started runtime across plugin registration contexts", async () => {
const first = setup({ provider: "mock" });
const second = setup({ provider: "mock" });
@@ -730,40 +757,6 @@ describe("voice-call plugin", () => {
expect(error?.message).not.toContain("endedAt=");
});
it("normalizes legacy config through runtime creation and warns to run doctor", async () => {
const { methods } = setup({
enabled: true,
provider: "log",
twilio: {
from: "+15550001234",
},
streaming: {
enabled: true,
sttProvider: "openai",
openaiApiKey: "sk-test", // pragma: allowlist secret
},
});
const handler = methods.get("voicecall.status") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({ params: { callId: "call-1" }, respond });
expect(vi.mocked(createVoiceCallRuntime)).toHaveBeenCalledTimes(1);
const runtimeConfig = firstRuntimeConfig();
expect(runtimeConfig?.enabled).toBe(true);
expect(runtimeConfig?.provider).toBe("mock");
expect(runtimeConfig?.fromNumber).toBe("+15550001234");
expect(runtimeConfig?.streaming?.enabled).toBe(true);
expect(runtimeConfig?.streaming?.provider).toBe("openai");
expect(runtimeConfig?.streaming?.providers?.openai?.apiKey).toBe("sk-test");
expectWarningIncludes('Run "openclaw doctor --fix"');
});
it("freezes the invoking agent on tool-created calls", async () => {
const { tools } = setup({ provider: "mock" }, { agentId: "support" });
const tool = tools[0] as {
+10 -21
View File
@@ -3,7 +3,10 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
asOptionalRecord,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import { Type } from "typebox";
import {
@@ -14,11 +17,7 @@ import {
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./runtime-entry.js";
import { registerVoiceCallCli } from "./src/cli.js";
import {
formatVoiceCallLegacyConfigWarnings,
normalizeVoiceCallLegacyConfigInput,
parseVoiceCallPluginConfig,
} from "./src/config-compat.js";
import {
VoiceCallConfigSchema,
resolveVoiceCallConfig,
validateProviderConfig,
type VoiceCallConfig,
@@ -32,12 +31,12 @@ const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" as const };
const voiceCallConfigSchema = {
parse(value: unknown): VoiceCallConfig {
const normalized = normalizeVoiceCallLegacyConfigInput(value);
const enabled = typeof normalized.enabled === "boolean" ? normalized.enabled : true;
return parseVoiceCallPluginConfig({
...normalized,
const config = asOptionalRecord(value) ?? {};
const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
return VoiceCallConfigSchema.parse({
...config,
enabled,
provider: normalized.provider ?? (enabled ? "mock" : undefined),
provider: config.provider ?? (enabled ? "mock" : undefined),
});
},
uiHints: {
@@ -290,16 +289,6 @@ export default definePluginEntry({
const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
const validation = validateProviderConfig(config);
if (api.pluginConfig && typeof api.pluginConfig === "object") {
for (const warning of formatVoiceCallLegacyConfigWarnings({
value: api.pluginConfig,
configPathPrefix: "plugins.entries.voice-call.config",
doctorFixCommand: "openclaw doctor --fix",
})) {
api.logger.warn(warning);
}
}
const runtimeState = getVoiceCallRuntimeGlobalState();
const continueOperationStore = createVoiceCallContinueOperationStore({
config,
+1 -1
View File
@@ -2,7 +2,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { migrateVoiceCallLegacyConfigInput } from "./config-api.js";
import { migrateVoiceCallLegacyConfigInput } from "./src/config-migration.js";
// Setup-time entrypoint for voice-call config migrations.
@@ -1,210 +0,0 @@
// Voice Call tests cover config compat plugin behavior.
import { describe, expect, it } from "vitest";
import {
VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION,
collectVoiceCallLegacyConfigIssues,
formatVoiceCallLegacyConfigWarnings,
migrateVoiceCallLegacyConfigInput,
normalizeVoiceCallLegacyConfigInput,
parseVoiceCallPluginConfig,
} from "./config-compat.js";
describe("voice-call config compatibility", () => {
it("maps deprecated provider and twilio.from fields into canonical config", () => {
const parsed = parseVoiceCallPluginConfig({
enabled: true,
provider: "log",
twilio: {
from: "+15550001234",
},
});
expect(parsed.provider).toBe("mock");
expect(parsed.fromNumber).toBe("+15550001234");
});
it("moves legacy streaming OpenAI fields into streaming.providers.openai", () => {
const normalized = normalizeVoiceCallLegacyConfigInput({
streaming: {
enabled: true,
sttProvider: "openai",
openaiApiKey: "sk-test", // pragma: allowlist secret
sttModel: "gpt-4o-transcribe",
silenceDurationMs: 700,
vadThreshold: 0.4,
},
});
const streaming = normalized.streaming as
| {
enabled?: boolean;
provider?: string;
providers?: {
openai?: {
apiKey?: string;
model?: string;
silenceDurationMs?: number;
vadThreshold?: number;
};
};
openaiApiKey?: unknown;
sttModel?: unknown;
}
| undefined;
expect(streaming?.enabled).toBe(true);
expect(streaming?.provider).toBe("openai");
expect(streaming?.providers?.openai).toEqual({
apiKey: "sk-test",
model: "gpt-4o-transcribe",
silenceDurationMs: 700,
vadThreshold: 0.4,
});
expect(streaming?.openaiApiKey).toBeUndefined();
expect(streaming?.sttModel).toBeUndefined();
});
it("removes legacy realtime agentContext system prompt toggle", () => {
const normalized = normalizeVoiceCallLegacyConfigInput({
realtime: {
agentContext: {
enabled: true,
includeSystemPrompt: false,
includeWorkspaceFiles: true,
},
},
});
const agentContext = (
normalized.realtime as
| {
agentContext?: {
enabled?: boolean;
includeSystemPrompt?: unknown;
includeWorkspaceFiles?: boolean;
};
}
| undefined
)?.agentContext;
expect(agentContext).toEqual({
enabled: true,
includeWorkspaceFiles: true,
});
});
it("does not migrate non-finite legacy streaming numbers", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
streaming: {
silenceDurationMs: Number.NaN,
vadThreshold: Number.POSITIVE_INFINITY,
},
},
configPathPrefix: "plugins.entries.voice-call.config",
});
const streaming = migration.config.streaming as
| {
providers?: {
openai?: {
silenceDurationMs?: number;
vadThreshold?: number;
};
};
}
| undefined;
expect(streaming?.providers?.openai).toBeUndefined();
expect(migration.changes).toEqual([
"Removed invalid plugins.entries.voice-call.config.streaming.silenceDurationMs.",
"Removed invalid plugins.entries.voice-call.config.streaming.vadThreshold.",
]);
expect(migration.issues.map((issue) => issue.path)).toEqual([
"streaming.silenceDurationMs",
"streaming.vadThreshold",
]);
});
it("reports doctor-oriented legacy issues and warnings", () => {
const raw = {
provider: "log",
twilio: {
from: "+15550001234",
},
streaming: {
sttProvider: "openai",
openaiApiKey: "sk-test", // pragma: allowlist secret
},
realtime: {
agentContext: {
includeSystemPrompt: true,
},
},
};
expect(collectVoiceCallLegacyConfigIssues(raw)).toEqual([
{
path: "provider",
replacement: "provider",
message: 'Replace provider "log" with "mock".',
},
{
path: "twilio.from",
replacement: "fromNumber",
message: "Move twilio.from to fromNumber.",
},
{
path: "streaming.sttProvider",
replacement: "streaming.provider",
message: "Move streaming.sttProvider to streaming.provider.",
},
{
path: "streaming.openaiApiKey",
replacement: "streaming.providers.openai.apiKey",
message: "Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
},
{
path: "realtime.agentContext.includeSystemPrompt",
replacement: "realtime.agentContext",
message:
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
},
]);
expect(
formatVoiceCallLegacyConfigWarnings({
value: raw,
configPathPrefix: "plugins.entries.voice-call.config",
doctorFixCommand: "openclaw doctor --fix",
}),
).toEqual([
`[voice-call] legacy config keys detected under plugins.entries.voice-call.config; runtime loading will not rewrite them, and support for the legacy shape will be removed in ${VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION}. Run "openclaw doctor --fix".`,
'[voice-call] plugins.entries.voice-call.config.provider: Replace provider "log" with "mock".',
"[voice-call] plugins.entries.voice-call.config.twilio.from: Move twilio.from to fromNumber.",
"[voice-call] plugins.entries.voice-call.config.streaming.sttProvider: Move streaming.sttProvider to streaming.provider.",
"[voice-call] plugins.entries.voice-call.config.streaming.openaiApiKey: Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
"[voice-call] plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt: Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
]);
});
it("returns doctor migration change lines", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
provider: "log",
streaming: {
sttProvider: "openai",
},
realtime: {
agentContext: {
includeSystemPrompt: true,
},
},
},
configPathPrefix: "plugins.entries.voice-call.config",
});
expect(migration.changes).toEqual([
'Moved plugins.entries.voice-call.config.provider "log" → "mock".',
"Moved plugins.entries.voice-call.config.streaming.sttProvider → plugins.entries.voice-call.config.streaming.provider.",
"Removed plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt.",
]);
});
});
@@ -0,0 +1,144 @@
// Voice Call tests cover setup-time config migration behavior.
import { describe, expect, it } from "vitest";
import { migrateVoiceCallLegacyConfigInput } from "./config-migration.js";
describe("voice-call config migration", () => {
it("maps deprecated provider and twilio.from fields into canonical config", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
enabled: true,
provider: "log",
twilio: {
from: "+15550001234",
},
},
});
expect(migration.config.provider).toBe("mock");
expect(migration.config.fromNumber).toBe("+15550001234");
});
it("moves legacy streaming OpenAI fields into streaming.providers.openai", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
streaming: {
enabled: true,
sttProvider: "openai",
openaiApiKey: "test",
sttModel: "gpt-4o-transcribe",
silenceDurationMs: 700,
vadThreshold: 0.4,
},
},
});
const streaming = migration.config.streaming as
| {
enabled?: boolean;
provider?: string;
providers?: {
openai?: {
apiKey?: string;
model?: string;
silenceDurationMs?: number;
vadThreshold?: number;
};
};
openaiApiKey?: unknown;
sttModel?: unknown;
}
| undefined;
expect(streaming?.enabled).toBe(true);
expect(streaming?.provider).toBe("openai");
expect(streaming?.providers?.openai).toEqual({
apiKey: "test",
model: "gpt-4o-transcribe",
silenceDurationMs: 700,
vadThreshold: 0.4,
});
expect(streaming?.openaiApiKey).toBeUndefined();
expect(streaming?.sttModel).toBeUndefined();
});
it("removes legacy realtime agentContext system prompt toggle", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
realtime: {
agentContext: {
enabled: true,
includeSystemPrompt: false,
includeWorkspaceFiles: true,
},
},
},
});
const agentContext = (
migration.config.realtime as
| {
agentContext?: {
enabled?: boolean;
includeSystemPrompt?: unknown;
includeWorkspaceFiles?: boolean;
};
}
| undefined
)?.agentContext;
expect(agentContext).toEqual({
enabled: true,
includeWorkspaceFiles: true,
});
});
it("does not migrate non-finite legacy streaming numbers", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
streaming: {
silenceDurationMs: Number.NaN,
vadThreshold: Number.POSITIVE_INFINITY,
},
},
configPathPrefix: "plugins.entries.voice-call.config",
});
const streaming = migration.config.streaming as
| {
providers?: {
openai?: {
silenceDurationMs?: number;
vadThreshold?: number;
};
};
}
| undefined;
expect(streaming?.providers?.openai).toBeUndefined();
expect(migration.changes).toEqual([
"Removed invalid plugins.entries.voice-call.config.streaming.silenceDurationMs.",
"Removed invalid plugins.entries.voice-call.config.streaming.vadThreshold.",
]);
});
it("returns doctor migration change lines", () => {
const migration = migrateVoiceCallLegacyConfigInput({
value: {
provider: "log",
streaming: {
sttProvider: "openai",
},
realtime: {
agentContext: {
includeSystemPrompt: true,
},
},
},
configPathPrefix: "plugins.entries.voice-call.config",
});
expect(migration.changes).toEqual([
'Moved plugins.entries.voice-call.config.provider "log" → "mock".',
"Moved plugins.entries.voice-call.config.streaming.sttProvider → plugins.entries.voice-call.config.streaming.provider.",
"Removed plugins.entries.voice-call.config.realtime.agentContext.includeSystemPrompt.",
]);
});
});
@@ -1,19 +1,5 @@
// Voice Call helper module supports config compat behavior.
// Voice Call setup helper migrates legacy config to the canonical schema.
import { asOptionalRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { VoiceCallConfig } from "./config.js";
import { VoiceCallConfigSchema } from "./config.js";
// Legacy voice-call config warnings and doctor-fix migration helpers.
/** Version where legacy voice-call config shape support is removed. */
export const VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION = "2026.6.0";
/** One legacy config issue with the replacement path and message. */
type VoiceCallLegacyConfigIssue = {
path: string;
replacement: string;
message: string;
};
const asObject = asOptionalRecord;
const getString = readStringField;
@@ -45,95 +31,6 @@ function mergeProviderConfig(
};
}
/** Collect legacy voice-call config keys that should be migrated. */
export function collectVoiceCallLegacyConfigIssues(value: unknown): VoiceCallLegacyConfigIssue[] {
const raw = asObject(value) ?? {};
const realtime = asObject(raw.realtime);
const realtimeAgentContext = asObject(realtime?.agentContext);
const twilio = asObject(raw.twilio);
const streaming = asObject(raw.streaming);
const issues: VoiceCallLegacyConfigIssue[] = [];
if (raw.provider === "log") {
issues.push({
path: "provider",
replacement: "provider",
message: 'Replace provider "log" with "mock".',
});
}
if (typeof twilio?.from === "string") {
issues.push({
path: "twilio.from",
replacement: "fromNumber",
message: "Move twilio.from to fromNumber.",
});
}
if (typeof streaming?.sttProvider === "string") {
issues.push({
path: "streaming.sttProvider",
replacement: "streaming.provider",
message: "Move streaming.sttProvider to streaming.provider.",
});
}
if (typeof streaming?.openaiApiKey === "string") {
issues.push({
path: "streaming.openaiApiKey",
replacement: "streaming.providers.openai.apiKey",
message: "Move streaming.openaiApiKey to streaming.providers.openai.apiKey.",
});
}
if (typeof streaming?.sttModel === "string") {
issues.push({
path: "streaming.sttModel",
replacement: "streaming.providers.openai.model",
message: "Move streaming.sttModel to streaming.providers.openai.model.",
});
}
if (typeof streaming?.silenceDurationMs === "number") {
issues.push({
path: "streaming.silenceDurationMs",
replacement: "streaming.providers.openai.silenceDurationMs",
message: "Move streaming.silenceDurationMs to streaming.providers.openai.silenceDurationMs.",
});
}
if (typeof streaming?.vadThreshold === "number") {
issues.push({
path: "streaming.vadThreshold",
replacement: "streaming.providers.openai.vadThreshold",
message: "Move streaming.vadThreshold to streaming.providers.openai.vadThreshold.",
});
}
if (realtimeAgentContext && Object.hasOwn(realtimeAgentContext, "includeSystemPrompt")) {
issues.push({
path: "realtime.agentContext.includeSystemPrompt",
replacement: "realtime.agentContext",
message:
"Remove realtime.agentContext.includeSystemPrompt; realtime context now uses the generated agent prompt.",
});
}
return issues;
}
/** Format runtime warnings for legacy voice-call config keys. */
export function formatVoiceCallLegacyConfigWarnings(params: {
value: unknown;
configPathPrefix: string;
doctorFixCommand: string;
}): string[] {
const issues = collectVoiceCallLegacyConfigIssues(params.value);
if (issues.length === 0) {
return [];
}
return [
`[voice-call] legacy config keys detected under ${params.configPathPrefix}; runtime loading will not rewrite them, and support for the legacy shape will be removed in ${VOICE_CALL_LEGACY_CONFIG_REMOVAL_VERSION}. Run "${params.doctorFixCommand}".`,
...issues.map(
(issue) => `[voice-call] ${params.configPathPrefix}.${issue.path}: ${issue.message}`,
),
];
}
/** Migrate legacy voice-call config input to the current canonical shape. */
export function migrateVoiceCallLegacyConfigInput(params: {
value: unknown;
@@ -141,7 +38,6 @@ export function migrateVoiceCallLegacyConfigInput(params: {
}): {
config: Record<string, unknown>;
changes: string[];
issues: VoiceCallLegacyConfigIssue[];
} {
const raw = asObject(params.value) ?? {};
const realtime = asObject(raw.realtime);
@@ -149,7 +45,6 @@ export function migrateVoiceCallLegacyConfigInput(params: {
const twilio = asObject(raw.twilio);
const streaming = asObject(raw.streaming);
const configPathPrefix = params.configPathPrefix ?? "plugins.entries.voice-call.config";
const issues = collectVoiceCallLegacyConfigIssues(raw);
const legacyStreamingOpenAICompat: Record<string, unknown> = {};
const streamingOpenAIApiKey = getString(streaming, "openaiApiKey");
@@ -261,15 +156,5 @@ export function migrateVoiceCallLegacyConfigInput(params: {
changes.push(`Removed ${configPathPrefix}.realtime.agentContext.includeSystemPrompt.`);
}
return { config, changes, issues };
}
/** Normalize legacy voice-call config input without returning migration metadata. */
export function normalizeVoiceCallLegacyConfigInput(value: unknown): Record<string, unknown> {
return migrateVoiceCallLegacyConfigInput({ value }).config;
}
/** Parse voice-call plugin config after applying legacy normalization. */
export function parseVoiceCallPluginConfig(value: unknown): VoiceCallConfig {
return VoiceCallConfigSchema.parse(normalizeVoiceCallLegacyConfigInput(value));
return { config, changes };
}