From 2284b1f4aabe19de7d3a2169c5609c493e26d382 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Fri, 30 Oct 2026 21:11:20 +0800 Subject: [PATCH 1/2] fix(extension): stop creating native task tab groups --- apps/extension/PRIVACY.md | 1 - apps/extension/PRIVACY.zh-CN.md | 1 - .../__tests__/background-overlays.test.ts | 16 +- apps/extension/src/entrypoints/background.ts | 8 +- .../__tests__/agent-window-tab-group.test.ts | 350 ------------------ .../__tests__/tab-detachment.browser.test.ts | 36 +- .../__tests__/window-routing.browser.test.ts | 21 +- .../session-manager/agent-window-tab-group.ts | 168 --------- apps/extension/wxt.config.ts | 1 - 9 files changed, 27 insertions(+), 575 deletions(-) delete mode 100644 apps/extension/src/session-manager/__tests__/agent-window-tab-group.test.ts delete mode 100644 apps/extension/src/session-manager/agent-window-tab-group.ts diff --git a/apps/extension/PRIVACY.md b/apps/extension/PRIVACY.md index 7331b8745..fc233c53a 100644 --- a/apps/extension/PRIVACY.md +++ b/apps/extension/PRIVACY.md @@ -59,7 +59,6 @@ The Extension requests the following Chrome permissions. Each is used solely for - **`scripting`** — Inject the full-page screenshot helper into the selected page when it is missing, such as after an extension reload. - **`webNavigation`** — Track page navigation and frames so captures, recordings, and human-help completion checks follow the correct document. - **`tabs`** — Inspect, create, and close tabs in the Agent Window; query tab metadata. -- **`tabGroups`** — Label controlled tabs in Agent Windows with an orange BrowserSkill group, and remove that group when the task ends. - **`windows`** — Create and manage the dedicated Agent Window that isolates agent activity from the user's normal browsing. - **`alarms`** — Periodically wake the service worker to keep the selected connection alive and renew remote device authorization. - **`idle`** — Detect when the device returns from idle/locked so the Extension can promptly re-establish the selected WebSocket connection after the machine wakes. No idle data is stored or transmitted. diff --git a/apps/extension/PRIVACY.zh-CN.md b/apps/extension/PRIVACY.zh-CN.md index c1a348bbf..8756532cd 100644 --- a/apps/extension/PRIVACY.zh-CN.md +++ b/apps/extension/PRIVACY.zh-CN.md @@ -59,7 +59,6 @@ BrowserSkill 让用户指定的 AI 助手通过 `bsk` 命令行工具操作 Chro - **`scripting`** —— 选定页面缺少长截图辅助脚本时补充注入,例如扩展重新加载后。 - **`webNavigation`** —— 跟踪页面导航和 frame,让截图、录制与人工协助完成检查对应到正确的文档。 - **`tabs`** —— 在代理窗口内查询、创建、关闭标签页;读取标签页元数据。 -- **`tabGroups`** —— 在代理窗口中以橙色 BrowserSkill 标签组标识受控标签页,并在任务结束时移除该分组。 - **`windows`** —— 创建并管理独立的代理窗口,将助手的活动与用户的日常浏览隔离开。 - **`alarms`** —— 周期性唤醒 service worker,保持所选 WebSocket 连接并续期远程授权。 - **`idle`** —— 检测设备从空闲或锁定状态恢复,以便及时重新建立所选 WebSocket 连接;不保存或传输空闲状态数据。 diff --git a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts index 908e6eb7b..61a0d74c3 100644 --- a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts +++ b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts @@ -4,7 +4,6 @@ import { OverlayController, shouldShowAgentControlOverlay } from "@/content/over import { watchDaemonConnection } from "@/lib/daemon-connection-preference"; import { OVERLAY_AGENT_STATE, type OverlayAgentStateMessage } from "@/lib/overlay-bridge"; import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag"; -import { AgentWindowTabGroups } from "@/session-manager/agent-window-tab-group"; import { isAgentControlledTab } from "@/session-manager/manager"; import { AgentWindowRouting } from "@/session-manager/window-routing"; import { ToolDispatcher } from "@/tools/dispatcher"; @@ -38,7 +37,6 @@ vi.mock("@/lib/recording/frame-coordinator"); vi.mock("@/lib/sessions-live-flag"); vi.mock("@/long-screenshot/background"); vi.mock("@/session-manager/event-handler"); -vi.mock("@/session-manager/agent-window-tab-group"); vi.mock("@/session-manager/window-routing"); vi.mock("@/session-manager/agent-window", () => ({ AGENT_WINDOW_HOME: "about:blank", @@ -132,7 +130,7 @@ async function fixture() { }; } -it("refreshes released-page controls and groups after attachment completes", async () => { +it("refreshes released-page controls after attachment completes", async () => { const f = await fixture(); f.overlay.applyAgentControlMode("one", "control"); f.deps.sessions.release("one"); @@ -143,18 +141,6 @@ it("refreshes released-page controls and groups after attachment completes", asy 10, expect.objectContaining({ sessionId: null, mode: "hidden" }), ); - const groups = vi.mocked(AgentWindowTabGroups).mock.instances.at(-1)!; - expect(groups.sync).toHaveBeenCalledOnce(); -}); - -it("refreshes native window identification after session changes and explicit tab claims", async () => { - const f = await fixture(); - const groups = vi.mocked(AgentWindowTabGroups).mock.instances.at(-1)!; - expect(AgentWindowTabGroups).toHaveBeenLastCalledWith(f.deps.sessions); - f.deps.onSessionsChanged?.(); - expect(groups.sync).toHaveBeenCalledTimes(1); - f.deps.onAgentTabClaimed?.(10, 10); - expect(groups.sync).toHaveBeenCalledTimes(2); }); it.each([false, true])("hides an observed tab after detachment, remote=%s", async (remote) => { diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 34424a3fb..8be7faabd 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -36,7 +36,6 @@ import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag"; import { captureTaskPreview, focusTask } from "@/lib/task-preview"; import { attachUiChannel } from "@/lib/ui-channel"; import { attachLongScreenshot } from "@/long-screenshot/background"; -import { AgentWindowTabGroups } from "@/session-manager/agent-window-tab-group"; import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup"; import { attachSessionEventHandler } from "@/session-manager/event-handler"; import { isAgentControlledTab, SessionManager } from "@/session-manager/manager"; @@ -92,7 +91,6 @@ export default defineBackground(() => { }, }); const sessions = new SessionManager({ remote: () => remoteEndpoint !== null }); - const windowTabGroups = new AgentWindowTabGroups(sessions); // Capture the original owner before cooperation releases a detached page. attachSessionEventHandler({ manager: sessions, @@ -333,7 +331,6 @@ export default defineBackground(() => { } function onOverlaySessionStateChanged(): void { - windowTabGroups.sync(); cooperation.syncSessions(); windowRouting.sync(); debug.sync(); @@ -374,10 +371,8 @@ export default defineBackground(() => { void pushOverlayStateForTab(tab.id, tab.windowId); }); chrome.tabs.onAttached.addListener((tabId, { newWindowId }) => { - // Reconcile again after Chrome finishes the move; editing a group or - // messaging a page can fail transiently while it is being dragged. + // Refresh controls after Chrome finishes moving the page. void pushOverlayStateForTab(tabId, newWindowId); - windowTabGroups.sync(); }); chrome.tabs.onDetached.addListener((tabId) => { void video.stopTab(tabId, "session_ended").catch(() => {}); @@ -460,7 +455,6 @@ export default defineBackground(() => { onSessionsChanged: onOverlaySessionStateChanged, onBrowserControlResumed, onAgentTabClaimed: (tabId, windowId) => { - windowTabGroups.sync(); void cooperation.observeTab(tabId).catch(() => {}); void pushOverlayStateForTab(tabId, windowId); }, diff --git a/apps/extension/src/session-manager/__tests__/agent-window-tab-group.test.ts b/apps/extension/src/session-manager/__tests__/agent-window-tab-group.test.ts deleted file mode 100644 index ef266c65a..000000000 --- a/apps/extension/src/session-manager/__tests__/agent-window-tab-group.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { AgentWindowTabGroups } from "../agent-window-tab-group"; -import { isAgentControlledTab, SessionManager } from "../manager"; - -function event() { - const listeners = new Set<(...args: T) => void>(); - return { - addListener: (listener: (...args: T) => void) => listeners.add(listener), - removeListener: (listener: (...args: T) => void) => listeners.delete(listener), - emit: (...args: T) => { - for (const listener of listeners) listener(...args); - }, - }; -} - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} - -const instances: AgentWindowTabGroups[] = []; -afterEach(() => { - for (const instance of instances.splice(0)) instance.dispose(); - vi.restoreAllMocks(); -}); - -async function fixture() { - let nextWindow = 10; - let nextGroup = 100; - const manager = new SessionManager({ - agentWindow: { - create: async () => ({ windowId: nextWindow++, initialTabIds: [] }), - ensureActiveTab: async (id) => id, - remove: vi.fn(async () => {}), - }, - }); - const task = await manager.start("one"); - const tabs = new Map(); - const groups = new Map(); - const addTab = (id: number, extra: Partial = {}) => { - const tab = { id, windowId: 10, groupId: -1, pinned: false, ...extra } as chrome.tabs.Tab; - tabs.set(id, tab); - return tab; - }; - addTab(10, { active: true }); - const updated = event<[number, chrome.tabs.TabChangeInfo, chrome.tabs.Tab]>(); - const attached = event<[number, chrome.tabs.TabAttachInfo]>(); - const detached = event<[number, chrome.tabs.TabDetachInfo]>(); - const removed = event<[number, chrome.tabs.TabRemoveInfo]>(); - const groupRemoved = event<[chrome.tabGroups.TabGroup]>(); - const groupMoved = event<[chrome.tabGroups.TabGroup]>(); - const api = { - tabs: { - onUpdated: updated, - onAttached: attached, - onDetached: detached, - onRemoved: removed, - get: vi.fn(async (id: number) => { - const tab = tabs.get(id); - if (!tab) throw new Error("Tab closed"); - return { ...tab }; - }), - query: vi.fn(async (query: chrome.tabs.QueryInfo) => - [...tabs.values()] - .filter( - (tab) => - (query.windowId === undefined || tab.windowId === query.windowId) && - (query.groupId === undefined || tab.groupId === query.groupId), - ) - .map((tab) => ({ ...tab })), - ), - group: vi.fn(async (options: chrome.tabs.GroupOptions) => { - const id = options.groupId ?? nextGroup++; - if (!groups.has(id)) - groups.set(id, { - id, - windowId: options.createProperties!.windowId!, - color: "grey", - collapsed: false, - }); - const group = groups.get(id)!; - for (const tabId of [options.tabIds!].flat()) { - const tab = tabs.get(tabId)!; - // Chrome can move tabs to the group's window: catch stale targets. - tab.windowId = group.windowId; - tab.groupId = id; - updated.emit(tabId, { groupId: id }, { ...tab }); - } - return id; - }), - ungroup: vi.fn(async (ids: number[]) => { - for (const id of ids) { - const tab = tabs.get(id)!; - const group = groups.get(tab.groupId); - tab.groupId = -1; - updated.emit(id, { groupId: -1 }, { ...tab }); - if (group && ![...tabs.values()].some((t) => t.groupId === group.id)) { - groups.delete(group.id); - groupRemoved.emit(group); - } - } - }), - }, - tabGroups: { - onRemoved: groupRemoved, - onMoved: groupMoved, - query: vi.fn(async () => [...groups.values()].map((group) => ({ ...group }))), - get: vi.fn(async (id: number) => { - const group = groups.get(id); - if (!group) throw new Error("Group removed"); - return { ...group }; - }), - update: vi.fn(async (id: number, properties: chrome.tabGroups.UpdateProperties) => { - const group = groups.get(id)!; - Object.assign(group, properties); - return { ...group }; - }), - }, - }; - const controller = new AgentWindowTabGroups(manager, api as unknown as typeof chrome); - instances.push(controller); - const sync = async () => { - controller.sync(); - // Drain the finite Chrome promise/event chain, including its queued refresh. - for (let i = 0; i < 50; i++) await Promise.resolve(); - }; - return { manager, task, tabs, groups, addTab, api, controller, sync, attached, detached }; -} - -describe("Agent Window identification", () => { - it("creates one orange, expanded BrowserSkill group without changing ownership or selection", async () => { - const f = await fixture(); - f.addTab(20); - f.task.agentCreatedTabs.add(20); - f.addTab(21); // A user tab in the same window remains free. - f.addTab(22, { windowId: 90 }); - await f.sync(); - await f.sync(); - expect([...f.groups.values()]).toEqual([ - { id: 100, windowId: 10, title: "BrowserSkill", color: "orange", collapsed: false }, - ]); - expect(f.api.tabs.group).toHaveBeenCalledTimes(1); - expect(f.api.tabs.group).toHaveBeenCalledWith({ - tabIds: [10, 20], - createProperties: { windowId: 10 }, - }); - expect(f.tabs.get(21)!.groupId).toBe(-1); - expect(f.tabs.get(22)!.groupId).toBe(-1); - expect(isAgentControlledTab(f.task, 21)).toBe(false); - expect(f.tabs.get(10)!.active).toBe(true); - }); - - it("adds created, borrowed and observed tabs after a claim, preserving a collapsed group", async () => { - const f = await fixture(); - await f.sync(); - f.groups.get(100)!.collapsed = true; - f.addTab(20); - f.addTab(21); - f.addTab(22); - f.attached.emit(21, { newWindowId: 10, newPosition: 1 }); - await f.sync(); - expect(f.tabs.get(21)!.groupId).toBe(-1); - f.task.agentCreatedTabs.add(20); - f.task.borrowedTabs.set(21, { tabId: 21, originalIndex: 0, originalWindowId: 90 }); - f.task.observedTabs = new Set([22]); - await f.sync(); - expect([20, 21, 22].map((id) => f.tabs.get(id)!.groupId)).toEqual([100, 100, 100]); - expect(f.groups.get(100)!.collapsed).toBe(true); - }); - - it("keeps pinned tabs and existing user groups, even if their name is BrowserSkill", async () => { - const f = await fixture(); - f.addTab(20, { pinned: true }); - f.addTab(21, { groupId: 200 }); - f.task.agentCreatedTabs.add(20).add(21); - f.groups.set(200, { - id: 200, - windowId: 10, - title: "BrowserSkill", - color: "blue", - collapsed: true, - }); - await f.sync(); - expect(f.tabs.get(20)!.groupId).toBe(-1); - expect(f.tabs.get(21)!.groupId).toBe(200); - expect(f.groups.get(200)!.color).toBe("blue"); - await f.manager.stop("one", { dropOnly: true }); - await f.sync(); - expect([...f.groups.keys()]).toEqual([200]); - expect(f.tabs.get(10)!.groupId).toBe(-1); - expect(f.tabs.size).toBe(3); - }); - - it("keeps sessions in separate groups", async () => { - const f = await fixture(); - await f.manager.start("two"); - f.addTab(11, { windowId: 11 }); - await f.sync(); - expect(f.groups.get(100)!.windowId).toBe(10); - expect(f.groups.get(101)!.windowId).toBe(11); - await f.manager.stop("one", { dropOnly: true }); - await f.sync(); - expect([...f.groups.keys()]).toEqual([101]); - }); - - it("does not pull a returned tab back into the task window", async () => { - const f = await fixture(); - await f.sync(); - f.addTab(20); - f.task.borrowedTabs.set(20, { tabId: 20, originalWindowId: 90, originalIndex: 0 }); - const get = f.api.tabs.get.getMockImplementation()!; - f.api.tabs.get.mockImplementationOnce(async (id) => { - f.tabs.get(id)!.windowId = 90; - f.task.borrowedTabs.delete(id); - return get(id); - }); - await f.sync(); - expect(f.tabs.get(20)!.windowId).toBe(90); - expect(f.api.tabs.group).toHaveBeenCalledTimes(1); - }); - - it("does not create a group if the task ends during a lookup", async () => { - const f = await fixture(); - const gate = deferred(); - f.api.tabs.query.mockReturnValueOnce(gate.promise); - f.controller.sync(); - await f.manager.stop("one", { dropOnly: true }); - gate.resolve([{ ...f.tabs.get(10)! }]); - await f.sync(); - expect(f.api.tabs.group).not.toHaveBeenCalled(); - }); - - it("cleans up a group whose creation completes after session stop", async () => { - const f = await fixture(); - const gate = deferred(); - const group = f.api.tabs.group.getMockImplementation()!; - f.api.tabs.group.mockImplementationOnce(async (options) => { - await gate.promise; - return group(options); - }); - await f.sync(); - expect(f.api.tabs.group).toHaveBeenCalledTimes(1); - await f.manager.stop("one", { dropOnly: true }); - f.controller.sync(); - gate.resolve(); - await f.sync(); - expect(f.groups.size).toBe(0); - expect(f.api.tabGroups.update).not.toHaveBeenCalled(); - }); - - it("removes the label from a group moved out of the task window and recreates it for remaining task tabs", async () => { - const f = await fixture(); - await f.sync(); - f.groups.get(100)!.windowId = 90; - f.tabs.get(10)!.windowId = 90; - f.addTab(20); - f.task.agentCreatedTabs.add(20); - await f.sync(); - expect(f.tabs.get(10)!.windowId).toBe(90); - expect(f.tabs.get(10)!.groupId).toBe(-1); - expect(f.groups.get(101)).toMatchObject({ - title: "BrowserSkill", - color: "orange", - windowId: 10, - }); - }); - - it("rechecks a group destination before adding newly claimed tabs", async () => { - const f = await fixture(); - await f.sync(); - f.addTab(20); - f.task.agentCreatedTabs.add(20); - const get = f.api.tabGroups.get.getMockImplementation()!; - f.api.tabGroups.get.mockImplementationOnce(async (id) => { - f.groups.get(id)!.windowId = 90; - f.tabs.get(10)!.windowId = 90; - return get(id); - }); - await f.sync(); - expect(f.tabs.get(20)!.windowId).toBe(10); - expect(f.tabs.get(20)!.groupId).toBe(101); - }); - - it("recovers from group deletion without reusing the old group id", async () => { - const f = await fixture(); - await f.sync(); - await f.api.tabs.ungroup([10]); - await f.sync(); - expect(f.tabs.get(10)!.groupId).toBe(101); - expect([...f.groups.values()]).toHaveLength(1); - }); - - it("isolates cosmetic API failures and retries on a later sync", async () => { - const f = await fixture(); - vi.spyOn(console, "debug").mockImplementation(() => {}); - f.api.tabs.group.mockRejectedValueOnce(new Error("Tabs cannot be edited right now")); - await f.sync(); - expect(f.manager.get("one")).toBe(f.task); - expect(f.task.agentCreatedTabs.has(10)).toBe(true); - f.api.tabs.onUpdated.emit(10, { status: "complete" }, f.tabs.get(10)!); - await f.sync(); - expect(f.groups.get(100)!.title).toBe("BrowserSkill"); - f.api.tabGroups.query.mockRejectedValueOnce(new Error("Temporary failure")); - await f.sync(); - expect(f.groups.size).toBe(1); - expect(f.api.tabs.group).toHaveBeenCalledTimes(2); - }); - - it("does not recreate groups while Chrome is closing the window", async () => { - const f = await fixture(); - await f.manager.withExpectedWindowClose(f.task, () => f.sync()); - expect(f.api.tabs.group).not.toHaveBeenCalled(); - }); - - it("does not ungroup a page the user moved to another group during cleanup", async () => { - const f = await fixture(); - await f.sync(); - await f.manager.stop("one", { dropOnly: true }); - const get = f.api.tabs.get.getMockImplementation()!; - f.api.tabs.get.mockImplementationOnce(async (id) => { - f.tabs.get(id)!.groupId = 200; - f.groups.delete(100); - f.groups.set(200, { - id: 200, - windowId: 10, - title: "User group", - color: "blue", - collapsed: false, - }); - return get(id); - }); - await f.sync(); - expect(f.api.tabs.ungroup).not.toHaveBeenCalled(); - expect(f.tabs.get(10)!.groupId).toBe(200); - }); - - it("stops pending lookups and listeners when disposed", async () => { - const f = await fixture(); - const gate = deferred(); - f.api.tabs.query.mockReturnValueOnce(gate.promise); - f.controller.sync(); - f.controller.dispose(); - gate.resolve([{ ...f.tabs.get(10)! }]); - await f.sync(); - expect(f.api.tabs.group).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/extension/src/session-manager/__tests__/tab-detachment.browser.test.ts b/apps/extension/src/session-manager/__tests__/tab-detachment.browser.test.ts index 0661a87ae..f5bd6ba71 100644 --- a/apps/extension/src/session-manager/__tests__/tab-detachment.browser.test.ts +++ b/apps/extension/src/session-manager/__tests__/tab-detachment.browser.test.ts @@ -29,8 +29,8 @@ chrome.runtime.onMessage.addListener((message, _sender, reply) => { }); `; -// Use the production lifecycle listener, ownership, tab tools, CDP driver and -// native group reconciler. Only the daemon transport is replaced by a recorder. +// Use the production lifecycle listener, ownership, tab tools and CDP driver. +// Only the daemon transport is replaced by a recorder. // A direct Chrome move deliberately bypasses the tool's expected-move registry, // producing the same browser events as a user dragging a tab between windows. const entry = ` @@ -38,7 +38,6 @@ import { ChromiumCdp } from '@/browser-driver/chromium-cdp'; import { attachCooperation } from '@/cooperation/browser-bridge'; import { nextOverlayVersion } from '@/lib/overlay-version'; import { OVERLAY_AGENT_STATE } from '@/lib/overlay-bridge'; -import { AgentWindowTabGroups } from '@/session-manager/agent-window-tab-group'; import { attachSessionEventHandler } from '@/session-manager/event-handler'; import { SessionManager } from '@/session-manager/manager'; import { handleSessionStop } from '@/tools/session'; @@ -57,12 +56,12 @@ globalThis.run = async url => { await chrome.windows.create({tabId: controller.id, type: 'popup', focused: false}); const user = (await chrome.windows.getAll({windowTypes: ['normal']}))[0]; const manager = new SessionManager(), cdp = new ChromiumCdp(chrome.debugger); - const groups = new AgentWindowTabGroups(manager), events = [], completed = []; + const events = [], completed = []; let cooperation; const lifecycle = attachSessionEventHandler({ manager, cdp, transport: {send: event => events.push(event)}, onSessionReleased: task => cooperation.release(task.sessionId), - onSessionsChanged: () => { cooperation.syncSessions(); groups.sync(); }, + onSessionsChanged: () => cooperation.syncSessions(), }); cooperation = attachCooperation(manager, () => {}); const readOverlay = id => chrome.tabs.sendMessage(id, {type: 'fixture.overlay'}); @@ -92,7 +91,6 @@ globalThis.run = async url => { check(!result.code, JSON.stringify(result)); await active(task, result.tab_id); } - groups.sync(); return task; }; const borrow = async task => { @@ -103,14 +101,12 @@ globalThis.run = async url => { check(!result.code, JSON.stringify(result)); check(manager.has(task.sessionId), 'borrowing ended the task'); await active(task, tab.id); - groups.sync(); return tab.id; }; const observe = async task => { const tab = await chrome.tabs.create({windowId: task.agentWindowId, url: url + '/observed-' + task.sessionId, active: false}); (task.observedTabs ??= new Set()).add(tab.id); await active(task, tab.id); - groups.sync(); return tab.id; }; const claimed = task => [...task.agentCreatedTabs, ...task.borrowedTabs.keys(), ...(task.observedTabs ?? [])]; @@ -122,9 +118,9 @@ globalThis.run = async url => { await wait(() => !manager.has(task.sessionId), 'task release'); await wait(() => sessionEvents(task).length > 0, 'terminal event'); await wait(async () => (await Promise.all(before.map(async ({id}) => { - const tab = await chrome.tabs.get(id), overlay = await readOverlay(id); - return tab.groupId === -1 && overlay.controlMode === 'hidden' && !overlay.controlVisible; - }))).every(Boolean), 'labels and overlays released'); + const overlay = await readOverlay(id); + return overlay.controlMode === 'hidden' && !overlay.controlVisible; + }))).every(Boolean), 'overlays released'); await wait(async () => { const targets = await chrome.debugger.getTargets(); return before.every(({id}) => !targets.find(target => target.tabId === id)?.attached); @@ -170,13 +166,18 @@ globalThis.run = async url => { const grouped = await start('whole-group'); const groupedTabs = await snapshot(grouped); - await wait(async () => { - const tabs = await Promise.all(groupedTabs.map(({id}) => chrome.tabs.get(id))); - return tabs.every(tab => tab.groupId !== -1 && tab.groupId === tabs[0].groupId); - }, 'native group ready'); - const groupId = (await chrome.tabs.get(groupedTabs[0].id)).groupId; + // Simulate a user-created group; BrowserSkill must release control without + // dismantling the user's grouping when the whole group is dragged out. + const groupId = await chrome.tabs.group({ + tabIds: groupedTabs.map(tab => tab.id), createProperties: {windowId: grouped.agentWindowId}, + }); + await chrome.tabGroups.update(groupId, {title: 'User group', color: 'blue'}); await chrome.tabGroups.move(groupId, {windowId: user.id, index: -1}); await assertReleased(grouped, groupedTabs, groupedTabs.map(tab => tab.id)); + const movedGroup = (await chrome.tabs.get(groupedTabs[0].id)).groupId; + check(movedGroup !== -1, 'user group removed'); + for (const {id} of groupedTabs) check((await chrome.tabs.get(id)).groupId === movedGroup, 'user group split'); + check((await chrome.tabGroups.get(movedGroup)).title === 'User group', 'user group renamed'); check(!(await chrome.windows.get(grouped.agentWindowId).catch(() => null)), 'empty source window remained'); const last = await start('last-tab', 0), lastTabs = await snapshot(last); @@ -220,7 +221,7 @@ globalThis.run = async url => { completed.push(stopping.sessionId); return {completed, terminalEvents: events}; } finally { - lifecycle.dispose(); groups.dispose(); await cdp.detachAll(); cdp.dispose(); + lifecycle.dispose(); await cdp.detachAll(); cdp.dispose(); } }; `; @@ -275,6 +276,7 @@ describe.skipIf(!process.env.BSK_TAB_DETACH_CHROME)("tab detachment browser regr name: "Tab detachment regression", version: "1.0", background: { service_worker: "worker.js" }, + // Only the fixture uses tabGroups to simulate the user's group drag. permissions: ["debugger", "tabs", "tabGroups", "storage", "webNavigation"], host_permissions: ["http://127.0.0.1/*"], content_scripts: [{ matches: ["http://127.0.0.1/*"], js: ["content.bundle.js"] }], diff --git a/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts b/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts index f6a0b1c6e..de83fdb43 100644 --- a/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts +++ b/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts @@ -33,7 +33,6 @@ document.querySelector('#run').onclick=()=>requestAnimationFrame(()=>{ const entry = ` import { ChromiumCdp } from '@/browser-driver/chromium-cdp'; import { attachCooperation } from '@/cooperation/browser-bridge'; -import { AgentWindowTabGroups } from '@/session-manager/agent-window-tab-group'; import { isAgentControlledTab, SessionManager } from '@/session-manager/manager'; import { AgentWindowRouting } from '@/session-manager/window-routing'; import { withTaskPopups } from '@/session-manager/task-popups'; @@ -52,7 +51,6 @@ globalThis.run=async(url,soak)=>{ await chrome.windows.create({tabId:controller.id,type:'popup',focused:false}); const manager=new SessionManager(),cdp=new ChromiumCdp(chrome.debugger); const cooperation=attachCooperation(manager,()=>{}); - const groups=new AgentWindowTabGroups(manager); const user=(await chrome.windows.getAll({windowTypes:['normal']}))[0]; const [control]=await chrome.tabs.query({windowId:user.id,active:true}); await chrome.tabs.update(control.id,{url:url+'/control'}); @@ -69,20 +67,13 @@ globalThis.run=async(url,soak)=>{ cooperation.startSession(task.sessionId); check(cooperation.hasSession(task.sessionId),'default collaboration missing'); router.sync(); - groups.sync(); const evaluate=async(id,expression)=>(await cdp.send(id,'Runtime.evaluate',{ expression,returnByValue:true,awaitPromise:true })).result.value; const created=await handleTabCreate(manager,{session_id:task.sessionId,url:url+'/agent'},{cdp}); check(!created.code,JSON.stringify(created)); const id=created.tab_id; - groups.sync(); - await wait(async()=>(await chrome.tabs.get(id)).groupId!==-1,'task tab grouped'); - const groupId=(await chrome.tabs.get(id)).groupId; - await wait(async()=>{ - const group=await chrome.tabGroups.get(groupId); - return group.title==='BrowserSkill'&&group.color==='orange'; - },'orange BrowserSkill label'); + check((await chrome.tabs.get(id)).groupId===-1,'task tab grouped automatically'); const params={session_id:task.sessionId,tab_id:id}; // Direct navigation handlers need the same opt-in supplied by ToolDispatcher. const deps={cdp,tabsApi:chrome.tabs,backgroundExecution:true}; @@ -186,8 +177,8 @@ globalThis.run=async(url,soak)=>{ check(await evaluate(external.id,'clicks')===0,'default click changed the external page'); } finally {finishCreation();} await wait(async()=>(await chrome.tabs.get(external.id)).windowId===user.id,'background external routing'); - check((await chrome.tabs.get(external.id)).groupId===-1,'external tab retained task label'); - check((await chrome.tabs.get(id)).groupId===groupId,'routing changed the task group'); + check((await chrome.tabs.get(external.id)).groupId===-1,'external tab grouped automatically'); + check((await chrome.tabs.get(id)).groupId===-1,'routing grouped the task tab'); check((await chrome.tabs.get(control.id)).active,'routing changed user selection'); check(!(await chrome.windows.get(task.agentWindowId)).focused,'routing stole focus'); // Let the host open a URL through Chrome's process singleton, as external @@ -259,12 +250,12 @@ globalThis.run=async(url,soak)=>{ } return {states,backgroundFocusEvents,nativeExternal:true,replacementWindow:nativeFallback.windowId, replacementFocused,closedSourcePopup:true,humanAssistancePopupChain:true,defaultTargetProtected:true, - defaultCooperation:true,orangeTaskGroup:true}; + defaultCooperation:true}; } catch(error) { throw new Error(JSON.stringify({error:error.message,phase:globalThis.phase,id,idleId,control:control.id,states,focused,removed, tabs:(await chrome.tabs.query({})).map(({id,windowId,url,frozen,discarded,openerTabId})=>({id,windowId,url,frozen,discarded,openerTabId})), sources:[...router.pageSourceWindows]})); - } finally {router.dispose();groups.dispose();await cdp.detachAll();cdp.dispose();} + } finally {router.dispose();await cdp.detachAll();cdp.dispose();} };`; describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)("Agent Window browser regression", () => { @@ -315,7 +306,7 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)("Agent Window browser regres name: "Agent Window regression", version: "1.0", background: { service_worker: "worker.js" }, - permissions: ["debugger", "tabs", "tabGroups", "scripting", "webNavigation", "storage"], + permissions: ["debugger", "tabs", "scripting", "webNavigation", "storage"], host_permissions: ["http://127.0.0.1/*"], }), ); diff --git a/apps/extension/src/session-manager/agent-window-tab-group.ts b/apps/extension/src/session-manager/agent-window-tab-group.ts deleted file mode 100644 index 026d3ea30..000000000 --- a/apps/extension/src/session-manager/agent-window-tab-group.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { isAgentControlledTab, type SessionContext, type SessionManager } from "./manager"; - -type TabGroupApi = Pick; - -/** Native window identification only; group membership never grants control. */ -export class AgentWindowTabGroups { - private readonly groups = new Map(); - private readonly pending = new Set(); - private running = false; - private disposed = false; - - constructor( - private readonly manager: SessionManager, - private readonly api: TabGroupApi = chrome, - ) { - api.tabs.onUpdated.addListener(this.updated); - api.tabs.onAttached.addListener(this.attached); - api.tabs.onDetached.addListener(this.detached); - api.tabs.onRemoved.addListener(this.removed); - api.tabGroups.onRemoved.addListener(this.groupsChanged); - api.tabGroups.onMoved.addListener(this.groupsChanged); - } - - /** Called after session changes and after a concrete tab has been claimed. */ - sync(): void { - if (this.disposed) return; - for (const task of this.manager.list()) this.pending.add(task.agentWindowId); - for (const windowId of this.groups.keys()) this.pending.add(windowId); - void this.flush(); - } - - dispose(): void { - this.disposed = true; - this.pending.clear(); - const { tabs, tabGroups } = this.api; - tabs.onUpdated.removeListener(this.updated); - tabs.onAttached.removeListener(this.attached); - tabs.onDetached.removeListener(this.detached); - tabs.onRemoved.removeListener(this.removed); - tabGroups.onRemoved.removeListener(this.groupsChanged); - tabGroups.onMoved.removeListener(this.groupsChanged); - } - - private groupsChanged = () => this.sync(); - private updated = (_id: number, change: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => { - if (change.groupId !== undefined || change.pinned !== undefined || change.status === "complete") - this.schedule(tab.windowId); - }; - private attached = (_id: number, info: chrome.tabs.TabAttachInfo) => { - this.schedule(info.newWindowId); - }; - private detached = (_id: number, info: chrome.tabs.TabDetachInfo) => { - this.schedule(info.oldWindowId); - }; - private removed = (_id: number, info: chrome.tabs.TabRemoveInfo) => { - this.schedule(info.windowId); - }; - - private schedule(windowId: number): void { - if (!this.manager.findByWindowId(windowId) && !this.groups.has(windowId)) return; - this.pending.add(windowId); - void this.flush(); - } - - private async flush(): Promise { - if (this.running || this.disposed) return; - this.running = true; - try { - // Serialize and coalesce browser events, including those raised by grouping. - // A cosmetic failure must never fail or delay an agent tool request. - for (const windowId of this.pending) { - this.pending.delete(windowId); - try { - await this.reconcile(windowId); - } catch (error) { - console.debug("[bsk] Agent Window tab group update failed", error); - } - if (this.disposed) break; - } - } finally { - this.running = false; - } - } - - private live(task: SessionContext): boolean { - return ( - !this.disposed && - this.manager.get(task.sessionId) === task && - !this.manager.isWindowCloseExpected(task) - ); - } - - private async reconcile(windowId: number): Promise { - const task = this.manager.findByWindowId(windowId); - let groupId = this.groups.get(windowId); - // Use a successful query to distinguish a deleted group from an API failure. - // Never adopt a user-created group merely because its name matches ours. - let group = - groupId === undefined - ? undefined - : (await this.api.tabGroups.query({})).find((candidate) => candidate.id === groupId); - if (this.disposed) return; - if (!group) { - this.groups.delete(windowId); - groupId = undefined; - } else if (!task || group.windowId !== windowId) { - const members = await this.api.tabs.query({ groupId }); - const current = await this.currentTabs(members); - if (this.disposed) return; - const ids = current.flatMap((tab) => (tab && tab.groupId === groupId ? [tab.id!] : [])); - if (ids.length) await this.api.tabs.ungroup(ids); - this.groups.delete(windowId); - groupId = undefined; - group = undefined; - } - if (!task || !this.live(task)) return; - - const tabs = await this.api.tabs.query({ windowId }); - if (!this.live(task)) return; - // Leave pinned tabs and the user's own groups intact. A tab entering this - // window is not necessarily controlled; wait for the existing claim hook. - const eligible = (tab: chrome.tabs.Tab): boolean => - tab.id !== undefined && - tab.windowId === windowId && - !tab.pinned && - tab.groupId === -1 && - isAgentControlledTab(task, tab.id); - const candidates = tabs.filter(eligible); - if (groupId !== undefined && candidates.length) { - const target = await this.api.tabGroups.get(groupId); - if (!this.live(task)) return; - if (target.windowId !== windowId) { - this.pending.add(windowId); - return; - } - } - const current = await this.currentTabs(candidates); - if (!this.live(task)) return; - const ids = current.flatMap((tab) => (tab && eligible(tab) ? [tab.id!] : [])); - if (ids.length) { - groupId = await this.api.tabs.group({ - tabIds: ids, - ...(groupId === undefined ? { createProperties: { windowId } } : { groupId }), - }); - this.groups.set(windowId, groupId); - if (!this.live(task)) { - this.pending.add(windowId); - return; - } - } - if (groupId !== undefined && (group?.title !== "BrowserSkill" || group.color !== "orange")) { - // Do not continually expand the group after the user has collapsed it. - await this.api.tabGroups.update(groupId, { - title: "BrowserSkill", - color: "orange", - ...(!group ? { collapsed: false } : {}), - }); - } - } - - private currentTabs(tabs: chrome.tabs.Tab[]): Promise<(chrome.tabs.Tab | undefined)[]> { - return Promise.all( - tabs.flatMap((tab) => - tab.id === undefined ? [] : [this.api.tabs.get(tab.id).catch(() => undefined)], - ), - ); - } -} diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 73b6f4ca0..683d93293 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -48,7 +48,6 @@ export default defineConfig({ "offscreen", "scripting", "tabs", - "tabGroups", "storage", "webNavigation", "windows", From 761aed144c3aa322e6858ddd6f379400afe07e8c Mon Sep 17 00:00:00 2001 From: drakezhang Date: Sat, 31 Oct 2026 12:31:52 +0800 Subject: [PATCH 2/2] test(extension): guard ungrouped task tabs and document removal --- CHANGELOG.md | 7 +++ .../__tests__/background-overlays.test.ts | 51 +++++++++++++++++++ .../__tests__/window-routing.browser.test.ts | 6 +-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10e930fb2..5cf947393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number. - CLI cancellation is armed before sending a browser command, so an immediate Ctrl-C can cancel the operation and return its outcome instead of terminating silently. +### Removed + +- Automatic native Agent Window tab groups and the `tabGroups` permission, to + prevent new `BrowserSkill` groups from accumulating across browsers through + Chrome sync. Existing saved groups are not deleted; page control indicators, + background window behavior and user-tab routing remain available. + ## [0.4.0] - 2026-10-30 ### Changed diff --git a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts index 61a0d74c3..87d0ba768 100644 --- a/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts +++ b/apps/extension/src/entrypoints/__tests__/background-overlays.test.ts @@ -9,6 +9,7 @@ import { AgentWindowRouting } from "@/session-manager/window-routing"; import { ToolDispatcher } from "@/tools/dispatcher"; import { hasActiveHelp } from "@/tools/human-loop"; import { hasActiveRecording } from "@/tools/record"; +import { handleTabCreate } from "@/tools/tabs"; import { VideoManager } from "@/video/manager"; vi.hoisted(() => { @@ -91,6 +92,8 @@ async function fixture() { sendMessage, get: vi.fn(async () => ({ url: "https://example.test" })), query: vi.fn(async () => []), + group: vi.fn(async () => 100), + ungroup: vi.fn(async () => {}), onDetached, onActivated: event(), onUpdated: event(), @@ -98,6 +101,13 @@ async function fixture() { onAttached, onRemoved: event(), }, + tabGroups: { + query: vi.fn(async () => []), + get: vi.fn(async () => ({ id: 100, windowId: 10 })), + update: vi.fn(async () => {}), + onRemoved: event(), + onMoved: event(), + }, debugger: { onDetach: event() }, webNavigation: { onBeforeNavigate: event(), @@ -130,6 +140,47 @@ async function fixture() { }; } +it("keeps task tabs ungrouped while showing controls after creation and attachment", async () => { + vi.useFakeTimers(); + const f = await fixture(); + const home = { id: 10, windowId: 10, groupId: -1, pinned: false } as chrome.tabs.Tab; + const created = { ...home, id: 20, url: "https://example.test" }; + const tabs = [home]; + Object.assign(chrome.tabs, { + query: vi.fn(async () => tabs), + get: vi.fn(async (id: number) => tabs.find((tab) => tab.id === id)), + create: vi.fn(async () => { + tabs.push(created); + f.onCreated.emit(created); + return created; + }), + }); + + f.deps.onSessionsChanged?.(); + const result = await handleTabCreate(f.deps.sessions, { + session_id: f.task.sessionId, + url: created.url, + }); + expect(result).toMatchObject({ tab_id: created.id, window_id: home.windowId }); + if ("code" in result) throw new Error(result.message); + // Use the real background callbacks invoked by the dispatcher and browser. + f.deps.onAgentTabClaimed?.(result.tab_id, result.window_id); + f.onAttached.emit(created.id, { newWindowId: home.windowId, newPosition: 1 }); + await vi.advanceTimersByTimeAsync(0); + + expect(isAgentControlledTab(f.task, created.id)).toBe(true); + for (const tab of tabs) { + expect(tab.groupId).toBe(-1); + expect(f.sendMessage).toHaveBeenCalledWith( + tab.id, + expect.objectContaining({ sessionId: f.task.sessionId, mode: "control" }), + ); + } + expect(chrome.tabs.group).not.toHaveBeenCalled(); + expect(chrome.tabs.ungroup).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); +}); + it("refreshes released-page controls after attachment completes", async () => { const f = await fixture(); f.overlay.applyAgentControlMode("one", "control"); diff --git a/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts b/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts index de83fdb43..0deb5c45b 100644 --- a/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts +++ b/apps/extension/src/session-manager/__tests__/window-routing.browser.test.ts @@ -73,7 +73,7 @@ globalThis.run=async(url,soak)=>{ const created=await handleTabCreate(manager,{session_id:task.sessionId,url:url+'/agent'},{cdp}); check(!created.code,JSON.stringify(created)); const id=created.tab_id; - check((await chrome.tabs.get(id)).groupId===-1,'task tab grouped automatically'); + check((await chrome.tabs.get(id)).groupId===-1,'expected task tab to remain ungrouped after creation'); const params={session_id:task.sessionId,tab_id:id}; // Direct navigation handlers need the same opt-in supplied by ToolDispatcher. const deps={cdp,tabsApi:chrome.tabs,backgroundExecution:true}; @@ -177,8 +177,8 @@ globalThis.run=async(url,soak)=>{ check(await evaluate(external.id,'clicks')===0,'default click changed the external page'); } finally {finishCreation();} await wait(async()=>(await chrome.tabs.get(external.id)).windowId===user.id,'background external routing'); - check((await chrome.tabs.get(external.id)).groupId===-1,'external tab grouped automatically'); - check((await chrome.tabs.get(id)).groupId===-1,'routing grouped the task tab'); + check((await chrome.tabs.get(external.id)).groupId===-1,'expected external tab to remain ungrouped after routing'); + check((await chrome.tabs.get(id)).groupId===-1,'expected task tab to remain ungrouped after routing'); check((await chrome.tabs.get(control.id)).active,'routing changed user selection'); check(!(await chrome.windows.get(task.agentWindowId)).focused,'routing stole focus'); // Let the host open a URL through Chrome's process singleton, as external