mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(extension): claim agent tabs explicitly
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
|
||||
import { SessionManager } from "../manager";
|
||||
import { isAgentControlledTab, SessionManager } from "../manager";
|
||||
|
||||
function fakeAgentWindow(): AgentWindowApi & {
|
||||
createMock: ReturnType<typeof vi.fn>;
|
||||
@@ -151,50 +151,29 @@ describe("SessionManager", () => {
|
||||
expect(sm.list()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("classifyNewTab (user vs agent tab freedom)", () => {
|
||||
it("classifies the home tab as agent", async () => {
|
||||
describe("explicit tab claims", () => {
|
||||
it("claims the exact home tab returned by AgentWindowApi", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
// The home tab is agent-owned and matched by home tab id. The prior
|
||||
// windowInitializing flag was cleared before the home tab's onCreated
|
||||
// arrived, so matching it by boot timing was never reliable.
|
||||
const homeTabId = ctx.homeTabId as number;
|
||||
const kind = sm.classifyNewTab(homeTabId, ctx.agentWindowId);
|
||||
expect(kind).toBe("agent");
|
||||
expect(ctx.agentCreatedTabs.has(homeTabId)).toBe(true);
|
||||
expect(ctx.userTabs.has(homeTabId)).toBe(false);
|
||||
expect(ctx.agentCreatedTabs).toEqual(new Set([0]));
|
||||
expect(isAgentControlledTab(ctx, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies a pending tab_create tab as agent", async () => {
|
||||
it("keeps every other tab free without classification state", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
sm.markAgentTabPending(ctx.agentWindowId);
|
||||
const kind = sm.classifyNewTab(11, ctx.agentWindowId);
|
||||
expect(kind).toBe("agent");
|
||||
expect(ctx.agentCreatedTabs.has(11)).toBe(true);
|
||||
expect(ctx.userTabs.has(11)).toBe(false);
|
||||
expect(ctx.pendingAgentTabCount).toBe(0);
|
||||
expect(isAgentControlledTab(ctx, 99)).toBe(false);
|
||||
expect(ctx).not.toHaveProperty("userTabs");
|
||||
expect(ctx).not.toHaveProperty("pendingAgentTabCount");
|
||||
});
|
||||
|
||||
it("classifies a user-opened tab (no pending) as user and keeps it free", async () => {
|
||||
it("treats a committed borrow as an explicit claim", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
const kind = sm.classifyNewTab(99, ctx.agentWindowId);
|
||||
expect(kind).toBe("user");
|
||||
expect(ctx.userTabs.has(99)).toBe(true);
|
||||
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches multiple pending agent tabs to multiple onCreated events", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
|
||||
const ctx = await sm.start("aa11");
|
||||
sm.markAgentTabPending(ctx.agentWindowId);
|
||||
sm.markAgentTabPending(ctx.agentWindowId);
|
||||
expect(sm.classifyNewTab(11, ctx.agentWindowId)).toBe("agent");
|
||||
// Second agent tab still pending → agent; not mistaken for user.
|
||||
expect(sm.classifyNewTab(12, ctx.agentWindowId)).toBe("agent");
|
||||
// No more pending → a later user tab is user.
|
||||
expect(sm.classifyNewTab(99, ctx.agentWindowId)).toBe("user");
|
||||
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
|
||||
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
|
||||
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 3 });
|
||||
expect(isAgentControlledTab(ctx, 42)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,40 +7,19 @@ export interface SessionContext {
|
||||
refStore: RefStore;
|
||||
borrowedTabs: Map<number, BorrowedTab>;
|
||||
/**
|
||||
* Tabs created by the agent via `tool.tab_create` in this session's
|
||||
* Agent Window. Tracked so `session_stop` can close them before
|
||||
* releasing the window (design §3.1). User-created tabs (via Chrome UI)
|
||||
* never enter this set.
|
||||
* Tabs explicitly claimed by the agent because it created them. This
|
||||
* includes the Agent Window's home tab and tabs created by `tool.tab_create`.
|
||||
* Tabs opened through Chrome UI never enter this set.
|
||||
*/
|
||||
agentCreatedTabs: Set<number>;
|
||||
/**
|
||||
* Tabs the user opened themselves inside the Agent Window via Chrome UI
|
||||
* (new-tab button, Cmd+T, right-click → open in new tab, …). These must
|
||||
* NOT be controlled by the agent overlay and must be left free for the
|
||||
* user to operate. Distinguishing them from agent-created tabs is done
|
||||
* by the `chrome.tabs.onCreated` listener in background.ts, which consults
|
||||
* `pendingAgentTabCount` to tell "opened via tool.tab_create" apart from
|
||||
* "opened by the user".
|
||||
*/
|
||||
userTabs: Set<number>;
|
||||
/**
|
||||
* Number of agent tabs currently being created via `tool.tab_create` but
|
||||
* whose `onCreated` event has not yet been observed. `handleTabCreate`
|
||||
* increments this *before* calling `chrome.tabs.create`; the
|
||||
* `onCreated` listener decrements it once per new tab in the Agent Window.
|
||||
* This lets us tell agent-created tabs from user-created tabs even though
|
||||
* both fire `onCreated` (design §3.1, user-tab freedom fix).
|
||||
*/
|
||||
pendingAgentTabCount: number;
|
||||
/**
|
||||
* Id of the home tab created/activated when the session started
|
||||
* (`ensureActiveTab`). Used to clean up the home tab precisely on
|
||||
* stop instead of matching by URL (design §3.2).
|
||||
*/
|
||||
homeTabId: number | null;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
/** Whether this session has explicitly claimed control of `tabId`. */
|
||||
export function isAgentControlledTab(ctx: SessionContext, tabId: number): boolean {
|
||||
return ctx.agentCreatedTabs.has(tabId) || ctx.borrowedTabs.has(tabId);
|
||||
}
|
||||
|
||||
export interface BorrowedTab {
|
||||
tabId: number;
|
||||
originalWindowId: number;
|
||||
@@ -219,10 +198,10 @@ export class SessionManager {
|
||||
agentWindowId: windowId,
|
||||
refStore: new RefStore(),
|
||||
borrowedTabs: new Map(),
|
||||
agentCreatedTabs: new Set(),
|
||||
userTabs: new Set(),
|
||||
pendingAgentTabCount: 0,
|
||||
homeTabId,
|
||||
// The home tab is the session's first explicit claim. Every other
|
||||
// tab remains free until `tab_create` or `tab_borrow` identifies it
|
||||
// by its concrete Chrome tab id.
|
||||
agentCreatedTabs: new Set([homeTabId]),
|
||||
createdAtMs: this.now(),
|
||||
};
|
||||
this.sessions.set(sessionId, ctx);
|
||||
@@ -240,50 +219,6 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a `tool.tab_create` is about to open a tab. Called *before*
|
||||
* `chrome.tabs.create` so the pending count is visible to the
|
||||
* `onCreated` listener that will fire for the new tab.
|
||||
*/
|
||||
markAgentTabPending(windowId: number): void {
|
||||
const ctx = this.findByWindowId(windowId);
|
||||
if (!ctx) return;
|
||||
ctx.pendingAgentTabCount += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a pending `tool.tab_create` slot when the create itself failed
|
||||
* and no `chrome.tabs.onCreated` event will arrive to consume it. Without
|
||||
* this the counter leaks and the *next* tab the user opens is misclassified
|
||||
* as agent-created — the exact confusion this counter exists to prevent.
|
||||
*/
|
||||
releaseAgentTabPending(windowId: number): void {
|
||||
const ctx = this.findByWindowId(windowId);
|
||||
if (!ctx || ctx.pendingAgentTabCount === 0) return;
|
||||
ctx.pendingAgentTabCount -= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the `onCreated` listener for each new tab in an Agent Window.
|
||||
* Returns `"agent"` when the tab is the window's home tab or corresponds to
|
||||
* a pending `tool.tab_create` (and registers it), `"user"` when the user
|
||||
* opened it via Chrome UI.
|
||||
*/
|
||||
classifyNewTab(tabId: number, windowId: number): "agent" | "user" | "unknown" {
|
||||
const ctx = this.findByWindowId(windowId);
|
||||
if (!ctx) return "unknown";
|
||||
// The home tab is agent-owned. Its `onCreated` fires before any
|
||||
// window-initialising flag would be observable, so we match it by home
|
||||
// tab id rather than by boot timing (which left the branch unreachable).
|
||||
if (tabId === ctx.homeTabId || ctx.pendingAgentTabCount > 0) {
|
||||
if (ctx.pendingAgentTabCount > 0) ctx.pendingAgentTabCount -= 1;
|
||||
ctx.agentCreatedTabs.add(tabId);
|
||||
return "agent";
|
||||
}
|
||||
ctx.userTabs.add(tabId);
|
||||
return "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down a session: close its Agent Window and drop the context.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user