diff --git a/extensions/google-meet/src/transports/chrome.test.ts b/extensions/google-meet/src/transports/chrome.test.ts index 12817fd6ca40..6e23fa0afab0 100644 --- a/extensions/google-meet/src/transports/chrome.test.ts +++ b/extensions/google-meet/src/transports/chrome.test.ts @@ -31,6 +31,49 @@ function browserRuntime(request: TestGatewayRequest): PluginRuntime { } describe("google meet chrome transport", () => { + it("prefers a meeting tab over a login fallback during untargeted recovery", async () => { + const gatewayRequest = vi.fn(async (_method, params) => { + if (params.path === "/tabs") { + return { + tabs: [ + { + targetId: "google-login-tab", + title: "Sign in - Google Accounts", + url: "https://accounts.google.com/signin", + }, + { + targetId: "meet-tab", + title: "Meet", + url: "https://meet.google.com/abc-defg-hij?hl=en", + }, + ], + }; + } + if (params.path === "/tabs/focus") { + return { ok: true }; + } + if (params.path === "/act") { + return { + result: JSON.stringify({ + inCall: true, + micMuted: true, + url: "https://meet.google.com/abc-defg-hij?hl=en", + }), + }; + } + throw new Error(`unexpected browser request path ${String(params.path)}`); + }); + + const recovered = await recoverCurrentMeetTab({ + runtime: browserRuntime(gatewayRequest), + config: resolveGoogleMeetConfig({}), + mode: "transcribe", + readOnly: true, + }); + + expect(recovered).toMatchObject({ found: true, targetId: "meet-tab" }); + }); + it("prefers the tracked target for an unchanged Google Meet URL", async () => { const gatewayRequest = vi.fn(async (_method, params) => { if (params.path === "/tabs") { diff --git a/src/meeting-bot/browser-controller.ts b/src/meeting-bot/browser-controller.ts index 8a2d320f98a0..9c95e992f82c 100644 --- a/src/meeting-bot/browser-controller.ts +++ b/src/meeting-bot/browser-controller.ts @@ -294,7 +294,16 @@ function findRecoverableTab< params.adapter.urls.isRecoverableTab(tab, params.requestedMeetingUrl), ); if (!params.requestedMeetingUrl) { - return candidates[0]; + // Untargeted recovery also admits login fallbacks. Prefer a real meeting + // identity so browser enumeration order cannot select a sign-in tab first. + const meetingCandidates = candidates.filter((tab) => + params.adapter.urls.normalizeForReuse(tab.url), + ); + return ( + meetingCandidates.find((tab) => params.adapter.urls.isPreferredJoinUrl(tab.url)) ?? + meetingCandidates[0] ?? + candidates[0] + ); } const accountHint = params.adapter.urls.accountHint(params.requestedMeetingUrl); const accountCandidates = accountHint diff --git a/src/meeting-bot/session-runtime.test.ts b/src/meeting-bot/session-runtime.test.ts index 75c25d94ecba..5cd8dfff9e9e 100644 --- a/src/meeting-bot/session-runtime.test.ts +++ b/src/meeting-bot/session-runtime.test.ts @@ -14,6 +14,7 @@ type TestSession = MeetingSessionRecord & { launched: boolean; tab?: MeetingBrowserTab; health?: MeetingBrowserHealth; + hasAudioBridge?: boolean; }; }; type TestJoinContext = MeetingSessionRuntimeJoinContext< @@ -25,6 +26,7 @@ type TestJoinContext = MeetingSessionRuntimeJoinContext< >; function createTestRuntime(params: { + talkBack?: boolean; joinTransport(input: { request: TestRequest; session: TestSession; @@ -91,7 +93,7 @@ function createTestRuntime(params: { }, resolveSpeechInstructions: () => undefined, isBrowserTransport: () => true, - isTalkBackMode: () => false, + isTalkBackMode: () => params.talkBack === true, isTranscribeMode: () => false, sameMeetingUrl: (left, right) => left === right, normalizeMeetingUrlForReuse: (url) => url, @@ -101,7 +103,7 @@ function createTestRuntime(params: { launched: session.browser.launched, tab: session.browser.tab, health: session.browser.health, - hasAudioBridge: false, + hasAudioBridge: session.browser.hasAudioBridge === true, } : undefined, setBrowserTab: (session, tab) => { @@ -354,3 +356,37 @@ describe("MeetingSessionRuntime leave cleanup", () => { expect(releaseBrowserTab).toHaveBeenCalledTimes(2); }); }); + +describe("MeetingSessionRuntime speech readiness", () => { + it("treats an unknown microphone state as transiently unverified", async () => { + const { runtime } = createTestRuntime({ + talkBack: true, + releaseBrowserTab: async () => true, + joinTransport: async ({ session }) => { + session.browser = { + launched: true, + hasAudioBridge: true, + health: { inCall: true }, + }; + return {}; + }, + }); + const { session } = await runtime.join({ + url: "https://meeting.example/room", + agentId: "main", + }); + + expect(runtime.refreshSpeechReadiness(session)).toEqual({ + ready: false, + reason: "browser-unverified", + message: "browser unverified", + }); + expect(session.browser?.health).toMatchObject({ + speechReady: false, + speechBlockedReason: "browser-unverified", + }); + + session.browser!.health = { ...session.browser?.health, micMuted: false }; + expect(runtime.refreshSpeechReadiness(session)).toEqual({ ready: true }); + }); +}); diff --git a/src/meeting-bot/session-runtime.ts b/src/meeting-bot/session-runtime.ts index 43d5e4a3bbad..3a3bcde14486 100644 --- a/src/meeting-bot/session-runtime.ts +++ b/src/meeting-bot/session-runtime.ts @@ -720,11 +720,13 @@ export class MeetingSessionRuntime< }; } if (health?.inCall === true) { - if (health.micMuted === true) { + if (health.micMuted !== false) { + const muted = health.micMuted === true; + // Unknown is transiently blocked: omitted mic controls cannot prove talk-back readiness. return { ready: false, - reason: speech.microphoneMutedReason, - message: speech.microphoneMuted, + reason: muted ? speech.microphoneMutedReason : speech.browserUnverifiedReason, + message: muted ? speech.microphoneMuted : speech.browserUnverified, }; } return browser.hasAudioBridge diff --git a/src/meeting-bot/session-transcript-store.test.ts b/src/meeting-bot/session-transcript-store.test.ts index 4344908cc769..b86f0b9bb664 100644 --- a/src/meeting-bot/session-transcript-store.test.ts +++ b/src/meeting-bot/session-transcript-store.test.ts @@ -1,33 +1,46 @@ import { describe, expect, it } from "vitest"; import { MeetingSessionTranscriptStore } from "./session-transcript-store.js"; -import type { MeetingSessionRecord } from "./session-types.js"; +import type { MeetingSessionRecord, MeetingTranscriptSnapshot } from "./session-types.js"; + +function createSession(): MeetingSessionRecord<"chrome", "transcribe"> { + return { + id: "session-1", + url: "https://meeting.example/room", + transport: "chrome", + mode: "transcribe", + agentId: "main", + state: "active", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + participantIdentity: "OpenClaw", + realtime: { enabled: false, toolPolicy: "none" }, + notes: [], + }; +} + +function createStore( + session: MeetingSessionRecord<"chrome", "transcribe">, + snapshots: MeetingTranscriptSnapshot[], +) { + return new MeetingSessionTranscriptStore({ + getSession: (sessionId) => (sessionId === session.id ? session : undefined), + isBrowserSession: () => true, + isTranscribeSession: () => true, + hasBrowserTab: () => true, + capture: async () => snapshots.shift(), + }); +} describe("MeetingSessionTranscriptStore", () => { it("trims an oversized initial snapshot to the retained tail", async () => { - const session: MeetingSessionRecord<"chrome", "transcribe"> = { - id: "session-1", - url: "https://meeting.example/room", - transport: "chrome", - mode: "transcribe", - agentId: "main", - state: "active", - createdAt: "2026-07-17T00:00:00.000Z", - updatedAt: "2026-07-17T00:00:00.000Z", - participantIdentity: "OpenClaw", - realtime: { enabled: false, toolPolicy: "none" }, - notes: [], - }; - const store = new MeetingSessionTranscriptStore({ - getSession: (sessionId) => (sessionId === session.id ? session : undefined), - isBrowserSession: () => true, - isTranscribeSession: () => true, - hasBrowserTab: () => true, - capture: async () => ({ + const session = createSession(); + const store = createStore(session, [ + { droppedLines: 7, epoch: "page-1", lines: Array.from({ length: 2_005 }, (_, index) => ({ text: `line-${index}` })), - }), - }); + }, + ]); const result = await store.read(session.id); @@ -41,4 +54,56 @@ describe("MeetingSessionTranscriptStore", () => { expect(result.lines?.[0]?.text).toBe("line-5"); expect(result.lines?.at(-1)?.text).toBe("line-2004"); }); + + it("drops a stale retained segment when the page cursor jumps past it", async () => { + const session = createSession(); + const store = createStore(session, [ + { + droppedLines: 0, + epoch: "page-1", + lines: [{ text: "old-0" }, { text: "old-1" }], + }, + { + droppedLines: 4, + epoch: "page-1", + lines: [{ text: "new-4" }, { text: "new-5" }], + }, + ]); + + await store.read(session.id); + const result = await store.read(session.id, { sinceIndex: 2 }); + + expect(result).toMatchObject({ startIndex: 4, nextIndex: 6, droppedLines: 4 }); + expect(result.lines?.map((line) => line.text)).toEqual(["new-4", "new-5"]); + }); + + it("keeps only the new epoch tail when its first snapshot already has a gap", async () => { + const session = createSession(); + const store = createStore(session, [ + { + droppedLines: 0, + epoch: "page-1", + lines: [{ text: "old-0" }, { text: "old-1" }], + }, + { + droppedLines: 3, + epoch: "page-2", + lines: [{ text: "new-3" }, { text: "new-4" }], + }, + { + droppedLines: 3, + epoch: "page-2", + lines: [{ text: "new-3" }, { text: "new-4" }, { text: "new-5" }], + }, + ]); + + await store.read(session.id); + const afterReload = await store.read(session.id, { sinceIndex: 2 }); + const afterAppend = await store.read(session.id, { sinceIndex: 7 }); + + expect(afterReload).toMatchObject({ startIndex: 5, nextIndex: 7, droppedLines: 5 }); + expect(afterReload.lines?.map((line) => line.text)).toEqual(["new-3", "new-4"]); + expect(afterAppend).toMatchObject({ startIndex: 7, nextIndex: 8, droppedLines: 5 }); + expect(afterAppend.lines?.map((line) => line.text)).toEqual(["new-5"]); + }); }); diff --git a/src/meeting-bot/session-transcript-store.ts b/src/meeting-bot/session-transcript-store.ts index 7b89b6eb6a91..5a86e6bfab0a 100644 --- a/src/meeting-bot/session-transcript-store.ts +++ b/src/meeting-bot/session-transcript-store.ts @@ -145,15 +145,30 @@ export class MeetingSessionTranscriptStore 0) { + // A new page epoch with an already-trimmed prefix leaves a cursor gap. + // Keep only its contiguous tail so older lines never move to new indices. + retained.droppedLines = retainedNextIndex + snapshot.droppedLines; + retained.lines = [...snapshot.lines]; + } else { + retained.lines.push(...snapshot.lines); + } retained.pageEpoch = snapshot.epoch; retained.pageNextIndex = pageNextIndex; } else if (pageNextIndex > retained.pageNextIndex) { - const appendFrom = Math.max(retained.pageNextIndex, snapshot.droppedLines); - retained.droppedLines += Math.max(0, snapshot.droppedLines - retained.pageNextIndex); - retained.lines.push(...snapshot.lines.slice(appendFrom - snapshot.droppedLines)); + if (snapshot.droppedLines > retained.pageNextIndex) { + // Preserve the accumulated cross-epoch offset, but discard the stale segment + // before the page gap instead of shifting it under the new cursor range. + const pageOffset = retainedNextIndex - retained.pageNextIndex; + retained.droppedLines = pageOffset + snapshot.droppedLines; + retained.lines = [...snapshot.lines]; + } else { + retained.lines.push( + ...snapshot.lines.slice(retained.pageNextIndex - snapshot.droppedLines), + ); + } retained.pageNextIndex = pageNextIndex; } const excess = retained.lines.length - TRANSCRIPT_MAX_LINES;