fix: harden recovered meeting browser sessions (#110089)

* fix(meeting-bot): harden browser session recovery

* chore: leave meeting notes to release generation

* docs: fix release-gate example lint
This commit is contained in:
Peter Steinberger
2026-07-17 23:20:51 +01:00
committed by GitHub
parent e32292e534
commit ecd285222c
6 changed files with 203 additions and 33 deletions
@@ -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") {
+10 -1
View File
@@ -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
+38 -2
View File
@@ -14,6 +14,7 @@ type TestSession = MeetingSessionRecord<TestTransport, TestMode> & {
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 });
});
});
+5 -3
View File
@@ -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
@@ -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"]);
});
});
+20 -5
View File
@@ -145,15 +145,30 @@ export class MeetingSessionTranscriptStore<TSession extends MeetingSessionRecord
});
return;
}
const retainedNextIndex = retained.droppedLines + retained.lines.length;
if (retained.pageEpoch !== snapshot.epoch) {
retained.droppedLines += snapshot.droppedLines;
retained.lines.push(...snapshot.lines);
if (snapshot.droppedLines > 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;