@openclaw/google-meet 2026.5.2 → 2026.5.3-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.test.ts DELETED
@@ -1,3616 +0,0 @@
1
- import { EventEmitter } from "node:events";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import path from "node:path";
5
- import { PassThrough, Writable } from "node:stream";
6
- import { createContext, Script } from "node:vm";
7
- import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
8
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
- import plugin, { __testing as googleMeetPluginTesting } from "./index.js";
10
- import {
11
- extractGoogleMeetUriFromCalendarEvent,
12
- findGoogleMeetCalendarEvent,
13
- listGoogleMeetCalendarEvents,
14
- } from "./src/calendar.js";
15
- import { resolveGoogleMeetConfig, resolveGoogleMeetConfigWithEnv } from "./src/config.js";
16
- import {
17
- buildGoogleMeetPreflightReport,
18
- createGoogleMeetSpace,
19
- fetchGoogleMeetArtifacts,
20
- fetchGoogleMeetAttendance,
21
- fetchLatestGoogleMeetConferenceRecord,
22
- fetchGoogleMeetSpace,
23
- normalizeGoogleMeetSpaceName,
24
- } from "./src/meet.js";
25
- import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
26
- import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js";
27
- import { startCommandRealtimeAudioBridge } from "./src/realtime.js";
28
- import { GoogleMeetRuntime, normalizeMeetUrl } from "./src/runtime.js";
29
- import {
30
- invokeGoogleMeetGatewayMethodForTest,
31
- noopLogger,
32
- setupGoogleMeetPlugin,
33
- } from "./src/test-support/plugin-harness.js";
34
- import { __testing as chromeTransportTesting } from "./src/transports/chrome.js";
35
- import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js";
36
- import type { GoogleMeetSession } from "./src/transports/types.js";
37
-
38
- const voiceCallMocks = vi.hoisted(() => ({
39
- joinMeetViaVoiceCallGateway: vi.fn(async () => ({
40
- callId: "call-1",
41
- dtmfSent: true,
42
- introSent: true,
43
- })),
44
- endMeetVoiceCallGatewayCall: vi.fn(async () => {}),
45
- speakMeetViaVoiceCallGateway: vi.fn(async () => {}),
46
- }));
47
-
48
- const fetchGuardMocks = vi.hoisted(() => ({
49
- fetchWithSsrFGuard: vi.fn(
50
- async (params: {
51
- url: string;
52
- init?: RequestInit;
53
- }): Promise<{
54
- response: Response;
55
- release: () => Promise<void>;
56
- }> => ({
57
- response: await fetch(params.url, params.init),
58
- release: vi.fn(async () => {}),
59
- }),
60
- ),
61
- }));
62
-
63
- vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
64
- const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
65
- return {
66
- ...actual,
67
- fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard,
68
- };
69
- });
70
-
71
- vi.mock("./src/voice-call-gateway.js", () => ({
72
- joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
73
- endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
74
- speakMeetViaVoiceCallGateway: voiceCallMocks.speakMeetViaVoiceCallGateway,
75
- }));
76
-
77
- function setup(
78
- config?: Parameters<typeof setupGoogleMeetPlugin>[1],
79
- options?: Parameters<typeof setupGoogleMeetPlugin>[2],
80
- ) {
81
- const harness = setupGoogleMeetPlugin(plugin, config, options);
82
- googleMeetPluginTesting.setCallGatewayFromCliForTests(
83
- async (method, _opts, params) =>
84
- (await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record<
85
- string,
86
- unknown
87
- >,
88
- );
89
- googleMeetPluginTesting.setPlatformForTests(() => options?.registerPlatform ?? "darwin");
90
- return harness;
91
- }
92
-
93
- function jsonResponse(value: unknown): Response {
94
- return new Response(JSON.stringify(value), {
95
- status: 200,
96
- headers: { "Content-Type": "application/json" },
97
- });
98
- }
99
-
100
- function requestUrl(input: RequestInfo | URL): URL {
101
- if (typeof input === "string") {
102
- return new URL(input);
103
- }
104
- if (input instanceof URL) {
105
- return input;
106
- }
107
- return new URL(input.url);
108
- }
109
-
110
- function mockLocalMeetBrowserRequest(
111
- browserActResult: Record<string, unknown> = {
112
- inCall: true,
113
- micMuted: false,
114
- title: "Meet call",
115
- url: "https://meet.google.com/abc-defg-hij",
116
- },
117
- ) {
118
- const callGatewayFromCli = vi.fn(
119
- async (
120
- _method: string,
121
- _opts: unknown,
122
- params?: unknown,
123
- _extra?: unknown,
124
- ): Promise<Record<string, unknown>> => {
125
- const request = params as {
126
- path?: string;
127
- body?: { fn?: string; targetId?: string; url?: string };
128
- };
129
- if (request.path === "/tabs") {
130
- return { tabs: [] };
131
- }
132
- if (request.path === "/tabs/open") {
133
- return {
134
- targetId: "local-meet-tab",
135
- title: "Meet",
136
- url: request.body?.url ?? "https://meet.google.com/abc-defg-hij",
137
- };
138
- }
139
- if (request.path === "/tabs/focus") {
140
- return { ok: true };
141
- }
142
- if (request.path === "/permissions/grant") {
143
- return {
144
- ok: true,
145
- origin: "https://meet.google.com",
146
- grantedPermissions: ["audioCapture", "videoCapture", "speakerSelection"],
147
- unsupportedPermissions: [],
148
- };
149
- }
150
- if (request.path === "/act") {
151
- return { result: JSON.stringify(browserActResult) };
152
- }
153
- throw new Error(`unexpected browser request path ${request.path}`);
154
- },
155
- );
156
- chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
157
- return callGatewayFromCli;
158
- }
159
-
160
- function stubMeetArtifactsApi() {
161
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
162
- const url = requestUrl(input);
163
- if (url.pathname === "/v2/spaces/abc-defg-hij") {
164
- return jsonResponse({
165
- name: "spaces/abc-defg-hij",
166
- meetingCode: "abc-defg-hij",
167
- meetingUri: "https://meet.google.com/abc-defg-hij",
168
- });
169
- }
170
- if (url.pathname === "/calendar/v3/calendars/primary/events") {
171
- return jsonResponse({
172
- items: [
173
- {
174
- id: "event-1",
175
- summary: "Project sync",
176
- hangoutLink: "https://meet.google.com/abc-defg-hij",
177
- start: { dateTime: "2026-04-25T10:00:00Z" },
178
- end: { dateTime: "2026-04-25T10:30:00Z" },
179
- },
180
- ],
181
- });
182
- }
183
- if (url.pathname === "/v2/conferenceRecords") {
184
- return jsonResponse({
185
- conferenceRecords: [
186
- {
187
- name: "conferenceRecords/rec-1",
188
- space: "spaces/abc-defg-hij",
189
- startTime: "2026-04-25T10:00:00Z",
190
- endTime: "2026-04-25T10:30:00Z",
191
- },
192
- ],
193
- });
194
- }
195
- if (url.pathname === "/v2/conferenceRecords/rec-1") {
196
- return jsonResponse({
197
- name: "conferenceRecords/rec-1",
198
- space: "spaces/abc-defg-hij",
199
- startTime: "2026-04-25T10:00:00Z",
200
- endTime: "2026-04-25T10:30:00Z",
201
- });
202
- }
203
- if (url.pathname === "/v2/conferenceRecords/rec-1/participants") {
204
- return jsonResponse({
205
- participants: [
206
- {
207
- name: "conferenceRecords/rec-1/participants/p1",
208
- earliestStartTime: "2026-04-25T10:00:00Z",
209
- latestEndTime: "2026-04-25T10:30:00Z",
210
- signedinUser: { user: "users/alice", displayName: "Alice" },
211
- },
212
- ],
213
- });
214
- }
215
- if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") {
216
- return jsonResponse({
217
- participantSessions: [
218
- {
219
- name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1",
220
- startTime: "2026-04-25T10:00:00Z",
221
- endTime: "2026-04-25T10:30:00Z",
222
- },
223
- ],
224
- });
225
- }
226
- if (url.pathname === "/v2/conferenceRecords/rec-1/recordings") {
227
- return jsonResponse({
228
- recordings: [
229
- {
230
- name: "conferenceRecords/rec-1/recordings/r1",
231
- driveDestination: { file: "drive/file-1" },
232
- },
233
- ],
234
- });
235
- }
236
- if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts") {
237
- return jsonResponse({
238
- transcripts: [
239
- {
240
- name: "conferenceRecords/rec-1/transcripts/t1",
241
- docsDestination: { document: "docs/doc-1" },
242
- },
243
- ],
244
- });
245
- }
246
- if (url.pathname === "/v2/conferenceRecords/rec-1/transcripts/t1/entries") {
247
- return jsonResponse({
248
- transcriptEntries: [
249
- {
250
- name: "conferenceRecords/rec-1/transcripts/t1/entries/e1",
251
- participant: "conferenceRecords/rec-1/participants/p1",
252
- text: "Hello from the transcript.",
253
- languageCode: "en-US",
254
- startTime: "2026-04-25T10:01:00Z",
255
- endTime: "2026-04-25T10:01:05Z",
256
- },
257
- ],
258
- });
259
- }
260
- if (url.pathname === "/v2/conferenceRecords/rec-1/smartNotes") {
261
- return jsonResponse({
262
- smartNotes: [
263
- {
264
- name: "conferenceRecords/rec-1/smartNotes/sn1",
265
- docsDestination: { document: "docs/doc-2" },
266
- },
267
- ],
268
- });
269
- }
270
- if (url.pathname === "/drive/v3/files/doc-1/export") {
271
- return new Response("Transcript document body.", {
272
- status: 200,
273
- headers: { "Content-Type": "text/plain" },
274
- });
275
- }
276
- if (url.pathname === "/drive/v3/files/doc-2/export") {
277
- return new Response("Smart note document body.", {
278
- status: 200,
279
- headers: { "Content-Type": "text/plain" },
280
- });
281
- }
282
- return new Response(`unexpected ${url.pathname}`, { status: 404 });
283
- });
284
- vi.stubGlobal("fetch", fetchMock);
285
- return fetchMock;
286
- }
287
-
288
- type TestBridgeProcess = {
289
- stdin?: { write(chunk: unknown): unknown } | null;
290
- stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null;
291
- stderr: PassThrough;
292
- killed: boolean;
293
- kill: ReturnType<typeof vi.fn>;
294
- on: EventEmitter["on"];
295
- emit: EventEmitter["emit"];
296
- };
297
-
298
- describe("google-meet plugin", () => {
299
- beforeEach(() => {
300
- vi.clearAllMocks();
301
- });
302
-
303
- afterEach(() => {
304
- vi.unstubAllGlobals();
305
- chromeTransportTesting.setDepsForTest(null);
306
- googleMeetPluginTesting.setCallGatewayFromCliForTests();
307
- googleMeetPluginTesting.setPlatformForTests();
308
- });
309
-
310
- it("defaults to chrome realtime with safe read-only tools", () => {
311
- expect(resolveGoogleMeetConfig({})).toMatchObject({
312
- enabled: true,
313
- defaults: {},
314
- preview: { enrollmentAcknowledged: false },
315
- defaultTransport: "chrome",
316
- defaultMode: "realtime",
317
- chrome: {
318
- audioBackend: "blackhole-2ch",
319
- launch: true,
320
- guestName: "OpenClaw Agent",
321
- reuseExistingTab: true,
322
- autoJoin: true,
323
- waitForInCallMs: 20000,
324
- audioFormat: "pcm16-24khz",
325
- audioInputCommand: [
326
- "sox",
327
- "-q",
328
- "-t",
329
- "coreaudio",
330
- "BlackHole 2ch",
331
- "-t",
332
- "raw",
333
- "-r",
334
- "24000",
335
- "-c",
336
- "1",
337
- "-e",
338
- "signed-integer",
339
- "-b",
340
- "16",
341
- "-L",
342
- "-",
343
- ],
344
- audioOutputCommand: [
345
- "sox",
346
- "-q",
347
- "-t",
348
- "raw",
349
- "-r",
350
- "24000",
351
- "-c",
352
- "1",
353
- "-e",
354
- "signed-integer",
355
- "-b",
356
- "16",
357
- "-L",
358
- "-",
359
- "-t",
360
- "coreaudio",
361
- "BlackHole 2ch",
362
- ],
363
- bargeInRmsThreshold: 650,
364
- bargeInPeakThreshold: 2500,
365
- bargeInCooldownMs: 900,
366
- },
367
- voiceCall: {
368
- enabled: true,
369
- requestTimeoutMs: 30000,
370
- dtmfDelayMs: 2500,
371
- postDtmfSpeechDelayMs: 5000,
372
- },
373
- realtime: {
374
- provider: "openai",
375
- introMessage: "Say exactly: I'm here and listening.",
376
- toolPolicy: "safe-read-only",
377
- },
378
- oauth: {},
379
- auth: { provider: "google-oauth" },
380
- });
381
- expect(resolveGoogleMeetConfig({}).realtime.instructions).toContain("openclaw_agent_consult");
382
- });
383
-
384
- it("declares barge-in config metadata in the plugin entry and manifest", () => {
385
- const manifest = JSON.parse(
386
- readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
387
- ) as {
388
- uiHints?: Record<string, unknown>;
389
- configSchema?: {
390
- properties?: {
391
- chrome?: {
392
- properties?: Record<string, unknown>;
393
- };
394
- };
395
- };
396
- };
397
- const entry = plugin as unknown as {
398
- configSchema: {
399
- uiHints?: Record<string, unknown>;
400
- };
401
- };
402
-
403
- expect(entry.configSchema.uiHints).toMatchObject({
404
- "chrome.bargeInInputCommand": expect.objectContaining({ advanced: true }),
405
- "chrome.bargeInRmsThreshold": expect.objectContaining({ advanced: true }),
406
- "chrome.bargeInPeakThreshold": expect.objectContaining({ advanced: true }),
407
- "chrome.bargeInCooldownMs": expect.objectContaining({ advanced: true }),
408
- });
409
- expect(manifest.uiHints).toMatchObject({
410
- "chrome.bargeInInputCommand": expect.objectContaining({ advanced: true }),
411
- "chrome.bargeInRmsThreshold": expect.objectContaining({ advanced: true }),
412
- "chrome.bargeInPeakThreshold": expect.objectContaining({ advanced: true }),
413
- "chrome.bargeInCooldownMs": expect.objectContaining({ advanced: true }),
414
- });
415
- expect(manifest.configSchema?.properties?.chrome?.properties).toMatchObject({
416
- bargeInInputCommand: expect.objectContaining({
417
- type: "array",
418
- items: { type: "string" },
419
- }),
420
- bargeInRmsThreshold: expect.objectContaining({ type: "number", default: 650 }),
421
- bargeInPeakThreshold: expect.objectContaining({ type: "number", default: 2500 }),
422
- bargeInCooldownMs: expect.objectContaining({ type: "number", default: 900 }),
423
- });
424
- });
425
-
426
- it("resolves the realtime consult agent id", () => {
427
- expect(
428
- resolveGoogleMeetConfig({
429
- realtime: {
430
- agentId: " jay ",
431
- },
432
- }).realtime.agentId,
433
- ).toBe("jay");
434
- });
435
-
436
- it("keeps legacy command-pair audio format when custom commands omit a format", () => {
437
- expect(
438
- resolveGoogleMeetConfig({
439
- chrome: {
440
- audioInputCommand: ["capture-legacy"],
441
- audioOutputCommand: ["play-legacy"],
442
- },
443
- }).chrome,
444
- ).toMatchObject({
445
- audioFormat: "g711-ulaw-8khz",
446
- audioInputCommand: ["capture-legacy"],
447
- audioOutputCommand: ["play-legacy"],
448
- });
449
- });
450
-
451
- it("uses env fallbacks for OAuth, preview, and default meeting values", () => {
452
- expect(
453
- resolveGoogleMeetConfigWithEnv(
454
- {},
455
- {
456
- OPENCLAW_GOOGLE_MEET_CLIENT_ID: "client-id",
457
- GOOGLE_MEET_CLIENT_SECRET: "client-secret",
458
- OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN: "refresh-token",
459
- GOOGLE_MEET_ACCESS_TOKEN: "access-token",
460
- OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT: "123456",
461
- GOOGLE_MEET_DEFAULT_MEETING: "https://meet.google.com/abc-defg-hij",
462
- OPENCLAW_GOOGLE_MEET_PREVIEW_ACK: "true",
463
- },
464
- ),
465
- ).toMatchObject({
466
- defaults: { meeting: "https://meet.google.com/abc-defg-hij" },
467
- preview: { enrollmentAcknowledged: true },
468
- oauth: {
469
- clientId: "client-id",
470
- clientSecret: "client-secret",
471
- refreshToken: "refresh-token",
472
- accessToken: "access-token",
473
- expiresAt: 123456,
474
- },
475
- });
476
- });
477
-
478
- it("requires explicit Meet URLs", () => {
479
- expect(normalizeMeetUrl("https://meet.google.com/abc-defg-hij")).toBe(
480
- "https://meet.google.com/abc-defg-hij",
481
- );
482
- expect(() => normalizeMeetUrl("https://example.com/abc-defg-hij")).toThrow("meet.google.com");
483
- });
484
-
485
- it("advertises only the googlemeet CLI descriptor", () => {
486
- const { cliRegistrations } = setup();
487
-
488
- expect(cliRegistrations).toContainEqual({
489
- commands: ["googlemeet"],
490
- descriptors: [
491
- {
492
- name: "googlemeet",
493
- description: "Join and manage Google Meet calls",
494
- hasSubcommands: true,
495
- },
496
- ],
497
- });
498
- });
499
-
500
- it("registers the node-host command used by chrome-node transport", () => {
501
- const { nodeHostCommands } = setup();
502
-
503
- expect(nodeHostCommands).toContainEqual(
504
- expect.objectContaining({
505
- command: "googlemeet.chrome",
506
- cap: "google-meet",
507
- handle: expect.any(Function),
508
- }),
509
- );
510
- });
511
-
512
- it("keeps the agent tool visible on non-macOS hosts but blocks local Chrome realtime joins", async () => {
513
- const { cliRegistrations, methods, tools } = setup(undefined, { registerPlatform: "linux" });
514
- const tool = tools[0] as {
515
- execute: (id: string, params: unknown) => Promise<{ isError?: boolean; content: unknown }>;
516
- };
517
-
518
- expect(tools).toHaveLength(1);
519
- expect(cliRegistrations).toHaveLength(1);
520
- expect(methods.has("googlemeet.setup")).toBe(true);
521
- expect(
522
- googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
523
- config: resolveGoogleMeetConfig({}),
524
- raw: { action: "join" },
525
- platform: "linux",
526
- }),
527
- ).toBe(true);
528
-
529
- const blocked = await tool.execute("id", { action: "join" });
530
- expect(JSON.stringify(blocked)).toContain("local Chrome realtime audio is macOS-only");
531
-
532
- expect(
533
- googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
534
- config: resolveGoogleMeetConfig({}),
535
- raw: { action: "join", mode: "transcribe" },
536
- platform: "linux",
537
- }),
538
- ).toBe(false);
539
- expect(
540
- googleMeetPluginTesting.isGoogleMeetAgentToolActionUnsupportedOnHost({
541
- config: resolveGoogleMeetConfig({}),
542
- raw: { action: "join", transport: "chrome-node" },
543
- platform: "linux",
544
- }),
545
- ).toBe(false);
546
- });
547
-
548
- it("returns structured gateway errors for missing session ids", async () => {
549
- const { methods } = setup();
550
- for (const method of ["googlemeet.leave", "googlemeet.speak"]) {
551
- const handler = methods.get(method) as
552
- | ((ctx: {
553
- params: Record<string, unknown>;
554
- respond: ReturnType<typeof vi.fn>;
555
- }) => Promise<void>)
556
- | undefined;
557
- const respond = vi.fn();
558
-
559
- await handler?.({ params: {}, respond });
560
-
561
- expect(respond).toHaveBeenCalledWith(
562
- false,
563
- { error: "sessionId required" },
564
- {
565
- code: "INVALID_REQUEST",
566
- message: "sessionId required",
567
- details: { error: "sessionId required" },
568
- },
569
- );
570
- }
571
- });
572
-
573
- it("uses a provider-safe flat tool parameter schema", () => {
574
- const { tools } = setup();
575
- const tool = tools[0] as { description?: string; parameters: unknown };
576
-
577
- expect(tool.description).toContain("recover_current_tab");
578
- expect(JSON.stringify(tool.parameters)).not.toContain("anyOf");
579
- expect(tool.parameters).toMatchObject({
580
- type: "object",
581
- properties: {
582
- action: {
583
- type: "string",
584
- enum: [
585
- "join",
586
- "create",
587
- "status",
588
- "setup_status",
589
- "resolve_space",
590
- "preflight",
591
- "latest",
592
- "calendar_events",
593
- "artifacts",
594
- "attendance",
595
- "export",
596
- "recover_current_tab",
597
- "leave",
598
- "end_active_conference",
599
- "speak",
600
- "test_speech",
601
- "test_listen",
602
- ],
603
- description: expect.stringContaining("recover_current_tab"),
604
- },
605
- transport: { type: "string", enum: ["chrome", "chrome-node", "twilio"] },
606
- mode: { type: "string", enum: ["realtime", "transcribe"] },
607
- },
608
- });
609
- });
610
-
611
- it("normalizes Meet URLs, codes, and space names for the Meet API", () => {
612
- expect(normalizeGoogleMeetSpaceName("spaces/abc-defg-hij")).toBe("spaces/abc-defg-hij");
613
- expect(normalizeGoogleMeetSpaceName("abc-defg-hij")).toBe("spaces/abc-defg-hij");
614
- expect(normalizeGoogleMeetSpaceName("https://meet.google.com/abc-defg-hij")).toBe(
615
- "spaces/abc-defg-hij",
616
- );
617
- expect(() => normalizeGoogleMeetSpaceName("https://example.com/abc-defg-hij")).toThrow(
618
- "meet.google.com",
619
- );
620
- });
621
-
622
- it("finds Google Meet links from Calendar events", async () => {
623
- const fetchMock = stubMeetArtifactsApi();
624
-
625
- expect(
626
- extractGoogleMeetUriFromCalendarEvent({
627
- conferenceData: {
628
- entryPoints: [
629
- {
630
- entryPointType: "video",
631
- uri: "https://meet.google.com/abc-defg-hij",
632
- },
633
- ],
634
- },
635
- }),
636
- ).toBe("https://meet.google.com/abc-defg-hij");
637
- await expect(
638
- findGoogleMeetCalendarEvent({
639
- accessToken: "token",
640
- now: new Date("2026-04-25T09:50:00Z"),
641
- timeMin: "2026-04-25T00:00:00Z",
642
- timeMax: "2026-04-26T00:00:00Z",
643
- }),
644
- ).resolves.toMatchObject({
645
- calendarId: "primary",
646
- meetingUri: "https://meet.google.com/abc-defg-hij",
647
- event: { summary: "Project sync" },
648
- });
649
- await expect(
650
- listGoogleMeetCalendarEvents({
651
- accessToken: "token",
652
- now: new Date("2026-04-25T09:50:00Z"),
653
- timeMin: "2026-04-25T00:00:00Z",
654
- timeMax: "2026-04-26T00:00:00Z",
655
- }),
656
- ).resolves.toMatchObject({
657
- events: [
658
- {
659
- meetingUri: "https://meet.google.com/abc-defg-hij",
660
- selected: true,
661
- },
662
- ],
663
- });
664
- const calendarCall = fetchMock.mock.calls.find(([input]) => {
665
- const url = requestUrl(input);
666
- return url.pathname === "/calendar/v3/calendars/primary/events";
667
- });
668
- if (!calendarCall) {
669
- throw new Error("Expected Calendar events.list fetch call");
670
- }
671
- const url = requestUrl(calendarCall[0]);
672
- expect(url.searchParams.get("singleEvents")).toBe("true");
673
- expect(url.searchParams.get("orderBy")).toBe("startTime");
674
- expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
675
- expect.objectContaining({
676
- policy: { allowedHostnames: ["www.googleapis.com"] },
677
- auditContext: "google-meet.calendar.events.list",
678
- }),
679
- );
680
- });
681
-
682
- it("adds a reauth hint for missing Calendar scopes", async () => {
683
- vi.stubGlobal(
684
- "fetch",
685
- vi.fn(async () => new Response("insufficientPermissions", { status: 403 })),
686
- );
687
-
688
- await expect(
689
- findGoogleMeetCalendarEvent({
690
- accessToken: "token",
691
- timeMin: "2026-04-25T00:00:00Z",
692
- timeMax: "2026-04-26T00:00:00Z",
693
- }),
694
- ).rejects.toThrow("calendar.events.readonly");
695
- await expect(
696
- findGoogleMeetCalendarEvent({
697
- accessToken: "token",
698
- timeMin: "2026-04-25T00:00:00Z",
699
- timeMax: "2026-04-26T00:00:00Z",
700
- }),
701
- ).rejects.toThrow("googlemeet auth login");
702
- });
703
-
704
- it("fetches Meet spaces without percent-encoding the spaces path separator", async () => {
705
- const fetchMock = vi.fn(async () => {
706
- return new Response(
707
- JSON.stringify({
708
- name: "spaces/abc-defg-hij",
709
- meetingCode: "abc-defg-hij",
710
- meetingUri: "https://meet.google.com/abc-defg-hij",
711
- }),
712
- { status: 200, headers: { "Content-Type": "application/json" } },
713
- );
714
- });
715
- vi.stubGlobal("fetch", fetchMock);
716
-
717
- await expect(
718
- fetchGoogleMeetSpace({
719
- accessToken: "token",
720
- meeting: "spaces/abc-defg-hij",
721
- }),
722
- ).resolves.toMatchObject({ name: "spaces/abc-defg-hij" });
723
- expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
724
- expect.objectContaining({
725
- url: "https://meet.googleapis.com/v2/spaces/abc-defg-hij",
726
- init: expect.objectContaining({
727
- headers: expect.objectContaining({ Authorization: "Bearer token" }),
728
- }),
729
- policy: { allowedHostnames: ["meet.googleapis.com"] },
730
- auditContext: "google-meet.spaces.get",
731
- }),
732
- );
733
- expect(fetchMock).toHaveBeenCalledWith(
734
- "https://meet.googleapis.com/v2/spaces/abc-defg-hij",
735
- expect.objectContaining({
736
- headers: expect.objectContaining({ Authorization: "Bearer token" }),
737
- }),
738
- );
739
- });
740
-
741
- it("creates Meet spaces and returns the meeting URL", async () => {
742
- const fetchMock = vi.fn(async () => {
743
- return new Response(
744
- JSON.stringify({
745
- name: "spaces/new-space",
746
- meetingCode: "new-abcd-xyz",
747
- meetingUri: "https://meet.google.com/new-abcd-xyz",
748
- }),
749
- { status: 200, headers: { "Content-Type": "application/json" } },
750
- );
751
- });
752
- vi.stubGlobal("fetch", fetchMock);
753
-
754
- await expect(createGoogleMeetSpace({ accessToken: "token" })).resolves.toMatchObject({
755
- meetingUri: "https://meet.google.com/new-abcd-xyz",
756
- space: { name: "spaces/new-space" },
757
- });
758
- expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
759
- expect.objectContaining({
760
- url: "https://meet.googleapis.com/v2/spaces",
761
- init: expect.objectContaining({
762
- method: "POST",
763
- headers: expect.objectContaining({ Authorization: "Bearer token" }),
764
- body: "{}",
765
- }),
766
- policy: { allowedHostnames: ["meet.googleapis.com"] },
767
- auditContext: "google-meet.spaces.create",
768
- }),
769
- );
770
- });
771
-
772
- it("lists Meet artifact metadata for the latest conference record by default", async () => {
773
- const fetchMock = stubMeetArtifactsApi();
774
-
775
- await expect(
776
- fetchGoogleMeetArtifacts({
777
- accessToken: "token",
778
- meeting: "abc-defg-hij",
779
- pageSize: 2,
780
- }),
781
- ).resolves.toMatchObject({
782
- input: "abc-defg-hij",
783
- space: { name: "spaces/abc-defg-hij" },
784
- conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
785
- artifacts: [
786
- {
787
- conferenceRecord: { name: "conferenceRecords/rec-1" },
788
- participants: [{ name: "conferenceRecords/rec-1/participants/p1" }],
789
- recordings: [{ name: "conferenceRecords/rec-1/recordings/r1" }],
790
- transcripts: [{ name: "conferenceRecords/rec-1/transcripts/t1" }],
791
- transcriptEntries: [
792
- {
793
- transcript: "conferenceRecords/rec-1/transcripts/t1",
794
- entries: [
795
- {
796
- name: "conferenceRecords/rec-1/transcripts/t1/entries/e1",
797
- text: "Hello from the transcript.",
798
- },
799
- ],
800
- },
801
- ],
802
- smartNotes: [{ name: "conferenceRecords/rec-1/smartNotes/sn1" }],
803
- },
804
- ],
805
- });
806
-
807
- const listCall = fetchMock.mock.calls.find(([input]) => {
808
- const url = requestUrl(input);
809
- return url.pathname === "/v2/conferenceRecords";
810
- });
811
- if (!listCall) {
812
- throw new Error("Expected conferenceRecords.list fetch call");
813
- }
814
- const listUrl = requestUrl(listCall[0]);
815
- expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
816
- expect(listUrl.searchParams.get("pageSize")).toBe("1");
817
- expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
818
- expect.objectContaining({
819
- url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/smartNotes?pageSize=2",
820
- auditContext: "google-meet.conferenceRecords.smartNotes.list",
821
- }),
822
- );
823
- expect(fetchGuardMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
824
- expect.objectContaining({
825
- url: "https://meet.googleapis.com/v2/conferenceRecords/rec-1/transcripts/t1/entries?pageSize=2",
826
- auditContext: "google-meet.conferenceRecords.transcripts.entries.list",
827
- }),
828
- );
829
- });
830
-
831
- it("keeps all conference records available when requested", async () => {
832
- const fetchMock = stubMeetArtifactsApi();
833
-
834
- await fetchGoogleMeetArtifacts({
835
- accessToken: "token",
836
- meeting: "abc-defg-hij",
837
- pageSize: 2,
838
- allConferenceRecords: true,
839
- });
840
-
841
- const listCall = fetchMock.mock.calls.find(([input]) => {
842
- const url = requestUrl(input);
843
- return url.pathname === "/v2/conferenceRecords";
844
- });
845
- if (!listCall) {
846
- throw new Error("Expected conferenceRecords.list fetch call");
847
- }
848
- const listUrl = requestUrl(listCall[0]);
849
- expect(listUrl.searchParams.get("pageSize")).toBe("2");
850
- expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
851
- });
852
-
853
- it("exports linked Google Docs bodies when requested", async () => {
854
- const fetchMock = stubMeetArtifactsApi();
855
-
856
- await expect(
857
- fetchGoogleMeetArtifacts({
858
- accessToken: "token",
859
- conferenceRecord: "rec-1",
860
- includeDocumentBodies: true,
861
- }),
862
- ).resolves.toMatchObject({
863
- artifacts: [
864
- {
865
- transcripts: [{ documentText: "Transcript document body." }],
866
- smartNotes: [{ documentText: "Smart note document body." }],
867
- },
868
- ],
869
- });
870
- const driveCalls = fetchMock.mock.calls
871
- .map(([input]) => requestUrl(input))
872
- .filter((url) => url.pathname.startsWith("/drive/v3/files/"));
873
- expect(driveCalls.map((url) => url.pathname)).toEqual([
874
- "/drive/v3/files/doc-1/export",
875
- "/drive/v3/files/doc-2/export",
876
- ]);
877
- expect(driveCalls.every((url) => url.searchParams.get("mimeType") === "text/plain")).toBe(true);
878
- });
879
-
880
- it("fetches only the latest Meet conference record for a meeting", async () => {
881
- const fetchMock = stubMeetArtifactsApi();
882
-
883
- await expect(
884
- fetchLatestGoogleMeetConferenceRecord({
885
- accessToken: "token",
886
- meeting: "abc-defg-hij",
887
- }),
888
- ).resolves.toMatchObject({
889
- input: "abc-defg-hij",
890
- space: { name: "spaces/abc-defg-hij" },
891
- conferenceRecord: { name: "conferenceRecords/rec-1" },
892
- });
893
-
894
- const listCall = fetchMock.mock.calls.find(([input]) => {
895
- const url = requestUrl(input);
896
- return url.pathname === "/v2/conferenceRecords";
897
- });
898
- if (!listCall) {
899
- throw new Error("Expected conferenceRecords.list fetch call");
900
- }
901
- const listUrl = requestUrl(listCall[0]);
902
- expect(listUrl.searchParams.get("pageSize")).toBe("1");
903
- expect(listUrl.searchParams.get("filter")).toBe('space.name = "spaces/abc-defg-hij"');
904
- });
905
-
906
- it("lists Meet attendance rows with participant sessions", async () => {
907
- const fetchMock = stubMeetArtifactsApi();
908
-
909
- await expect(
910
- fetchGoogleMeetAttendance({
911
- accessToken: "token",
912
- conferenceRecord: "rec-1",
913
- pageSize: 3,
914
- }),
915
- ).resolves.toMatchObject({
916
- input: "rec-1",
917
- conferenceRecords: [{ name: "conferenceRecords/rec-1" }],
918
- attendance: [
919
- {
920
- conferenceRecord: "conferenceRecords/rec-1",
921
- participant: "conferenceRecords/rec-1/participants/p1",
922
- displayName: "Alice",
923
- user: "users/alice",
924
- sessions: [
925
- {
926
- name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1",
927
- },
928
- ],
929
- },
930
- ],
931
- });
932
- expect(fetchMock).toHaveBeenCalledWith(
933
- "https://meet.googleapis.com/v2/conferenceRecords/rec-1",
934
- expect.objectContaining({
935
- headers: expect.objectContaining({ Authorization: "Bearer token" }),
936
- }),
937
- );
938
- });
939
-
940
- it("merges duplicate attendance participants and annotates timing", async () => {
941
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
942
- const url = requestUrl(input);
943
- if (url.pathname === "/v2/conferenceRecords/rec-1") {
944
- return jsonResponse({
945
- name: "conferenceRecords/rec-1",
946
- startTime: "2026-04-25T10:00:00Z",
947
- endTime: "2026-04-25T11:00:00Z",
948
- });
949
- }
950
- if (url.pathname === "/v2/conferenceRecords/rec-1/participants") {
951
- return jsonResponse({
952
- participants: [
953
- {
954
- name: "conferenceRecords/rec-1/participants/p1",
955
- signedinUser: { user: "users/alice", displayName: "Alice" },
956
- },
957
- {
958
- name: "conferenceRecords/rec-1/participants/p2",
959
- signedinUser: { user: "users/alice", displayName: "Alice" },
960
- },
961
- ],
962
- });
963
- }
964
- if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p1/participantSessions") {
965
- return jsonResponse({
966
- participantSessions: [
967
- {
968
- name: "conferenceRecords/rec-1/participants/p1/participantSessions/s1",
969
- startTime: "2026-04-25T10:10:00Z",
970
- endTime: "2026-04-25T10:30:00Z",
971
- },
972
- ],
973
- });
974
- }
975
- if (url.pathname === "/v2/conferenceRecords/rec-1/participants/p2/participantSessions") {
976
- return jsonResponse({
977
- participantSessions: [
978
- {
979
- name: "conferenceRecords/rec-1/participants/p2/participantSessions/s1",
980
- startTime: "2026-04-25T10:40:00Z",
981
- endTime: "2026-04-25T10:50:00Z",
982
- },
983
- ],
984
- });
985
- }
986
- return new Response(`unexpected ${url.pathname}`, { status: 404 });
987
- });
988
- vi.stubGlobal("fetch", fetchMock);
989
-
990
- await expect(
991
- fetchGoogleMeetAttendance({
992
- accessToken: "token",
993
- conferenceRecord: "rec-1",
994
- }),
995
- ).resolves.toMatchObject({
996
- attendance: [
997
- {
998
- displayName: "Alice",
999
- participants: [
1000
- "conferenceRecords/rec-1/participants/p1",
1001
- "conferenceRecords/rec-1/participants/p2",
1002
- ],
1003
- firstJoinTime: "2026-04-25T10:10:00.000Z",
1004
- lastLeaveTime: "2026-04-25T10:50:00.000Z",
1005
- durationMs: 1_800_000,
1006
- late: true,
1007
- earlyLeave: true,
1008
- sessions: [
1009
- { name: expect.stringContaining("/p1/") },
1010
- { name: expect.stringContaining("/p2/") },
1011
- ],
1012
- },
1013
- ],
1014
- });
1015
- });
1016
-
1017
- it("surfaces Developer Preview acknowledgment blockers in preflight reports", () => {
1018
- expect(
1019
- buildGoogleMeetPreflightReport({
1020
- input: "abc-defg-hij",
1021
- space: { name: "spaces/abc-defg-hij" },
1022
- previewAcknowledged: false,
1023
- tokenSource: "cached-access-token",
1024
- }),
1025
- ).toMatchObject({
1026
- resolvedSpaceName: "spaces/abc-defg-hij",
1027
- previewAcknowledged: false,
1028
- blockers: [expect.stringContaining("Developer Preview Program")],
1029
- });
1030
- });
1031
-
1032
- it("builds Twilio dial plans from a PIN", () => {
1033
- expect(normalizeDialInNumber("+1 (555) 123-4567")).toBe("+15551234567");
1034
- expect(buildMeetDtmfSequence({ pin: "123 456" })).toBe("123456#");
1035
- expect(buildMeetDtmfSequence({ dtmfSequence: "ww123#" })).toBe("ww123#");
1036
- });
1037
-
1038
- it("joins a Twilio session through the tool without page parsing", async () => {
1039
- const { tools } = setup({ defaultTransport: "twilio" });
1040
- const tool = tools[0] as {
1041
- execute: (id: string, params: unknown) => Promise<{ details: { session: unknown } }>;
1042
- };
1043
- const result = await tool.execute("id", {
1044
- action: "join",
1045
- url: "https://meet.google.com/abc-defg-hij",
1046
- dialInNumber: "+15551234567",
1047
- pin: "123456",
1048
- });
1049
-
1050
- expect(result.details.session).toMatchObject({
1051
- transport: "twilio",
1052
- mode: "realtime",
1053
- twilio: {
1054
- dialInNumber: "+15551234567",
1055
- pinProvided: true,
1056
- dtmfSequence: "123456#",
1057
- voiceCallId: "call-1",
1058
- dtmfSent: true,
1059
- introSent: true,
1060
- },
1061
- });
1062
- expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith({
1063
- config: expect.objectContaining({ defaultTransport: "twilio" }),
1064
- dialInNumber: "+15551234567",
1065
- dtmfSequence: "123456#",
1066
- logger: expect.objectContaining({ info: expect.any(Function) }),
1067
- message: "Say exactly: I'm here and listening.",
1068
- });
1069
- });
1070
-
1071
- it("explains that Twilio joins need dial-in details", async () => {
1072
- const { tools } = setup({ defaultTransport: "twilio" });
1073
- const tool = tools[0] as {
1074
- execute: (id: string, params: unknown) => Promise<{ details: { error?: string } }>;
1075
- };
1076
-
1077
- const result = await tool.execute("id", {
1078
- action: "join",
1079
- url: "https://meet.google.com/abc-defg-hij",
1080
- });
1081
-
1082
- expect(result.details.error).toContain("Twilio transport requires a Meet dial-in phone number");
1083
- expect(result.details.error).toContain("Google Meet URLs do not include dial-in details");
1084
- });
1085
-
1086
- it("hangs up delegated Twilio calls on leave", async () => {
1087
- const { tools } = setup({ defaultTransport: "twilio" });
1088
- const tool = tools[0] as {
1089
- execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
1090
- };
1091
- const joined = await tool.execute("id", {
1092
- action: "join",
1093
- url: "https://meet.google.com/abc-defg-hij",
1094
- dialInNumber: "+15551234567",
1095
- pin: "123456",
1096
- });
1097
-
1098
- await tool.execute("id", { action: "leave", sessionId: joined.details.session.id });
1099
-
1100
- expect(voiceCallMocks.endMeetVoiceCallGatewayCall).toHaveBeenCalledWith({
1101
- config: expect.objectContaining({ defaultTransport: "twilio" }),
1102
- callId: "call-1",
1103
- });
1104
- });
1105
-
1106
- it("delegates Twilio session speech through voice-call", async () => {
1107
- const { tools } = setup({ defaultTransport: "twilio" });
1108
- const tool = tools[0] as {
1109
- execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
1110
- };
1111
- const joined = await tool.execute("id", {
1112
- action: "join",
1113
- url: "https://meet.google.com/abc-defg-hij",
1114
- dialInNumber: "+15551234567",
1115
- pin: "123456",
1116
- });
1117
-
1118
- const spoken = await tool.execute("id", {
1119
- action: "speak",
1120
- sessionId: joined.details.session.id,
1121
- message: "Say exactly: hello after joining.",
1122
- });
1123
-
1124
- expect(spoken.details).toMatchObject({ spoken: true });
1125
- expect(voiceCallMocks.speakMeetViaVoiceCallGateway).toHaveBeenCalledWith({
1126
- config: expect.objectContaining({ defaultTransport: "twilio" }),
1127
- callId: "call-1",
1128
- message: "Say exactly: hello after joining.",
1129
- });
1130
- });
1131
-
1132
- it("reports setup status through the tool", async () => {
1133
- const originalPlatform = process.platform;
1134
- Object.defineProperty(process, "platform", { value: "darwin" });
1135
- try {
1136
- const { tools } = setup({
1137
- chrome: {
1138
- audioInputCommand: ["openclaw-audio-bridge", "capture"],
1139
- audioOutputCommand: ["openclaw-audio-bridge", "play"],
1140
- },
1141
- });
1142
- const tool = tools[0] as {
1143
- execute: (id: string, params: unknown) => Promise<{ details: { ok?: boolean } }>;
1144
- };
1145
-
1146
- const result = await tool.execute("id", { action: "setup_status" });
1147
-
1148
- expect(result.details.ok).toBe(true);
1149
- } finally {
1150
- Object.defineProperty(process, "platform", { value: originalPlatform });
1151
- }
1152
- });
1153
-
1154
- it("reports attendance through the tool", async () => {
1155
- stubMeetArtifactsApi();
1156
- const { tools } = setup();
1157
- const tool = tools[0] as {
1158
- execute: (
1159
- id: string,
1160
- params: unknown,
1161
- ) => Promise<{ details: { attendance?: Array<{ displayName?: string }> } }>;
1162
- };
1163
-
1164
- const result = await tool.execute("id", {
1165
- action: "attendance",
1166
- accessToken: "token",
1167
- expiresAt: Date.now() + 120_000,
1168
- conferenceRecord: "rec-1",
1169
- pageSize: 3,
1170
- });
1171
-
1172
- expect(result.details.attendance).toEqual([expect.objectContaining({ displayName: "Alice" })]);
1173
- });
1174
-
1175
- it("writes export bundles through the tool", async () => {
1176
- stubMeetArtifactsApi();
1177
- const tempDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-tool-export-"));
1178
- const { tools } = setup();
1179
- const tool = tools[0] as {
1180
- execute: (
1181
- id: string,
1182
- params: unknown,
1183
- ) => Promise<{ details: { files?: string[]; zipFile?: string } }>;
1184
- };
1185
-
1186
- try {
1187
- const result = await tool.execute("id", {
1188
- action: "export",
1189
- accessToken: "token",
1190
- expiresAt: Date.now() + 120_000,
1191
- conferenceRecord: "rec-1",
1192
- includeDocumentBodies: true,
1193
- outputDir: tempDir,
1194
- zip: true,
1195
- });
1196
-
1197
- expect(result.details.files).toEqual(
1198
- expect.arrayContaining([path.join(tempDir, "manifest.json")]),
1199
- );
1200
- expect(result.details.zipFile).toBe(`${tempDir}.zip`);
1201
- const manifest = JSON.parse(readFileSync(path.join(tempDir, "manifest.json"), "utf8"));
1202
- expect(manifest).toMatchObject({
1203
- request: {
1204
- conferenceRecord: "rec-1",
1205
- includeDocumentBodies: true,
1206
- },
1207
- counts: {
1208
- attendanceRows: 1,
1209
- warnings: 0,
1210
- },
1211
- files: expect.arrayContaining(["summary.md", "manifest.json"]),
1212
- });
1213
- } finally {
1214
- rmSync(tempDir, { recursive: true, force: true });
1215
- rmSync(`${tempDir}.zip`, { force: true });
1216
- }
1217
- });
1218
-
1219
- it("dry-runs export bundles through the tool", async () => {
1220
- stubMeetArtifactsApi();
1221
- const parentDir = mkdtempSync(path.join(tmpdir(), "openclaw-google-meet-tool-dry-run-"));
1222
- const outputDir = path.join(parentDir, "bundle");
1223
- const { tools } = setup();
1224
- const tool = tools[0] as {
1225
- execute: (
1226
- id: string,
1227
- params: unknown,
1228
- ) => Promise<{ details: { dryRun?: boolean; manifest?: { files?: string[] } } }>;
1229
- };
1230
-
1231
- try {
1232
- const result = await tool.execute("id", {
1233
- action: "export",
1234
- accessToken: "token",
1235
- expiresAt: Date.now() + 120_000,
1236
- conferenceRecord: "rec-1",
1237
- outputDir,
1238
- dryRun: true,
1239
- });
1240
-
1241
- expect(result.details).toMatchObject({
1242
- dryRun: true,
1243
- manifest: {
1244
- files: expect.arrayContaining(["summary.md", "manifest.json"]),
1245
- },
1246
- });
1247
- expect(existsSync(outputDir)).toBe(false);
1248
- } finally {
1249
- rmSync(parentDir, { recursive: true, force: true });
1250
- }
1251
- });
1252
-
1253
- it("reports the latest conference record through the tool", async () => {
1254
- stubMeetArtifactsApi();
1255
- const { tools } = setup();
1256
- const tool = tools[0] as {
1257
- execute: (
1258
- id: string,
1259
- params: unknown,
1260
- ) => Promise<{ details: { conferenceRecord?: { name?: string } } }>;
1261
- };
1262
-
1263
- const result = await tool.execute("id", {
1264
- action: "latest",
1265
- accessToken: "token",
1266
- expiresAt: Date.now() + 120_000,
1267
- meeting: "abc-defg-hij",
1268
- });
1269
-
1270
- expect(result.details.conferenceRecord).toMatchObject({ name: "conferenceRecords/rec-1" });
1271
- });
1272
-
1273
- it("reports the latest conference record from today's calendar through the tool", async () => {
1274
- stubMeetArtifactsApi();
1275
- const { tools } = setup();
1276
- const tool = tools[0] as {
1277
- execute: (
1278
- id: string,
1279
- params: unknown,
1280
- ) => Promise<{ details: { calendarEvent?: { meetingUri?: string } } }>;
1281
- };
1282
-
1283
- const result = await tool.execute("id", {
1284
- action: "latest",
1285
- accessToken: "token",
1286
- expiresAt: Date.now() + 120_000,
1287
- today: true,
1288
- });
1289
-
1290
- expect(result.details.calendarEvent).toMatchObject({
1291
- meetingUri: "https://meet.google.com/abc-defg-hij",
1292
- });
1293
- });
1294
-
1295
- it("reports calendar event previews through the tool", async () => {
1296
- stubMeetArtifactsApi();
1297
- const { tools } = setup();
1298
- const tool = tools[0] as {
1299
- execute: (
1300
- id: string,
1301
- params: unknown,
1302
- ) => Promise<{ details: { events?: Array<{ selected?: boolean; meetingUri?: string }> } }>;
1303
- };
1304
-
1305
- const result = await tool.execute("id", {
1306
- action: "calendar_events",
1307
- accessToken: "token",
1308
- expiresAt: Date.now() + 120_000,
1309
- today: true,
1310
- });
1311
-
1312
- expect(result.details.events).toEqual([
1313
- expect.objectContaining({
1314
- selected: true,
1315
- meetingUri: "https://meet.google.com/abc-defg-hij",
1316
- }),
1317
- ]);
1318
- });
1319
-
1320
- it("fails setup status when the configured Chrome node is not connected", async () => {
1321
- const { tools } = setup(
1322
- {
1323
- defaultTransport: "chrome-node",
1324
- chromeNode: { node: "parallels-macos" },
1325
- },
1326
- {
1327
- nodesListResult: {
1328
- nodes: [
1329
- {
1330
- nodeId: "node-1",
1331
- displayName: "parallels-macos",
1332
- connected: false,
1333
- caps: [],
1334
- commands: [],
1335
- remoteIp: "192.168.0.25",
1336
- },
1337
- ],
1338
- },
1339
- },
1340
- );
1341
- const tool = tools[0] as {
1342
- execute: (
1343
- id: string,
1344
- params: unknown,
1345
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1346
- };
1347
-
1348
- const result = await tool.execute("id", { action: "setup_status" });
1349
-
1350
- expect(result.details.ok).toBe(false);
1351
- expect(result.details.checks).toEqual(
1352
- expect.arrayContaining([
1353
- expect.objectContaining({
1354
- id: "chrome-node-connected",
1355
- ok: false,
1356
- message: expect.stringContaining("parallels-macos"),
1357
- }),
1358
- ]),
1359
- );
1360
- const check = result.details.checks?.find(
1361
- (item) => (item as { id?: unknown }).id === "chrome-node-connected",
1362
- ) as { message?: string } | undefined;
1363
- expect(check?.message).toContain("offline");
1364
- expect(check?.message).toContain("missing googlemeet.chrome");
1365
- expect(check?.message).toContain("missing browser.proxy/browser capability");
1366
- });
1367
-
1368
- it("reports missing local Chrome audio prerequisites in setup status", async () => {
1369
- const originalPlatform = process.platform;
1370
- Object.defineProperty(process, "platform", { value: "darwin" });
1371
- try {
1372
- const { tools } = setup(
1373
- { defaultTransport: "chrome" },
1374
- {
1375
- runCommandWithTimeoutHandler: async (argv) => {
1376
- if (argv[0] === "/usr/sbin/system_profiler") {
1377
- return { code: 0, stdout: "Built-in Output", stderr: "" };
1378
- }
1379
- return { code: 0, stdout: "", stderr: "" };
1380
- },
1381
- },
1382
- );
1383
- const tool = tools[0] as {
1384
- execute: (
1385
- id: string,
1386
- params: unknown,
1387
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1388
- };
1389
-
1390
- const result = await tool.execute("id", { action: "setup_status", transport: "chrome" });
1391
-
1392
- expect(result.details.ok).toBe(false);
1393
- expect(result.details.checks).toEqual(
1394
- expect.arrayContaining([
1395
- expect.objectContaining({
1396
- id: "chrome-local-audio-device",
1397
- ok: false,
1398
- message: expect.stringContaining("BlackHole 2ch audio device not found"),
1399
- }),
1400
- ]),
1401
- );
1402
- } finally {
1403
- Object.defineProperty(process, "platform", { value: originalPlatform });
1404
- }
1405
- });
1406
-
1407
- it("reports missing local Chrome audio commands in setup status", async () => {
1408
- const originalPlatform = process.platform;
1409
- Object.defineProperty(process, "platform", { value: "darwin" });
1410
- try {
1411
- const { tools } = setup(
1412
- { defaultTransport: "chrome" },
1413
- {
1414
- runCommandWithTimeoutHandler: async (argv) => {
1415
- if (argv[0] === "/usr/sbin/system_profiler") {
1416
- return { code: 0, stdout: "BlackHole 2ch", stderr: "" };
1417
- }
1418
- if (argv[0] === "/bin/sh" && argv.at(-1) === "sox") {
1419
- return { code: 1, stdout: "", stderr: "" };
1420
- }
1421
- return { code: 0, stdout: "", stderr: "" };
1422
- },
1423
- },
1424
- );
1425
- const tool = tools[0] as {
1426
- execute: (
1427
- id: string,
1428
- params: unknown,
1429
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1430
- };
1431
-
1432
- const result = await tool.execute("id", { action: "setup_status", transport: "chrome" });
1433
-
1434
- expect(result.details.ok).toBe(false);
1435
- expect(result.details.checks).toEqual(
1436
- expect.arrayContaining([
1437
- expect.objectContaining({
1438
- id: "chrome-local-audio-commands",
1439
- ok: false,
1440
- message: "Chrome audio command missing: sox",
1441
- }),
1442
- ]),
1443
- );
1444
- } finally {
1445
- Object.defineProperty(process, "platform", { value: originalPlatform });
1446
- }
1447
- });
1448
-
1449
- it("checks a configured local barge-in command in setup status", async () => {
1450
- const originalPlatform = process.platform;
1451
- Object.defineProperty(process, "platform", { value: "darwin" });
1452
- try {
1453
- const { tools } = setup(
1454
- {
1455
- defaultTransport: "chrome",
1456
- chrome: {
1457
- bargeInInputCommand: ["missing-barge-capture"],
1458
- },
1459
- },
1460
- {
1461
- runCommandWithTimeoutHandler: async (argv) => {
1462
- if (argv[0] === "/usr/sbin/system_profiler") {
1463
- return { code: 0, stdout: "BlackHole 2ch", stderr: "" };
1464
- }
1465
- if (argv[0] === "/bin/sh" && argv.at(-1) === "missing-barge-capture") {
1466
- return { code: 1, stdout: "", stderr: "" };
1467
- }
1468
- return { code: 0, stdout: "", stderr: "" };
1469
- },
1470
- },
1471
- );
1472
- const tool = tools[0] as {
1473
- execute: (
1474
- id: string,
1475
- params: unknown,
1476
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1477
- };
1478
-
1479
- const result = await tool.execute("id", { action: "setup_status", transport: "chrome" });
1480
-
1481
- expect(result.details.ok).toBe(false);
1482
- expect(result.details.checks).toEqual(
1483
- expect.arrayContaining([
1484
- expect.objectContaining({
1485
- id: "chrome-local-audio-commands",
1486
- ok: false,
1487
- message: "Chrome audio command missing: missing-barge-capture",
1488
- }),
1489
- ]),
1490
- );
1491
- } finally {
1492
- Object.defineProperty(process, "platform", { value: originalPlatform });
1493
- }
1494
- });
1495
-
1496
- it("skips local Chrome audio prerequisites for observe-only setup status", async () => {
1497
- const originalPlatform = process.platform;
1498
- Object.defineProperty(process, "platform", { value: "darwin" });
1499
- try {
1500
- const { tools, runCommandWithTimeout } = setup(
1501
- { defaultMode: "transcribe", defaultTransport: "chrome" },
1502
- {
1503
- runCommandWithTimeoutHandler: async () => ({
1504
- code: 1,
1505
- stdout: "Built-in Output",
1506
- stderr: "",
1507
- }),
1508
- },
1509
- );
1510
- const tool = tools[0] as {
1511
- execute: (
1512
- id: string,
1513
- params: unknown,
1514
- ) => Promise<{ details: { ok?: boolean; checks?: Array<{ id?: string; ok?: boolean }> } }>;
1515
- };
1516
-
1517
- const result = await tool.execute("id", {
1518
- action: "setup_status",
1519
- transport: "chrome",
1520
- mode: "transcribe",
1521
- });
1522
-
1523
- expect(result.details.ok).toBe(true);
1524
- expect(result.details.checks).toEqual(
1525
- expect.arrayContaining([
1526
- expect.objectContaining({
1527
- id: "audio-bridge",
1528
- ok: true,
1529
- message: "Chrome observe-only mode does not require a realtime audio bridge",
1530
- }),
1531
- ]),
1532
- );
1533
- expect(result.details.checks?.some((check) => check.id === "chrome-local-audio-device")).toBe(
1534
- false,
1535
- );
1536
- expect(runCommandWithTimeout).not.toHaveBeenCalled();
1537
- } finally {
1538
- Object.defineProperty(process, "platform", { value: originalPlatform });
1539
- }
1540
- });
1541
-
1542
- it("reports Twilio delegation readiness when voice-call is enabled", async () => {
1543
- vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
1544
- vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
1545
- vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
1546
- const { tools } = setup(
1547
- {
1548
- defaultTransport: "chrome-node",
1549
- chromeNode: { node: "parallels-macos" },
1550
- },
1551
- {
1552
- fullConfig: {
1553
- plugins: {
1554
- allow: ["google-meet", "voice-call"],
1555
- entries: {
1556
- "voice-call": {
1557
- enabled: true,
1558
- config: {
1559
- provider: "twilio",
1560
- publicUrl: "https://voice.example.com/voice/webhook",
1561
- },
1562
- },
1563
- },
1564
- },
1565
- },
1566
- },
1567
- );
1568
- const tool = tools[0] as {
1569
- execute: (
1570
- id: string,
1571
- params: unknown,
1572
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1573
- };
1574
-
1575
- const result = await tool.execute("id", { action: "setup_status" });
1576
-
1577
- expect(result.details.ok).toBe(true);
1578
- expect(result.details.checks).toEqual(
1579
- expect.arrayContaining([
1580
- expect.objectContaining({
1581
- id: "twilio-voice-call-plugin",
1582
- ok: true,
1583
- }),
1584
- expect.objectContaining({
1585
- id: "twilio-voice-call-credentials",
1586
- ok: true,
1587
- }),
1588
- expect.objectContaining({
1589
- id: "twilio-voice-call-webhook",
1590
- ok: true,
1591
- }),
1592
- ]),
1593
- );
1594
- });
1595
-
1596
- it("reports missing voice-call wiring for explicit Twilio transport", async () => {
1597
- vi.stubEnv("TWILIO_ACCOUNT_SID", "");
1598
- vi.stubEnv("TWILIO_AUTH_TOKEN", "");
1599
- vi.stubEnv("TWILIO_FROM_NUMBER", "");
1600
- const { tools } = setup(
1601
- { defaultTransport: "chrome" },
1602
- {
1603
- fullConfig: {
1604
- plugins: {
1605
- allow: ["google-meet"],
1606
- entries: {
1607
- "voice-call": { enabled: false },
1608
- },
1609
- },
1610
- },
1611
- },
1612
- );
1613
- const tool = tools[0] as {
1614
- execute: (
1615
- id: string,
1616
- params: unknown,
1617
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1618
- };
1619
-
1620
- const result = await tool.execute("id", { action: "setup_status", transport: "twilio" });
1621
-
1622
- expect(result.details.ok).toBe(false);
1623
- expect(result.details.checks).toEqual(
1624
- expect.arrayContaining([
1625
- expect.objectContaining({
1626
- id: "twilio-voice-call-plugin",
1627
- ok: false,
1628
- }),
1629
- expect.objectContaining({
1630
- id: "twilio-voice-call-credentials",
1631
- ok: false,
1632
- }),
1633
- ]),
1634
- );
1635
- });
1636
-
1637
- it("reports missing Twilio dial plan for explicit Twilio setup", async () => {
1638
- vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
1639
- vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
1640
- vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
1641
- const { tools } = setup(
1642
- { defaultTransport: "chrome" },
1643
- {
1644
- fullConfig: {
1645
- plugins: {
1646
- allow: ["google-meet", "voice-call"],
1647
- entries: {
1648
- "voice-call": {
1649
- enabled: true,
1650
- config: {
1651
- provider: "twilio",
1652
- publicUrl: "https://voice.example.com/voice/webhook",
1653
- },
1654
- },
1655
- },
1656
- },
1657
- },
1658
- },
1659
- );
1660
- const tool = tools[0] as {
1661
- execute: (
1662
- id: string,
1663
- params: unknown,
1664
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1665
- };
1666
-
1667
- const result = await tool.execute("id", { action: "setup_status", transport: "twilio" });
1668
-
1669
- expect(result.details.ok).toBe(false);
1670
- expect(result.details.checks).toEqual(
1671
- expect.arrayContaining([
1672
- expect.objectContaining({
1673
- id: "twilio-dial-plan",
1674
- ok: false,
1675
- message: expect.stringContaining("dial-in phone number"),
1676
- }),
1677
- ]),
1678
- );
1679
- });
1680
-
1681
- it("accepts request-provided Twilio dial-in details during setup", async () => {
1682
- vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
1683
- vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
1684
- vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
1685
- const { tools } = setup(
1686
- { defaultTransport: "chrome" },
1687
- {
1688
- fullConfig: {
1689
- plugins: {
1690
- allow: ["google-meet", "voice-call"],
1691
- entries: {
1692
- "voice-call": {
1693
- enabled: true,
1694
- config: {
1695
- provider: "twilio",
1696
- publicUrl: "https://voice.example.com/voice/webhook",
1697
- },
1698
- },
1699
- },
1700
- },
1701
- },
1702
- },
1703
- );
1704
- const tool = tools[0] as {
1705
- execute: (
1706
- id: string,
1707
- params: unknown,
1708
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1709
- };
1710
-
1711
- const result = await tool.execute("id", {
1712
- action: "setup_status",
1713
- transport: "twilio",
1714
- dialInNumber: "+15551234567",
1715
- });
1716
-
1717
- expect(result.details.ok).toBe(true);
1718
- expect(result.details.checks).toEqual(
1719
- expect.arrayContaining([
1720
- expect.objectContaining({
1721
- id: "twilio-dial-plan",
1722
- ok: true,
1723
- message: expect.stringContaining("request includes"),
1724
- }),
1725
- ]),
1726
- );
1727
- });
1728
-
1729
- it.each([
1730
- "http://127.0.0.1:3334/voice/webhook",
1731
- "http://[::1]:3334/voice/webhook",
1732
- "http://[fd00::1]/voice/webhook",
1733
- ])(
1734
- "reports local voice-call publicUrl %s as unusable for Twilio transport",
1735
- async (publicUrl) => {
1736
- vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123");
1737
- vi.stubEnv("TWILIO_AUTH_TOKEN", "secret");
1738
- vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234");
1739
- const { tools } = setup(
1740
- { defaultTransport: "twilio" },
1741
- {
1742
- fullConfig: {
1743
- plugins: {
1744
- allow: ["google-meet", "voice-call"],
1745
- entries: {
1746
- "voice-call": {
1747
- enabled: true,
1748
- config: {
1749
- provider: "twilio",
1750
- publicUrl,
1751
- },
1752
- },
1753
- },
1754
- },
1755
- },
1756
- },
1757
- );
1758
- const tool = tools[0] as {
1759
- execute: (
1760
- id: string,
1761
- params: unknown,
1762
- ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>;
1763
- };
1764
-
1765
- const result = await tool.execute("id", { action: "setup_status" });
1766
-
1767
- expect(result.details.ok).toBe(false);
1768
- expect(result.details.checks).toEqual(
1769
- expect.arrayContaining([
1770
- expect.objectContaining({
1771
- id: "twilio-voice-call-webhook",
1772
- ok: false,
1773
- }),
1774
- ]),
1775
- );
1776
- },
1777
- );
1778
-
1779
- it("opens local Chrome Meet in observe-only mode without BlackHole checks", async () => {
1780
- const originalPlatform = process.platform;
1781
- Object.defineProperty(process, "platform", { value: "darwin" });
1782
- try {
1783
- const { methods, runCommandWithTimeout } = setup({
1784
- defaultMode: "transcribe",
1785
- });
1786
- const callGatewayFromCli = mockLocalMeetBrowserRequest({
1787
- inCall: true,
1788
- micMuted: true,
1789
- captioning: true,
1790
- captionsEnabledAttempted: true,
1791
- transcriptLines: 1,
1792
- lastCaptionAt: "2026-04-27T10:00:00.000Z",
1793
- lastCaptionSpeaker: "Alice",
1794
- lastCaptionText: "Can everyone hear the agent?",
1795
- recentTranscript: [
1796
- {
1797
- at: "2026-04-27T10:00:00.000Z",
1798
- speaker: "Alice",
1799
- text: "Can everyone hear the agent?",
1800
- },
1801
- ],
1802
- title: "Meet call",
1803
- url: "https://meet.google.com/abc-defg-hij",
1804
- });
1805
- const handler = methods.get("googlemeet.join") as
1806
- | ((ctx: {
1807
- params: Record<string, unknown>;
1808
- respond: ReturnType<typeof vi.fn>;
1809
- }) => Promise<void>)
1810
- | undefined;
1811
- const respond = vi.fn();
1812
-
1813
- await handler?.({
1814
- params: { url: "https://meet.google.com/abc-defg-hij" },
1815
- respond,
1816
- });
1817
-
1818
- expect(respond.mock.calls[0]?.[0]).toBe(true);
1819
- expect(runCommandWithTimeout).not.toHaveBeenCalled();
1820
- expect(callGatewayFromCli).toHaveBeenCalledWith(
1821
- "browser.request",
1822
- expect.any(Object),
1823
- expect.objectContaining({
1824
- method: "POST",
1825
- path: "/tabs/open",
1826
- body: { url: "https://meet.google.com/abc-defg-hij" },
1827
- }),
1828
- { progress: false },
1829
- );
1830
- expect(
1831
- callGatewayFromCli.mock.calls.some(
1832
- ([, , request]) => (request as { path?: string }).path === "/permissions/grant",
1833
- ),
1834
- ).toBe(false);
1835
- expect(respond.mock.calls[0]?.[1]).toMatchObject({
1836
- session: {
1837
- chrome: {
1838
- health: {
1839
- captioning: true,
1840
- captionsEnabledAttempted: true,
1841
- transcriptLines: 1,
1842
- lastCaptionSpeaker: "Alice",
1843
- lastCaptionText: "Can everyone hear the agent?",
1844
- recentTranscript: [
1845
- {
1846
- speaker: "Alice",
1847
- text: "Can everyone hear the agent?",
1848
- },
1849
- ],
1850
- },
1851
- },
1852
- },
1853
- });
1854
- const actCall = callGatewayFromCli.mock.calls.find(
1855
- ([, , request]) => (request as { path?: string }).path === "/act",
1856
- );
1857
- expect(String((actCall?.[2] as { body?: { fn?: string } } | undefined)?.body?.fn)).toContain(
1858
- "const allowMicrophone = false",
1859
- );
1860
- expect(String((actCall?.[2] as { body?: { fn?: string } } | undefined)?.body?.fn)).toContain(
1861
- "const captureCaptions = true",
1862
- );
1863
- } finally {
1864
- Object.defineProperty(process, "platform", { value: originalPlatform });
1865
- }
1866
- });
1867
-
1868
- it("refreshes observe-only caption health when status is requested", async () => {
1869
- let openedTab = false;
1870
- let actCount = 0;
1871
- const callGatewayFromCli = vi.fn(
1872
- async (
1873
- _method: string,
1874
- _opts: unknown,
1875
- params?: unknown,
1876
- _extra?: unknown,
1877
- ): Promise<Record<string, unknown>> => {
1878
- const request = params as {
1879
- path?: string;
1880
- body?: { targetId?: string; url?: string };
1881
- };
1882
- if (request.path === "/tabs") {
1883
- return openedTab
1884
- ? {
1885
- tabs: [
1886
- {
1887
- targetId: "local-meet-tab",
1888
- title: "Meet",
1889
- url: "https://meet.google.com/abc-defg-hij",
1890
- },
1891
- ],
1892
- }
1893
- : { tabs: [] };
1894
- }
1895
- if (request.path === "/tabs/open") {
1896
- openedTab = true;
1897
- return {
1898
- targetId: "local-meet-tab",
1899
- title: "Meet",
1900
- url: request.body?.url ?? "https://meet.google.com/abc-defg-hij",
1901
- };
1902
- }
1903
- if (request.path === "/tabs/focus") {
1904
- return { ok: true };
1905
- }
1906
- if (request.path === "/act") {
1907
- actCount += 1;
1908
- return {
1909
- result: JSON.stringify(
1910
- actCount === 1
1911
- ? {
1912
- inCall: true,
1913
- captioning: false,
1914
- captionsEnabledAttempted: true,
1915
- transcriptLines: 0,
1916
- title: "Meet call",
1917
- url: "https://meet.google.com/abc-defg-hij",
1918
- }
1919
- : {
1920
- inCall: true,
1921
- captioning: true,
1922
- captionsEnabledAttempted: true,
1923
- transcriptLines: 1,
1924
- lastCaptionAt: "2026-04-27T10:00:00.000Z",
1925
- lastCaptionSpeaker: "Alice",
1926
- lastCaptionText: "Please capture this.",
1927
- recentTranscript: [
1928
- {
1929
- at: "2026-04-27T10:00:00.000Z",
1930
- speaker: "Alice",
1931
- text: "Please capture this.",
1932
- },
1933
- ],
1934
- title: "Meet call",
1935
- url: "https://meet.google.com/abc-defg-hij",
1936
- },
1937
- ),
1938
- };
1939
- }
1940
- throw new Error(`unexpected browser request path ${request.path}`);
1941
- },
1942
- );
1943
- chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
1944
- const { methods } = setup({
1945
- defaultMode: "transcribe",
1946
- defaultTransport: "chrome",
1947
- });
1948
-
1949
- const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
1950
- url: "https://meet.google.com/abc-defg-hij",
1951
- })) as { session: { id: string; chrome?: { health?: { transcriptLines?: number } } } };
1952
- expect(join.session.chrome?.health?.transcriptLines).toBe(0);
1953
-
1954
- const status = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.status", {
1955
- sessionId: join.session.id,
1956
- })) as {
1957
- session?: {
1958
- chrome?: {
1959
- health?: {
1960
- captioning?: boolean;
1961
- transcriptLines?: number;
1962
- lastCaptionText?: string;
1963
- };
1964
- };
1965
- };
1966
- };
1967
-
1968
- expect(status.session?.chrome?.health).toMatchObject({
1969
- captioning: true,
1970
- transcriptLines: 1,
1971
- lastCaptionText: "Please capture this.",
1972
- });
1973
- expect(callGatewayFromCli).toHaveBeenCalledWith(
1974
- "browser.request",
1975
- expect.any(Object),
1976
- expect.objectContaining({
1977
- method: "POST",
1978
- path: "/tabs/focus",
1979
- body: { targetId: "local-meet-tab" },
1980
- }),
1981
- { progress: false },
1982
- );
1983
- });
1984
-
1985
- it("does not mutate realtime browser prompts when status is requested", async () => {
1986
- let openedTab = false;
1987
- const { methods, nodesInvoke } = setup(
1988
- {
1989
- defaultMode: "realtime",
1990
- defaultTransport: "chrome-node",
1991
- },
1992
- {
1993
- nodesInvokeHandler: async ({ command, params }) => {
1994
- const raw = params as { path?: string; body?: { url?: string; targetId?: string } };
1995
- if (command === "browser.proxy") {
1996
- if (raw.path === "/tabs") {
1997
- return { payload: { result: { running: true, tabs: [] } } };
1998
- }
1999
- if (raw.path === "/tabs/open") {
2000
- openedTab = true;
2001
- return {
2002
- payload: {
2003
- result: {
2004
- targetId: "tab-1",
2005
- title: "Meet",
2006
- url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij",
2007
- },
2008
- },
2009
- };
2010
- }
2011
- if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") {
2012
- return { payload: { result: { ok: true } } };
2013
- }
2014
- if (raw.path === "/act") {
2015
- return {
2016
- payload: {
2017
- result: {
2018
- ok: true,
2019
- targetId: raw.body?.targetId ?? "tab-1",
2020
- result: JSON.stringify({
2021
- inCall: false,
2022
- manualActionRequired: true,
2023
- manualActionReason: "meet-audio-choice-required",
2024
- manualActionMessage: "Choose the Meet microphone path manually.",
2025
- title: "Meet",
2026
- url: "https://meet.google.com/abc-defg-hij",
2027
- }),
2028
- },
2029
- },
2030
- };
2031
- }
2032
- }
2033
- if (command === "googlemeet.chrome") {
2034
- return { payload: { launched: openedTab } };
2035
- }
2036
- throw new Error(`unexpected invoke ${command}`);
2037
- },
2038
- },
2039
- );
2040
-
2041
- const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
2042
- url: "https://meet.google.com/abc-defg-hij",
2043
- })) as { session: { id: string } };
2044
- nodesInvoke.mockClear();
2045
-
2046
- const status = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.status", {
2047
- sessionId: join.session.id,
2048
- })) as { session?: { chrome?: { health?: { manualActionRequired?: boolean } } } };
2049
-
2050
- expect(status.session?.chrome?.health?.manualActionRequired).toBe(true);
2051
- expect(nodesInvoke).not.toHaveBeenCalledWith(
2052
- expect.objectContaining({ command: "browser.proxy" }),
2053
- );
2054
- });
2055
-
2056
- it("retries caption enable until the captions button is available", () => {
2057
- const makeButton = (label: string) => ({
2058
- disabled: false,
2059
- innerText: "",
2060
- textContent: "",
2061
- click: vi.fn(),
2062
- getAttribute: vi.fn((name: string) => (name === "aria-label" ? label : null)),
2063
- });
2064
- const leaveButton = makeButton("Leave call");
2065
- const captionButton = makeButton("Turn on captions");
2066
- const page = {
2067
- buttons: [leaveButton],
2068
- };
2069
- const windowState: Record<string, unknown> = {};
2070
- const document = {
2071
- body: { innerText: "", textContent: "" },
2072
- title: "Meet",
2073
- querySelector: vi.fn(() => null),
2074
- querySelectorAll: vi.fn((selector: string) => {
2075
- if (selector === "button") {
2076
- return page.buttons;
2077
- }
2078
- if (selector === "input") {
2079
- return [];
2080
- }
2081
- return [];
2082
- }),
2083
- };
2084
- const context = createContext({
2085
- Date,
2086
- JSON,
2087
- String,
2088
- document,
2089
- location: {
2090
- href: "https://meet.google.com/abc-defg-hij",
2091
- hostname: "meet.google.com",
2092
- },
2093
- MutationObserver: class {
2094
- observe = vi.fn();
2095
- },
2096
- window: windowState,
2097
- });
2098
- const inspect = new Script(
2099
- `(${chromeTransportTesting.meetStatusScriptForTest({
2100
- allowMicrophone: false,
2101
- autoJoin: false,
2102
- captureCaptions: true,
2103
- guestName: "OpenClaw Agent",
2104
- })})`,
2105
- ).runInContext(context) as () => string;
2106
-
2107
- const first = JSON.parse(inspect()) as { captionsEnabledAttempted?: boolean };
2108
- const stateAfterFirst = windowState.__openclawMeetCaptions as { enabledAttempted?: boolean };
2109
- expect(first.captionsEnabledAttempted).toBe(false);
2110
- expect(stateAfterFirst.enabledAttempted).toBe(false);
2111
- expect(captionButton.click).not.toHaveBeenCalled();
2112
-
2113
- page.buttons = [leaveButton, captionButton];
2114
- const second = JSON.parse(inspect()) as { captionsEnabledAttempted?: boolean };
2115
- const stateAfterSecond = windowState.__openclawMeetCaptions as { enabledAttempted?: boolean };
2116
- expect(second.captionsEnabledAttempted).toBe(true);
2117
- expect(stateAfterSecond.enabledAttempted).toBe(true);
2118
- expect(captionButton.click).toHaveBeenCalledTimes(1);
2119
- });
2120
-
2121
- it("joins Chrome on a paired node without local Chrome or BlackHole", async () => {
2122
- const { methods, nodesList, nodesInvoke } = setup(
2123
- {
2124
- defaultTransport: "chrome-node",
2125
- defaultMode: "transcribe",
2126
- chromeNode: { node: "parallels-macos" },
2127
- },
2128
- {
2129
- nodesInvokeResult: { payload: { launched: true } },
2130
- },
2131
- );
2132
- const handler = methods.get("googlemeet.join") as
2133
- | ((ctx: {
2134
- params: Record<string, unknown>;
2135
- respond: ReturnType<typeof vi.fn>;
2136
- }) => Promise<void>)
2137
- | undefined;
2138
- const respond = vi.fn();
2139
-
2140
- await handler?.({
2141
- params: { url: "https://meet.google.com/abc-defg-hij" },
2142
- respond,
2143
- });
2144
-
2145
- expect(respond.mock.calls[0]?.[0]).toBe(true);
2146
- expect(nodesList.mock.calls[0]).toEqual([]);
2147
- expect(nodesInvoke).toHaveBeenCalledWith(
2148
- expect.objectContaining({
2149
- nodeId: "node-1",
2150
- command: "googlemeet.chrome",
2151
- params: expect.objectContaining({
2152
- action: "stopByUrl",
2153
- url: "https://meet.google.com/abc-defg-hij",
2154
- mode: "transcribe",
2155
- }),
2156
- }),
2157
- );
2158
- expect(nodesInvoke).toHaveBeenCalledWith(
2159
- expect.objectContaining({
2160
- nodeId: "node-1",
2161
- command: "browser.proxy",
2162
- params: expect.objectContaining({
2163
- path: "/tabs/open",
2164
- body: { url: "https://meet.google.com/abc-defg-hij" },
2165
- }),
2166
- }),
2167
- );
2168
- expect(nodesInvoke).toHaveBeenCalledWith(
2169
- expect.objectContaining({
2170
- nodeId: "node-1",
2171
- command: "googlemeet.chrome",
2172
- params: expect.objectContaining({
2173
- action: "start",
2174
- url: "https://meet.google.com/abc-defg-hij",
2175
- mode: "transcribe",
2176
- launch: false,
2177
- }),
2178
- }),
2179
- );
2180
- expect(respond.mock.calls[0]?.[1]).toMatchObject({
2181
- session: {
2182
- transport: "chrome-node",
2183
- chrome: {
2184
- nodeId: "node-1",
2185
- launched: true,
2186
- },
2187
- },
2188
- });
2189
- });
2190
-
2191
- it("reuses an active Meet session for the same URL and transport", async () => {
2192
- const { methods, nodesInvoke } = setup(
2193
- {
2194
- defaultTransport: "chrome-node",
2195
- defaultMode: "transcribe",
2196
- },
2197
- {
2198
- nodesInvokeResult: {
2199
- payload: {
2200
- launched: true,
2201
- browser: { inCall: true, micMuted: false },
2202
- },
2203
- },
2204
- },
2205
- );
2206
- const handler = methods.get("googlemeet.join") as
2207
- | ((ctx: {
2208
- params: Record<string, unknown>;
2209
- respond: ReturnType<typeof vi.fn>;
2210
- }) => Promise<void>)
2211
- | undefined;
2212
- const first = vi.fn();
2213
- const second = vi.fn();
2214
-
2215
- await handler?.({
2216
- params: { url: "https://meet.google.com/abc-defg-hij" },
2217
- respond: first,
2218
- });
2219
- await handler?.({
2220
- params: { url: "https://meet.google.com/abc-defg-hij" },
2221
- respond: second,
2222
- });
2223
-
2224
- expect(
2225
- nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"),
2226
- ).toHaveLength(2);
2227
- expect(second.mock.calls[0]?.[1]).toMatchObject({
2228
- session: {
2229
- chrome: { health: { inCall: true, micMuted: false } },
2230
- notes: expect.arrayContaining(["Reused existing active Meet session."]),
2231
- },
2232
- });
2233
- });
2234
-
2235
- it("reuses active Meet sessions across URL query differences", async () => {
2236
- const { methods, nodesInvoke } = setup(
2237
- {
2238
- defaultTransport: "chrome-node",
2239
- defaultMode: "transcribe",
2240
- },
2241
- {
2242
- nodesInvokeResult: {
2243
- payload: {
2244
- launched: true,
2245
- browser: { inCall: true, micMuted: false },
2246
- },
2247
- },
2248
- },
2249
- );
2250
- const handler = methods.get("googlemeet.join") as
2251
- | ((ctx: {
2252
- params: Record<string, unknown>;
2253
- respond: ReturnType<typeof vi.fn>;
2254
- }) => Promise<void>)
2255
- | undefined;
2256
- const first = vi.fn();
2257
- const second = vi.fn();
2258
-
2259
- await handler?.({
2260
- params: { url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com" },
2261
- respond: first,
2262
- });
2263
- await handler?.({
2264
- params: { url: "https://meet.google.com/abc-defg-hij" },
2265
- respond: second,
2266
- });
2267
-
2268
- expect(
2269
- nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"),
2270
- ).toHaveLength(2);
2271
- expect(second.mock.calls[0]?.[1]).toMatchObject({
2272
- session: {
2273
- notes: expect.arrayContaining(["Reused existing active Meet session."]),
2274
- },
2275
- });
2276
- });
2277
-
2278
- it("reuses existing Meet browser tabs across URL query differences", async () => {
2279
- const { methods, nodesInvoke } = setup(
2280
- {
2281
- defaultTransport: "chrome-node",
2282
- defaultMode: "transcribe",
2283
- },
2284
- {
2285
- nodesInvokeHandler: async (params) => {
2286
- if (params.command !== "browser.proxy") {
2287
- return { payload: { launched: true } };
2288
- }
2289
- const proxy = params.params as {
2290
- path?: string;
2291
- body?: { targetId?: string; url?: string };
2292
- };
2293
- if (proxy.path === "/tabs") {
2294
- return {
2295
- payload: {
2296
- result: {
2297
- running: true,
2298
- tabs: [
2299
- {
2300
- targetId: "existing-meet-tab",
2301
- title: "Meet",
2302
- url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
2303
- },
2304
- ],
2305
- },
2306
- },
2307
- };
2308
- }
2309
- if (proxy.path === "/tabs/focus") {
2310
- return { payload: { result: { ok: true } } };
2311
- }
2312
- if (proxy.path === "/act") {
2313
- return {
2314
- payload: {
2315
- result: {
2316
- result: JSON.stringify({
2317
- inCall: true,
2318
- title: "Meet",
2319
- url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
2320
- }),
2321
- },
2322
- },
2323
- };
2324
- }
2325
- throw new Error(`unexpected browser proxy path ${proxy.path}`);
2326
- },
2327
- },
2328
- );
2329
- const handler = methods.get("googlemeet.join") as
2330
- | ((ctx: {
2331
- params: Record<string, unknown>;
2332
- respond: ReturnType<typeof vi.fn>;
2333
- }) => Promise<void>)
2334
- | undefined;
2335
- const respond = vi.fn();
2336
-
2337
- await handler?.({
2338
- params: { url: "https://meet.google.com/abc-defg-hij" },
2339
- respond,
2340
- });
2341
-
2342
- expect(nodesInvoke).toHaveBeenCalledWith(
2343
- expect.objectContaining({
2344
- params: expect.objectContaining({
2345
- path: "/tabs/focus",
2346
- body: { targetId: "existing-meet-tab" },
2347
- }),
2348
- }),
2349
- );
2350
- expect(nodesInvoke).not.toHaveBeenCalledWith(
2351
- expect.objectContaining({
2352
- params: expect.objectContaining({ path: "/tabs/open" }),
2353
- }),
2354
- );
2355
- });
2356
-
2357
- it("recovers and inspects an existing Meet tab without opening a new one", async () => {
2358
- const { tools, nodesInvoke } = setup(
2359
- {
2360
- defaultTransport: "chrome-node",
2361
- },
2362
- {
2363
- nodesInvokeHandler: async (params) => {
2364
- if (params.command !== "browser.proxy") {
2365
- throw new Error(`unexpected command ${params.command}`);
2366
- }
2367
- const proxy = params.params as { path?: string; body?: { targetId?: string } };
2368
- if (proxy.path === "/tabs") {
2369
- return {
2370
- payload: {
2371
- result: {
2372
- tabs: [
2373
- {
2374
- targetId: "existing-meet-tab",
2375
- title: "Meet",
2376
- url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
2377
- },
2378
- ],
2379
- },
2380
- },
2381
- };
2382
- }
2383
- if (proxy.path === "/tabs/focus") {
2384
- return { payload: { result: { ok: true } } };
2385
- }
2386
- if (proxy.path === "/act") {
2387
- return {
2388
- payload: {
2389
- result: {
2390
- result: JSON.stringify({
2391
- inCall: false,
2392
- manualActionRequired: true,
2393
- manualActionReason: "meet-admission-required",
2394
- manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.",
2395
- title: "Meet",
2396
- url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
2397
- }),
2398
- },
2399
- },
2400
- };
2401
- }
2402
- throw new Error(`unexpected browser proxy path ${proxy.path}`);
2403
- },
2404
- },
2405
- );
2406
- const tool = tools[0] as {
2407
- execute: (
2408
- id: string,
2409
- params: unknown,
2410
- ) => Promise<{ details: { found?: boolean; browser?: unknown } }>;
2411
- };
2412
-
2413
- const result = await tool.execute("id", {
2414
- action: "recover_current_tab",
2415
- url: "https://meet.google.com/abc-defg-hij",
2416
- });
2417
-
2418
- expect(result.details).toMatchObject({
2419
- found: true,
2420
- targetId: "existing-meet-tab",
2421
- browser: {
2422
- manualActionRequired: true,
2423
- manualActionReason: "meet-admission-required",
2424
- },
2425
- });
2426
- expect(nodesInvoke).toHaveBeenCalledWith(
2427
- expect.objectContaining({
2428
- params: expect.objectContaining({
2429
- path: "/tabs/focus",
2430
- body: { targetId: "existing-meet-tab" },
2431
- }),
2432
- }),
2433
- );
2434
- expect(nodesInvoke).not.toHaveBeenCalledWith(
2435
- expect.objectContaining({
2436
- params: expect.objectContaining({ path: "/tabs/open" }),
2437
- }),
2438
- );
2439
- });
2440
-
2441
- it("recovers and inspects an existing local Chrome Meet tab", async () => {
2442
- const callGatewayFromCli = vi.fn(
2443
- async (
2444
- _method: string,
2445
- _opts: unknown,
2446
- params?: unknown,
2447
- _extra?: unknown,
2448
- ): Promise<Record<string, unknown>> => {
2449
- const request = params as { path?: string; body?: { targetId?: string } };
2450
- if (request.path === "/tabs") {
2451
- return {
2452
- tabs: [
2453
- {
2454
- targetId: "local-meet-tab",
2455
- title: "Meet",
2456
- url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
2457
- },
2458
- ],
2459
- };
2460
- }
2461
- if (request.path === "/tabs/focus") {
2462
- return { ok: true };
2463
- }
2464
- if (request.path === "/act") {
2465
- return {
2466
- result: JSON.stringify({
2467
- inCall: false,
2468
- manualActionRequired: true,
2469
- manualActionReason: "meet-admission-required",
2470
- manualActionMessage: "Admit the OpenClaw browser participant in Google Meet.",
2471
- title: "Meet",
2472
- url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
2473
- }),
2474
- };
2475
- }
2476
- throw new Error(`unexpected browser request path ${request.path}`);
2477
- },
2478
- );
2479
- chromeTransportTesting.setDepsForTest({ callGatewayFromCli });
2480
- const { tools, nodesInvoke } = setup({ defaultTransport: "chrome" });
2481
- const tool = tools[0] as {
2482
- execute: (
2483
- id: string,
2484
- params: unknown,
2485
- ) => Promise<{ details: { transport?: string; found?: boolean; browser?: unknown } }>;
2486
- };
2487
-
2488
- const result = await tool.execute("id", {
2489
- action: "recover_current_tab",
2490
- url: "https://meet.google.com/abc-defg-hij",
2491
- });
2492
-
2493
- expect(result.details).toMatchObject({
2494
- transport: "chrome",
2495
- found: true,
2496
- targetId: "local-meet-tab",
2497
- browser: {
2498
- manualActionRequired: true,
2499
- manualActionReason: "meet-admission-required",
2500
- },
2501
- });
2502
- expect(callGatewayFromCli).toHaveBeenCalledWith(
2503
- "browser.request",
2504
- expect.any(Object),
2505
- expect.objectContaining({ method: "POST", path: "/tabs/focus" }),
2506
- { progress: false },
2507
- );
2508
- expect(nodesInvoke).not.toHaveBeenCalled();
2509
- });
2510
-
2511
- it("exposes a test-speech action that joins the requested meeting", async () => {
2512
- const { tools, nodesInvoke } = setup(
2513
- {
2514
- defaultTransport: "chrome-node",
2515
- },
2516
- {
2517
- nodesInvokeResult: {
2518
- payload: {
2519
- launched: true,
2520
- browser: { inCall: true },
2521
- },
2522
- },
2523
- },
2524
- );
2525
- const tool = tools[0] as {
2526
- execute: (id: string, params: unknown) => Promise<{ details: { createdSession?: boolean } }>;
2527
- };
2528
-
2529
- const result = await tool.execute("id", {
2530
- action: "test_speech",
2531
- url: "https://meet.google.com/abc-defg-hij",
2532
- message: "Say exactly: hello.",
2533
- });
2534
-
2535
- expect(nodesInvoke).toHaveBeenCalledWith(
2536
- expect.objectContaining({
2537
- command: "googlemeet.chrome",
2538
- params: expect.objectContaining({ action: "start" }),
2539
- }),
2540
- );
2541
- expect(result.details).toMatchObject({ createdSession: true });
2542
- });
2543
-
2544
- it("exposes a test-listen action that proves transcript movement", async () => {
2545
- const { tools, nodesInvoke } = setup(
2546
- {
2547
- defaultTransport: "chrome-node",
2548
- },
2549
- {
2550
- browserActResult: {
2551
- inCall: true,
2552
- captioning: true,
2553
- transcriptLines: 1,
2554
- lastCaptionText: "hello from the meeting",
2555
- title: "Meet call",
2556
- url: "https://meet.google.com/abc-defg-hij",
2557
- },
2558
- nodesInvokeResult: {
2559
- payload: {
2560
- launched: true,
2561
- },
2562
- },
2563
- },
2564
- );
2565
- const tool = tools[0] as {
2566
- execute: (
2567
- id: string,
2568
- params: unknown,
2569
- ) => Promise<{ details: { listenVerified?: boolean; transcriptLines?: number } }>;
2570
- };
2571
-
2572
- const result = await tool.execute("id", {
2573
- action: "test_listen",
2574
- url: "https://meet.google.com/abc-defg-hij",
2575
- timeoutMs: 100,
2576
- });
2577
-
2578
- expect(nodesInvoke).toHaveBeenCalledWith(
2579
- expect.objectContaining({
2580
- command: "googlemeet.chrome",
2581
- params: expect.objectContaining({
2582
- action: "start",
2583
- mode: "transcribe",
2584
- }),
2585
- }),
2586
- );
2587
- expect(result.details).toMatchObject({ listenVerified: true, transcriptLines: 1 });
2588
- });
2589
-
2590
- it("does not start a second realtime response for test speech", async () => {
2591
- const runtime = new GoogleMeetRuntime({
2592
- config: resolveGoogleMeetConfig({}),
2593
- fullConfig: {} as never,
2594
- runtime: {} as never,
2595
- logger: noopLogger,
2596
- });
2597
- const session: GoogleMeetSession = {
2598
- id: "meet_1",
2599
- url: "https://meet.google.com/abc-defg-hij",
2600
- transport: "chrome",
2601
- mode: "realtime",
2602
- state: "active",
2603
- createdAt: "2026-04-27T00:00:00.000Z",
2604
- updatedAt: "2026-04-27T00:00:00.000Z",
2605
- participantIdentity: "signed-in Google Chrome profile",
2606
- realtime: { enabled: true, provider: "openai", toolPolicy: "safe-read-only" },
2607
- chrome: {
2608
- audioBackend: "blackhole-2ch",
2609
- launched: true,
2610
- health: { audioOutputActive: true, lastOutputBytes: 10 },
2611
- },
2612
- notes: [],
2613
- };
2614
- vi.spyOn(runtime, "list").mockReturnValue([session]);
2615
- const join = vi.spyOn(runtime, "join").mockResolvedValue({ session, spoken: true });
2616
- const speak = vi.spyOn(runtime, "speak");
2617
-
2618
- const result = await runtime.testSpeech({
2619
- url: "https://meet.google.com/abc-defg-hij",
2620
- message: "Say exactly: hello.",
2621
- });
2622
-
2623
- expect(join).toHaveBeenCalledWith(
2624
- expect.objectContaining({
2625
- message: "Say exactly: hello.",
2626
- mode: "realtime",
2627
- }),
2628
- );
2629
- expect(speak).not.toHaveBeenCalled();
2630
- expect(result.spoken).toBe(true);
2631
- expect(result.speechOutputVerified).toBe(false);
2632
- expect(result.speechOutputTimedOut).toBe(false);
2633
- });
2634
-
2635
- it("rejects observe-only mode for test speech", async () => {
2636
- const runtime = new GoogleMeetRuntime({
2637
- config: resolveGoogleMeetConfig({}),
2638
- fullConfig: {} as never,
2639
- runtime: {} as never,
2640
- logger: noopLogger,
2641
- });
2642
-
2643
- await expect(
2644
- runtime.testSpeech({
2645
- url: "https://meet.google.com/abc-defg-hij",
2646
- mode: "transcribe",
2647
- }),
2648
- ).rejects.toThrow("test_speech requires mode: realtime");
2649
- });
2650
-
2651
- it("rejects realtime and Twilio modes for test listen", async () => {
2652
- const runtime = new GoogleMeetRuntime({
2653
- config: resolveGoogleMeetConfig({}),
2654
- fullConfig: {} as never,
2655
- runtime: {} as never,
2656
- logger: noopLogger,
2657
- });
2658
-
2659
- await expect(
2660
- runtime.testListen({
2661
- url: "https://meet.google.com/abc-defg-hij",
2662
- mode: "realtime",
2663
- }),
2664
- ).rejects.toThrow("test_listen requires mode: transcribe");
2665
-
2666
- await expect(
2667
- runtime.testListen({
2668
- url: "https://meet.google.com/abc-defg-hij",
2669
- transport: "twilio",
2670
- }),
2671
- ).rejects.toThrow("test_listen supports chrome or chrome-node");
2672
- });
2673
-
2674
- it("reports manual action when the browser profile needs Google login", async () => {
2675
- const { tools } = setup(
2676
- {
2677
- defaultTransport: "chrome-node",
2678
- },
2679
- {
2680
- browserActResult: {
2681
- inCall: false,
2682
- manualActionRequired: true,
2683
- manualActionReason: "google-login-required",
2684
- manualActionMessage:
2685
- "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.",
2686
- title: "Sign in - Google Accounts",
2687
- url: "https://accounts.google.com/signin",
2688
- },
2689
- nodesInvokeResult: {
2690
- payload: {
2691
- launched: true,
2692
- },
2693
- },
2694
- },
2695
- );
2696
- const tool = tools[0] as {
2697
- execute: (
2698
- id: string,
2699
- params: unknown,
2700
- ) => Promise<{
2701
- details: {
2702
- manualActionRequired?: boolean;
2703
- manualActionReason?: string;
2704
- speechReady?: boolean;
2705
- speechBlockedReason?: string;
2706
- spoken?: boolean;
2707
- session?: { chrome?: { health?: { manualActionRequired?: boolean } } };
2708
- };
2709
- }>;
2710
- };
2711
-
2712
- const result = await tool.execute("id", {
2713
- action: "test_speech",
2714
- url: "https://meet.google.com/abc-defg-hij",
2715
- message: "Say exactly: hello.",
2716
- });
2717
-
2718
- expect(result.details).toMatchObject({
2719
- manualActionRequired: true,
2720
- manualActionReason: "google-login-required",
2721
- spoken: false,
2722
- speechReady: false,
2723
- speechBlockedReason: "google-login-required",
2724
- session: {
2725
- chrome: {
2726
- health: {
2727
- manualActionRequired: true,
2728
- manualActionReason: "google-login-required",
2729
- speechReady: false,
2730
- speechBlockedReason: "google-login-required",
2731
- },
2732
- },
2733
- },
2734
- });
2735
- });
2736
-
2737
- it("refreshes browser health before blocking an explicit speech retry", async () => {
2738
- let openedTab = false;
2739
- let browserReady = false;
2740
- const { methods, nodesInvoke } = setup(
2741
- {
2742
- defaultTransport: "chrome-node",
2743
- defaultMode: "realtime",
2744
- },
2745
- {
2746
- nodesInvokeHandler: async ({ command, params }) => {
2747
- const raw = params as { path?: string; body?: { url?: string; targetId?: string } };
2748
- if (command === "browser.proxy") {
2749
- if (raw.path === "/tabs") {
2750
- return {
2751
- payload: {
2752
- result: {
2753
- running: true,
2754
- tabs: openedTab
2755
- ? [
2756
- {
2757
- targetId: "tab-1",
2758
- title: "Meet",
2759
- url: "https://meet.google.com/abc-defg-hij",
2760
- },
2761
- ]
2762
- : [],
2763
- },
2764
- },
2765
- };
2766
- }
2767
- if (raw.path === "/tabs/open") {
2768
- openedTab = true;
2769
- return {
2770
- payload: {
2771
- result: {
2772
- targetId: "tab-1",
2773
- title: "Meet",
2774
- url: raw.body?.url ?? "https://meet.google.com/abc-defg-hij",
2775
- },
2776
- },
2777
- };
2778
- }
2779
- if (raw.path === "/tabs/focus" || raw.path === "/permissions/grant") {
2780
- return { payload: { result: { ok: true } } };
2781
- }
2782
- if (raw.path === "/act") {
2783
- return {
2784
- payload: {
2785
- result: {
2786
- ok: true,
2787
- targetId: raw.body?.targetId ?? "tab-1",
2788
- result: JSON.stringify(
2789
- browserReady
2790
- ? {
2791
- inCall: true,
2792
- micMuted: false,
2793
- manualActionRequired: false,
2794
- title: "Meet call",
2795
- url: "https://meet.google.com/abc-defg-hij",
2796
- }
2797
- : {
2798
- inCall: false,
2799
- manualActionRequired: true,
2800
- manualActionReason: "google-login-required",
2801
- manualActionMessage:
2802
- "Sign in to Google in the OpenClaw browser profile, then retry the Meet join.",
2803
- title: "Sign in - Google Accounts",
2804
- url: "https://accounts.google.com/signin",
2805
- },
2806
- ),
2807
- },
2808
- },
2809
- };
2810
- }
2811
- }
2812
- if (command === "googlemeet.chrome") {
2813
- return { payload: { launched: true } };
2814
- }
2815
- throw new Error(`unexpected invoke ${command}`);
2816
- },
2817
- },
2818
- );
2819
-
2820
- const join = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
2821
- url: "https://meet.google.com/abc-defg-hij",
2822
- message: "Say exactly: hello.",
2823
- })) as {
2824
- session: { id: string; chrome?: { health?: { speechBlockedReason?: string } } };
2825
- spoken: boolean;
2826
- };
2827
- expect(join.spoken).toBe(false);
2828
- expect(join.session.chrome?.health?.speechBlockedReason).toBe("google-login-required");
2829
-
2830
- browserReady = true;
2831
- const retry = (await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.speak", {
2832
- sessionId: join.session.id,
2833
- message: "Say exactly: hello again.",
2834
- })) as {
2835
- found: boolean;
2836
- spoken: boolean;
2837
- session?: {
2838
- chrome?: {
2839
- health?: {
2840
- inCall?: boolean;
2841
- manualActionRequired?: boolean;
2842
- speechBlockedReason?: string;
2843
- };
2844
- };
2845
- };
2846
- };
2847
-
2848
- expect(retry).toMatchObject({
2849
- found: true,
2850
- spoken: false,
2851
- session: {
2852
- chrome: {
2853
- health: {
2854
- inCall: true,
2855
- manualActionRequired: false,
2856
- speechBlockedReason: "audio-bridge-unavailable",
2857
- },
2858
- },
2859
- },
2860
- });
2861
- expect(nodesInvoke).toHaveBeenCalledWith(
2862
- expect.objectContaining({
2863
- command: "browser.proxy",
2864
- params: expect.objectContaining({
2865
- path: "/tabs/focus",
2866
- body: { targetId: "tab-1" },
2867
- }),
2868
- }),
2869
- );
2870
- });
2871
-
2872
- it("explains when chrome-node has no capable paired node", async () => {
2873
- const { tools } = setup(
2874
- {
2875
- defaultTransport: "chrome-node",
2876
- defaultMode: "transcribe",
2877
- },
2878
- {
2879
- nodesListResult: { nodes: [] },
2880
- },
2881
- );
2882
- const tool = tools[0] as {
2883
- execute: (id: string, params: unknown) => Promise<{ details: { error?: string } }>;
2884
- };
2885
-
2886
- const result = await tool.execute("id", {
2887
- action: "join",
2888
- url: "https://meet.google.com/abc-defg-hij",
2889
- });
2890
-
2891
- expect(result.details.error).toContain("No connected Google Meet-capable node");
2892
- expect(result.details.error).toContain("openclaw node run");
2893
- });
2894
-
2895
- it("requires chromeNode.node when multiple capable nodes are connected", async () => {
2896
- const { tools } = setup(
2897
- {
2898
- defaultTransport: "chrome-node",
2899
- defaultMode: "transcribe",
2900
- },
2901
- {
2902
- nodesListResult: {
2903
- nodes: [
2904
- {
2905
- nodeId: "node-1",
2906
- displayName: "parallels-macos",
2907
- connected: true,
2908
- caps: ["browser"],
2909
- commands: ["browser.proxy", "googlemeet.chrome"],
2910
- },
2911
- {
2912
- nodeId: "node-2",
2913
- displayName: "mac-studio-vm",
2914
- connected: true,
2915
- caps: ["browser"],
2916
- commands: ["browser.proxy", "googlemeet.chrome"],
2917
- },
2918
- ],
2919
- },
2920
- },
2921
- );
2922
- const tool = tools[0] as {
2923
- execute: (id: string, params: unknown) => Promise<{ details: { error?: string } }>;
2924
- };
2925
-
2926
- const result = await tool.execute("id", {
2927
- action: "join",
2928
- url: "https://meet.google.com/abc-defg-hij",
2929
- });
2930
-
2931
- expect(result.details.error).toContain("Multiple Google Meet-capable nodes connected");
2932
- expect(result.details.error).toContain("chromeNode.node");
2933
- });
2934
-
2935
- it("runs configured Chrome audio bridge commands before launch", async () => {
2936
- const originalPlatform = process.platform;
2937
- Object.defineProperty(process, "platform", { value: "darwin" });
2938
- try {
2939
- const { methods, runCommandWithTimeout } = setup({
2940
- chrome: {
2941
- audioBridgeHealthCommand: ["bridge", "status"],
2942
- audioBridgeCommand: ["bridge", "start"],
2943
- },
2944
- });
2945
- const callGatewayFromCli = mockLocalMeetBrowserRequest();
2946
- const handler = methods.get("googlemeet.join") as
2947
- | ((ctx: {
2948
- params: Record<string, unknown>;
2949
- respond: ReturnType<typeof vi.fn>;
2950
- }) => Promise<void>)
2951
- | undefined;
2952
- const respond = vi.fn();
2953
-
2954
- await handler?.({
2955
- params: { url: "https://meet.google.com/abc-defg-hij" },
2956
- respond,
2957
- });
2958
-
2959
- expect(respond.mock.calls[0]?.[0]).toBe(true);
2960
- expect(runCommandWithTimeout).toHaveBeenNthCalledWith(2, ["bridge", "status"], {
2961
- timeoutMs: 30000,
2962
- });
2963
- expect(runCommandWithTimeout).toHaveBeenNthCalledWith(3, ["bridge", "start"], {
2964
- timeoutMs: 30000,
2965
- });
2966
- expect(callGatewayFromCli).toHaveBeenCalledWith(
2967
- "browser.request",
2968
- expect.any(Object),
2969
- expect.objectContaining({
2970
- method: "POST",
2971
- path: "/tabs/open",
2972
- body: { url: "https://meet.google.com/abc-defg-hij" },
2973
- }),
2974
- { progress: false },
2975
- );
2976
- } finally {
2977
- Object.defineProperty(process, "platform", { value: originalPlatform });
2978
- }
2979
- });
2980
-
2981
- it("pipes Chrome command-pair audio through the realtime provider", async () => {
2982
- let callbacks:
2983
- | {
2984
- onAudio: (audio: Buffer) => void;
2985
- onClearAudio: () => void;
2986
- onMark?: (markName: string) => void;
2987
- onToolCall?: (event: {
2988
- itemId: string;
2989
- callId: string;
2990
- name: string;
2991
- args: unknown;
2992
- }) => void;
2993
- onReady?: () => void;
2994
- tools?: unknown[];
2995
- }
2996
- | undefined;
2997
- const sendAudio = vi.fn();
2998
- const bridge = {
2999
- supportsToolResultContinuation: true,
3000
- connect: vi.fn(async () => {}),
3001
- sendAudio,
3002
- setMediaTimestamp: vi.fn(),
3003
- handleBargeIn: vi.fn(),
3004
- submitToolResult: vi.fn(),
3005
- acknowledgeMark: vi.fn(),
3006
- close: vi.fn(),
3007
- triggerGreeting: vi.fn(),
3008
- isConnected: vi.fn(() => true),
3009
- };
3010
- const provider: RealtimeVoiceProviderPlugin = {
3011
- id: "openai",
3012
- label: "OpenAI",
3013
- autoSelectOrder: 1,
3014
- resolveConfig: ({ rawConfig }) => rawConfig,
3015
- isConfigured: () => true,
3016
- createBridge: (req) => {
3017
- callbacks = req;
3018
- return bridge;
3019
- },
3020
- };
3021
- const inputStdout = new PassThrough();
3022
- const outputStdinWrites: Buffer[] = [];
3023
- const replacementOutputStdinWrites: Buffer[] = [];
3024
- const makeProcess = (stdio: {
3025
- stdin?: { write(chunk: unknown): unknown } | null;
3026
- stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null;
3027
- }): TestBridgeProcess => {
3028
- const proc = new EventEmitter() as unknown as TestBridgeProcess;
3029
- proc.stdin = stdio.stdin;
3030
- proc.stdout = stdio.stdout;
3031
- proc.stderr = new PassThrough();
3032
- proc.killed = false;
3033
- proc.kill = vi.fn(() => {
3034
- proc.killed = true;
3035
- return true;
3036
- });
3037
- return proc;
3038
- };
3039
- const outputStdin = new Writable({
3040
- write(chunk, _encoding, done) {
3041
- outputStdinWrites.push(Buffer.from(chunk));
3042
- done();
3043
- },
3044
- });
3045
- const replacementOutputStdin = new Writable({
3046
- write(chunk, _encoding, done) {
3047
- replacementOutputStdinWrites.push(Buffer.from(chunk));
3048
- done();
3049
- },
3050
- });
3051
- const inputProcess = makeProcess({ stdout: inputStdout, stdin: null });
3052
- const outputProcess = makeProcess({ stdin: outputStdin, stdout: null });
3053
- const replacementOutputProcess = makeProcess({ stdin: replacementOutputStdin, stdout: null });
3054
- const spawnMock = vi
3055
- .fn()
3056
- .mockReturnValueOnce(outputProcess)
3057
- .mockReturnValueOnce(inputProcess)
3058
- .mockReturnValueOnce(replacementOutputProcess);
3059
- const sessionStore: Record<string, unknown> = {};
3060
- const runtime = {
3061
- agent: {
3062
- resolveAgentDir: vi.fn(() => "/tmp/agent"),
3063
- resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
3064
- ensureAgentWorkspace: vi.fn(async () => {}),
3065
- session: {
3066
- resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
3067
- loadSessionStore: vi.fn(() => sessionStore),
3068
- saveSessionStore: vi.fn(async () => {}),
3069
- updateSessionStore: vi.fn(async (_storePath, mutator) => mutator(sessionStore as never)),
3070
- resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
3071
- },
3072
- runEmbeddedPiAgent: vi.fn(async () => ({
3073
- payloads: [{ text: "Use the Portugal launch data." }],
3074
- meta: {},
3075
- })),
3076
- resolveAgentTimeoutMs: vi.fn(() => 1000),
3077
- },
3078
- };
3079
-
3080
- const handle = await startCommandRealtimeAudioBridge({
3081
- config: resolveGoogleMeetConfig({
3082
- realtime: { provider: "openai", model: "gpt-realtime", agentId: "jay" },
3083
- }),
3084
- fullConfig: {} as never,
3085
- runtime: runtime as never,
3086
- meetingSessionId: "meet-1",
3087
- inputCommand: ["capture-meet"],
3088
- outputCommand: ["play-meet"],
3089
- logger: noopLogger,
3090
- providers: [provider],
3091
- spawn: spawnMock,
3092
- });
3093
-
3094
- inputStdout.write(Buffer.from([1, 2, 3]));
3095
- callbacks?.onAudio(Buffer.from([4, 5]));
3096
- callbacks?.onMark?.("mark-1");
3097
- callbacks?.onClearAudio();
3098
- callbacks?.onAudio(Buffer.from([6, 7]));
3099
- callbacks?.onReady?.();
3100
- callbacks?.onToolCall?.({
3101
- itemId: "item-1",
3102
- callId: "tool-call-1",
3103
- name: "openclaw_agent_consult",
3104
- args: { question: "What should I say about launch timing?" },
3105
- });
3106
- expect(bridge.submitToolResult).toHaveBeenNthCalledWith(
3107
- 1,
3108
- "tool-call-1",
3109
- expect.objectContaining({
3110
- status: "working",
3111
- tool: "openclaw_agent_consult",
3112
- }),
3113
- { willContinue: true },
3114
- );
3115
-
3116
- expect(spawnMock).toHaveBeenNthCalledWith(1, "play-meet", [], {
3117
- stdio: ["pipe", "ignore", "pipe"],
3118
- });
3119
- expect(spawnMock).toHaveBeenNthCalledWith(2, "capture-meet", [], {
3120
- stdio: ["ignore", "pipe", "pipe"],
3121
- });
3122
- expect(sendAudio).toHaveBeenCalledWith(Buffer.from([1, 2, 3]));
3123
- expect(outputStdinWrites).toEqual([Buffer.from([4, 5])]);
3124
- expect(outputProcess.kill).toHaveBeenCalledWith("SIGKILL");
3125
- expect(replacementOutputStdinWrites).toEqual([Buffer.from([6, 7])]);
3126
- outputProcess.emit("error", new Error("stale output process failed after clear"));
3127
- expect(bridge.close).not.toHaveBeenCalled();
3128
- expect(bridge.acknowledgeMark).toHaveBeenCalled();
3129
- expect(bridge.triggerGreeting).not.toHaveBeenCalled();
3130
- handle.speak("Say exactly: hello from the meeting.");
3131
- expect(bridge.triggerGreeting).toHaveBeenLastCalledWith("Say exactly: hello from the meeting.");
3132
- expect(handle.getHealth()).toMatchObject({
3133
- providerConnected: true,
3134
- realtimeReady: true,
3135
- audioInputActive: true,
3136
- audioOutputActive: true,
3137
- lastInputBytes: 3,
3138
- lastOutputBytes: 4,
3139
- clearCount: 1,
3140
- });
3141
- expect(callbacks).toMatchObject({
3142
- audioFormat: {
3143
- encoding: "pcm16",
3144
- sampleRateHz: 24000,
3145
- channels: 1,
3146
- },
3147
- tools: [
3148
- expect.objectContaining({
3149
- name: "openclaw_agent_consult",
3150
- }),
3151
- ],
3152
- });
3153
- await vi.waitFor(() => {
3154
- expect(bridge.submitToolResult).toHaveBeenLastCalledWith(
3155
- "tool-call-1",
3156
- {
3157
- text: "Use the Portugal launch data.",
3158
- },
3159
- undefined,
3160
- );
3161
- });
3162
- expect(runtime.agent.runEmbeddedPiAgent).toHaveBeenCalledWith(
3163
- expect.objectContaining({
3164
- messageProvider: "google-meet",
3165
- agentId: "jay",
3166
- sessionKey: "agent:jay:google-meet:meet-1",
3167
- sandboxSessionKey: "agent:jay:google-meet:meet-1",
3168
- thinkLevel: "high",
3169
- toolsAllow: ["read", "web_search", "web_fetch", "x_search", "memory_search", "memory_get"],
3170
- }),
3171
- );
3172
- expect(sessionStore).toHaveProperty("agent:jay:google-meet:meet-1");
3173
-
3174
- await handle.stop();
3175
- expect(bridge.close).toHaveBeenCalled();
3176
- expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM");
3177
- expect(replacementOutputProcess.kill).toHaveBeenCalledWith("SIGTERM");
3178
- });
3179
-
3180
- it("uses a local barge-in input command to clear active Chrome playback", async () => {
3181
- let callbacks:
3182
- | {
3183
- onAudio: (audio: Buffer) => void;
3184
- }
3185
- | undefined;
3186
- const sendAudio = vi.fn();
3187
- const bridge = {
3188
- connect: vi.fn(async () => {}),
3189
- sendAudio,
3190
- setMediaTimestamp: vi.fn(),
3191
- handleBargeIn: vi.fn(),
3192
- submitToolResult: vi.fn(),
3193
- acknowledgeMark: vi.fn(),
3194
- close: vi.fn(),
3195
- isConnected: vi.fn(() => true),
3196
- };
3197
- const provider: RealtimeVoiceProviderPlugin = {
3198
- id: "openai",
3199
- label: "OpenAI",
3200
- autoSelectOrder: 1,
3201
- resolveConfig: ({ rawConfig }) => rawConfig,
3202
- isConfigured: () => true,
3203
- createBridge: (req) => {
3204
- callbacks = req;
3205
- return bridge;
3206
- },
3207
- };
3208
- const inputStdout = new PassThrough();
3209
- const bargeInStdout = new PassThrough();
3210
- const outputStdin = new Writable({
3211
- write(_chunk, _encoding, done) {
3212
- done();
3213
- },
3214
- });
3215
- const replacementOutputStdin = new Writable({
3216
- write(_chunk, _encoding, done) {
3217
- done();
3218
- },
3219
- });
3220
- const makeProcess = (stdio: {
3221
- stdin?: { write(chunk: unknown): unknown } | null;
3222
- stdout?: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null;
3223
- }): TestBridgeProcess => {
3224
- const proc = new EventEmitter() as unknown as TestBridgeProcess;
3225
- proc.stdin = stdio.stdin;
3226
- proc.stdout = stdio.stdout;
3227
- proc.stderr = new PassThrough();
3228
- proc.killed = false;
3229
- proc.kill = vi.fn(() => {
3230
- proc.killed = true;
3231
- return true;
3232
- });
3233
- return proc;
3234
- };
3235
- const outputProcess = makeProcess({ stdin: outputStdin, stdout: null });
3236
- const inputProcess = makeProcess({ stdout: inputStdout, stdin: null });
3237
- const bargeInProcess = makeProcess({ stdout: bargeInStdout, stdin: null });
3238
- const replacementOutputProcess = makeProcess({ stdin: replacementOutputStdin, stdout: null });
3239
- const spawnMock = vi
3240
- .fn()
3241
- .mockReturnValueOnce(outputProcess)
3242
- .mockReturnValueOnce(inputProcess)
3243
- .mockReturnValueOnce(bargeInProcess)
3244
- .mockReturnValueOnce(replacementOutputProcess);
3245
-
3246
- const handle = await startCommandRealtimeAudioBridge({
3247
- config: resolveGoogleMeetConfig({
3248
- chrome: {
3249
- bargeInInputCommand: ["capture-human"],
3250
- bargeInRmsThreshold: 10,
3251
- bargeInPeakThreshold: 10,
3252
- bargeInCooldownMs: 1,
3253
- },
3254
- realtime: { provider: "openai", model: "gpt-realtime" },
3255
- }),
3256
- fullConfig: {} as never,
3257
- runtime: {} as never,
3258
- meetingSessionId: "meet-1",
3259
- inputCommand: ["capture-meet"],
3260
- outputCommand: ["play-meet"],
3261
- logger: noopLogger,
3262
- providers: [provider],
3263
- spawn: spawnMock,
3264
- });
3265
-
3266
- callbacks?.onAudio(Buffer.alloc(48_000));
3267
- inputStdout.write(Buffer.from([1, 2, 3, 4]));
3268
- bargeInStdout.write(Buffer.from([0xff, 0x7f, 0xff, 0x7f]));
3269
-
3270
- expect(spawnMock).toHaveBeenNthCalledWith(3, "capture-human", [], {
3271
- stdio: ["ignore", "pipe", "pipe"],
3272
- });
3273
- expect(bridge.handleBargeIn).toHaveBeenCalled();
3274
- expect(outputProcess.kill).toHaveBeenCalledWith("SIGKILL");
3275
- expect(sendAudio).not.toHaveBeenCalledWith(Buffer.from([1, 2, 3, 4]));
3276
- expect(handle.getHealth()).toMatchObject({
3277
- clearCount: 1,
3278
- suppressedInputBytes: 4,
3279
- });
3280
-
3281
- await handle.stop();
3282
- expect(inputProcess.kill).toHaveBeenCalledWith("SIGTERM");
3283
- expect(bargeInProcess.kill).toHaveBeenCalledWith("SIGTERM");
3284
- expect(replacementOutputProcess.kill).toHaveBeenCalledWith("SIGTERM");
3285
- });
3286
-
3287
- it("pipes paired-node command-pair audio through the realtime provider", async () => {
3288
- let callbacks:
3289
- | {
3290
- onAudio: (audio: Buffer) => void;
3291
- onClearAudio: () => void;
3292
- onToolCall?: (event: {
3293
- itemId: string;
3294
- callId: string;
3295
- name: string;
3296
- args: unknown;
3297
- }) => void;
3298
- onReady?: () => void;
3299
- tools?: unknown[];
3300
- }
3301
- | undefined;
3302
- const sendAudio = vi.fn();
3303
- const bridge = {
3304
- supportsToolResultContinuation: true,
3305
- connect: vi.fn(async () => {}),
3306
- sendAudio,
3307
- setMediaTimestamp: vi.fn(),
3308
- submitToolResult: vi.fn(),
3309
- acknowledgeMark: vi.fn(),
3310
- close: vi.fn(),
3311
- triggerGreeting: vi.fn(),
3312
- isConnected: vi.fn(() => true),
3313
- };
3314
- const provider: RealtimeVoiceProviderPlugin = {
3315
- id: "openai",
3316
- label: "OpenAI",
3317
- autoSelectOrder: 1,
3318
- resolveConfig: ({ rawConfig }) => rawConfig,
3319
- isConfigured: () => true,
3320
- createBridge: (req) => {
3321
- callbacks = req;
3322
- return bridge;
3323
- },
3324
- };
3325
- let pullCount = 0;
3326
- const sessionStore: Record<string, unknown> = {};
3327
- const runtime = {
3328
- nodes: {
3329
- invoke: vi.fn(async ({ params }: { params?: { action?: string; base64?: string } }) => {
3330
- if (params?.action === "pullAudio") {
3331
- pullCount += 1;
3332
- if (pullCount === 1) {
3333
- return { bridgeId: "bridge-1", base64: Buffer.from([9, 8, 7]).toString("base64") };
3334
- }
3335
- await new Promise((resolve) => setTimeout(resolve, 1_000));
3336
- return { bridgeId: "bridge-1" };
3337
- }
3338
- return { ok: true };
3339
- }),
3340
- },
3341
- agent: {
3342
- resolveAgentDir: vi.fn(() => "/tmp/agent"),
3343
- resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
3344
- ensureAgentWorkspace: vi.fn(async () => {}),
3345
- session: {
3346
- resolveStorePath: vi.fn(() => "/tmp/sessions.json"),
3347
- loadSessionStore: vi.fn(() => sessionStore),
3348
- saveSessionStore: vi.fn(async () => {}),
3349
- updateSessionStore: vi.fn(async (_storePath, mutator) => mutator(sessionStore as never)),
3350
- resolveSessionFilePath: vi.fn(() => "/tmp/session.json"),
3351
- },
3352
- runEmbeddedPiAgent: vi.fn(async () => ({
3353
- payloads: [{ text: "Use the launch update." }],
3354
- meta: {},
3355
- })),
3356
- resolveAgentTimeoutMs: vi.fn(() => 1000),
3357
- },
3358
- };
3359
-
3360
- const handle = await startNodeRealtimeAudioBridge({
3361
- config: resolveGoogleMeetConfig({
3362
- realtime: { provider: "openai", model: "gpt-realtime" },
3363
- }),
3364
- fullConfig: {} as never,
3365
- runtime: runtime as never,
3366
- meetingSessionId: "meet-1",
3367
- nodeId: "node-1",
3368
- bridgeId: "bridge-1",
3369
- logger: noopLogger,
3370
- providers: [provider],
3371
- });
3372
-
3373
- callbacks?.onAudio(Buffer.from([1, 2, 3]));
3374
- callbacks?.onClearAudio();
3375
- callbacks?.onReady?.();
3376
- callbacks?.onToolCall?.({
3377
- itemId: "item-1",
3378
- callId: "tool-call-1",
3379
- name: "openclaw_agent_consult",
3380
- args: { question: "What should I say?" },
3381
- });
3382
- expect(bridge.submitToolResult).toHaveBeenNthCalledWith(
3383
- 1,
3384
- "tool-call-1",
3385
- expect.objectContaining({
3386
- status: "working",
3387
- tool: "openclaw_agent_consult",
3388
- }),
3389
- { willContinue: true },
3390
- );
3391
-
3392
- await vi.waitFor(() => {
3393
- expect(sendAudio).toHaveBeenCalledWith(Buffer.from([9, 8, 7]));
3394
- });
3395
- await vi.waitFor(() => {
3396
- expect(runtime.nodes.invoke).toHaveBeenCalledWith(
3397
- expect.objectContaining({
3398
- nodeId: "node-1",
3399
- command: "googlemeet.chrome",
3400
- params: expect.objectContaining({
3401
- action: "pushAudio",
3402
- bridgeId: "bridge-1",
3403
- base64: Buffer.from([1, 2, 3]).toString("base64"),
3404
- }),
3405
- }),
3406
- );
3407
- });
3408
- await vi.waitFor(() => {
3409
- expect(runtime.nodes.invoke).toHaveBeenCalledWith(
3410
- expect.objectContaining({
3411
- nodeId: "node-1",
3412
- command: "googlemeet.chrome",
3413
- params: {
3414
- action: "clearAudio",
3415
- bridgeId: "bridge-1",
3416
- },
3417
- timeoutMs: 5_000,
3418
- }),
3419
- );
3420
- });
3421
- await vi.waitFor(() => {
3422
- expect(bridge.submitToolResult).toHaveBeenLastCalledWith(
3423
- "tool-call-1",
3424
- {
3425
- text: "Use the launch update.",
3426
- },
3427
- undefined,
3428
- );
3429
- });
3430
- expect(bridge.triggerGreeting).not.toHaveBeenCalled();
3431
- handle.speak("Say exactly: hello from the node.");
3432
- expect(bridge.triggerGreeting).toHaveBeenLastCalledWith("Say exactly: hello from the node.");
3433
- expect(callbacks).toMatchObject({
3434
- audioFormat: {
3435
- encoding: "pcm16",
3436
- sampleRateHz: 24000,
3437
- channels: 1,
3438
- },
3439
- tools: [
3440
- expect.objectContaining({
3441
- name: "openclaw_agent_consult",
3442
- }),
3443
- ],
3444
- });
3445
- expect(handle).toMatchObject({
3446
- type: "node-command-pair",
3447
- providerId: "openai",
3448
- nodeId: "node-1",
3449
- bridgeId: "bridge-1",
3450
- });
3451
- expect(handle.getHealth()).toMatchObject({
3452
- providerConnected: true,
3453
- realtimeReady: true,
3454
- audioInputActive: true,
3455
- audioOutputActive: true,
3456
- lastInputBytes: 3,
3457
- lastOutputBytes: 3,
3458
- clearCount: 1,
3459
- });
3460
-
3461
- await handle.stop();
3462
-
3463
- expect(bridge.close).toHaveBeenCalled();
3464
- expect(runtime.nodes.invoke).toHaveBeenCalledWith(
3465
- expect.objectContaining({
3466
- nodeId: "node-1",
3467
- command: "googlemeet.chrome",
3468
- params: { action: "stop", bridgeId: "bridge-1" },
3469
- timeoutMs: 5_000,
3470
- }),
3471
- );
3472
- });
3473
-
3474
- it("keeps paired-node realtime audio alive after transient input pull failures", async () => {
3475
- const sendAudio = vi.fn();
3476
- const bridge = {
3477
- connect: vi.fn(async () => {}),
3478
- sendAudio,
3479
- setMediaTimestamp: vi.fn(),
3480
- submitToolResult: vi.fn(),
3481
- acknowledgeMark: vi.fn(),
3482
- close: vi.fn(),
3483
- triggerGreeting: vi.fn(),
3484
- isConnected: vi.fn(() => true),
3485
- };
3486
- const provider: RealtimeVoiceProviderPlugin = {
3487
- id: "openai",
3488
- label: "OpenAI",
3489
- autoSelectOrder: 1,
3490
- resolveConfig: ({ rawConfig }) => rawConfig,
3491
- isConfigured: () => true,
3492
- createBridge: () => bridge,
3493
- };
3494
- let pullCount = 0;
3495
- const runtime = {
3496
- nodes: {
3497
- invoke: vi.fn(async ({ params }: { params?: { action?: string } }) => {
3498
- if (params?.action === "pullAudio") {
3499
- pullCount += 1;
3500
- if (pullCount === 1) {
3501
- throw new Error("transient node timeout");
3502
- }
3503
- if (pullCount === 2) {
3504
- return { bridgeId: "bridge-1", base64: Buffer.from([5, 4, 3]).toString("base64") };
3505
- }
3506
- await new Promise((resolve) => setTimeout(resolve, 1_000));
3507
- return { bridgeId: "bridge-1" };
3508
- }
3509
- return { ok: true };
3510
- }),
3511
- },
3512
- };
3513
-
3514
- const handle = await startNodeRealtimeAudioBridge({
3515
- config: resolveGoogleMeetConfig({
3516
- realtime: { provider: "openai", model: "gpt-realtime" },
3517
- }),
3518
- fullConfig: {} as never,
3519
- runtime: runtime as never,
3520
- meetingSessionId: "meet-1",
3521
- nodeId: "node-1",
3522
- bridgeId: "bridge-1",
3523
- logger: noopLogger,
3524
- providers: [provider],
3525
- });
3526
-
3527
- await vi.waitFor(() => {
3528
- expect(sendAudio).toHaveBeenCalledWith(Buffer.from([5, 4, 3]));
3529
- });
3530
- expect(bridge.close).not.toHaveBeenCalled();
3531
- expect(handle.getHealth()).toMatchObject({
3532
- audioInputActive: true,
3533
- lastInputBytes: 3,
3534
- consecutiveInputErrors: 0,
3535
- });
3536
-
3537
- await handle.stop();
3538
- });
3539
-
3540
- it("stops paired-node realtime audio after repeated input pull failures", async () => {
3541
- const bridge = {
3542
- connect: vi.fn(async () => {}),
3543
- sendAudio: vi.fn(),
3544
- setMediaTimestamp: vi.fn(),
3545
- submitToolResult: vi.fn(),
3546
- acknowledgeMark: vi.fn(),
3547
- close: vi.fn(),
3548
- triggerGreeting: vi.fn(),
3549
- isConnected: vi.fn(() => true),
3550
- };
3551
- const provider: RealtimeVoiceProviderPlugin = {
3552
- id: "openai",
3553
- label: "OpenAI",
3554
- autoSelectOrder: 1,
3555
- resolveConfig: ({ rawConfig }) => rawConfig,
3556
- isConfigured: () => true,
3557
- createBridge: () => bridge,
3558
- };
3559
- const runtime = {
3560
- nodes: {
3561
- invoke: vi.fn(async ({ params }: { params?: { action?: string } }) => {
3562
- if (params?.action === "pullAudio") {
3563
- throw new Error("node invoke timeout");
3564
- }
3565
- return { ok: true };
3566
- }),
3567
- },
3568
- };
3569
-
3570
- const handle = await startNodeRealtimeAudioBridge({
3571
- config: resolveGoogleMeetConfig({
3572
- realtime: { provider: "openai", model: "gpt-realtime" },
3573
- }),
3574
- fullConfig: {} as never,
3575
- runtime: runtime as never,
3576
- meetingSessionId: "meet-1",
3577
- nodeId: "node-1",
3578
- bridgeId: "bridge-1",
3579
- logger: noopLogger,
3580
- providers: [provider],
3581
- });
3582
-
3583
- await vi.waitFor(
3584
- () => {
3585
- expect(bridge.close).toHaveBeenCalled();
3586
- },
3587
- { timeout: 3_000 },
3588
- );
3589
- expect(handle.getHealth()).toMatchObject({
3590
- bridgeClosed: true,
3591
- consecutiveInputErrors: 5,
3592
- lastInputError: "node invoke timeout",
3593
- });
3594
- expect(runtime.nodes.invoke).toHaveBeenCalledWith(
3595
- expect.objectContaining({
3596
- nodeId: "node-1",
3597
- command: "googlemeet.chrome",
3598
- params: { action: "stop", bridgeId: "bridge-1" },
3599
- timeoutMs: 5_000,
3600
- }),
3601
- );
3602
- });
3603
-
3604
- it("exposes node-host list and stop-by-url bridge actions", async () => {
3605
- const listed = JSON.parse(
3606
- await handleGoogleMeetNodeHostCommand(
3607
- JSON.stringify({ action: "list", url: "https://meet.google.com/abc-defg-hij" }),
3608
- ),
3609
- );
3610
- expect(listed).toEqual({ bridges: [] });
3611
-
3612
- await expect(
3613
- handleGoogleMeetNodeHostCommand(JSON.stringify({ action: "stopByUrl" })),
3614
- ).rejects.toThrow("url required");
3615
- });
3616
- });