mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
e32292e534
commit
ecd285222c
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user