mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(extension): release automation state when returning borrowed tabs
This commit is contained in:
@@ -651,6 +651,155 @@ describe("ChromiumCdp", () => {
|
|||||||
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("releases only the returning session's claim on a tab", async () => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
await cdp.ensureAttached(2);
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
cdp.trackSessionTab("bb22", 1);
|
||||||
|
cdp.trackSessionTab("aa11", 2);
|
||||||
|
|
||||||
|
await cdp.releaseSessionTab("unknown", 1);
|
||||||
|
await cdp.releaseSessionTab("aa11", 1);
|
||||||
|
expect(api.detach).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await cdp.releaseSessionTab("bb22", 1);
|
||||||
|
await cdp.releaseSessionTab("bb22", 1);
|
||||||
|
expect(api.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 1 });
|
||||||
|
expect(cdp.isAttached(2)).toBe(true);
|
||||||
|
|
||||||
|
await cdp.detachSession("aa11");
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
])("releases a tab with an attachment in flight (new owner: %s)", async (newOwner) => {
|
||||||
|
const { api } = fakeApi();
|
||||||
|
let finishAttach!: () => void;
|
||||||
|
vi.mocked(api.attach).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishAttach = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
const attaching = cdp.ensureAttached(1);
|
||||||
|
const releasing = cdp.releaseSessionTab("aa11", 1);
|
||||||
|
if (newOwner) cdp.trackSessionTab("bb22", 1);
|
||||||
|
finishAttach();
|
||||||
|
await Promise.all([attaching, releasing]);
|
||||||
|
|
||||||
|
expect(cdp.isAttached(1)).toBe(newOwner);
|
||||||
|
expect(api.detach).toHaveBeenCalledTimes(newOwner ? 0 : 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops accepting dialogs on a returned tab even when another session keeps CDP attached", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
let controlled = true;
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog: () => controlled });
|
||||||
|
cdp.trackSessionTab("aa11", 1);
|
||||||
|
cdp.trackSessionTab("bb22", 1);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "before return",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
|
||||||
|
vi.mocked(api.sendCommand).mockClear();
|
||||||
|
|
||||||
|
controlled = false;
|
||||||
|
await cdp.releaseSessionTab("aa11", 1);
|
||||||
|
expect(cdp.isAttached(1)).toBe(true);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "confirm",
|
||||||
|
message: "after return",
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toHaveLength(1);
|
||||||
|
|
||||||
|
controlled = true;
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
|
||||||
|
type: "alert",
|
||||||
|
message: "borrowed again",
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(2));
|
||||||
|
expect(api.sendCommand).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
{ tabId: 1 },
|
||||||
|
"Page.handleJavaScriptDialog",
|
||||||
|
{ accept: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores dialog events after detach, including a pending eligibility check", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
let resolveEligibility!: (value: boolean) => void;
|
||||||
|
const shouldAutoAcceptDialog = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
resolveEligibility = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
await cdp.detach(1);
|
||||||
|
resolveEligibility(true);
|
||||||
|
await Promise.resolve();
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
|
||||||
|
expect(shouldAutoAcceptDialog).toHaveBeenCalledOnce();
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"Page.handleJavaScriptDialog",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept dialogs when the tab's current scope cannot be determined", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
const shouldAutoAcceptDialog = vi.fn(async () => {
|
||||||
|
throw new Error("tab is gone");
|
||||||
|
});
|
||||||
|
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
vi.mocked(api.sendCommand).mockClear();
|
||||||
|
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
|
||||||
|
expect(api.sendCommand).not.toHaveBeenCalled();
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore dialog state when an acceptance completes after detach", async () => {
|
||||||
|
const { api, onEvent } = fakeApi();
|
||||||
|
const cdp = new ChromiumCdp(api);
|
||||||
|
await cdp.ensureAttached(1);
|
||||||
|
let finishAccept!: () => void;
|
||||||
|
vi.mocked(api.sendCommand).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishAccept = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
|
||||||
|
await cdp.detach(1);
|
||||||
|
finishAccept();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(cdp.dialogsSince(1, 0)).toEqual([]);
|
||||||
|
expect(cdp.dialogCursor(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("detachSession only detaches tabs no other session owns", async () => {
|
it("detachSession only detaches tabs no other session owns", async () => {
|
||||||
const { api } = fakeApi();
|
const { api } = fakeApi();
|
||||||
const cdp = new ChromiumCdp(api);
|
const cdp = new ChromiumCdp(api);
|
||||||
|
|||||||
@@ -182,7 +182,13 @@ export class ChromiumCdp {
|
|||||||
private networkSubscription: { dispose(): void } | null = null;
|
private networkSubscription: { dispose(): void } | null = null;
|
||||||
private frameTargetSubscription: { dispose(): void } | null = null;
|
private frameTargetSubscription: { dispose(): void } | null = null;
|
||||||
|
|
||||||
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
|
constructor(
|
||||||
|
api: CdpDebuggerApi = chromeDebuggerApi,
|
||||||
|
private readonly options: {
|
||||||
|
/** CDP observation alone does not authorize dismissing user dialogs. */
|
||||||
|
shouldAutoAcceptDialog?: (tabId: number) => boolean | Promise<boolean>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.bindAutoDetach();
|
this.bindAutoDetach();
|
||||||
this.bindDialogHandler();
|
this.bindDialogHandler();
|
||||||
@@ -453,6 +459,17 @@ export class ChromiumCdp {
|
|||||||
this.tabOwners.set(tabId, owners);
|
this.tabOwners.set(tabId, owners);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Release one session's claim, preserving attachments still used by another. */
|
||||||
|
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
|
||||||
|
const owners = this.tabOwners.get(tabId);
|
||||||
|
if (!owners?.delete(sessionId) || owners.size > 0) return;
|
||||||
|
this.tabOwners.delete(tabId);
|
||||||
|
// An observation may still be attaching when the tab is returned. Wait
|
||||||
|
// for it so detach cannot miss the attachment or remove a new owner's claim.
|
||||||
|
await this.attachInFlight.get(tabId)?.catch(() => {});
|
||||||
|
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Subscribe to all CDP events. Returned disposable removes the listener. */
|
/** Subscribe to all CDP events. Returned disposable removes the listener. */
|
||||||
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -639,13 +656,23 @@ export class ChromiumCdp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
|
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const parsed = parseDialogOpeningParams(params);
|
const parsed = parseDialogOpeningParams(params);
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
this.options.shouldAutoAcceptDialog &&
|
||||||
|
!(await this.options.shouldAutoAcceptDialog(tabId))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The tab may have been returned while its current scope was checked.
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
|
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
|
||||||
if (parsed.type === "prompt") {
|
if (parsed.type === "prompt") {
|
||||||
handleParams.promptText = parsed.defaultPrompt ?? "";
|
handleParams.promptText = parsed.defaultPrompt ?? "";
|
||||||
}
|
}
|
||||||
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
|
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
|
||||||
|
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
|
||||||
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
|
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
|
||||||
this.dialogSequences.set(tabId, sequence);
|
this.dialogSequences.set(tabId, sequence);
|
||||||
this.appendDialog(tabId, {
|
this.appendDialog(tabId, {
|
||||||
@@ -835,15 +862,9 @@ export class ChromiumCdp {
|
|||||||
|
|
||||||
/** Detach tabs only when no other live session has claimed them. */
|
/** Detach tabs only when no other live session has claimed them. */
|
||||||
async detachSession(sessionId: string): Promise<void> {
|
async detachSession(sessionId: string): Promise<void> {
|
||||||
const tabsToDetach: number[] = [];
|
await Promise.all(
|
||||||
for (const [tabId, owners] of this.tabOwners) {
|
Array.from(this.tabOwners.keys(), (tabId) => this.releaseSessionTab(sessionId, tabId)),
|
||||||
owners.delete(sessionId);
|
);
|
||||||
if (owners.size === 0) {
|
|
||||||
this.tabOwners.delete(tabId);
|
|
||||||
tabsToDetach.push(tabId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ export default defineBackground(() => {
|
|||||||
const controller = new ConnectionController();
|
const controller = new ConnectionController();
|
||||||
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
|
||||||
const sessions = new SessionManager();
|
const sessions = new SessionManager();
|
||||||
const cdp = new ChromiumCdp();
|
const cdp = new ChromiumCdp(undefined, {
|
||||||
|
shouldAutoAcceptDialog: async (tabId) => {
|
||||||
|
const tab = await chrome.tabs.get(tabId);
|
||||||
|
return sessions.findByWindowId(tab.windowId) !== null;
|
||||||
|
},
|
||||||
|
});
|
||||||
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
|
||||||
let overlayGeneration = 0;
|
let overlayGeneration = 0;
|
||||||
const controlModes = new Map<string, OverlayMode>();
|
const controlModes = new Map<string, OverlayMode>();
|
||||||
|
|||||||
@@ -747,6 +747,78 @@ describe("ToolDispatcher", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("releases the returned tab's hover before moving it and leaves other tabs alone", async () => {
|
||||||
|
const sendMessage = vi.fn(async () => undefined);
|
||||||
|
const move = vi.fn(async () => {
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
|
||||||
|
return { id: 7, windowId: 200, index: 4 };
|
||||||
|
});
|
||||||
|
vi.stubGlobal("chrome", {
|
||||||
|
tabs: { sendMessage, move },
|
||||||
|
windows: { get: vi.fn(async () => ({ id: 200 })) },
|
||||||
|
});
|
||||||
|
const { transport, sent, deliver } = fakeTransport();
|
||||||
|
const sessions = new SessionManager({
|
||||||
|
agentWindow: {
|
||||||
|
create: vi.fn(async () => 100),
|
||||||
|
remove: vi.fn(async () => {}),
|
||||||
|
ensureActiveTab: vi.fn(async () => 1),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await sessions.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const cdp = {
|
||||||
|
send: vi.fn(async <T>() => ({}) as T),
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const dispatcher = new ToolDispatcher({
|
||||||
|
transport,
|
||||||
|
sessions,
|
||||||
|
cdp: cdp as unknown as TestDispatcherCdp,
|
||||||
|
});
|
||||||
|
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>,
|
||||||
|
) => Promise<T>;
|
||||||
|
};
|
||||||
|
for (const [sessionId, tabId] of [
|
||||||
|
["aa11", 7],
|
||||||
|
["aa11", 8],
|
||||||
|
["bb22", 9],
|
||||||
|
] as const) {
|
||||||
|
helpers.rememberHover(sessionId, { tab_id: tabId, x: 10, y: 20 });
|
||||||
|
await helpers.setHoverBypass(sessionId, tabId, true);
|
||||||
|
}
|
||||||
|
dispatcher.start();
|
||||||
|
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id: 7 }));
|
||||||
|
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(sent[0]).toMatchObject({ result: { tab_id: 7, returned_to_window_id: 200 } });
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 7);
|
||||||
|
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
|
||||||
|
expect(cdp.send).not.toHaveBeenCalled();
|
||||||
|
for (const [sessionId, tabId] of [
|
||||||
|
["aa11", 8],
|
||||||
|
["bb22", 9],
|
||||||
|
] as const) {
|
||||||
|
expect(sendMessage).not.toHaveBeenCalledWith(
|
||||||
|
tabId,
|
||||||
|
expect.objectContaining({ enabled: false }),
|
||||||
|
);
|
||||||
|
await helpers.withHoverReassert({ session_id: sessionId, tab_id: tabId }, async () => ({}));
|
||||||
|
expect(cdp.send).toHaveBeenLastCalledWith(tabId, "Input.dispatchMouseEvent", {
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dispatcher.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("transfers hover overlay bypass ownership between sessions", async () => {
|
it("transfers hover overlay bypass ownership between sessions", async () => {
|
||||||
vi.stubGlobal("chrome", {
|
vi.stubGlobal("chrome", {
|
||||||
tabs: {
|
tabs: {
|
||||||
|
|||||||
@@ -257,11 +257,15 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
moves: [],
|
moves: [],
|
||||||
};
|
};
|
||||||
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
|
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
|
||||||
|
const cdp = {
|
||||||
|
detachSession: vi.fn(async () => {}),
|
||||||
|
releaseSessionTab: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
const res = await handleSessionStop(
|
const res = await handleSessionStop(
|
||||||
sm,
|
sm,
|
||||||
{ session_id: "aa11" },
|
{ session_id: "aa11" },
|
||||||
{ tabManagement: { tabs, windows } },
|
{ cdp, tabManagement: { tabs, windows } },
|
||||||
);
|
);
|
||||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||||
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
|
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
|
||||||
@@ -269,6 +273,8 @@ describe("handleSessionStop with auto-return", () => {
|
|||||||
expect(sm.has("aa11")).toBe(true);
|
expect(sm.has("aa11")).toBe(true);
|
||||||
expect(ctx.borrowedTabs.has(1)).toBe(true);
|
expect(ctx.borrowedTabs.has(1)).toBe(true);
|
||||||
expect(ctx.borrowedTabs.has(2)).toBe(false);
|
expect(ctx.borrowedTabs.has(2)).toBe(false);
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 2);
|
||||||
|
expect(cdp.detachSession).not.toHaveBeenCalled();
|
||||||
expect(aw.remove).not.toHaveBeenCalled();
|
expect(aw.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -547,6 +547,58 @@ describe("handleTabBorrow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleTabReturn", () => {
|
describe("handleTabReturn", () => {
|
||||||
|
it.each([
|
||||||
|
"original",
|
||||||
|
"fallback",
|
||||||
|
"cleanup_failed",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
])("releases CDP only after a successful return (%s)", async (mode) => {
|
||||||
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
|
const ctx = await sm.start("aa11");
|
||||||
|
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
|
||||||
|
const state: FakeTabState = {
|
||||||
|
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
|
||||||
|
nextTabId: 50,
|
||||||
|
windowsClosed: new Set(),
|
||||||
|
};
|
||||||
|
const { api, spies } = makeTabMutationApi(state);
|
||||||
|
const { api: windowsApi } = makeWindowsApi(state, { lastFocused: 500 });
|
||||||
|
const success = mode !== "failed" && mode !== "cancelled";
|
||||||
|
if (mode === "fallback") spies.move.mockRejectedValueOnce(new Error("original move failed"));
|
||||||
|
if (mode === "failed") spies.move.mockRejectedValue(new Error("move failed"));
|
||||||
|
let windowAtRelease: number | undefined;
|
||||||
|
const cdp = {
|
||||||
|
releaseSessionTab: vi.fn(async () => {
|
||||||
|
windowAtRelease = state.tabs.get(7)?.windowId;
|
||||||
|
if (mode === "cleanup_failed") throw new Error("debugger release failed");
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (mode === "cancelled") controller.abort();
|
||||||
|
|
||||||
|
const result = await handleTabReturn(
|
||||||
|
sm,
|
||||||
|
{ session_id: "aa11", tab_id: 7 },
|
||||||
|
{
|
||||||
|
tabs: api,
|
||||||
|
windows: windowsApi,
|
||||||
|
cdp,
|
||||||
|
signal: controller.signal,
|
||||||
|
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect("code" in result).toBe(!success);
|
||||||
|
expect(ctx.borrowedTabs.has(7)).toBe(!success);
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledTimes(success ? 1 : 0);
|
||||||
|
if (success) {
|
||||||
|
expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7);
|
||||||
|
expect(windowAtRelease).toBe(mode === "fallback" ? 500 : 200);
|
||||||
|
expect(spies.move).toHaveBeenCalledTimes(mode === "fallback" ? 2 : 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
|
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
|
||||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||||
const ctx = await sm.start("aa11");
|
const ctx = await sm.start("aa11");
|
||||||
|
|||||||
@@ -345,7 +345,15 @@ export class ToolDispatcher {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
case "tool.tab_return":
|
case "tool.tab_return":
|
||||||
return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal });
|
return this.withHoverReleaseForRequest(
|
||||||
|
req.params as TabReturnParams,
|
||||||
|
() =>
|
||||||
|
handleTabReturn(this.sessions, req.params as TabReturnParams, {
|
||||||
|
signal,
|
||||||
|
cdp: this.cdp,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
case "tool.window_resize":
|
case "tool.window_resize":
|
||||||
return handleWindowResize(
|
return handleWindowResize(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { type SessionManager, SessionStartCleanupError } from "@/session-manager
|
|||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { clearRecordingForSession } from "./record";
|
import { clearRecordingForSession } from "./record";
|
||||||
import type { ChromeTabsApi } from "./shared";
|
import type { CdpRunner, ChromeTabsApi } from "./shared";
|
||||||
import { isRpcError } from "./shared";
|
import { isRpcError } from "./shared";
|
||||||
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export interface SessionStopResult {
|
|||||||
|
|
||||||
export interface SessionStopDeps {
|
export interface SessionStopDeps {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
cdp?: {
|
cdp?: Pick<CdpRunner, "releaseSessionTab"> & {
|
||||||
detachSession(sessionId: string): Promise<void>;
|
detachSession(sessionId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -209,6 +209,7 @@ export async function handleSessionStop(
|
|||||||
try {
|
try {
|
||||||
const tabManagement = {
|
const tabManagement = {
|
||||||
...(deps.tabManagement ?? {}),
|
...(deps.tabManagement ?? {}),
|
||||||
|
cdp: deps.cdp ?? deps.tabManagement?.cdp,
|
||||||
signal: deps.signal,
|
signal: deps.signal,
|
||||||
isAgentWindowId:
|
isAgentWindowId:
|
||||||
deps.tabManagement?.isAgentWindowId ??
|
deps.tabManagement?.isAgentWindowId ??
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface CdpRunner {
|
|||||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||||
|
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
||||||
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||||
import type { RpcError } from "@/transport/types";
|
import type { RpcError } from "@/transport/types";
|
||||||
import { rpcError } from "./errors";
|
import { rpcError } from "./errors";
|
||||||
import { isRpcError, lookupSession } from "./shared";
|
import { type CdpRunner, isRpcError, lookupSession } from "./shared";
|
||||||
|
|
||||||
export type TabScope = "user" | "agent" | "all";
|
export type TabScope = "user" | "agent" | "all";
|
||||||
|
|
||||||
@@ -287,6 +287,8 @@ export interface TabManagementDeps {
|
|||||||
approveBorrow?: BorrowConfirmationApprover;
|
approveBorrow?: BorrowConfirmationApprover;
|
||||||
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
|
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
|
||||||
agentOverlayReset?: AgentOverlayResetApi;
|
agentOverlayReset?: AgentOverlayResetApi;
|
||||||
|
/** Releases this session's CDP claim after a borrowed tab is returned. */
|
||||||
|
cdp?: Pick<CdpRunner, "releaseSessionTab">;
|
||||||
/**
|
/**
|
||||||
* Reports whether `windowId` is any live session's Agent Window.
|
* Reports whether `windowId` is any live session's Agent Window.
|
||||||
* `tab_return`'s fallback window picker uses this to avoid moving a
|
* `tab_return`'s fallback window picker uses this to avoid moving a
|
||||||
@@ -945,16 +947,22 @@ async function cleanupUnusedFallbackWindow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAgentOverlaysInReturnedTab(
|
async function releaseReturnedTabState(
|
||||||
ctx: SessionContext,
|
ctx: SessionContext,
|
||||||
tabId: number,
|
tabId: number,
|
||||||
deps: TabManagementDeps,
|
deps: TabManagementDeps,
|
||||||
): void {
|
): Promise<void> {
|
||||||
void getAgentOverlayResetApi(deps)
|
void getAgentOverlayResetApi(deps)
|
||||||
.resetAgentOverlays(tabId, ctx.sessionId)
|
.resetAgentOverlays(tabId, ctx.sessionId)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.debug("[bsk tab_return] agent overlay reset failed", err);
|
console.debug("[bsk tab_return] agent overlay reset failed", err);
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await deps.cdp?.releaseSessionTab?.(ctx.sessionId, tabId);
|
||||||
|
} catch (err) {
|
||||||
|
// A successful move must not be retried because debugger cleanup failed.
|
||||||
|
console.debug("[bsk tab_return] CDP release failed", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1028,7 +1036,7 @@ export async function returnBorrowedTab(
|
|||||||
});
|
});
|
||||||
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
||||||
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
|
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
|
||||||
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
|
await releaseReturnedTabState(ctx, tabId, deps);
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
toWindowId: targetWindowId,
|
toWindowId: targetWindowId,
|
||||||
@@ -1070,7 +1078,7 @@ export async function returnBorrowedTab(
|
|||||||
});
|
});
|
||||||
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
const movedTab = Array.isArray(moved) ? moved[0] : moved;
|
||||||
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
|
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
|
||||||
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
|
await releaseReturnedTabState(ctx, tabId, deps);
|
||||||
return {
|
return {
|
||||||
tabId,
|
tabId,
|
||||||
toWindowId: target.windowId,
|
toWindowId: target.windowId,
|
||||||
|
|||||||
Reference in New Issue
Block a user