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
21 changes: 19 additions & 2 deletions apps/extension/src/session-manager/__tests__/agent-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("chromeAgentWindowApi.ensureActiveTab", () => {
query.mockResolvedValue([{ id: 7, active: false }]);
update.mockResolvedValue({});

await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME);
await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME, new Set([7]));

expect(query).toHaveBeenCalledWith({ windowId: 100 });
expect(update).toHaveBeenCalledWith(7, { active: true });
Expand All @@ -34,7 +34,7 @@ describe("chromeAgentWindowApi.ensureActiveTab", () => {
query.mockResolvedValue([]);
create.mockResolvedValue({ id: 8 });

await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME);
await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME, new Set());

expect(create).toHaveBeenCalledWith({
windowId: 100,
Expand All @@ -43,6 +43,15 @@ describe("chromeAgentWindowApi.ensureActiveTab", () => {
});
expect(update).not.toHaveBeenCalled();
});

it("creates its own home tab instead of adopting a tab opened by the user", async () => {
query.mockResolvedValue([{ id: 99, active: true }]);
create.mockResolvedValue({ id: 8 });
const home = await chromeAgentWindowApi.ensureActiveTab(100, AGENT_WINDOW_HOME, new Set([7]));
expect(home).toBe(8);
expect(update).not.toHaveBeenCalled();
expect(create).toHaveBeenCalledWith({ windowId: 100, url: AGENT_WINDOW_HOME, active: true });
});
});

describe("chromeAgentWindowApi.create", () => {
Expand All @@ -60,6 +69,14 @@ describe("chromeAgentWindowApi.create", () => {
vi.unstubAllGlobals();
});

it("returns initial tab identities from the creation result", async () => {
create.mockResolvedValue({ id: 100, tabs: [{ id: 7 }, { id: 8 }] });
expect(await chromeAgentWindowApi.create(AGENT_WINDOW_HOME)).toEqual({
windowId: 100,
initialTabIds: [7, 8],
});
});

it("focuses Agent Windows by default", async () => {
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe("disconnect session cleanup", () => {
let nextWindowId = 100;
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => nextWindowId++),
create: vi.fn(async () => ({ windowId: nextWindowId++, initialTabIds: [] })),
ensureActiveTab: vi.fn(async () => 1),
remove,
},
Expand Down Expand Up @@ -40,7 +40,7 @@ describe("disconnect session cleanup", () => {
});
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
create: vi.fn(async () => ({ windowId: 100, initialTabIds: [] })),
ensureActiveTab: vi.fn(async () => 1),
remove: vi.fn(() => removeGate),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe("attachSessionEventHandler", () => {
};
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
create: vi.fn(async () => ({ windowId: 4242, initialTabIds: [] })),
remove: vi.fn(path === "window" ? emitAndFlush : async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
Expand All @@ -74,7 +74,7 @@ describe("attachSessionEventHandler", () => {
const events = fakeWindowEvents();
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
create: vi.fn(async () => ({ windowId: 4242, initialTabIds: [] })),
remove: vi.fn(async () => {
throw new Error("close failed");
}),
Expand All @@ -100,7 +100,7 @@ describe("attachSessionEventHandler", () => {
it("drops the local session and emits session.window_closed when the agent window closes", async () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
create: vi.fn(async () => ({ windowId: 4242, initialTabIds: [] })),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
Expand Down Expand Up @@ -134,7 +134,7 @@ describe("attachSessionEventHandler", () => {
it("reports borrowed tabs as return failures when the Agent Window was already closed", async () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
create: vi.fn(async () => ({ windowId: 4242, initialTabIds: [] })),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
Expand Down Expand Up @@ -175,7 +175,7 @@ describe("attachSessionEventHandler", () => {
it("ignores non-agent windows", async () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
create: vi.fn(async () => ({ windowId: 1, initialTabIds: [] })),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
},
Expand All @@ -191,7 +191,7 @@ describe("attachSessionEventHandler", () => {
it("dispose() removes the listener", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
create: vi.fn(async () => ({ windowId: 1, initialTabIds: [] })),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
},
Expand Down
23 changes: 16 additions & 7 deletions apps/extension/src/session-manager/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
import type {
AgentWindowApi,
AgentWindowCreateOptions,
AgentWindowCreation,
} from "../agent-window";
import { isAgentControlledTab, SessionManager } from "../manager";

function fakeAgentWindow(): AgentWindowApi & {
Expand All @@ -10,7 +14,7 @@ function fakeAgentWindow(): AgentWindowApi & {
let nextId = 100;
const createMock = vi.fn(async (_url: string, _opts?: AgentWindowCreateOptions) => {
const id = nextId++;
return id;
return { windowId: id, initialTabIds: [] };
});
const removeMock = vi.fn(async (_id: number) => {});
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => 0);
Expand All @@ -32,7 +36,7 @@ describe("SessionManager", () => {
expect(aw.createMock).toHaveBeenCalledOnce();
expect(aw.createMock).toHaveBeenCalledWith("about:blank", {});
expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce();
expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank");
expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank", expect.any(Set));
expect(ctx.sessionId).toBe("aa11");
expect(ctx.agentWindowId).toBe(100);
expect(ctx.createdAtMs).toBe(1700000000000);
Expand Down Expand Up @@ -77,10 +81,10 @@ describe("SessionManager", () => {

it("removes a newly created Agent Window when startup is aborted", async () => {
const aw = fakeAgentWindow();
let resolveCreate: (windowId: number) => void = () => {};
let resolveCreate: (result: AgentWindowCreation) => void = () => {};
aw.createMock.mockImplementationOnce(
() =>
new Promise<number>((resolve) => {
new Promise<AgentWindowCreation>((resolve) => {
resolveCreate = resolve;
}),
);
Expand All @@ -89,7 +93,7 @@ describe("SessionManager", () => {
const pending = sm.start("aa11", { signal: controller.signal });

controller.abort();
resolveCreate(777);
resolveCreate({ windowId: 777, initialTabIds: [7] });

await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(aw.removeMock).toHaveBeenCalledWith(777);
Expand All @@ -107,7 +111,7 @@ describe("SessionManager", () => {
expect(sm.has("aa11")).toBe(false);
});

it("surfaces the orphan Agent Window id when startup cleanup fails", async () => {
it("retains a failed startup window so stop can retry cleanup", async () => {
const aw = fakeAgentWindow();
aw.ensureActiveTabMock.mockRejectedValueOnce(new Error("tab setup failed"));
aw.removeMock.mockRejectedValueOnce(new Error("window removal denied"));
Expand All @@ -118,7 +122,12 @@ describe("SessionManager", () => {
windowId: 100,
message: expect.stringMatching(/cleanup of Agent Window 100 failed.*window removal denied/),
});
expect(sm.has("aa11")).toBe(true);
expect(sm.findByWindowId(100)?.sessionId).toBe("aa11");
await sm.stop("aa11");
expect(aw.removeMock).toHaveBeenCalledTimes(2);
expect(sm.has("aa11")).toBe(false);
expect(sm.findByWindowId(100)).toBeNull();
});

it("stop() closes the Agent Window and forgets the session", async () => {
Expand Down
29 changes: 23 additions & 6 deletions apps/extension/src/session-manager/agent-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export interface AgentWindowApi {
create(url: string, opts?: AgentWindowCreateOptions): Promise<number>;
create(url: string, opts?: AgentWindowCreateOptions): Promise<AgentWindowCreation>;
remove(windowId: number): Promise<void>;
/**
* Guarantee the Agent Window has an active, CDP-navigable tab.
Expand All @@ -18,7 +18,13 @@ export interface AgentWindowApi {
* Resolves with the id of the activated (or newly created) tab, so callers
* can track the session's home tab without re-querying Chrome.
*/
ensureActiveTab(windowId: number, url: string): Promise<number>;
ensureActiveTab(windowId: number, url: string, ownedTabIds: ReadonlySet<number>): Promise<number>;
}

/** Resource identities captured from the creation result, before initialization. */
export interface AgentWindowCreation {
windowId: number;
initialTabIds: number[];
}

/** Creation hints for a new Agent Window. */
Expand All @@ -33,7 +39,7 @@ export interface AgentWindowCreateOptions {
export const AGENT_WINDOW_HOME = "about:blank";

export const chromeAgentWindowApi: AgentWindowApi = {
async create(url: string, opts: AgentWindowCreateOptions = {}): Promise<number> {
async create(url: string, opts: AgentWindowCreateOptions = {}): Promise<AgentWindowCreation> {
const win = await chrome.windows.create({
type: "normal",
focused: opts.focused ?? true,
Expand All @@ -43,7 +49,12 @@ export const chromeAgentWindowApi: AgentWindowApi = {
if (typeof win?.id !== "number") {
throw new Error("[bh] chrome.windows.create returned no window id");
}
return win.id;
return {
windowId: win.id,
initialTabIds: (win.tabs ?? []).flatMap((tab) =>
typeof tab.id === "number" ? [tab.id] : [],
),
};
},
async remove(windowId: number): Promise<void> {
// Callers decide whether a missing/failed removal is benign. In
Expand All @@ -52,9 +63,15 @@ export const chromeAgentWindowApi: AgentWindowApi = {
// cancellation success while the Agent Window remains open.
await chrome.windows.remove(windowId);
},
async ensureActiveTab(windowId: number, url: string): Promise<number> {
async ensureActiveTab(
windowId: number,
url: string,
ownedTabIds: ReadonlySet<number>,
): Promise<number> {
const tabs = await chrome.tabs.query({ windowId });
const first = tabs.find((t) => typeof t.id === "number");
// A user may have opened a tab while window initialization was pending.
// Reuse only a tab whose identity came from our creation result.
const first = tabs.find((t) => t.id !== undefined && ownedTabIds.has(t.id));
if (first?.id !== undefined) {
if (!first.active) {
await chrome.tabs.update(first.id, { active: true });
Expand Down
33 changes: 27 additions & 6 deletions apps/extension/src/session-manager/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,19 @@ export class SessionManager {
throwIfSessionStartAborted(opts.signal);

let windowId: number | null = null;
const agentCreatedTabs = new Set<number>();
try {
const { signal: _signal, ...createOptions } = opts;
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
const created = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
windowId = created.windowId;
for (const tabId of created.initialTabIds) agentCreatedTabs.add(tabId);
throwIfSessionStartAborted(opts.signal);
const homeTabId = await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
const homeTabId = await this.agentWindow.ensureActiveTab(
windowId,
AGENT_WINDOW_HOME,
agentCreatedTabs,
);
agentCreatedTabs.add(homeTabId);
throwIfSessionStartAborted(opts.signal);

const ctx: SessionContext = {
Expand All @@ -239,10 +247,10 @@ export class SessionManager {
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
// 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]),
// Capture ownership at creation, before initialization can fail.
// Later tabs remain free until `tab_create` or `tab_borrow` identifies
// them by their concrete Chrome tab id.
agentCreatedTabs,
createdAtMs: this.now(),
};
this.sessions.set(sessionId, ctx);
Expand All @@ -253,6 +261,19 @@ export class SessionManager {
try {
await this.agentWindow.remove(windowId);
} catch (cleanupError) {
// The daemon may retry stop after a failed startup rollback. Retain
// the exact window handle until closure is confirmed.
const pending: SessionContext = {
...(this.remote() ? { remote: true } : {}),
sessionId,
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
agentCreatedTabs,
createdAtMs: this.now(),
};
this.sessions.set(sessionId, pending);
this.windowIndex.set(windowId, sessionId);
throw new SessionStartCleanupError(windowId, startupError, cleanupError);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)(
);
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
create: async () => ({ windowId: 100, initialTabIds: [] }),
remove: async () => {},
ensureActiveTab: async () => 1,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { prepareBackgroundExecution } from "../background-execution";
async function fixture() {
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
create: async () => ({ windowId: 100, initialTabIds: [] }),
remove: async () => {},
ensureActiveTab: async () => 1,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe.skipIf(!process.env.BSK_BACKGROUND_CHROME)(
);
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
create: async () => ({ windowId: 100, initialTabIds: [] }),
remove: async () => {},
ensureActiveTab: async () => 1,
},
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/src/tools/__tests__/click.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe.skipIf(!process.env.BSK_CLICK_CHROME)("real browser click readiness", (
const foreground = await page(false);
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
create: async () => ({ windowId: 100, initialTabIds: [] }),
remove: async () => {},
ensureActiveTab: async () => 4,
},
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/src/tools/__tests__/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function fakeAgentWindow(ids: number[]) {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
return { windowId: id, initialTabIds: [] };
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
Expand Down
Loading
Loading