Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
940
extensions/google/realtime-voice-provider.test.ts
Normal file
940
extensions/google/realtime-voice-provider.test.ts
Normal file
@@ -0,0 +1,940 @@
|
||||
// Google tests cover realtime voice provider plugin behavior.
|
||||
import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleRealtimeVoiceProvider } from "./realtime-voice-provider.js";
|
||||
|
||||
type MockGoogleLiveSession = {
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
sendClientContent: ReturnType<typeof vi.fn>;
|
||||
sendRealtimeInput: ReturnType<typeof vi.fn>;
|
||||
sendToolResponse: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockGoogleLiveConnectParams = {
|
||||
model: string;
|
||||
config: Record<string, unknown>;
|
||||
callbacks: {
|
||||
onopen: () => void;
|
||||
onmessage: (message: Record<string, unknown>) => void;
|
||||
onerror: (event: { error?: unknown; message?: string }) => void;
|
||||
onclose: (event?: { code?: number; reason?: string; wasClean?: boolean }) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const { connectMock, createTokenMock, session } = vi.hoisted(() => {
|
||||
const sessionValue: MockGoogleLiveSession = {
|
||||
close: vi.fn(),
|
||||
sendClientContent: vi.fn(),
|
||||
sendRealtimeInput: vi.fn(),
|
||||
sendToolResponse: vi.fn(),
|
||||
};
|
||||
const connectMockLocal = vi.fn(async (_params: MockGoogleLiveConnectParams) => sessionValue);
|
||||
const createTokenMockLocal = vi.fn(async (_params: unknown) => ({
|
||||
name: "auth_tokens/browser-session",
|
||||
}));
|
||||
return {
|
||||
connectMock: connectMockLocal,
|
||||
createTokenMock: createTokenMockLocal,
|
||||
session: sessionValue,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./google-genai-runtime.js", () => ({
|
||||
createGoogleGenAI: vi.fn(() => ({
|
||||
authTokens: {
|
||||
create: createTokenMock,
|
||||
},
|
||||
live: {
|
||||
connect: connectMock,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const ENV_KEYS = ["GEMINI_API_KEY", "GOOGLE_API_KEY"] as const;
|
||||
|
||||
let envSnapshot: Partial<Record<(typeof ENV_KEYS)[number], string>>;
|
||||
|
||||
function lastConnectParams(): MockGoogleLiveConnectParams {
|
||||
const params = connectMock.mock.calls.at(-1)?.[0];
|
||||
if (!params) {
|
||||
throw new Error("expected google live connect call");
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function sentAudio(index = 0): { data?: unknown; mimeType?: unknown } {
|
||||
const audio = session.sendRealtimeInput.mock.calls[index]?.[0]?.audio;
|
||||
if (!audio) {
|
||||
throw new Error(`Expected sent audio at index ${index}`);
|
||||
}
|
||||
return audio as { data?: unknown; mimeType?: unknown };
|
||||
}
|
||||
|
||||
function requireFirstMockArg(mock: ReturnType<typeof vi.fn>, label: string): unknown {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function requireFirstError(mock: ReturnType<typeof vi.fn>): { message?: string } {
|
||||
const error = requireFirstMockArg(mock, "Google Live error");
|
||||
if (!error || typeof error !== "object" || Array.isArray(error)) {
|
||||
throw new Error("expected Google Live error");
|
||||
}
|
||||
return error as { message?: string };
|
||||
}
|
||||
|
||||
function requireFirstAudio(mock: ReturnType<typeof vi.fn>): unknown {
|
||||
return requireFirstMockArg(mock, "Google Live audio");
|
||||
}
|
||||
|
||||
describe("buildGoogleRealtimeVoiceProvider", () => {
|
||||
beforeEach(() => {
|
||||
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
connectMock.mockClear();
|
||||
createTokenMock.mockClear();
|
||||
session.close.mockClear();
|
||||
session.sendClientContent.mockClear();
|
||||
session.sendRealtimeInput.mockClear();
|
||||
session.sendToolResponse.mockClear();
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
delete process.env.GOOGLE_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = envSnapshot[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./google-genai-runtime.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("declares realtime Talk capabilities for catalog selection", () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
|
||||
expect(provider.capabilities).toEqual({
|
||||
transports: ["provider-websocket", "gateway-relay"],
|
||||
inputAudioFormats: [
|
||||
{ encoding: "g711_ulaw", sampleRateHz: 8000, channels: 1 },
|
||||
{ encoding: "pcm16", sampleRateHz: 24000, channels: 1 },
|
||||
],
|
||||
outputAudioFormats: [
|
||||
{ encoding: "g711_ulaw", sampleRateHz: 8000, channels: 1 },
|
||||
{ encoding: "pcm16", sampleRateHz: 24000, channels: 1 },
|
||||
],
|
||||
supportsBrowserSession: true,
|
||||
supportsBargeIn: true,
|
||||
supportsToolCalls: true,
|
||||
supportsVideoFrames: true,
|
||||
supportsSessionResumption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes provider config and cfg model-provider key fallback", () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const resolved = provider.resolveConfig?.({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
apiKey: "cfg-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
rawConfig: {
|
||||
providers: {
|
||||
google: {
|
||||
model: "gemini-live-2.5-flash-preview",
|
||||
voice: "Puck",
|
||||
temperature: 0.4,
|
||||
silenceDurationMs: 700,
|
||||
startSensitivity: "high",
|
||||
activityHandling: "no_interruption",
|
||||
turnCoverage: "turn_includes_only_activity",
|
||||
automaticActivityDetectionDisabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
apiKey: "cfg-key",
|
||||
model: "gemini-live-2.5-flash-preview",
|
||||
voice: "Puck",
|
||||
temperature: 0.4,
|
||||
apiVersion: undefined,
|
||||
prefixPaddingMs: undefined,
|
||||
silenceDurationMs: 700,
|
||||
startSensitivity: "high",
|
||||
endSensitivity: undefined,
|
||||
activityHandling: "no-interruption",
|
||||
turnCoverage: "only-activity",
|
||||
automaticActivityDetectionDisabled: false,
|
||||
enableAffectiveDialog: undefined,
|
||||
sessionResumption: undefined,
|
||||
contextWindowCompression: undefined,
|
||||
thinkingLevel: undefined,
|
||||
thinkingBudget: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("connects with Google Live setup config and tool declarations", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
model: "gemini-live-2.5-flash-preview",
|
||||
voice: "Kore",
|
||||
temperature: 0.3,
|
||||
startSensitivity: "low",
|
||||
endSensitivity: "low",
|
||||
activityHandling: "no-interruption",
|
||||
turnCoverage: "only-activity",
|
||||
},
|
||||
instructions: "Speak briefly.",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "lookup",
|
||||
description: "Look something up",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
name: "openclaw_agent_consult",
|
||||
description: "Ask OpenClaw",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
question: { type: "string" },
|
||||
},
|
||||
required: ["question"],
|
||||
},
|
||||
},
|
||||
],
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
expect(connectMock).toHaveBeenCalledTimes(1);
|
||||
const params = lastConnectParams();
|
||||
expect(params.model).toBe("gemini-live-2.5-flash-preview");
|
||||
const config = params.config as {
|
||||
contextWindowCompression?: unknown;
|
||||
outputAudioTranscription?: unknown;
|
||||
realtimeInputConfig?: {
|
||||
activityHandling?: string;
|
||||
automaticActivityDetection?: {
|
||||
endOfSpeechSensitivity?: string;
|
||||
startOfSpeechSensitivity?: string;
|
||||
};
|
||||
turnCoverage?: string;
|
||||
};
|
||||
responseModalities?: string[];
|
||||
sessionResumption?: unknown;
|
||||
speechConfig?: { voiceConfig?: { prebuiltVoiceConfig?: { voiceName?: string } } };
|
||||
systemInstruction?: string;
|
||||
temperature?: number;
|
||||
tools?: Array<{
|
||||
functionDeclarations?: Array<{
|
||||
behavior?: string;
|
||||
description?: string;
|
||||
name?: string;
|
||||
parametersJsonSchema?: unknown;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
expect(config.responseModalities).toEqual(["AUDIO"]);
|
||||
expect(config.temperature).toBe(0.3);
|
||||
expect(config.systemInstruction).toBe("Speak briefly.");
|
||||
expect(config.speechConfig?.voiceConfig?.prebuiltVoiceConfig?.voiceName).toBe("Kore");
|
||||
expect(config.outputAudioTranscription).toEqual({});
|
||||
expect(config.realtimeInputConfig?.activityHandling).toBe("NO_INTERRUPTION");
|
||||
expect(config.realtimeInputConfig?.automaticActivityDetection?.startOfSpeechSensitivity).toBe(
|
||||
"START_SENSITIVITY_LOW",
|
||||
);
|
||||
expect(config.realtimeInputConfig?.automaticActivityDetection?.endOfSpeechSensitivity).toBe(
|
||||
"END_SENSITIVITY_LOW",
|
||||
);
|
||||
expect(config.realtimeInputConfig?.turnCoverage).toBe("TURN_INCLUDES_ONLY_ACTIVITY");
|
||||
expect(config.sessionResumption).toEqual({});
|
||||
expect(config.contextWindowCompression).toEqual({ slidingWindow: {} });
|
||||
const declarations = config.tools?.[0]?.functionDeclarations ?? [];
|
||||
expect(declarations[0]?.name).toBe("lookup");
|
||||
expect(declarations[0]?.description).toBe("Look something up");
|
||||
expect(declarations[0]?.parametersJsonSchema).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
required: ["query"],
|
||||
});
|
||||
expect(declarations[1]?.name).toBe("openclaw_agent_consult");
|
||||
expect(declarations[1]?.description).toBe("Ask OpenClaw");
|
||||
expect(declarations[1]?.parametersJsonSchema).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
question: { type: "string" },
|
||||
},
|
||||
required: ["question"],
|
||||
});
|
||||
expect(declarations[1]?.behavior).toBe("NON_BLOCKING");
|
||||
});
|
||||
|
||||
it("omits zero temperature for native audio responses", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
temperature: 0,
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
expect(lastConnectParams().config).not.toHaveProperty("temperature");
|
||||
});
|
||||
|
||||
it("drops malformed VAD timing values before connecting", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
prefixPaddingMs: -1,
|
||||
silenceDurationMs: 250.5,
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
expect(lastConnectParams().config).not.toHaveProperty("realtimeInputConfig");
|
||||
});
|
||||
|
||||
it("drops malformed thinking budgets before connecting", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
thinkingBudget: 24_576.5,
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
expect(lastConnectParams().config).not.toHaveProperty("thinkingConfig");
|
||||
});
|
||||
|
||||
it("passes Google Live dynamic thinking budget through", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
thinkingBudget: -1,
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
expect(lastConnectParams().config.thinkingConfig).toEqual({ thinkingBudget: -1 });
|
||||
});
|
||||
|
||||
it("creates constrained browser sessions for Google Live Talk", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
|
||||
const sessionLocal = await provider.createBrowserSession?.({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
model: "gemini-live-2.5-flash-preview",
|
||||
voice: "Puck",
|
||||
temperature: 0.4,
|
||||
},
|
||||
instructions: "Speak briefly.",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "openclaw_agent_consult",
|
||||
description: "Ask OpenClaw",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
question: { type: "string" },
|
||||
},
|
||||
required: ["question"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(createTokenMock).toHaveBeenCalledTimes(1);
|
||||
const tokenConfig = requireFirstMockArg(createTokenMock, "Google Live auth token config") as {
|
||||
config?: {
|
||||
liveConnectConstraints?: {
|
||||
config?: {
|
||||
responseModalities?: string[];
|
||||
speechConfig?: { voiceConfig?: { prebuiltVoiceConfig?: { voiceName?: string } } };
|
||||
systemInstruction?: string;
|
||||
temperature?: number;
|
||||
tools?: Array<{ functionDeclarations?: Array<{ behavior?: string; name?: string }> }>;
|
||||
};
|
||||
model?: string;
|
||||
};
|
||||
uses?: number;
|
||||
};
|
||||
};
|
||||
const liveConstraints = tokenConfig.config?.liveConnectConstraints;
|
||||
expect(tokenConfig.config?.uses).toBe(1);
|
||||
expect(liveConstraints?.model).toBe("gemini-live-2.5-flash-preview");
|
||||
expect(liveConstraints?.config?.responseModalities).toEqual(["AUDIO"]);
|
||||
expect(liveConstraints?.config?.temperature).toBe(0.4);
|
||||
expect(liveConstraints?.config?.systemInstruction).toBe("Speak briefly.");
|
||||
expect(liveConstraints?.config?.speechConfig?.voiceConfig?.prebuiltVoiceConfig?.voiceName).toBe(
|
||||
"Puck",
|
||||
);
|
||||
expect(liveConstraints?.config?.tools?.[0]?.functionDeclarations?.[0]?.name).toBe(
|
||||
"openclaw_agent_consult",
|
||||
);
|
||||
expect(liveConstraints?.config?.tools?.[0]?.functionDeclarations?.[0]?.behavior).toBe(
|
||||
"NON_BLOCKING",
|
||||
);
|
||||
expect(sessionLocal?.provider).toBe("google");
|
||||
expect(sessionLocal?.transport).toBe("provider-websocket");
|
||||
const websocketSession = sessionLocal as {
|
||||
audio: {
|
||||
inputEncoding: string;
|
||||
inputSampleRateHz: number;
|
||||
outputEncoding: string;
|
||||
outputSampleRateHz: number;
|
||||
};
|
||||
clientSecret: string;
|
||||
initialMessage: {
|
||||
setup: { generationConfig: { responseModalities: string[] }; model: string };
|
||||
};
|
||||
protocol: string;
|
||||
websocketUrl: string;
|
||||
};
|
||||
expect(websocketSession.protocol).toBe("google-live-bidi");
|
||||
expect(websocketSession.clientSecret).toBe("auth_tokens/browser-session");
|
||||
expect(websocketSession.websocketUrl).toBe(
|
||||
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained",
|
||||
);
|
||||
expect(websocketSession.audio.inputEncoding).toBe("pcm16");
|
||||
expect(websocketSession.audio.inputSampleRateHz).toBe(16000);
|
||||
expect(websocketSession.audio.outputEncoding).toBe("pcm16");
|
||||
expect(websocketSession.audio.outputSampleRateHz).toBe(24000);
|
||||
expect(websocketSession.initialMessage.setup.model).toBe(
|
||||
"models/gemini-live-2.5-flash-preview",
|
||||
);
|
||||
expect(websocketSession.initialMessage.setup.generationConfig.responseModalities).toEqual([
|
||||
"AUDIO",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects browser session expiry outside Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
|
||||
await expect(
|
||||
provider.createBrowserSession?.({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Google realtime browser session expiry is outside the supported Date range");
|
||||
expect(createTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects browser session creation while the process clock is invalid", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
|
||||
await expect(
|
||||
provider.createBrowserSession?.({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Google realtime browser session expiry is outside the supported Date range");
|
||||
expect(createTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can opt out of Google Live session resumption and context compression", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
contextWindowCompression: false,
|
||||
sessionResumption: false,
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
expect(lastConnectParams().config).not.toHaveProperty("contextWindowCompression");
|
||||
expect(lastConnectParams().config).not.toHaveProperty("sessionResumption");
|
||||
});
|
||||
|
||||
it("captures Google Live resumption handles and reuses them on reconnect", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
sessionResumptionUpdate: { resumable: true, newHandle: "resume-1" },
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
expect(lastConnectParams().config.sessionResumption).toEqual({ handle: "resume-1" });
|
||||
});
|
||||
|
||||
it("reconnects unexpected Google Live closes with the latest resumption handle", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onClose = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onClose,
|
||||
onError,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
setupComplete: { sessionId: "session-1" },
|
||||
sessionResumptionUpdate: { resumable: true, newHandle: "resume-1" },
|
||||
});
|
||||
lastConnectParams().callbacks.onclose({
|
||||
code: 1011,
|
||||
reason: "temporary upstream close",
|
||||
wasClean: false,
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
const error = requireFirstError(onError);
|
||||
expect(error.message).toContain("reconnecting 1/3");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(connectMock).toHaveBeenCalledTimes(2);
|
||||
expect(lastConnectParams().config.sessionResumption).toEqual({ handle: "resume-1" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for setup completion before draining audio and firing ready", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onReady = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onReady,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onopen();
|
||||
bridge.sendAudio(Buffer.from([0xff, 0xff]));
|
||||
|
||||
expect(session.sendRealtimeInput).not.toHaveBeenCalled();
|
||||
expect(onReady).not.toHaveBeenCalled();
|
||||
|
||||
lastConnectParams().callbacks.onmessage({ setupComplete: { sessionId: "session-1" } });
|
||||
|
||||
expect(onReady).toHaveBeenCalledTimes(1);
|
||||
expect(session.sendRealtimeInput).toHaveBeenCalledTimes(1);
|
||||
const audio = sentAudio();
|
||||
expect(typeof audio.data).toBe("string");
|
||||
expect(audio.mimeType).toBe("audio/pcm;rate=16000");
|
||||
});
|
||||
|
||||
it("marks the Google audio stream complete after sustained telephony silence", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key", silenceDurationMs: 60 },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onopen();
|
||||
lastConnectParams().callbacks.onmessage({ setupComplete: { sessionId: "session-1" } });
|
||||
|
||||
const silence20ms = Buffer.alloc(160, 0xff);
|
||||
bridge.sendAudio(silence20ms);
|
||||
bridge.sendAudio(silence20ms);
|
||||
bridge.sendAudio(silence20ms);
|
||||
|
||||
expect(session.sendRealtimeInput).toHaveBeenCalledWith({ audioStreamEnd: true });
|
||||
|
||||
const callsAfterStreamEnd = session.sendRealtimeInput.mock.calls.length;
|
||||
bridge.sendAudio(silence20ms);
|
||||
expect(session.sendRealtimeInput).toHaveBeenCalledTimes(callsAfterStreamEnd);
|
||||
|
||||
session.sendRealtimeInput.mockClear();
|
||||
bridge.sendAudio(Buffer.alloc(160, 0x7f));
|
||||
bridge.sendAudio(silence20ms);
|
||||
bridge.sendAudio(silence20ms);
|
||||
bridge.sendAudio(silence20ms);
|
||||
|
||||
expect(session.sendRealtimeInput).toHaveBeenCalledWith({ audioStreamEnd: true });
|
||||
});
|
||||
|
||||
it("fuses telephony mu-law conversion into the Gemini 16 kHz PCM input frame", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onopen();
|
||||
lastConnectParams().callbacks.onmessage({ setupComplete: { sessionId: "session-1" } });
|
||||
|
||||
bridge.sendAudio(Buffer.from([0xff, 0x00]));
|
||||
|
||||
const audio = sentAudio();
|
||||
expect(typeof audio.data).toBe("string");
|
||||
expect(audio.mimeType).toBe("audio/pcm;rate=16000");
|
||||
const sent = Buffer.from(audio.data as string, "base64");
|
||||
expect(Array.from({ length: sent.length / 2 }, (_, i) => sent.readInt16LE(i * 2))).toEqual([
|
||||
0, -16062, -32124, -32124,
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts PCM16 24 kHz audio without the telephony mu-law hop", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onopen();
|
||||
lastConnectParams().callbacks.onmessage({ setupComplete: { sessionId: "session-1" } });
|
||||
|
||||
bridge.sendAudio(Buffer.alloc(480));
|
||||
|
||||
const audio = sentAudio();
|
||||
expect(typeof audio.data).toBe("string");
|
||||
expect(audio.mimeType).toBe("audio/pcm;rate=16000");
|
||||
const sent = Buffer.from(audio.data as string, "base64");
|
||||
expect(sent).toHaveLength(320);
|
||||
});
|
||||
|
||||
it("can disable automatic VAD for manual activity signaling experiments", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
automaticActivityDetectionDisabled: true,
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
const config = lastConnectParams().config as {
|
||||
realtimeInputConfig?: { automaticActivityDetection?: { disabled?: boolean } };
|
||||
};
|
||||
expect(config.realtimeInputConfig?.automaticActivityDetection?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sends text prompts as ordered client turns", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onopen();
|
||||
lastConnectParams().callbacks.onmessage({ setupComplete: { sessionId: "session-1" } });
|
||||
|
||||
bridge.sendUserMessage?.(" Say hello. ");
|
||||
|
||||
expect(session.sendClientContent).toHaveBeenCalledWith({
|
||||
turns: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
||||
turnComplete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts Google PCM output to mu-law audio", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onAudio = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio,
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
const pcm24k = Buffer.alloc(480);
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
setupComplete: { sessionId: "session-1" },
|
||||
serverContent: {
|
||||
modelTurn: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "audio/L16;codec=pcm;rate=24000",
|
||||
data: pcm24k.toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(onAudio).toHaveBeenCalledTimes(1);
|
||||
const audio = requireFirstAudio(onAudio);
|
||||
expect(audio).toBeInstanceOf(Buffer);
|
||||
expect(audio).toHaveLength(80);
|
||||
});
|
||||
|
||||
it("can keep Google PCM output as PCM16 24 kHz audio", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onAudio = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
|
||||
onAudio,
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
const pcm24k = Buffer.alloc(480);
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
setupComplete: { sessionId: "session-1" },
|
||||
serverContent: {
|
||||
modelTurn: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "audio/L16;codec=pcm;rate=24000",
|
||||
data: pcm24k.toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(onAudio).toHaveBeenCalledTimes(1);
|
||||
expect(requireFirstAudio(onAudio)).toEqual(pcm24k);
|
||||
});
|
||||
|
||||
it("does not forward Google thought text as assistant transcript", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onTranscript = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onTranscript,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
setupComplete: {},
|
||||
serverContent: {
|
||||
modelTurn: {
|
||||
parts: [{ text: "internal reasoning", thought: true }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(onTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards Live API tool calls and submits matching function responses", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onToolCall = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onToolCall,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
setupComplete: { sessionId: "session-1" },
|
||||
toolCall: {
|
||||
functionCalls: [{ id: "call-1", name: "lookup", args: { query: "hi" } }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(onToolCall).toHaveBeenCalledWith({
|
||||
itemId: "call-1",
|
||||
callId: "call-1",
|
||||
name: "lookup",
|
||||
args: { query: "hi" },
|
||||
});
|
||||
|
||||
bridge.submitToolResult("call-1", { result: "ok" });
|
||||
|
||||
expect(session.sendToolResponse).toHaveBeenCalledWith({
|
||||
functionResponses: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "lookup",
|
||||
response: { result: "ok" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Google Live consult calls open after continuing tool responses", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
setupComplete: { sessionId: "session-1" },
|
||||
toolCall: {
|
||||
functionCalls: [
|
||||
{ id: "consult-call", name: "openclaw_agent_consult", args: { prompt: "hi" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
bridge.submitToolResult(
|
||||
"consult-call",
|
||||
{ status: "working", message: "Tell the participant you are checking." },
|
||||
{ willContinue: true },
|
||||
);
|
||||
bridge.submitToolResult("consult-call", { text: "The meeting starts at 3." });
|
||||
|
||||
expect(session.sendToolResponse).toHaveBeenNthCalledWith(1, {
|
||||
functionResponses: [
|
||||
{
|
||||
id: "consult-call",
|
||||
name: "openclaw_agent_consult",
|
||||
scheduling: "WHEN_IDLE",
|
||||
willContinue: true,
|
||||
response: { status: "working", message: "Tell the participant you are checking." },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(session.sendToolResponse).toHaveBeenNthCalledWith(2, {
|
||||
functionResponses: [
|
||||
{
|
||||
id: "consult-call",
|
||||
name: "openclaw_agent_consult",
|
||||
scheduling: "WHEN_IDLE",
|
||||
response: { text: "The meeting starts at 3." },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send malformed Live API tool responses without a matching call name", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onError = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onError,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
|
||||
bridge.submitToolResult("missing-call", { result: "ok" });
|
||||
|
||||
expect(session.sendToolResponse).not.toHaveBeenCalled();
|
||||
const error = requireFirstError(onError);
|
||||
expect(error.message).toBe(
|
||||
"Google Live function response is missing a matching function call for missing-call",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports Google Live tool response send failures without losing the call name", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const onError = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "gemini-key" },
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onError,
|
||||
});
|
||||
|
||||
await bridge.connect();
|
||||
lastConnectParams().callbacks.onmessage({
|
||||
setupComplete: { sessionId: "session-1" },
|
||||
toolCall: {
|
||||
functionCalls: [{ id: "call-1", name: "lookup", args: { query: "hi" } }],
|
||||
},
|
||||
});
|
||||
|
||||
const sendError = new Error("SDK send failed");
|
||||
session.sendToolResponse.mockImplementationOnce(() => {
|
||||
throw sendError;
|
||||
});
|
||||
|
||||
bridge.submitToolResult("call-1", ["retryable"]);
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(sendError);
|
||||
|
||||
bridge.submitToolResult("call-1", { result: "ok" });
|
||||
|
||||
expect(session.sendToolResponse).toHaveBeenLastCalledWith({
|
||||
functionResponses: [
|
||||
{
|
||||
id: "call-1",
|
||||
name: "lookup",
|
||||
response: { result: "ok" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user