mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
718 lines
24 KiB
TypeScript
718 lines
24 KiB
TypeScript
// Google Meet realtime tests cover real local command-pair substitute processes.
|
|
import { spawn as spawnChildProcess, type ChildProcess } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
createLocalMeetingRealtimeAudioTransport,
|
|
startMeetingAgentRealtimeEngine,
|
|
startMeetingRealtimeEngine,
|
|
type MeetingRealtimeAudioTransport,
|
|
} from "openclaw/plugin-sdk/meeting-runtime";
|
|
import type { RealtimeTranscriptionProviderPlugin } from "openclaw/plugin-sdk/realtime-transcription";
|
|
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { resolveGoogleMeetConfig } from "./config.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
const spawnedChildren: ChildProcess[] = [];
|
|
|
|
type MeetRealtimeAudioSpawn = NonNullable<
|
|
Parameters<typeof createLocalMeetingRealtimeAudioTransport>[0]["spawn"]
|
|
>;
|
|
|
|
const GOOGLE_MEET_ENGINE_BINDINGS = {
|
|
platform: {
|
|
displayName: "Google Meet",
|
|
logScope: "[google-meet]",
|
|
sessionIdPrefix: "google-meet",
|
|
},
|
|
consultAgent: async () => ({ text: "" }),
|
|
tools: [],
|
|
handleToolCall: async () => {},
|
|
};
|
|
|
|
function writeBridgeCommand(): string {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-bridge-"));
|
|
tempDirs.push(dir);
|
|
const scriptPath = path.join(dir, "bridge-command.mjs");
|
|
writeFileSync(
|
|
scriptPath,
|
|
[
|
|
"process.on('SIGTERM', () => {",
|
|
" process.exit(0);",
|
|
"});",
|
|
"process.stdin.resume();",
|
|
"setInterval(() => {}, 1000);",
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
return scriptPath;
|
|
}
|
|
|
|
function writeSigtermResistantBridgeCommand(): string {
|
|
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-resistant-bridge-"));
|
|
tempDirs.push(dir);
|
|
const scriptPath = path.join(dir, "bridge-command.mjs");
|
|
writeFileSync(
|
|
scriptPath,
|
|
[
|
|
"process.on('SIGTERM', () => {",
|
|
" process.stderr.write('sigterm\\n');",
|
|
"});",
|
|
"process.stdin.resume();",
|
|
"setTimeout(() => process.stderr.write(`ready:${process.argv[2]}\\n`), 50);",
|
|
"setInterval(() => {}, 1000);",
|
|
"",
|
|
].join("\n"),
|
|
{ mode: 0o755 },
|
|
);
|
|
return scriptPath;
|
|
}
|
|
|
|
function makeRecordingSpawn(): MeetRealtimeAudioSpawn {
|
|
return (command, args, options) => {
|
|
const child = spawnChildProcess(command, args, options);
|
|
spawnedChildren.push(child);
|
|
return child as ReturnType<MeetRealtimeAudioSpawn>;
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const child of spawnedChildren.splice(0)) {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("local Meet realtime transport process stream errors", () => {
|
|
it("disposes transport when transcription session creation throws", async () => {
|
|
const initError = new Error("transcription session creation failed");
|
|
const provider: RealtimeTranscriptionProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
defaultModel: "gpt-4o-transcribe",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createSession: () => {
|
|
throw initError;
|
|
},
|
|
};
|
|
const startInput = vi.fn();
|
|
const stop = vi.fn(async () => {});
|
|
const dispose = vi.fn(async () => {});
|
|
const transport: MeetingRealtimeAudioTransport = {
|
|
onFatal: vi.fn(),
|
|
startInput,
|
|
stop,
|
|
writeOutput: vi.fn(async () => {}),
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose,
|
|
};
|
|
|
|
await expect(
|
|
startMeetingAgentRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({
|
|
chrome: { audioFormat: "pcm16-24khz" },
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-create-session-failure",
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
providers: [provider],
|
|
transport,
|
|
}),
|
|
).rejects.toBe(initError);
|
|
|
|
expect(startInput).not.toHaveBeenCalled();
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("closes the STT session and disposes transport when input startup throws", async () => {
|
|
const initError = new Error("input startup failed");
|
|
const sttSession = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => false),
|
|
};
|
|
const provider: RealtimeTranscriptionProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
defaultModel: "gpt-4o-transcribe",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createSession: () => sttSession,
|
|
};
|
|
const startInput = vi.fn(() => {
|
|
throw initError;
|
|
});
|
|
const stop = vi.fn(async () => {});
|
|
const dispose = vi.fn(async () => {});
|
|
const transport: MeetingRealtimeAudioTransport = {
|
|
onFatal: vi.fn(),
|
|
startInput,
|
|
stop,
|
|
writeOutput: vi.fn(async () => {}),
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose,
|
|
};
|
|
|
|
await expect(
|
|
startMeetingAgentRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({
|
|
chrome: { audioFormat: "pcm16-24khz" },
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-input-start-failure",
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
providers: [provider],
|
|
transport,
|
|
}),
|
|
).rejects.toBe(initError);
|
|
|
|
expect(sttSession.connect).not.toHaveBeenCalled();
|
|
expect(sttSession.close).toHaveBeenCalledOnce();
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("closes the STT session and disposes transport when connect rejects", async () => {
|
|
const connectError = new Error("transcription connect failed");
|
|
const sttSession = {
|
|
connect: vi.fn(async () => {
|
|
throw connectError;
|
|
}),
|
|
sendAudio: vi.fn(),
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => false),
|
|
};
|
|
const provider: RealtimeTranscriptionProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
defaultModel: "gpt-4o-transcribe",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createSession: () => sttSession,
|
|
};
|
|
const stop = vi.fn(async () => {});
|
|
const dispose = vi.fn(async () => {});
|
|
const transport: MeetingRealtimeAudioTransport = {
|
|
onFatal: vi.fn(),
|
|
startInput: vi.fn(),
|
|
stop,
|
|
writeOutput: vi.fn(async () => {}),
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose,
|
|
};
|
|
|
|
await expect(
|
|
startMeetingAgentRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({
|
|
chrome: { audioFormat: "pcm16-24khz" },
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-connect-failure",
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
providers: [provider],
|
|
transport,
|
|
}),
|
|
).rejects.toBe(connectError);
|
|
|
|
expect(sttSession.close).toHaveBeenCalledOnce();
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("stops the engine when input fails during provider setup", async () => {
|
|
const bridgeScript = writeBridgeCommand();
|
|
let finishConnect = () => {};
|
|
const connectGate = new Promise<void>((resolve) => {
|
|
finishConnect = resolve;
|
|
});
|
|
const sttSession = {
|
|
connect: vi.fn(() => connectGate),
|
|
sendAudio: vi.fn(),
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => false),
|
|
};
|
|
const provider: RealtimeTranscriptionProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
defaultModel: "gpt-4o-transcribe",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createSession: () => sttSession,
|
|
};
|
|
const logger = {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
};
|
|
const transport = createLocalMeetingRealtimeAudioTransport({
|
|
inputCommand: [path.join(path.dirname(bridgeScript), "missing-capture")],
|
|
outputCommand: [process.execPath, bridgeScript, "play"],
|
|
bargeInRmsThreshold: 10,
|
|
bargeInPeakThreshold: 10,
|
|
bargeInCooldownMs: 1,
|
|
logger,
|
|
logScope: "[google-meet]",
|
|
spawn: makeRecordingSpawn(),
|
|
});
|
|
const config = resolveGoogleMeetConfig({
|
|
chrome: { audioFormat: "pcm16-24khz" },
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
});
|
|
const engineResult = startMeetingAgentRealtimeEngine({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-startup-failure",
|
|
logger,
|
|
providers: [provider],
|
|
transport,
|
|
}).then(
|
|
() => new Error("Expected Google Meet engine startup to fail"),
|
|
(error: unknown) => error,
|
|
);
|
|
const inputProcess = spawnedChildren[1];
|
|
if (!inputProcess) {
|
|
throw new Error("Expected Google Meet transport to spawn an input child process");
|
|
}
|
|
|
|
await once(inputProcess, "error");
|
|
await vi.waitFor(() => {
|
|
expect(sttSession.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
finishConnect();
|
|
|
|
await expect(engineResult).resolves.toEqual(
|
|
new Error("Google Meet audio transport stopped during transcription provider setup"),
|
|
);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("[google-meet] audio input command failed:"),
|
|
);
|
|
await transport.dispose();
|
|
});
|
|
|
|
it("contains a forced local command-pair stdout stream error through bridge stop", async () => {
|
|
const bridgeScript = writeBridgeCommand();
|
|
const sttSession = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
close: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeTranscriptionProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
defaultModel: "gpt-4o-transcribe",
|
|
autoSelectOrder: 1,
|
|
resolveConfig: ({ rawConfig }) => rawConfig,
|
|
isConfigured: () => true,
|
|
createSession: () => sttSession,
|
|
};
|
|
const logger = {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
};
|
|
|
|
const config = resolveGoogleMeetConfig({
|
|
chrome: { audioFormat: "pcm16-24khz" },
|
|
realtime: { provider: "openai", agentId: "jay", introMessage: "" },
|
|
});
|
|
const transport = createLocalMeetingRealtimeAudioTransport({
|
|
inputCommand: [process.execPath, bridgeScript, "capture"],
|
|
outputCommand: [process.execPath, bridgeScript, "play"],
|
|
bargeInRmsThreshold: config.chrome.bargeInRmsThreshold,
|
|
bargeInPeakThreshold: config.chrome.bargeInPeakThreshold,
|
|
bargeInCooldownMs: config.chrome.bargeInCooldownMs,
|
|
logger: logger as never,
|
|
logScope: "[google-meet]",
|
|
spawn: makeRecordingSpawn(),
|
|
});
|
|
const handle = await startMeetingAgentRealtimeEngine({
|
|
config,
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-1",
|
|
logger: logger as never,
|
|
providers: [provider],
|
|
transport,
|
|
});
|
|
const [outputProcess, inputProcess] = spawnedChildren;
|
|
if (!inputProcess || !outputProcess) {
|
|
throw new Error("Expected Google Meet bridge to spawn input and output child processes");
|
|
}
|
|
const inputClosed = once(inputProcess, "close");
|
|
const outputClosed = once(outputProcess, "close");
|
|
const originalInputKill = inputProcess.kill.bind(inputProcess);
|
|
const originalOutputKill = outputProcess.kill.bind(outputProcess);
|
|
const inputKillSpy = vi
|
|
.spyOn(inputProcess, "kill")
|
|
.mockImplementation((signal) => originalInputKill(signal));
|
|
const outputKillSpy = vi
|
|
.spyOn(outputProcess, "kill")
|
|
.mockImplementation((signal) => originalOutputKill(signal));
|
|
|
|
inputProcess.stdout?.destroy(new Error("EPIPE from real bridge input stdout"));
|
|
await new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
inputProcess.stderr?.destroy(new Error("duplicate stderr EPIPE"));
|
|
|
|
await Promise.all([inputClosed, outputClosed]);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
"[google-meet] audio input command stdout failed: EPIPE from real bridge input stdout",
|
|
);
|
|
expect(handle.getHealth().bridgeClosed).toBe(true);
|
|
expect(sttSession.close).toHaveBeenCalledTimes(1);
|
|
expect(inputKillSpy.mock.calls.filter(([signal]) => signal === "SIGTERM")).toHaveLength(1);
|
|
expect(outputKillSpy.mock.calls.filter(([signal]) => signal === "SIGTERM")).toHaveLength(1);
|
|
console.info(
|
|
`[proof] local command-pair substitute stopped after forced input stdout stream error; inputPid=${
|
|
inputProcess.pid ?? "unknown"
|
|
} outputPid=${outputProcess.pid ?? "unknown"}`,
|
|
);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"waits for SIGTERM-resistant bridge processes and shares the stop promise",
|
|
async () => {
|
|
const bridgeScript = writeSigtermResistantBridgeCommand();
|
|
const transport = createLocalMeetingRealtimeAudioTransport({
|
|
inputCommand: [process.execPath, bridgeScript, "capture"],
|
|
outputCommand: [process.execPath, bridgeScript, "play"],
|
|
bargeInRmsThreshold: 10,
|
|
bargeInPeakThreshold: 10,
|
|
bargeInCooldownMs: 1,
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
logScope: "[google-meet]",
|
|
spawn: makeRecordingSpawn(),
|
|
});
|
|
const [outputProcess, inputProcess] = spawnedChildren;
|
|
if (!inputProcess || !outputProcess || !inputProcess.stderr || !outputProcess.stderr) {
|
|
throw new Error("Expected Google Meet bridge to spawn stderr-backed child processes");
|
|
}
|
|
await Promise.all([once(inputProcess.stderr, "data"), once(outputProcess.stderr, "data")]);
|
|
const originalInputKill = inputProcess.kill.bind(inputProcess);
|
|
const originalOutputKill = outputProcess.kill.bind(outputProcess);
|
|
const inputKillSpy = vi
|
|
.spyOn(inputProcess, "kill")
|
|
.mockImplementation((signal) => originalInputKill(signal));
|
|
const outputKillSpy = vi
|
|
.spyOn(outputProcess, "kill")
|
|
.mockImplementation((signal) => originalOutputKill(signal));
|
|
|
|
const startedAt = Date.now();
|
|
const firstStop = transport.stop();
|
|
const secondStop = transport.stop();
|
|
|
|
expect(secondStop).toBe(firstStop);
|
|
await firstStop;
|
|
const elapsedMs = Date.now() - startedAt;
|
|
expect(elapsedMs).toBeGreaterThanOrEqual(900);
|
|
expect(elapsedMs).toBeLessThan(5_000);
|
|
expect(inputKillSpy.mock.calls.filter(([signal]) => signal === "SIGTERM")).toHaveLength(1);
|
|
expect(inputKillSpy.mock.calls.filter(([signal]) => signal === "SIGKILL")).toHaveLength(1);
|
|
expect(outputKillSpy.mock.calls.filter(([signal]) => signal === "SIGTERM")).toHaveLength(1);
|
|
expect(outputKillSpy.mock.calls.filter(([signal]) => signal === "SIGKILL")).toHaveLength(1);
|
|
},
|
|
);
|
|
});
|
|
|
|
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");
|
|
const bridge = {
|
|
connect: vi.fn(async () => {
|
|
throw connectError;
|
|
}),
|
|
sendAudio: vi.fn(),
|
|
sendUserMessage: vi.fn(),
|
|
setMediaTimestamp: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(),
|
|
triggerGreeting: vi.fn(),
|
|
isConnected: vi.fn(() => false),
|
|
};
|
|
const provider: RealtimeVoiceProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
isConfigured: () => true,
|
|
createBridge: () => bridge,
|
|
};
|
|
const stop = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockRejectedValueOnce(stopError)
|
|
.mockResolvedValueOnce();
|
|
const dispose = vi.fn(async () => {});
|
|
const transport: MeetingRealtimeAudioTransport = {
|
|
onFatal: vi.fn(),
|
|
startInput: vi.fn(),
|
|
stop,
|
|
writeOutput: vi.fn(async () => {}),
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose,
|
|
};
|
|
|
|
await expect(
|
|
startMeetingRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({ realtime: { strategy: "bidi", provider: "openai" } }),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-connect-failure",
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
providers: [provider],
|
|
transport,
|
|
}),
|
|
).rejects.toBe(connectError);
|
|
|
|
expect(bridge.close).toHaveBeenCalledOnce();
|
|
expect(stop).toHaveBeenCalledTimes(2);
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("stops the audio transport when the provider bridge closes unexpectedly", async () => {
|
|
let closeBridge: ((reason: "completed" | "error") => void) | undefined;
|
|
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: () => true,
|
|
createBridge: (params) => {
|
|
closeBridge = params.onClose;
|
|
return bridge;
|
|
},
|
|
};
|
|
const stop = vi.fn(async () => {});
|
|
const dispose = vi.fn(async () => {});
|
|
const transport: MeetingRealtimeAudioTransport = {
|
|
onFatal: vi.fn(),
|
|
startInput: vi.fn(),
|
|
stop,
|
|
writeOutput: vi.fn(async () => {}),
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose,
|
|
};
|
|
const handle = await startMeetingRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({ realtime: { strategy: "bidi", provider: "openai" } }),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-unexpected-close",
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
providers: [provider],
|
|
transport,
|
|
});
|
|
|
|
closeBridge?.("completed");
|
|
await vi.waitFor(() => {
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
});
|
|
expect(handle.getHealth().bridgeClosed).toBe(true);
|
|
});
|
|
|
|
it("retries only the unsettled transport teardown phase", async () => {
|
|
const stopError = new Error("transport stop failed");
|
|
let closeBridge: ((reason: "completed" | "error") => void) | undefined;
|
|
const bridge = {
|
|
connect: vi.fn(async () => {}),
|
|
sendAudio: vi.fn(),
|
|
sendUserMessage: vi.fn(),
|
|
setMediaTimestamp: vi.fn(),
|
|
submitToolResult: vi.fn(),
|
|
acknowledgeMark: vi.fn(),
|
|
close: vi.fn(() => closeBridge?.("completed")),
|
|
triggerGreeting: vi.fn(),
|
|
isConnected: vi.fn(() => true),
|
|
};
|
|
const provider: RealtimeVoiceProviderPlugin = {
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
isConfigured: () => true,
|
|
createBridge: (params) => {
|
|
closeBridge = params.onClose;
|
|
return bridge;
|
|
},
|
|
};
|
|
const stop = vi
|
|
.fn<() => Promise<void>>()
|
|
.mockRejectedValueOnce(stopError)
|
|
.mockResolvedValueOnce();
|
|
const dispose = vi.fn(async () => {});
|
|
const transport: MeetingRealtimeAudioTransport = {
|
|
onFatal: vi.fn(),
|
|
startInput: vi.fn(),
|
|
stop,
|
|
writeOutput: vi.fn(async () => {}),
|
|
clearOutput: vi.fn(async () => {}),
|
|
dispose,
|
|
};
|
|
const handle = await startMeetingRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({ realtime: { strategy: "bidi", provider: "openai" } }),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "meet-stop-retry",
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
providers: [provider],
|
|
transport,
|
|
});
|
|
|
|
await expect(handle.stop()).rejects.toBe(stopError);
|
|
await expect(handle.stop()).resolves.toBeUndefined();
|
|
|
|
expect(stop).toHaveBeenCalledTimes(2);
|
|
expect(dispose).toHaveBeenCalledOnce();
|
|
expect(bridge.close).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("Google Meet realtime model logs", () => {
|
|
it("keeps a whole code point when a provider id crosses the log boundary", async () => {
|
|
const prefix = "a".repeat(179);
|
|
const providerId = `${prefix}😀tail`;
|
|
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: providerId,
|
|
label: "Long identifier provider",
|
|
isConfigured: () => true,
|
|
createBridge: () => 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 logger = {
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
};
|
|
const handle = await startMeetingRealtimeEngine({
|
|
config: resolveGoogleMeetConfig({
|
|
realtime: { strategy: "native", provider: providerId },
|
|
}),
|
|
fullConfig: {} as never,
|
|
runtime: {} as never,
|
|
...GOOGLE_MEET_ENGINE_BINDINGS,
|
|
meetingSessionId: "long-provider-log",
|
|
logger,
|
|
providers: [provider],
|
|
transport,
|
|
});
|
|
|
|
expect(logger.info).toHaveBeenCalledWith(
|
|
expect.stringContaining(`provider=${prefix} model=provider-default`),
|
|
);
|
|
await handle.stop();
|
|
});
|
|
});
|