fix(discord): preserve routed agent for realtime voice (#125111)

This commit is contained in:
Peter Steinberger
2026-08-16 23:41:38 -07:00
committed by GitHub
parent 6b86199892
commit 73ab74eccc
26 changed files with 312 additions and 16 deletions
@@ -227,6 +227,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
providerConfigs: buildProviderConfigs(this.realtimeConfig),
providerConfigOverrides: buildProviderConfigOverrides(this.realtimeConfig),
cfg: this.params.cfg,
agentId: this.params.entry.route.agentId,
defaultModel: this.realtimeConfig?.model,
noRegisteredProviderMessage: "No configured realtime voice provider registered",
});
@@ -284,6 +285,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
this.bridge = this.harness.createBridge({
provider: resolved.provider,
cfg: this.params.cfg,
agentId: this.params.entry.route.agentId,
providerConfig: resolved.providerConfig,
audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
instructions,
@@ -15,7 +15,9 @@ defineDiscordVoiceTests(
joinVoiceChannelMock,
entersStateMock,
createAudioPlayerMock,
resolveAgentRouteMock,
resolveRealtimeBootstrapContextInstructionsMock,
resolveConfiguredRealtimeVoiceProviderMock,
createRealtimeVoiceBridgeSessionMock,
realtimeSessionMock,
managerModule,
@@ -420,6 +422,44 @@ defineDiscordVoiceTests(
expectConnectedStatus(manager, "1002");
});
it("preserves the routed agent through realtime autoJoin startup", async () => {
resolveAgentRouteMock.mockReturnValue({
agentId: "molty",
sessionKey: "agent:molty:discord:channel:g1:1001",
});
resolveConfiguredRealtimeVoiceProviderMock.mockImplementation((params?: unknown) => {
if (requireRecord(params, "provider resolution params").agentId !== "molty") {
throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty");
}
return {
provider: { id: "openai", capabilities: { supportsActivationNameGating: true } },
providerConfig: { model: "gpt-realtime-2", voice: "cedar" },
};
});
createRealtimeVoiceBridgeSessionMock.mockImplementation((params?: unknown) => {
if (requireRecord(params, "bridge session params").agentId !== "molty") {
throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty");
}
return realtimeSessionMock;
});
const manager = createManager(
makeVoiceConfig({
mode: "agent-proxy",
autoJoin: [{ guildId: "g1", channelId: "1001" }],
realtime: { provider: "openai" },
}),
undefined,
{ agents: { list: [{ id: "helper" }, { id: "molty" }] } },
);
await manager.autoJoin();
expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledTimes(1);
expect(createRealtimeVoiceBridgeSessionMock).toHaveBeenCalledTimes(1);
expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1);
expectConnectedStatus(manager, "1001");
});
it("suppresses repeated autoJoin attempts after fatal realtime startup failures", async () => {
realtimeSessionMock.connect.mockRejectedValueOnce(new Error("Incorrect API key provided"));
const manager = createManager(
@@ -446,6 +446,60 @@ describe("local Meet realtime transport process stream errors", () => {
});
describe("Google Meet bidi realtime engine cleanup", () => {
it("preserves the configured realtime agent through provider startup", async () => {
let bridgeRequest: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0] | undefined;
const isConfigured = vi.fn(({ agentId }) => agentId === "molty");
const bridge = {
connect: vi.fn(async () => {}),
sendAudio: vi.fn(),
sendUserMessage: vi.fn(),
setMediaTimestamp: vi.fn(),
submitToolResult: vi.fn(),
acknowledgeMark: vi.fn(),
close: vi.fn(),
triggerGreeting: vi.fn(),
isConnected: vi.fn(() => true),
};
const provider: RealtimeVoiceProviderPlugin = {
id: "openai",
label: "OpenAI",
isConfigured,
createBridge: (request) => {
bridgeRequest = request;
return bridge;
},
};
const transport: MeetingRealtimeAudioTransport = {
onFatal: vi.fn(),
startInput: vi.fn(),
stop: vi.fn(async () => {}),
writeOutput: vi.fn(async () => {}),
clearOutput: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
};
const config = resolveGoogleMeetConfig({
realtime: { strategy: "bidi", provider: "openai", agentId: "molty" },
});
const fullConfig = {
agents: { list: [{ id: "helper" }, { id: "molty" }] },
} as never;
const handle = await startMeetingRealtimeEngine({
config,
fullConfig,
runtime: {} as never,
...GOOGLE_MEET_ENGINE_BINDINGS,
meetingSessionId: "meet-routed-agent",
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
providers: [provider],
transport,
});
expect(isConfigured).toHaveBeenCalledWith(expect.objectContaining({ agentId: "molty" }));
expect(bridgeRequest?.agentId).toBe("molty");
await handle.stop();
});
it("disposes the audio transport when provider connection fails", async () => {
const connectError = new Error("voice bridge connect failed");
const stopError = new Error("transport stop failed");
@@ -462,6 +462,7 @@ export class OpenAIRealtimeBridge extends OpenAIRealtimeEvents implements Realti
const auth = await requireOpenAIRealtimePlatformAuth({
configuredApiKey: this.config.apiKey,
cfg: this.config.cfg,
agentId: this.config.agentId,
});
return this.resolveApiKeyConnectionParams(auth.value, model);
}
@@ -448,15 +448,60 @@ describe("OpenAI realtime voice browser authentication", () => {
).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key");
});
it("treats OpenAI API-key auth profiles as configured for browser realtime sessions", () => {
isProviderAuthProfileConfiguredMock.mockReturnValue(true);
it("checks bridge readiness in the selected agent directory", () => {
isProviderAuthProfileConfiguredMock.mockImplementation(
({ agentDir }: { agentDir?: string }) => agentDir === "/tmp/openclaw-molty-agent",
);
const provider = buildOpenAIRealtimeVoiceProvider();
const cfg = { agents: { defaults: {} } } as never;
const cfg = {
agents: {
list: [
{ id: "helper", agentDir: "/tmp/openclaw-helper-agent" },
{ id: "molty", agentDir: "/tmp/openclaw-molty-agent" },
],
},
} as never;
expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(true);
expect(provider.isConfigured({ cfg, providerConfig: {}, agentId: "molty" })).toBe(true);
expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({
provider: "openai",
cfg,
agentDir: "/tmp/openclaw-molty-agent",
profileTypes: ["api_key"],
includeExternalCliAuth: false,
});
});
it("resolves bridge Platform auth from the selected agent directory", async () => {
resolveProviderAuthProfileApiKeyMock.mockImplementation(
async ({ agentDir }: { agentDir?: string }) =>
agentDir === "/tmp/openclaw-molty-agent" ? "test-api-key-molty" : undefined,
);
const provider = buildOpenAIRealtimeVoiceProvider();
const cfg = {
agents: {
list: [
{ id: "helper", agentDir: "/tmp/openclaw-helper-agent" },
{ id: "molty", agentDir: "/tmp/openclaw-molty-agent" },
],
},
} as never;
const bridge = provider.createBridge({
cfg,
agentId: "molty",
providerConfig: { model: "gpt-realtime-2" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
void bridge.connect();
await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
bridge.close();
expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledWith({
provider: "openai",
cfg,
agentDir: "/tmp/openclaw-molty-agent",
profileTypes: ["api_key"],
includeExternalCliAuth: false,
});
+9 -1
View File
@@ -187,6 +187,7 @@ async function createOpenAIRealtimeBrowserSession(
const auth = await requireOpenAIRealtimePlatformAuth({
configuredApiKey: config.apiKey,
cfg: req.cfg,
agentId: req.agentId,
});
const voice = normalizeOpenAIRealtimeVoice(req.voice) ?? config.voice ?? "alloy";
const sessionConfig = buildOpenAIRealtimeGaSessionPolicy({
@@ -268,12 +269,14 @@ async function createOpenAIRealtimeBrowserSession(
const auth = await resolveOpenAIRealtimePlatformAuth({
configuredApiKey: config.apiKey,
cfg: req.cfg,
agentId: req.agentId,
});
if (auth.status === "missing") {
if (
hasOpenAIRealtimePlatformAuthInput({
configuredApiKey: config.apiKey,
cfg: req.cfg,
agentId: req.agentId,
})
) {
throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED);
@@ -334,7 +337,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: {
autoSelectOrder: 10,
capabilities: OPENAI_REALTIME_CAPABILITIES,
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
isConfigured: ({ cfg, providerConfig }) => {
isConfigured: ({ cfg, providerConfig, agentId }) => {
const config = normalizeProviderConfig(providerConfig);
if (config.azureEndpoint || config.azureDeployment) {
return hasOpenAIRealtimeApiKeyInput(config.apiKey);
@@ -343,6 +346,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: {
hasOpenAIRealtimePlatformAuthInput({
configuredApiKey: config.apiKey,
cfg,
agentId,
})
) {
return true;
@@ -384,6 +388,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: {
await requireOpenAIRealtimePlatformAuth({
configuredApiKey: config.apiKey,
cfg: req.cfg,
agentId: req.agentId,
})
).value,
}),
@@ -429,6 +434,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: {
(hasOpenAIRealtimePlatformAuthInput({
configuredApiKey: config.apiKey,
cfg,
agentId,
}) ||
hasOpenAIChatGptSubscriptionAuthInput({ cfg, agentId }))
);
@@ -437,6 +443,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: {
hasOpenAIRealtimePlatformAuthInput({
configuredApiKey: config.apiKey,
cfg,
agentId,
}) ||
(options?.quicksilverBrowserSessionBroker !== undefined &&
hasOpenAIChatGptSubscriptionAuthInput({ cfg, agentId }))
@@ -471,6 +478,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: {
(hasOpenAIRealtimePlatformAuthInput({
configuredApiKey: config.apiKey,
cfg,
agentId,
}) ||
hasOpenAIChatGptSubscriptionAuthInput({ cfg, agentId }))
);
@@ -520,6 +520,7 @@ export function buildOpenAIRealtimeGaSessionPolicy(params: {
export async function resolveOpenAIRealtimePlatformAuth(params: {
configuredApiKey: string | undefined;
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
agentId?: string;
}): Promise<OpenAIRealtimeApiKeyResolution> {
const configured = resolveOpenAIRealtimeSecretInput(params.configuredApiKey);
if (
@@ -532,6 +533,9 @@ export async function resolveOpenAIRealtimePlatformAuth(params: {
const profileApiKey = await resolveProviderAuthProfileApiKey({
provider: "openai",
cfg: params.cfg,
...(params.cfg && params.agentId
? { agentDir: resolveAgentDir(params.cfg, params.agentId) }
: {}),
profileTypes: ["api_key"],
includeExternalCliAuth: false,
});
@@ -548,6 +552,7 @@ export async function resolveOpenAIRealtimePlatformAuth(params: {
export async function requireOpenAIRealtimePlatformAuth(params: {
configuredApiKey: string | undefined;
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
agentId?: string;
}): Promise<Extract<OpenAIRealtimeApiKeyResolution, { status: "available" }>> {
const resolved = await resolveOpenAIRealtimePlatformAuth(params);
if (resolved.status === "available") {
@@ -577,6 +582,7 @@ export async function resolveOpenAIQuicksilverBridgeAuth(params: {
hasOpenAIRealtimePlatformAuthInput({
configuredApiKey: params.configuredApiKey,
cfg: params.cfg,
agentId: params.agentId,
})
) {
throw new Error(OPENAI_GPT_LIVE_AUTHORED_PLATFORM_AUTH_UNAVAILABLE);
@@ -587,6 +593,7 @@ export async function resolveOpenAIQuicksilverBridgeAuth(params: {
export function hasOpenAIRealtimePlatformAuthInput(params: {
configuredApiKey: string | undefined;
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
agentId?: string;
}): boolean {
if (hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)) {
return true;
@@ -595,6 +602,9 @@ export function hasOpenAIRealtimePlatformAuthInput(params: {
isProviderAuthProfileConfigured({
provider: "openai",
cfg: params.cfg,
...(params.cfg && params.agentId
? { agentDir: resolveAgentDir(params.cfg, params.agentId) }
: {}),
profileTypes: ["api_key"],
includeExternalCliAuth: false,
})
@@ -366,6 +366,9 @@ describe("createVoiceCallRuntime lifecycle", () => {
});
const resolveInstructions = mocks.realtimeHandlerCtorArgs[0]?.[7];
expect(mocks.resolveConfiguredRealtimeVoiceProvider).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "operator" }),
);
if (typeof resolveInstructions !== "function") {
throw new Error("expected per-call realtime instruction resolver");
}
+1
View File
@@ -238,6 +238,7 @@ async function resolveRealtimeProvider(params: {
configuredProviderId: params.config.realtime.provider,
providerConfigs: params.config.realtime.providers,
cfg: params.fullConfig,
agentId: params.config.agentId,
});
}
@@ -572,6 +572,7 @@ describe("RealtimeCallHandler path routing", () => {
}),
);
expect(createBridge.mock.calls[0]?.[0].instructions).toBe("instructions:support");
expect(createBridge.mock.calls[0]?.[0].agentId).toBe("support");
} finally {
if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
ws.close();
@@ -256,6 +256,7 @@ export type StreamSession = {
type CallRegistration = {
callId: string;
agentId?: string;
instructions: string;
initialGreetingInstructions?: string;
};
@@ -654,7 +655,7 @@ export class RealtimeCallHandler {
return null;
}
const { callId, instructions, initialGreetingInstructions } = registration;
const { callId, agentId, instructions, initialGreetingInstructions } = registration;
const callRecord = this.manager.getCallByProviderCallId(callSid);
const harness = createRealtimeVoiceSessionHarness({
talk: {
@@ -792,6 +793,7 @@ export class RealtimeCallHandler {
const bridgeParams: Parameters<typeof harness.createBridge>[0] = {
provider: this.realtimeProvider,
cfg: this.coreConfig,
agentId,
providerConfig: this.providerConfig,
interruptResponseOnInputAudio,
instructions,
@@ -1595,6 +1597,7 @@ export class RealtimeCallHandler {
const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions;
return {
callId: callRecord.callId,
agentId: callRecord.agentId,
instructions,
initialGreetingInstructions: buildGreetingInstructions(instructions, initialGreeting),
};
@@ -268,8 +268,12 @@ export function createXaiRealtimeVoiceProviderMetadata() {
supportsSessionResumption: true,
},
resolveConfig: ({ rawConfig }) => normalizeXaiRealtimeProviderConfig(rawConfig),
isConfigured: ({ providerConfig, cfg }) =>
hasXaiRealtimeApiKeyInput(normalizeXaiRealtimeProviderConfig(providerConfig).apiKey, cfg),
isConfigured: ({ providerConfig, cfg, agentId }) =>
hasXaiRealtimeApiKeyInput(
normalizeXaiRealtimeProviderConfig(providerConfig).apiKey,
cfg,
agentId,
),
} satisfies Omit<RealtimeVoiceProviderPlugin, "createBridge" | "createBrowserSession">;
}
@@ -1,3 +1,4 @@
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -5,13 +6,18 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
export async function resolveXaiRealtimeApiKey(
configApiKey: string | undefined,
cfg: OpenClawConfig | undefined,
agentId?: string,
): Promise<string> {
const direct =
normalizeOptionalString(configApiKey) ?? normalizeOptionalString(process.env.XAI_API_KEY);
if (direct) {
return direct;
}
const auth = await resolveApiKeyForProvider({ provider: "xai", cfg });
const auth = await resolveApiKeyForProvider({
provider: "xai",
cfg,
...(cfg && agentId ? { agentDir: resolveAgentDir(cfg, agentId) } : {}),
});
const oauthKey = normalizeOptionalString(auth?.apiKey);
if (oauthKey) {
return oauthKey;
+1 -1
View File
@@ -342,7 +342,7 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re
}> {
const apiKey = this.config.resolveApiKey
? await this.config.resolveApiKey()
: await resolveXaiRealtimeApiKey(this.config.apiKey, this.config.cfg);
: await resolveXaiRealtimeApiKey(this.config.apiKey, this.config.cfg, this.config.agentId);
const model = this.config.model ?? XAI_REALTIME_DEFAULT_MODEL;
const url = toXaiRealtimeWsUrl(
this.config.baseUrl,
+7 -1
View File
@@ -1,3 +1,4 @@
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import {
isProviderAuthProfileConfigured,
type OpenClawConfig,
@@ -237,9 +238,14 @@ export function toXaiRealtimeWsUrl(
export function hasXaiRealtimeApiKeyInput(
configApiKey: string | undefined,
cfg: OpenClawConfig | undefined,
agentId?: string,
): boolean {
if (normalizeOptionalString(configApiKey) || normalizeOptionalString(process.env.XAI_API_KEY)) {
return true;
}
return isProviderAuthProfileConfigured({ provider: "xai", cfg });
return isProviderAuthProfileConfigured({
provider: "xai",
cfg,
...(cfg && agentId ? { agentDir: resolveAgentDir(cfg, agentId) } : {}),
});
}
+58 -2
View File
@@ -80,9 +80,11 @@ const { FakeWebSocket, isProviderAuthProfileConfiguredMock, resolveApiKeyForProv
return {
FakeWebSocket: MockWebSocket,
isProviderAuthProfileConfiguredMock: vi.fn(() => false),
isProviderAuthProfileConfiguredMock: vi.fn((_params: { agentDir?: string }) => false),
resolveApiKeyForProviderMock: vi.fn(
async (): Promise<{ apiKey: string | undefined }> => ({ apiKey: undefined }),
async (_params: { agentDir?: string }): Promise<{ apiKey: string | undefined }> => ({
apiKey: undefined,
}),
),
};
});
@@ -238,6 +240,60 @@ describe("buildXaiRealtimeVoiceProvider", () => {
expect(FakeWebSocket.instances).toHaveLength(0);
});
it("checks realtime readiness in the selected agent directory", () => {
isProviderAuthProfileConfiguredMock.mockImplementation(
({ agentDir }) => agentDir === "/tmp/openclaw-molty-agent",
);
const provider = buildXaiRealtimeVoiceProvider();
const cfg = {
agents: {
list: [
{ id: "helper", agentDir: "/tmp/openclaw-helper-agent" },
{ id: "molty", agentDir: "/tmp/openclaw-molty-agent" },
],
},
} as never;
expect(provider.isConfigured({ cfg, providerConfig: {}, agentId: "molty" })).toBe(true);
expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({
provider: "xai",
cfg,
agentDir: "/tmp/openclaw-molty-agent",
});
});
it("resolves realtime auth from the selected agent directory", async () => {
resolveApiKeyForProviderMock.mockImplementation(async ({ agentDir }) => ({
apiKey: agentDir === "/tmp/openclaw-molty-agent" ? "xai-molty" : undefined,
}));
const cfg = {
agents: {
list: [
{ id: "helper", agentDir: "/tmp/openclaw-helper-agent" },
{ id: "molty", agentDir: "/tmp/openclaw-molty-agent" },
],
},
} as never;
const bridge = createTestBridge({
cfg,
agentId: "molty",
providerConfig: {},
});
const { connecting, socket } = await startRealtimeBridge(bridge);
await connecting;
bridge.close();
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({
provider: "xai",
cfg,
agentDir: "/tmp/openclaw-molty-agent",
});
expect((socket.args[1] as { headers?: Record<string, string> }).headers?.Authorization).toBe(
"Bearer xai-molty",
);
});
it("coalesces concurrent connects and ignores connects after readiness", async () => {
vi.stubEnv("XAI_API_KEY", "xai-env"); // pragma: allowlist secret
const bridge = createTestBridge();
+1 -1
View File
@@ -27,7 +27,7 @@ export function buildXaiRealtimeVoiceProvider(): RealtimeVoiceProviderPlugin {
prefixPaddingMs: config.prefixPaddingMs,
reasoningEffort: config.reasoningEffort,
sessionResumption: config.sessionResumption,
resolveApiKey: () => resolveXaiRealtimeApiKey(config.apiKey, req.cfg),
resolveApiKey: () => resolveXaiRealtimeApiKey(config.apiKey, req.cfg, req.agentId),
});
},
};
@@ -195,13 +195,13 @@ export function createTalkRealtimeRelaySession(
createBridge: (request: Parameters<typeof params.provider.createBridge>[0]) =>
params.provider.createBridge({
...request,
...(relayAgentId ? { agentId: relayAgentId } : {}),
runAgentConsult,
}),
};
const bridge = harness.createBridge({
provider: relayProvider,
cfg: params.cfg,
agentId: relayAgentId,
providerConfig: params.providerConfig,
audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
instructions: params.instructions,
-1
View File
@@ -47,7 +47,6 @@ export type MeetingPluginConfig = MeetingRealtimeEngineConfig & {
chromeNode: { node?: string };
realtime: MeetingRealtimeEngineConfig["realtime"] & {
strategy: "agent" | "bidi";
agentId?: string;
toolPolicy: RealtimeVoiceAgentConsultToolPolicy;
};
};
@@ -41,6 +41,7 @@ type MeetingRealtimeLifecycleHandlersParams = {
type MeetingRealtimeProviderSelectionConfig = {
realtime: {
agentId?: string;
provider?: string;
transcriptionProvider?: string;
voiceProvider?: string;
@@ -73,6 +74,7 @@ export function resolveMeetingRealtimeProvider(params: {
configuredProviderId: providerId,
providerConfigs: params.config.realtime.providers,
cfg: params.fullConfig,
agentId: params.config.realtime.agentId,
providers: params.providers,
defaultModel: params.config.realtime.model,
noRegisteredProviderMessage: "No configured realtime voice provider registered",
+2
View File
@@ -48,6 +48,7 @@ export type MeetingRealtimeEngineConfig = {
chrome: { audioFormat: MeetingRealtimeAudioFormat };
realtime: {
strategy: string;
agentId?: string;
provider?: string;
transcriptionProvider?: string;
voiceProvider?: string;
@@ -485,6 +486,7 @@ export async function startMeetingRealtimeEngine(params: {
bridge = harness.createBridge({
provider: resolved.provider,
cfg: params.fullConfig,
agentId: params.config.realtime.agentId,
providerConfig: resolved.providerConfig,
audioFormat: resolveMeetingRealtimeAudioFormat(params.config.chrome.audioFormat),
instructions: params.config.realtime.instructions,
+25
View File
@@ -76,6 +76,31 @@ describe("realtime voice provider resolver", () => {
});
});
it("passes the host-selected agent to public provider readiness", () => {
const isConfigured = vi.fn(({ agentId }) => agentId === "molty");
const provider: RealtimeVoiceProviderPlugin = {
id: "agent-scoped",
label: "Agent scoped",
isConfigured,
createBridge: () => {
throw new Error("unused");
},
};
expect(
resolveConfiguredRealtimeVoiceProvider({
cfg: {},
agentId: "molty",
providers: [provider],
}).provider,
).toBe(provider);
expect(isConfigured).toHaveBeenCalledWith({
cfg: {},
agentId: "molty",
providerConfig: {},
});
});
it("keeps browser-only providers out of bridge auto-selection", () => {
const isBrowserSessionConfigured = vi.fn(
({ agentId }: { agentId?: string }) => agentId === "voice-agent",
+1
View File
@@ -84,6 +84,7 @@ export function isRealtimeVoiceProviderConfigured(params: {
}
return params.provider.isConfigured({
cfg: params.cfg,
agentId: params.agentId,
providerConfig: params.providerConfig,
});
}
+2
View File
@@ -183,6 +183,8 @@ export type RealtimeVoiceProviderResolveConfigContext = {
export type RealtimeVoiceProviderConfiguredContext = {
cfg?: OpenClawConfig;
/** Host-selected agent scope for provider auth readiness. */
agentId?: string;
providerConfig: RealtimeVoiceProviderConfig;
};
+22
View File
@@ -120,6 +120,28 @@ describe("realtime voice bridge session runtime", () => {
);
});
it("passes the host-selected agent to the provider bridge", () => {
let request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0] | undefined;
const provider: RealtimeVoiceProviderPlugin = {
id: "test",
label: "Test",
isConfigured: () => true,
createBridge: (nextRequest) => {
request = nextRequest;
return makeBridge();
},
};
createRealtimeVoiceBridgeSession({
provider,
agentId: "molty",
providerConfig: {},
audioSink: { sendAudio: vi.fn() },
});
expect(expectBridgeRequest(request).agentId).toBe("molty");
});
it("passes the audio auto-response preference to the provider bridge", () => {
let request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0] | undefined;
const provider: RealtimeVoiceProviderPlugin = {
+3
View File
@@ -57,6 +57,8 @@ export type RealtimeVoiceBridgeSession = {
export type RealtimeVoiceBridgeSessionParams = {
provider: RealtimeVoiceProviderPlugin;
cfg?: OpenClawConfig;
/** Host-selected agent scope for provider auth and agent-owned bridge state. */
agentId?: string;
providerConfig: RealtimeVoiceProviderConfig;
audioFormat?: RealtimeVoiceAudioFormat;
audioSink: RealtimeVoiceAudioSink;
@@ -164,6 +166,7 @@ export function createRealtimeVoiceBridgeSession(
};
const bridge = params.provider.createBridge({
cfg: params.cfg,
agentId: params.agentId,
providerConfig: params.providerConfig,
audioFormat: params.audioFormat,
instructions: params.instructions,