Skip to content

Commit 2785be2

Browse files
authored
Fix Google Meet realtime interruption playback (#72524)
Fixes #72523. Remote proof: - CI run 24980529154 passed on 29f825b. - Blacksmith Testbox tbx_01kq6tsgbaxgstxmtearwy9n4w passed focused formatting, Google Meet tests, Google realtime provider tests, and extension test typecheck. Thanks @BsnizND. Co-authored-by: BSnizND <[email protected]>
1 parent 8811112 commit 2785be2

10 files changed

Lines changed: 373 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424
- Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc.
2525
- Voice Call: allow SecretRef-backed Twilio auth tokens and call-specific OpenAI/ElevenLabs TTS API keys through the plugin config surface. Fixes #68690. Thanks @joshavant.
2626
- Google Meet: clean stale chrome-node realtime audio bridges by URL before rejoining, expose active node bridge inspection, and tolerate transient node input pull failures instead of dropping the Meet session. Fixes #72371. (#72372) Thanks @BsnizND.
27+
- Google Meet: clear queued Gemini Live playback when realtime interruptions arrive, restart Chrome command-pair audio output after clears, and expose Google Live interruption/VAD config knobs for Meet and Voice Call realtime bridges. Fixes #72523. (#72524) Thanks @BsnizND.
2728
- Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras.
2829
- Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors.
2930
- Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech.

docs/providers/google.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ Gemini Live API for backend audio bridges such as Voice Call and Google Meet.
308308
| VAD start sensitivity | `...google.startSensitivity` | (unset) |
309309
| VAD end sensitivity | `...google.endSensitivity` | (unset) |
310310
| Silence duration | `...google.silenceDurationMs` | (unset) |
311+
| Activity handling | `...google.activityHandling` | Google default, `start-of-activity-interrupts` |
312+
| Turn coverage | `...google.turnCoverage` | Google default, `only-activity` |
313+
| Disable auto VAD | `...google.automaticActivityDetectionDisabled` | `false` |
311314
| API key | `...google.apiKey` | Falls back to `models.providers.google.apiKey`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` |
312315

313316
Example Voice Call realtime config:
@@ -326,6 +329,8 @@ Example Voice Call realtime config:
326329
google: {
327330
model: "gemini-2.5-flash-native-audio-preview-12-2025",
328331
voice: "Kore",
332+
activityHandling: "start-of-activity-interrupts",
333+
turnCoverage: "only-activity",
329334
},
330335
},
331336
},

extensions/google-meet/index.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ type TestBridgeProcess = {
217217
killed: boolean;
218218
kill: ReturnType<typeof vi.fn>;
219219
on: EventEmitter["on"];
220+
emit: EventEmitter["emit"];
220221
};
221222

222223
describe("google-meet plugin", () => {
@@ -1881,6 +1882,7 @@ describe("google-meet plugin", () => {
18811882
let callbacks:
18821883
| {
18831884
onAudio: (audio: Buffer) => void;
1885+
onClearAudio: () => void;
18841886
onMark?: (markName: string) => void;
18851887
onToolCall?: (event: {
18861888
itemId: string;
@@ -1916,6 +1918,7 @@ describe("google-meet plugin", () => {
19161918
};
19171919
const inputStdout = new PassThrough();
19181920
const outputStdinWrites: Buffer[] = [];
1921+
const replacementOutputStdinWrites: Buffer[] = [];
19191922
const makeProcess = (stdio: {
19201923
stdin?: { write(chunk: unknown): unknown } | null;
19211924
stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null;
@@ -1937,9 +1940,20 @@ describe("google-meet plugin", () => {
19371940
done();
19381941
},
19391942
});
1943+
const replacementOutputStdin = new Writable({
1944+
write(chunk, _encoding, done) {
1945+
replacementOutputStdinWrites.push(Buffer.from(chunk));
1946+
done();
1947+
},
1948+
});
19401949
const inputProcess = makeProcess({ stdout: inputStdout, stdin: null });
19411950
const outputProcess = makeProcess({ stdin: outputStdin, stdout: null });
1942-
const spawnMock = vi.fn().mockReturnValueOnce(outputProcess).mockReturnValueOnce(inputProcess);
1951+
const replacementOutputProcess = makeProcess({ stdin: replacementOutputStdin, stdout: null });
1952+
const spawnMock = vi
1953+
.fn()
1954+
.mockReturnValueOnce(outputProcess)
1955+
.mockReturnValueOnce(inputProcess)
1956+
.mockReturnValueOnce(replacementOutputProcess);
19431957
const sessionStore: Record<string, unknown> = {};
19441958
const runtime = {
19451959
agent: {
@@ -1977,6 +1991,8 @@ describe("google-meet plugin", () => {
19771991
inputStdout.write(Buffer.from([1, 2, 3]));
19781992
callbacks?.onAudio(Buffer.from([4, 5]));
19791993
callbacks?.onMark?.("mark-1");
1994+
callbacks?.onClearAudio();
1995+
callbacks?.onAudio(Buffer.from([6, 7]));
19801996
callbacks?.onReady?.();
19811997
callbacks?.onToolCall?.({
19821998
itemId: "item-1",
@@ -1993,6 +2009,10 @@ describe("google-meet plugin", () => {
19932009
});
19942010
expect(sendAudio).toHaveBeenCalledWith(Buffer.from([1, 2, 3]));
19952011
expect(outputStdinWrites).toEqual([Buffer.from([4, 5])]);
2012+
expect(outputProcess.kill).toHaveBeenCalledWith("SIGTERM");
2013+
expect(replacementOutputStdinWrites).toEqual([Buffer.from([6, 7])]);
2014+
outputProcess.emit("error", new Error("stale output process failed after clear"));
2015+
expect(bridge.close).not.toHaveBeenCalled();
19962016
expect(bridge.acknowledgeMark).toHaveBeenCalled();
19972017
expect(bridge.triggerGreeting).not.toHaveBeenCalled();
19982018
handle.speak("Say exactly: hello from the meeting.");
@@ -2003,7 +2023,8 @@ describe("google-meet plugin", () => {
20032023
audioInputActive: true,
20042024
audioOutputActive: true,
20052025
lastInputBytes: 3,
2006-
lastOutputBytes: 2,
2026+
lastOutputBytes: 4,
2027+
clearCount: 1,
20072028
});
20082029
expect(callbacks).toMatchObject({
20092030
tools: [
@@ -2035,6 +2056,7 @@ describe("google-meet plugin", () => {
20352056
let callbacks:
20362057
| {
20372058
onAudio: (audio: Buffer) => void;
2059+
onClearAudio: () => void;
20382060
onToolCall?: (event: {
20392061
itemId: string;
20402062
callId: string;
@@ -2114,6 +2136,7 @@ describe("google-meet plugin", () => {
21142136
});
21152137

21162138
callbacks?.onAudio(Buffer.from([1, 2, 3]));
2139+
callbacks?.onClearAudio();
21172140
callbacks?.onReady?.();
21182141
callbacks?.onToolCall?.({
21192142
itemId: "item-1",
@@ -2138,6 +2161,19 @@ describe("google-meet plugin", () => {
21382161
}),
21392162
);
21402163
});
2164+
await vi.waitFor(() => {
2165+
expect(runtime.nodes.invoke).toHaveBeenCalledWith(
2166+
expect.objectContaining({
2167+
nodeId: "node-1",
2168+
command: "googlemeet.chrome",
2169+
params: {
2170+
action: "clearAudio",
2171+
bridgeId: "bridge-1",
2172+
},
2173+
timeoutMs: 5_000,
2174+
}),
2175+
);
2176+
});
21412177
await vi.waitFor(() => {
21422178
expect(bridge.submitToolResult).toHaveBeenCalledWith("tool-call-1", {
21432179
text: "Use the launch update.",
@@ -2166,6 +2202,7 @@ describe("google-meet plugin", () => {
21662202
audioOutputActive: true,
21672203
lastInputBytes: 3,
21682204
lastOutputBytes: 3,
2205+
clearCount: 1,
21692206
});
21702207

21712208
await handle.stop();

extensions/google-meet/node-host.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,83 @@ vi.mock("node:child_process", async (importOriginal) => {
4040
});
4141

4242
describe("google-meet node host bridge sessions", () => {
43+
it("clears output playback without closing the active bridge when the old output exits", async () => {
44+
const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
45+
const originalPlatform = process.platform;
46+
children.length = 0;
47+
48+
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
49+
try {
50+
const start = JSON.parse(
51+
await handleGoogleMeetNodeHostCommand(
52+
JSON.stringify({
53+
action: "start",
54+
url: "https://meet.google.com/xyz-abcd-uvw",
55+
mode: "realtime",
56+
launch: false,
57+
audioInputCommand: ["mock-rec"],
58+
audioOutputCommand: ["mock-play"],
59+
}),
60+
),
61+
);
62+
63+
expect(children).toHaveLength(2);
64+
const firstOutput = children[0];
65+
66+
const cleared = JSON.parse(
67+
await handleGoogleMeetNodeHostCommand(
68+
JSON.stringify({
69+
action: "clearAudio",
70+
bridgeId: start.bridgeId,
71+
}),
72+
),
73+
);
74+
75+
expect(cleared).toEqual({ bridgeId: start.bridgeId, ok: true, clearCount: 1 });
76+
expect(children).toHaveLength(3);
77+
expect(firstOutput?.kill).toHaveBeenCalledWith("SIGTERM");
78+
79+
firstOutput?.emit("error", new Error("stale output failed after clear"));
80+
firstOutput?.emit("exit", 0, "SIGTERM");
81+
82+
const status = JSON.parse(
83+
await handleGoogleMeetNodeHostCommand(
84+
JSON.stringify({
85+
action: "status",
86+
bridgeId: start.bridgeId,
87+
}),
88+
),
89+
);
90+
91+
expect(status.bridge).toMatchObject({
92+
bridgeId: start.bridgeId,
93+
closed: false,
94+
clearCount: 1,
95+
});
96+
97+
const audio = Buffer.from([1, 2, 3]);
98+
await handleGoogleMeetNodeHostCommand(
99+
JSON.stringify({
100+
action: "pushAudio",
101+
bridgeId: start.bridgeId,
102+
base64: audio.toString("base64"),
103+
}),
104+
);
105+
106+
expect(children[2]?.stdin?.write).toHaveBeenCalledWith(audio);
107+
expect(firstOutput?.stdin?.write).not.toHaveBeenCalled();
108+
109+
await handleGoogleMeetNodeHostCommand(
110+
JSON.stringify({
111+
action: "stop",
112+
bridgeId: start.bridgeId,
113+
}),
114+
);
115+
} finally {
116+
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
117+
}
118+
});
119+
43120
it("lists active bridge sessions and hides closed sessions", async () => {
44121
const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
45122
const originalPlatform = process.platform;

extensions/google-meet/src/node-host.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type NodeBridgeSession = {
1515
id: string;
1616
url?: string;
1717
mode?: string;
18+
outputCommand: { command: string; args: string[] };
1819
input?: ChildProcess;
1920
output?: ChildProcess;
2021
chunks: Buffer[];
@@ -23,9 +24,11 @@ type NodeBridgeSession = {
2324
createdAt: string;
2425
lastInputAt?: string;
2526
lastOutputAt?: string;
27+
lastClearAt?: string;
2628
lastInputBytes: number;
2729
lastOutputBytes: number;
2830
closedAt?: string;
31+
clearCount: number;
2932
};
3033

3134
const sessions = new Map<string, NodeBridgeSession>();
@@ -110,6 +113,25 @@ function stopSession(session: NodeBridgeSession) {
110113
wake(session);
111114
}
112115

116+
function attachOutputProcessHandlers(session: NodeBridgeSession, outputProcess: ChildProcess) {
117+
outputProcess.on("exit", () => {
118+
if (session.output === outputProcess) {
119+
stopSession(session);
120+
}
121+
});
122+
outputProcess.on("error", () => {
123+
if (session.output === outputProcess) {
124+
stopSession(session);
125+
}
126+
});
127+
}
128+
129+
function startOutputProcess(command: { command: string; args: string[] }) {
130+
return spawn(command.command, command.args, {
131+
stdio: ["pipe", "ignore", "pipe"],
132+
});
133+
}
134+
113135
function startCommandPair(params: {
114136
inputCommand: string[];
115137
outputCommand: string[];
@@ -122,16 +144,16 @@ function startCommandPair(params: {
122144
id: `meet_node_${randomUUID()}`,
123145
url: params.url,
124146
mode: params.mode,
147+
outputCommand: output,
125148
chunks: [],
126149
waiters: [],
127150
closed: false,
128151
createdAt: new Date().toISOString(),
129152
lastInputBytes: 0,
130153
lastOutputBytes: 0,
154+
clearCount: 0,
131155
};
132-
const outputProcess = spawn(output.command, output.args, {
133-
stdio: ["pipe", "ignore", "pipe"],
134-
});
156+
const outputProcess = startOutputProcess(output);
135157
const inputProcess = spawn(input.command, input.args, {
136158
stdio: ["ignore", "pipe", "pipe"],
137159
});
@@ -148,9 +170,8 @@ function startCommandPair(params: {
148170
wake(session);
149171
});
150172
inputProcess.on("exit", () => stopSession(session));
151-
outputProcess.on("exit", () => stopSession(session));
173+
attachOutputProcessHandlers(session, outputProcess);
152174
inputProcess.on("error", () => stopSession(session));
153-
outputProcess.on("error", () => stopSession(session));
154175
sessions.set(session.id, session);
155176
return session;
156177
}
@@ -224,6 +245,25 @@ function pushAudio(params: Record<string, unknown>) {
224245
return { bridgeId, ok: true };
225246
}
226247

248+
function clearAudio(params: Record<string, unknown>) {
249+
const bridgeId = readString(params.bridgeId);
250+
if (!bridgeId) {
251+
throw new Error("bridgeId required");
252+
}
253+
const session = sessions.get(bridgeId);
254+
if (!session || session.closed) {
255+
throw new Error(`bridge is not open: ${bridgeId}`);
256+
}
257+
const previousOutput = session.output;
258+
const outputProcess = startOutputProcess(session.outputCommand);
259+
session.output = outputProcess;
260+
attachOutputProcessHandlers(session, outputProcess);
261+
session.clearCount += 1;
262+
session.lastClearAt = new Date().toISOString();
263+
terminateChild(previousOutput);
264+
return { bridgeId, ok: true, clearCount: session.clearCount };
265+
}
266+
227267
function startChrome(params: Record<string, unknown>) {
228268
const url = readString(params.url);
229269
if (!url) {
@@ -317,8 +357,11 @@ function bridgeStatus(params: Record<string, unknown>) {
317357
createdAt: session.createdAt,
318358
lastInputAt: session.lastInputAt,
319359
lastOutputAt: session.lastOutputAt,
360+
lastClearAt: session.lastClearAt,
320361
lastInputBytes: session.lastInputBytes,
321362
lastOutputBytes: session.lastOutputBytes,
363+
clearCount: session.clearCount,
364+
queuedInputChunks: session.chunks.length,
322365
}
323366
: bridgeId
324367
? { bridgeId, closed: true }
@@ -438,6 +481,9 @@ export async function handleGoogleMeetNodeHostCommand(paramsJSON?: string | null
438481
case "pushAudio":
439482
result = pushAudio(params);
440483
break;
484+
case "clearAudio":
485+
result = clearAudio(params);
486+
break;
441487
case "stop":
442488
result = stopChrome(params);
443489
break;

0 commit comments

Comments
 (0)