Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.

## [Unreleased]

### Fixed

- Extension: input to a background Agent Window tab no longer keeps failing with
`input_not_ready` after Chrome drops the session's focus override without a detach
([#355](https://github.com/Tencent/BrowserSkill/issues/355)). The session's
background-execution lease resends the override and waits for a rendered frame; the
input is sent only once the page reports `visible`, and otherwise still fails before
dispatch.

## [0.3.2] - 2026-09-30

### Changed
Expand Down
47 changes: 47 additions & 0 deletions apps/extension/src/browser-driver/__tests__/chromium-cdp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,53 @@ describe("controlled background execution", () => {
await cdp.detachSession("agent");
cdp.dispose();
});

it("resends an owned override that the applied cache still reports active", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
const toggles = () =>
vi
.mocked(api.sendCommand)
.mock.calls.filter(([, method]) => method === "Emulation.setFocusEmulationEnabled")
.map(([, , params]) => (params as { enabled: boolean }).enabled);
await cdp.acquireBackgroundExecution("agent", 4);
await cdp.send(4, "Runtime.evaluate", {});
expect(toggles()).toEqual([true]);
await cdp.restoreBackgroundExecution("agent", 4);
expect(toggles()).toEqual([true, true]);
await expect(cdp.restoreBackgroundExecution("other", 4)).rejects.toThrow("not owned");
expect(toggles()).toEqual([true, true]);
await cdp.releaseSessionTab("agent", 4);
expect(toggles()).toEqual([true, true, false]);
await expect(cdp.restoreBackgroundExecution("agent", 4)).rejects.toThrow("not owned");
expect(toggles()).toEqual([true, true, false]);
cdp.dispose();
});

it("still disables on release after a failed resend", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.acquireBackgroundExecution("agent", 4);
// A passive reader keeps the attachment, so only the disable command can restore focus.
cdp.trackSessionTab("reader", 4);
vi.mocked(api.sendCommand).mockImplementation(async (_target, method, params) => {
if (
method === "Emulation.setFocusEmulationEnabled" &&
(params as { enabled: boolean }).enabled
)
throw new Error("resend failed");
return {};
});
await expect(cdp.restoreBackgroundExecution("agent", 4)).rejects.toThrow("resend failed");
await cdp.releaseSessionTab("agent", 4);
expect(api.sendCommand).toHaveBeenLastCalledWith(
{ tabId: 4 },
"Emulation.setFocusEmulationEnabled",
{ enabled: false },
);
expect(api.detach).not.toHaveBeenCalled();
cdp.dispose();
});
});

it("detaches on failed policy release even when a passive reader remains", async () => {
Expand Down
17 changes: 15 additions & 2 deletions apps/extension/src/browser-driver/background-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ export class BackgroundExecution {
this.applied.clear();
}

async synchronize(tabId: number): Promise<void> {
/** Resend an owned override that Chrome dropped without a detach. Never forces a disable. */
reapply(tabId: number): Promise<void> {
return this.synchronize(tabId, true);
}

async synchronize(tabId: number, reapply = false): Promise<void> {
// Keep raw commands serialized across detach/reattach as well. Invalidating
// applied state does not cancel an issued Chrome command.
// Join the preceding toggle, but retry a failed toggle on a subsequent call.
Expand All @@ -49,13 +54,21 @@ export class BackgroundExecution {
const next = (async () => {
await previous?.catch(() => {});
if (this.attachment(tabId) !== initialAttachment) return;
// Keep the applied record while reapplying: if the resend fails, release must still disable.
let force = reapply;
for (;;) {
const attachment = this.attachment(tabId);
if (!attachment) return;
const enabled = this.owners.has(tabId);
const applied = this.applied.get(tabId);
if (applied?.attachment === attachment && applied.enabled === enabled) return;
if (
applied?.attachment === attachment &&
applied.enabled === enabled &&
!(force && enabled)
)
return;
if (!enabled && applied?.attachment !== attachment) return;
force = false;
await this.toggle(tabId, enabled);
if (this.attachment(tabId) !== attachment) {
throw new Error("Background execution attachment changed during setup");
Expand Down
10 changes: 10 additions & 0 deletions apps/extension/src/browser-driver/chromium-cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,16 @@ export class ChromiumCdp {
return this.backgroundExecution.has(sessionId, tabId);
}

/** Resend this session's override when Chrome lost it without clearing the applied-state cache. */
async restoreBackgroundExecution(sessionId: string, tabId: number): Promise<void> {
if (!this.backgroundExecution.has(sessionId, tabId))
throw new Error("Background execution is not owned by this session");
await this.ensureRawAttached(tabId);
await this.backgroundExecution.reapply(tabId);
if (!this.backgroundExecution.has(sessionId, tabId))
throw new Error("Background execution was released during restore");
}

private async ensureRawAttached(tabId: number): Promise<void> {
// Returning a tab clears the cache before Chrome finishes detaching.
// New observers must wait before opening the next connection to that tab.
Expand Down
28 changes: 28 additions & 0 deletions apps/extension/src/tools/__tests__/click.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,34 @@ describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser click readiness", (
).toBe(false);
expect(documentChanges).toBe(0);
expect(ctx.refStore.resolve(ref!, { tabId: 4 })).not.toBeNull();

// Drop the override on the same debugger session, bypassing the applied-state
// cache as a service-worker command would. The lease owner must restore it.
await send("Emulation.setFocusEmulationEnabled", { enabled: false }, target.sessionId);
expect(await target.evaluate("document.visibilityState")).toBe("hidden");
expect(await prepare("tool.click")).toBeUndefined();
cdpCommands.length = 0;
const recovered = await handleClick(
manager,
{ session_id: ctx.sessionId, tab_id: 4, ref: ref! },
{ cdp, tabsApi },
);
expect(
recovered,
JSON.stringify({ recovered, commands: cdpCommands }),
).not.toHaveProperty("code");
expect(await target.evaluate("window.clicks")).toEqual([true, true]);
expect(
cdpCommands.filter(
(command) => command.method === "Emulation.setFocusEmulationEnabled",
),
).toHaveLength(1);
expect(cdpCommands.some((command) => command.method === "Page.captureScreenshot")).toBe(
false,
);
expect(await target.evaluate("document.visibilityState")).toBe("visible");
expect(cdp.ownsBackgroundExecution(ctx.sessionId, 4)).toBe(true);
expect(await foreground.evaluate("document.visibilityState")).toBe("visible");
await cdp.send(4, "Page.navigate", { url: `${url}/next` });
await vi.waitFor(() => expect(documentUpdates).toBeGreaterThan(0), { timeout: 5000 });
expect(documentChanges).toBeGreaterThan(0);
Expand Down
97 changes: 97 additions & 0 deletions apps/extension/src/tools/__tests__/dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { SessionManager } from "@/session-manager/manager";
import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport";
import type {
Expand Down Expand Up @@ -345,6 +346,102 @@ describe("ToolDispatcher", () => {
dispatcher.stop();
});

it("recovers a leased click after Chrome drops the applied focus override", async () => {
const tab = { id: 7, windowId: 4242, active: false, url: "https://example.test" };
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => tab),
query: vi.fn(async () => [tab]),
sendMessage: vi.fn(async () => undefined),
},
});
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => ({ windowId: 4242, initialTabIds: [7] })),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 7),
},
});
const ctx = await sessions.start("aa11");
ctx.refStore.set("e1", 12, { tabId: 7 });
// Chrome's actual override, independent of BackgroundExecution's applied-state cache.
let emulated = false;
const toggles: boolean[] = [];
let pointerEvents = 0;
const chromeEvent = {
addListener: () => {},
removeListener: () => {},
} as unknown as CdpDebuggerApi["onEvent"];
const api: CdpDebuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target, method, params) => {
const p = (params ?? {}) as { enabled?: boolean; expression?: string };
if (method === "Emulation.setFocusEmulationEnabled") {
emulated = p.enabled === true;
toggles.push(emulated);
return {};
}
if (method === "Runtime.evaluate" && p.expression === "document.visibilityState")
return { result: { value: emulated ? "visible" : "hidden" } };
// Animation frames run only while the page is visible.
if (method === "Runtime.evaluate" && p.expression?.includes("requestAnimationFrame"))
return { result: { value: emulated } };
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 40, 0, 40, 20, 0, 20]] };
if (method === "DOM.getBoxModel")
return { model: { content: [0, 0, 40, 0, 40, 20, 0, 20] } };
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
if (method === "Runtime.evaluate")
return {
result: { value: { overlayHostPresent: false, overlayHostConnected: false } },
};
if (method === "Input.dispatchMouseEvent") {
if (!emulated) throw new Error("input sent to a hidden page");
pointerEvents++;
}
return {};
}),
onEvent: chromeEvent,
onDetach: chromeEvent as unknown as CdpDebuggerApi["onDetach"],
};
const cdp = new ChromiumCdp(api);
const { transport, sent, deliver } = fakeTransport();
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
const click = async (count: number) => {
deliver(makeRequest("tool.click", { session_id: "aa11", ref: "e1" }));
await vi.waitFor(() => expect(sent).toHaveLength(count));
return sent[count - 1];
};
try {
expect(await click(1)).toMatchObject({ result: { tab_id: 7, used_ref: "e1" } });
expect(toggles).toEqual([true]);
expect(pointerEvents).toBe(3);

// Turned off outside the cache, e.g. from the service worker.
emulated = false;
expect(await click(2)).toMatchObject({ result: { tab_id: 7, used_ref: "e1" } });
expect(toggles).toEqual([true, true]);
expect(pointerEvents).toBe(6);
expect(emulated).toBe(true);
expect(cdp.ownsBackgroundExecution("aa11", 7)).toBe(true);
expect(
vi
.mocked(api.sendCommand)
.mock.calls.some(([, method]) => method === "Page.captureScreenshot"),
).toBe(false);

// The restored override stays with the lease for the next tool.
expect(await click(3)).toMatchObject({ result: { tab_id: 7, used_ref: "e1" } });
expect(toggles).toEqual([true, true]);
expect(pointerEvents).toBe(9);
} finally {
dispatcher.stop();
cdp.dispose();
}
});

it("wires the production tab APIs into tool.session_stop so a surviving user tab releases the window", async () => {
// Regression guard for the deps that session_stop reads directly: when
// `tabManagement.tabs` / `tabsQuery` are not injected by the dispatcher,
Expand Down
Loading
Loading