feat(extension): dispatcher fix

This commit is contained in:
Ljy-0827
2026-08-06 15:48:11 +08:00
parent dbc4845c0d
commit d24bb6aa93
2 changed files with 223 additions and 59 deletions
@@ -346,20 +346,23 @@ describe("ToolDispatcher", () => {
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
(dispatcher as unknown as { rememberHover: (result: unknown) => unknown }).rememberHover({
(
dispatcher as unknown as { rememberHover: (sessionId: string, result: unknown) => unknown }
).rememberHover("aa11", {
tab_id: 7,
x: 10,
y: 20,
});
const helpers = dispatcher as unknown as {
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
options?: { releaseAfter?: boolean },
) => Promise<T>;
setHoverBypass: (tabId: number, enabled: boolean) => Promise<void>;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
};
await helpers.withHoverReassert(async () => {
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => {
order.push("observe");
return {};
});
@@ -369,8 +372,9 @@ describe("ToolDispatcher", () => {
expect.objectContaining({ enabled: false }),
);
await helpers.setHoverBypass(7, true);
await helpers.setHoverBypass("aa11", 7, true);
await helpers.withHoverReassert(
{ session_id: "aa11", tab_id: 7 },
async () => {
order.push("click");
return {};
@@ -389,6 +393,106 @@ describe("ToolDispatcher", () => {
);
});
it("does not reassert or disable hover latches from another session", async () => {
vi.stubGlobal("chrome", {
tabs: {
sendMessage: vi.fn(async () => undefined),
},
});
const { transport } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
const cdp = {
send: vi.fn(async () => ({})),
detachSession: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
setDeviceMetricsOverride: vi.fn(async () => {}),
clearDeviceMetricsOverride: vi.fn(async () => {}),
setUserAgentOverride: vi.fn(async () => {}),
setTouchEmulationEnabled: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
options?: { releaseAfter?: boolean },
) => Promise<T>;
releaseHoverLatch: (sessionId?: string, tabId?: number) => Promise<void>;
};
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
await helpers.setHoverBypass("aa11", 7, true);
await helpers.withHoverReassert({ session_id: "bb22", tab_id: 7 }, async () => ({}), {
releaseAfter: true,
});
expect(cdp.send).not.toHaveBeenCalled();
expect(chrome.tabs.sendMessage).not.toHaveBeenCalledWith(
7,
expect.objectContaining({ enabled: false }),
);
await helpers.releaseHoverLatch("aa11", 7);
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
7,
expect.objectContaining({ enabled: false }),
);
});
it("releases the current session hover latch before navigation-style work", async () => {
vi.stubGlobal("chrome", {
tabs: {
sendMessage: vi.fn(async () => undefined),
},
});
const { transport } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReleaseForRequest: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
await helpers.setHoverBypass("aa11", 7, true);
await helpers.withHoverReleaseForRequest({ session_id: "aa11", tab_id: 7 }, async () => ({}));
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
7,
expect.objectContaining({ enabled: false }),
);
});
it("disconnects the transport when send() fails so keepalive can rebuild", async () => {
const { transport, deliver } = fakeTransport();
const sessions = new SessionManager({
+115 -55
View File
@@ -83,6 +83,13 @@ type DispatcherCdpRunner = CdpRunner &
detachSession(sessionId: string): Promise<void>;
};
interface HoverLatch {
sessionId: string;
tabId: number;
x: number;
y: number;
}
export interface DispatcherDeps {
transport: Transport;
sessions: SessionManager;
@@ -128,8 +135,8 @@ export class ToolDispatcher {
private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string };
private subscription: { dispose(): void } | null = null;
private readonly hoverBypassTabs = new Set<number>();
private readonly hoverLatches = new Map<number, { x: number; y: number }>();
private readonly hoverBypassTabs = new Map<number, string>();
private readonly hoverLatches = new Map<number, HoverLatch>();
/**
* Per-rpc-id `AbortController` registry. Populated inside
* [`dispatch`] before we await the tool handler and torn down in
@@ -276,7 +283,7 @@ export class ToolDispatcher {
case "tool.session_start":
return handleSessionStart(this.sessions, req.params as SessionStartParams);
case "tool.session_stop": {
await this.releaseHoverLatch();
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
cdp: this.cdp,
});
@@ -286,7 +293,9 @@ export class ToolDispatcher {
case "tool.tab_create":
return handleTabCreate(this.sessions, req.params as TabCreateParams);
case "tool.tab_close":
return handleTabClose(this.sessions, req.params as TabCloseParams);
return this.withHoverReleaseForRequest(req.params as TabCloseParams, () =>
handleTabClose(this.sessions, req.params as TabCloseParams),
);
case "tool.tab_select":
return handleTabSelect(this.sessions, req.params as TabSelectParams);
case "tool.tab_borrow":
@@ -325,7 +334,7 @@ export class ToolDispatcher {
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
);
case "tool.snapshot":
return this.withHoverReassert(() =>
return this.withHoverReassert(req.params as SnapshotParams, () =>
handleSnapshot(
this.sessions,
req.params as SnapshotParams,
@@ -333,7 +342,7 @@ export class ToolDispatcher {
),
);
case "tool.observe":
return this.withHoverReassert(() =>
return this.withHoverReassert(req.params as ObserveParams, () =>
handleObserve(
this.sessions,
req.params as ObserveParams,
@@ -341,7 +350,9 @@ export class ToolDispatcher {
? {
cdp: this.cdp,
tabsApi: chromeTabsCaptureApi,
conditionalSurfaceProbe: !this.hasHoverLatch(),
conditionalSurfaceProbe: !this.hasHoverLatchForRequest(
req.params as ObserveParams,
),
hoverProbeBypassOverlay: bypassOverlay,
}
: undefined,
@@ -354,31 +365,40 @@ export class ToolDispatcher {
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
);
case "tool.navigate":
return handleNavigate(
this.sessions,
req.params as NavigateParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
return this.withHoverReleaseForRequest(req.params as NavigateParams, () =>
handleNavigate(
this.sessions,
req.params as NavigateParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
);
case "tool.navigate_back":
return handleNavigateBack(
this.sessions,
req.params as NavigateBackParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
return this.withHoverReleaseForRequest(req.params as NavigateBackParams, () =>
handleNavigateBack(
this.sessions,
req.params as NavigateBackParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
);
case "tool.navigate_forward":
return handleNavigateForward(
this.sessions,
req.params as NavigateForwardParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
return this.withHoverReleaseForRequest(req.params as NavigateForwardParams, () =>
handleNavigateForward(
this.sessions,
req.params as NavigateForwardParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
);
case "tool.reload":
return handleReload(
this.sessions,
req.params as ReloadParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
return this.withHoverReleaseForRequest(req.params as ReloadParams, () =>
handleReload(
this.sessions,
req.params as ReloadParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
);
case "tool.click":
return this.withHoverReassert(
req.params as ClickParams,
() =>
handleClick(
this.sessions,
@@ -403,30 +423,37 @@ export class ToolDispatcher {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
bypassOverlay: (tabId, enabled) => this.setHoverBypass(tabId, enabled),
bypassOverlay: (tabId, enabled) =>
this.setHoverBypass((req.params as HoverParams).session_id, tabId, enabled),
keepOverlayBypassAfterHover: true,
}
: undefined,
);
return this.rememberHover(result);
return this.rememberHover((req.params as HoverParams).session_id, result);
}
case "tool.fill":
return handleFill(
this.sessions,
req.params as FillParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
return this.withHoverReleaseForRequest(req.params as FillParams, () =>
handleFill(
this.sessions,
req.params as FillParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
);
case "tool.press":
return handlePress(
this.sessions,
req.params as PressParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
return this.withHoverReleaseForRequest(req.params as PressParams, () =>
handlePress(
this.sessions,
req.params as PressParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
);
case "tool.select":
return handleSelect(
this.sessions,
req.params as SelectParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
return this.withHoverReleaseForRequest(req.params as SelectParams, () =>
handleSelect(
this.sessions,
req.params as SelectParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
);
case "tool.evaluate":
return handleEvaluate(
@@ -500,11 +527,11 @@ export class ToolDispatcher {
}
}
private async setHoverBypass(tabId: number, enabled: boolean): Promise<void> {
private async setHoverBypass(sessionId: string, tabId: number, enabled: boolean): Promise<void> {
if (enabled) {
if (this.hoverBypassTabs.has(tabId)) return;
await bypassOverlay(tabId, true);
this.hoverBypassTabs.add(tabId);
this.hoverBypassTabs.set(tabId, sessionId);
} else {
if (!this.hoverBypassTabs.has(tabId)) return;
await bypassOverlay(tabId, false);
@@ -512,51 +539,84 @@ export class ToolDispatcher {
}
}
private rememberHover(result: HoverResult | RpcError): HoverResult | RpcError {
private rememberHover(sessionId: string, result: HoverResult | RpcError): HoverResult | RpcError {
if (!isRpcError(result)) {
this.hoverLatches.set(result.tab_id, { x: result.x, y: result.y });
this.hoverLatches.set(result.tab_id, {
sessionId,
tabId: result.tab_id,
x: result.x,
y: result.y,
});
}
return result;
}
private hasHoverLatch(): boolean {
return this.hoverLatches.size > 0;
private hasHoverLatchForRequest(params: { session_id: string; tab_id?: number }): boolean {
return this.hoverLatchesForRequest(params).length > 0;
}
private async withHoverReassert<T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
options: { releaseAfter?: boolean } = {},
): Promise<T> {
await this.reassertHover();
await this.reassertHover(params);
try {
return await work();
} finally {
if (options.releaseAfter) {
await this.releaseHoverLatch();
await this.releaseHoverLatch(params.session_id, params.tab_id);
}
}
}
private async reassertHover(): Promise<void> {
private async withHoverReleaseForRequest<T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
): Promise<T> {
await this.releaseHoverLatch(params.session_id, params.tab_id);
return work();
}
private hoverLatchesForRequest(params: { session_id: string; tab_id?: number }): HoverLatch[] {
return [...this.hoverLatches.values()].filter((latch) => {
if (latch.sessionId !== params.session_id) return false;
return params.tab_id === undefined || latch.tabId === params.tab_id;
});
}
private async reassertHover(params: { session_id: string; tab_id?: number }): Promise<void> {
if (!this.cdp) return;
await Promise.all(
[...this.hoverLatches.entries()].map(([tabId, point]) =>
this.cdp!.send(tabId, "Input.dispatchMouseEvent", {
this.hoverLatchesForRequest(params).map((latch) =>
this.cdp!.send(latch.tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
x: latch.x,
y: latch.y,
}).catch((err) => {
console.debug("[bsk dispatcher] hover reassert failed", err);
this.hoverLatches.delete(tabId);
this.hoverLatches.delete(latch.tabId);
}),
),
);
}
private async releaseHoverLatch(): Promise<void> {
const tabs = new Set([...this.hoverBypassTabs, ...this.hoverLatches.keys()]);
this.hoverBypassTabs.clear();
this.hoverLatches.clear();
private async releaseHoverLatch(sessionId?: string, tabId?: number): Promise<void> {
const matchesScope = (entrySessionId: string, entryTabId: number): boolean => {
if (sessionId !== undefined && entrySessionId !== sessionId) return false;
return tabId === undefined || entryTabId === tabId;
};
const tabs = new Set<number>();
for (const [bypassTabId, bypassSessionId] of this.hoverBypassTabs) {
if (!matchesScope(bypassSessionId, bypassTabId)) continue;
tabs.add(bypassTabId);
this.hoverBypassTabs.delete(bypassTabId);
}
for (const latch of this.hoverLatches.values()) {
if (!matchesScope(latch.sessionId, latch.tabId)) continue;
tabs.add(latch.tabId);
this.hoverLatches.delete(latch.tabId);
}
await Promise.all([...tabs].map((tabId) => bypassOverlay(tabId, false)));
}
}