mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(discord): preserve routed agent for realtime voice (#125111)
This commit is contained in:
committed by
GitHub
parent
6b86199892
commit
73ab74eccc
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -84,6 +84,7 @@ export function isRealtimeVoiceProviderConfigured(params: {
|
||||
}
|
||||
return params.provider.isConfigured({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
providerConfig: params.providerConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,6 +183,8 @@ export type RealtimeVoiceProviderResolveConfigContext = {
|
||||
|
||||
export type RealtimeVoiceProviderConfiguredContext = {
|
||||
cfg?: OpenClawConfig;
|
||||
/** Host-selected agent scope for provider auth readiness. */
|
||||
agentId?: string;
|
||||
providerConfig: RealtimeVoiceProviderConfig;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user