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
7 changes: 7 additions & 0 deletions apps/extension/src/session-manager/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ describe("SessionManager", () => {
expect(ctx.refStore.isEmpty()).toBe(true);
expect(ctx.borrowedTabs.size).toBe(0);
});
it("forwards an optional window size when starting a session", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11", { width: 1280, height: 800 });
expect(aw.createMock).toHaveBeenCalledWith("about:blank", { width: 1280, height: 800 });
expect(ctx.agentWindowId).toBe(100);
});

it("indexes the session by sessionId and agent window id", async () => {
const aw = fakeAgentWindow();
Expand Down
5 changes: 3 additions & 2 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): Promise<number>;
create(url: string, size?: { width: number; height: number }): Promise<number>;
remove(windowId: number): Promise<void>;
/**
* Guarantee the Agent Window has an active, CDP-navigable tab.
Expand All @@ -22,11 +22,12 @@ export interface AgentWindowApi {
export const AGENT_WINDOW_HOME = "about:blank";

export const chromeAgentWindowApi: AgentWindowApi = {
async create(url: string): Promise<number> {
async create(url: string, size?: { width: number; height: number }): Promise<number> {
const win = await chrome.windows.create({
type: "normal",
focused: true,
url,
...(size ? { width: size.width, height: size.height } : {}),
});
if (typeof win?.id !== "number") {
throw new Error("[bh] chrome.windows.create returned no window id");
Expand Down
11 changes: 9 additions & 2 deletions apps/extension/src/session-manager/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,18 @@ export class SessionManager {
* Returns the created window id so callers can echo it back to the
* daemon in the `tool.session_start` reply.
*/
async start(sessionId: string): Promise<SessionContext> {
async start(
sessionId: string,
size?: { width: number; height: number },
): Promise<SessionContext> {
if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`);
}
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME);
// Only pass `size` when given so the no-size call shape (and its
// chrome.windows.create payload) stays exactly as before.
const windowId = size
? await this.agentWindow.create(AGENT_WINDOW_HOME, size)
: await this.agentWindow.create(AGENT_WINDOW_HOME);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
const ctx: SessionContext = {
sessionId,
Expand Down
138 changes: 138 additions & 0 deletions apps/extension/src/tools/__tests__/window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { handleSessionStart } from "../session";
import { handleWindowResize, type WindowResizeApi } from "../window";

function fakeAgentWindow(ids: number[]) {
let i = 0;
const create = vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
});
const remove = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => {});
return { create, remove, ensureActiveTab };
}

async function makeManager(): Promise<SessionManager> {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
return sm;
}

function fakeUpdateApi(opts?: { throw?: boolean }) {
const calls: Array<{ windowId: number; width: number; height: number }> = [];
const api: WindowResizeApi = {
update: vi.fn(async (windowId: number, updateInfo: { width: number; height: number }) => {
if (opts?.throw) throw new Error("simulated chrome failure");
calls.push({ windowId, width: updateInfo.width, height: updateInfo.height });
return undefined;
}),
};
return { api, calls };
}

describe("handleWindowResize", () => {
it("resizes the session's Agent Window", async () => {
const sm = await makeManager();
const { api, calls } = fakeUpdateApi();
const result = await handleWindowResize(
sm,
{ session_id: "aa11", width: 1280, height: 800 },
api,
);
expect(result).toEqual({ window_id: 100, width: 1280, height: 800 });
expect(calls).toEqual([{ windowId: 100, width: 1280, height: 800 }]);
});

it("rejects an unknown session", async () => {
const sm = await makeManager();
const { api } = fakeUpdateApi();
const result = await handleWindowResize(
sm,
{ session_id: "zz99", width: 1280, height: 800 },
api,
);
expect(result).toMatchObject({ code: "not_found" });
});

it("rejects missing session_id", async () => {
const sm = await makeManager();
const { api } = fakeUpdateApi();
const result = await handleWindowResize(sm, { session_id: "", width: 1280, height: 800 }, api);
expect(result).toMatchObject({ code: "invalid_params" });
});

it("rejects missing dimensions", async () => {
const sm = await makeManager();
const { api, calls } = fakeUpdateApi();
const result = await handleWindowResize(
sm,
{ session_id: "aa11" } as unknown as { session_id: string; width: number; height: number },
api,
);
expect(result).toMatchObject({ code: "invalid_params" });
expect(calls).toEqual([]);
});

it.each([
[99, 800],
[100, 7681],
[1280.5, 800],
[Number.NaN, 800],
])("rejects out-of-range or non-integer dimensions (%s, %s)", async (width, height) => {
const sm = await makeManager();
const { api, calls } = fakeUpdateApi();
const result = await handleWindowResize(sm, { session_id: "aa11", width, height }, api);
expect(result).toMatchObject({ code: "invalid_params" });
expect(calls).toEqual([]);
});

it("maps chrome API failures to protocol_error", async () => {
const sm = await makeManager();
const { api } = fakeUpdateApi({ throw: true });
const result = await handleWindowResize(
sm,
{ session_id: "aa11", width: 1280, height: 800 },
api,
);
expect(result).toMatchObject({ code: "protocol_error", message: "simulated chrome failure" });
});
});

describe("handleSessionStart window size", () => {
it("passes width/height through to Agent Window creation", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const result = await handleSessionStart(sm, { session_id: "aa11", width: 1280, height: 800 });
expect(result).toEqual({ agent_window_id: 100 });
expect(aw.create).toHaveBeenCalledWith("about:blank", { width: 1280, height: 800 });
});

it("creates the window without size when width/height are omitted", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const result = await handleSessionStart(sm, { session_id: "aa11" });
expect(result).toEqual({ agent_window_id: 100 });
expect(aw.create).toHaveBeenCalledWith("about:blank");
});

it("rejects a lone width without height", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const result = await handleSessionStart(sm, { session_id: "aa11", width: 1280 });
expect(result).toMatchObject({ code: "invalid_params" });
expect(sm.has("aa11")).toBe(false);
});

it.each([
[99, 800],
[1280, 7681],
[1280.5, 800],
])("rejects invalid dimensions (%s, %s)", async (width, height) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const result = await handleSessionStart(sm, { session_id: "aa11", width, height });
expect(result).toMatchObject({ code: "invalid_params" });
expect(sm.has("aa11")).toBe(false);
});
});
4 changes: 4 additions & 0 deletions apps/extension/src/tools/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
type TabSelectParams,
} from "./tabs";
import { handleWaitForNavigation } from "./waits";
import { handleWindowResize, type WindowResizeParams } from "./window";

type DispatcherCdpRunner = CdpRunner &
NetworkCdpRunner & {
Expand Down Expand Up @@ -286,6 +287,8 @@ export class ToolDispatcher {
});
case "tool.tab_return":
return handleTabReturn(this.sessions, req.params as TabReturnParams);
case "tool.window_resize":
return handleWindowResize(this.sessions, req.params as WindowResizeParams);
case "tool.screenshot":
return handleScreenshot(
this.sessions,
Expand Down Expand Up @@ -478,6 +481,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
case "tool.tab_select":
case "tool.tab_borrow":
case "tool.tab_return":
case "tool.window_resize":
case "tool.navigate":
case "tool.navigate_back":
case "tool.navigate_forward":
Expand Down
48 changes: 47 additions & 1 deletion apps/extension/src/tools/session.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,55 @@
import type { SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { clearRecordingForSession } from "./record";
import { isRpcError } from "./shared";
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";

/** Valid range for Agent Window dimensions in CSS pixels. */
export const WINDOW_SIZE_MIN = 100;
export const WINDOW_SIZE_MAX = 7680;

/**
* Validate an optional width/height pair. Both must be given (or
* neither), be integers, and fall within 100..=7680. Returns the size
* to pass down, or an `invalid_params` RpcError.
*/
export function validateWindowSize(
width: unknown,
height: unknown,
): { width: number; height: number } | undefined | RpcError {
if (width === undefined && height === undefined) return undefined;
if (width === undefined || height === undefined) {
return {
code: "invalid_params",
message: "width and height must be given together",
};
}
for (const [name, value] of [
["width", width],
["height", height],
] as const) {
if (
typeof value !== "number" ||
!Number.isInteger(value) ||
value < WINDOW_SIZE_MIN ||
value > WINDOW_SIZE_MAX
) {
return {
code: "invalid_params",
message: `${name} must be an integer in 100..=7680`,
};
}
}
return { width: width as number, height: height as number };
}

export interface SessionStartParams {
session_id: string;
browser_instance_id?: string;
/** Optional Agent Window outer width in CSS pixels (100..=7680). */
width?: number;
/** Optional Agent Window outer height in CSS pixels (100..=7680). */
height?: number;
}

export interface SessionStartResult {
Expand Down Expand Up @@ -52,8 +96,10 @@ export async function handleSessionStart(
message: "session.start requires session_id",
};
}
const sizeOrErr = validateWindowSize(params.width, params.height);
if (isRpcError(sizeOrErr)) return sizeOrErr;
try {
const ctx = await manager.start(params.session_id);
const ctx = await manager.start(params.session_id, sizeOrErr);
return { agent_window_id: ctx.agentWindowId };
} catch (err) {
// chrome.windows.create / SessionManager failures are not CDP
Expand Down
82 changes: 82 additions & 0 deletions apps/extension/src/tools/window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Window-tool handlers: `tool.window_resize` resizes the session's
// Agent Window via `chrome.windows.update`.

import type { SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { validateWindowSize } from "./session";
import { isRpcError, lookupSession } from "./shared";

/**
* Mirror of bsk-protocol `WindowResizeParams` /
* `WindowResizeResult` (see crates/bsk-protocol/src/tools/window.rs).
*/
export interface WindowResizeParams {
session_id: string;
width: number;
height: number;
}

export interface WindowResizeResult {
window_id: number;
width: number;
height: number;
}

/**
* Subset of `chrome.windows` we depend on, injectable so unit tests
* can fake the browser without monkey-patching the global `chrome`.
*/
export interface WindowResizeApi {
update(windowId: number, updateInfo: { width: number; height: number }): Promise<unknown>;
}

export const chromeWindowResizeApi: WindowResizeApi = {
async update(windowId, updateInfo) {
return chrome.windows.update(windowId, updateInfo);
},
};

/**
* Handler for `tool.window_resize` (called by the daemon over WS).
*
* Resizes the session's Agent Window to the given outer dimensions in
* CSS pixels. chrome API failures are surfaced as `protocol_error`
* (§4.5 reserves `cdp_failed` for raw CDP errors).
*/
export async function handleWindowResize(
manager: SessionManager,
params: WindowResizeParams,
api: WindowResizeApi = chromeWindowResizeApi,
): Promise<WindowResizeResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "window_resize");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;

const sizeOrErr = validateWindowSize(params.width, params.height);
if (isRpcError(sizeOrErr)) return sizeOrErr;
if (!sizeOrErr) {
// validateWindowSize only returns undefined when both are absent,
// which is valid for session_start but not for an explicit resize.
return {
code: "invalid_params",
message: "window_resize requires width and height",
};
}

try {
await api.update(ctx.agentWindowId, {
width: sizeOrErr.width,
height: sizeOrErr.height,
});
} catch (err) {
return {
code: "protocol_error",
message: err instanceof Error ? err.message : String(err),
};
}
return {
window_id: ctx.agentWindowId,
width: sizeOrErr.width,
height: sizeOrErr.height,
};
}
8 changes: 7 additions & 1 deletion crates/bsk-cli/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,17 @@ Details and flags: **`bsk <cmd> --help`**

| Command | Summary |
|---------|---------|
| `bsk session start` | Open Agent Window; prints **4-letter session id** |
| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** |
| `bsk session stop <id>` | End session, close Agent Window, auto-return borrowed tabs |
| `bsk session stop --all` | Stop every active session |
| `bsk session list` | List active sessions |

### Window (require `--session <id>`)

| Command | Summary |
|---------|---------|
| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) |

### Tabs (require `--session <id>`)

| Command | Summary |
Expand Down
Loading