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 }); 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, tab_id: 7,
x: 10, x: 10,
y: 20, y: 20,
}); });
const helpers = dispatcher as unknown as { const helpers = dispatcher as unknown as {
withHoverReassert: <T>( withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>, work: () => Promise<T>,
options?: { releaseAfter?: boolean }, options?: { releaseAfter?: boolean },
) => Promise<T>; ) => 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"); order.push("observe");
return {}; return {};
}); });
@@ -369,8 +372,9 @@ describe("ToolDispatcher", () => {
expect.objectContaining({ enabled: false }), expect.objectContaining({ enabled: false }),
); );
await helpers.setHoverBypass(7, true); await helpers.setHoverBypass("aa11", 7, true);
await helpers.withHoverReassert( await helpers.withHoverReassert(
{ session_id: "aa11", tab_id: 7 },
async () => { async () => {
order.push("click"); order.push("click");
return {}; 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 () => { it("disconnects the transport when send() fails so keepalive can rebuild", async () => {
const { transport, deliver } = fakeTransport(); const { transport, deliver } = fakeTransport();
const sessions = new SessionManager({ const sessions = new SessionManager({
+115 -55
View File
@@ -83,6 +83,13 @@ type DispatcherCdpRunner = CdpRunner &
detachSession(sessionId: string): Promise<void>; detachSession(sessionId: string): Promise<void>;
}; };
interface HoverLatch {
sessionId: string;
tabId: number;
x: number;
y: number;
}
export interface DispatcherDeps { export interface DispatcherDeps {
transport: Transport; transport: Transport;
sessions: SessionManager; sessions: SessionManager;
@@ -128,8 +135,8 @@ export class ToolDispatcher {
private readonly approveBorrow?: BorrowConfirmationApprover; private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string }; private readonly helpNotificationCopy?: () => { title: string; body: string };
private subscription: { dispose(): void } | null = null; private subscription: { dispose(): void } | null = null;
private readonly hoverBypassTabs = new Set<number>(); private readonly hoverBypassTabs = new Map<number, string>();
private readonly hoverLatches = new Map<number, { x: number; y: number }>(); private readonly hoverLatches = new Map<number, HoverLatch>();
/** /**
* Per-rpc-id `AbortController` registry. Populated inside * Per-rpc-id `AbortController` registry. Populated inside
* [`dispatch`] before we await the tool handler and torn down in * [`dispatch`] before we await the tool handler and torn down in
@@ -276,7 +283,7 @@ export class ToolDispatcher {
case "tool.session_start": case "tool.session_start":
return handleSessionStart(this.sessions, req.params as SessionStartParams); return handleSessionStart(this.sessions, req.params as SessionStartParams);
case "tool.session_stop": { case "tool.session_stop": {
await this.releaseHoverLatch(); await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
return handleSessionStop(this.sessions, req.params as SessionStopParams, { return handleSessionStop(this.sessions, req.params as SessionStopParams, {
cdp: this.cdp, cdp: this.cdp,
}); });
@@ -286,7 +293,9 @@ export class ToolDispatcher {
case "tool.tab_create": case "tool.tab_create":
return handleTabCreate(this.sessions, req.params as TabCreateParams); return handleTabCreate(this.sessions, req.params as TabCreateParams);
case "tool.tab_close": 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": case "tool.tab_select":
return handleTabSelect(this.sessions, req.params as TabSelectParams); return handleTabSelect(this.sessions, req.params as TabSelectParams);
case "tool.tab_borrow": case "tool.tab_borrow":
@@ -325,7 +334,7 @@ export class ToolDispatcher {
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
); );
case "tool.snapshot": case "tool.snapshot":
return this.withHoverReassert(() => return this.withHoverReassert(req.params as SnapshotParams, () =>
handleSnapshot( handleSnapshot(
this.sessions, this.sessions,
req.params as SnapshotParams, req.params as SnapshotParams,
@@ -333,7 +342,7 @@ export class ToolDispatcher {
), ),
); );
case "tool.observe": case "tool.observe":
return this.withHoverReassert(() => return this.withHoverReassert(req.params as ObserveParams, () =>
handleObserve( handleObserve(
this.sessions, this.sessions,
req.params as ObserveParams, req.params as ObserveParams,
@@ -341,7 +350,9 @@ export class ToolDispatcher {
? { ? {
cdp: this.cdp, cdp: this.cdp,
tabsApi: chromeTabsCaptureApi, tabsApi: chromeTabsCaptureApi,
conditionalSurfaceProbe: !this.hasHoverLatch(), conditionalSurfaceProbe: !this.hasHoverLatchForRequest(
req.params as ObserveParams,
),
hoverProbeBypassOverlay: bypassOverlay, hoverProbeBypassOverlay: bypassOverlay,
} }
: undefined, : undefined,
@@ -354,31 +365,40 @@ export class ToolDispatcher {
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
); );
case "tool.navigate": case "tool.navigate":
return handleNavigate( return this.withHoverReleaseForRequest(req.params as NavigateParams, () =>
this.sessions, handleNavigate(
req.params as NavigateParams, this.sessions,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, req.params as NavigateParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
); );
case "tool.navigate_back": case "tool.navigate_back":
return handleNavigateBack( return this.withHoverReleaseForRequest(req.params as NavigateBackParams, () =>
this.sessions, handleNavigateBack(
req.params as NavigateBackParams, this.sessions,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, req.params as NavigateBackParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
); );
case "tool.navigate_forward": case "tool.navigate_forward":
return handleNavigateForward( return this.withHoverReleaseForRequest(req.params as NavigateForwardParams, () =>
this.sessions, handleNavigateForward(
req.params as NavigateForwardParams, this.sessions,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, req.params as NavigateForwardParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
); );
case "tool.reload": case "tool.reload":
return handleReload( return this.withHoverReleaseForRequest(req.params as ReloadParams, () =>
this.sessions, handleReload(
req.params as ReloadParams, this.sessions,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, req.params as ReloadParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
); );
case "tool.click": case "tool.click":
return this.withHoverReassert( return this.withHoverReassert(
req.params as ClickParams,
() => () =>
handleClick( handleClick(
this.sessions, this.sessions,
@@ -403,30 +423,37 @@ export class ToolDispatcher {
cdp: this.cdp, cdp: this.cdp,
tabsApi: chromeTabsApi, tabsApi: chromeTabsApi,
signal, signal,
bypassOverlay: (tabId, enabled) => this.setHoverBypass(tabId, enabled), bypassOverlay: (tabId, enabled) =>
this.setHoverBypass((req.params as HoverParams).session_id, tabId, enabled),
keepOverlayBypassAfterHover: true, keepOverlayBypassAfterHover: true,
} }
: undefined, : undefined,
); );
return this.rememberHover(result); return this.rememberHover((req.params as HoverParams).session_id, result);
} }
case "tool.fill": case "tool.fill":
return handleFill( return this.withHoverReleaseForRequest(req.params as FillParams, () =>
this.sessions, handleFill(
req.params as FillParams, this.sessions,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, req.params as FillParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
); );
case "tool.press": case "tool.press":
return handlePress( return this.withHoverReleaseForRequest(req.params as PressParams, () =>
this.sessions, handlePress(
req.params as PressParams, this.sessions,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, req.params as PressParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
); );
case "tool.select": case "tool.select":
return handleSelect( return this.withHoverReleaseForRequest(req.params as SelectParams, () =>
this.sessions, handleSelect(
req.params as SelectParams, this.sessions,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, req.params as SelectParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined,
),
); );
case "tool.evaluate": case "tool.evaluate":
return handleEvaluate( 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 (enabled) {
if (this.hoverBypassTabs.has(tabId)) return; if (this.hoverBypassTabs.has(tabId)) return;
await bypassOverlay(tabId, true); await bypassOverlay(tabId, true);
this.hoverBypassTabs.add(tabId); this.hoverBypassTabs.set(tabId, sessionId);
} else { } else {
if (!this.hoverBypassTabs.has(tabId)) return; if (!this.hoverBypassTabs.has(tabId)) return;
await bypassOverlay(tabId, false); 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)) { 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; return result;
} }
private hasHoverLatch(): boolean { private hasHoverLatchForRequest(params: { session_id: string; tab_id?: number }): boolean {
return this.hoverLatches.size > 0; return this.hoverLatchesForRequest(params).length > 0;
} }
private async withHoverReassert<T>( private async withHoverReassert<T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>, work: () => Promise<T>,
options: { releaseAfter?: boolean } = {}, options: { releaseAfter?: boolean } = {},
): Promise<T> { ): Promise<T> {
await this.reassertHover(); await this.reassertHover(params);
try { try {
return await work(); return await work();
} finally { } finally {
if (options.releaseAfter) { 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; if (!this.cdp) return;
await Promise.all( await Promise.all(
[...this.hoverLatches.entries()].map(([tabId, point]) => this.hoverLatchesForRequest(params).map((latch) =>
this.cdp!.send(tabId, "Input.dispatchMouseEvent", { this.cdp!.send(latch.tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved", type: "mouseMoved",
x: point.x, x: latch.x,
y: point.y, y: latch.y,
}).catch((err) => { }).catch((err) => {
console.debug("[bsk dispatcher] hover reassert failed", err); console.debug("[bsk dispatcher] hover reassert failed", err);
this.hoverLatches.delete(tabId); this.hoverLatches.delete(latch.tabId);
}), }),
), ),
); );
} }
private async releaseHoverLatch(): Promise<void> { private async releaseHoverLatch(sessionId?: string, tabId?: number): Promise<void> {
const tabs = new Set([...this.hoverBypassTabs, ...this.hoverLatches.keys()]); const matchesScope = (entrySessionId: string, entryTabId: number): boolean => {
this.hoverBypassTabs.clear(); if (sessionId !== undefined && entrySessionId !== sessionId) return false;
this.hoverLatches.clear(); 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))); await Promise.all([...tabs].map((tabId) => bypassOverlay(tabId, false)));
} }
} }