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
50 changes: 50 additions & 0 deletions apps/extension/src/session-manager/__tests__/agent-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,53 @@ describe("chromeAgentWindowApi.ensureActiveTab", () => {
expect(update).not.toHaveBeenCalled();
});
});

describe("chromeAgentWindowApi.create", () => {
const create = vi.fn();

beforeEach(() => {
vi.stubGlobal("chrome", {
windows: { create },
});
create.mockReset();
create.mockResolvedValue({ id: 100 });
});

afterEach(() => {
vi.unstubAllGlobals();
});

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

expect(create).toHaveBeenCalledWith({
type: "normal",
focused: true,
url: AGENT_WINDOW_HOME,
});
});

it("can create an Agent Window without stealing focus", async () => {
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, { focused: false });

expect(create).toHaveBeenCalledWith({
type: "normal",
focused: false,
url: AGENT_WINDOW_HOME,
});
});

it("passes an optional window size through the options object", async () => {
await chromeAgentWindowApi.create(AGENT_WINDOW_HOME, {
size: { width: 1280, height: 800 },
});

expect(create).toHaveBeenCalledWith({
type: "normal",
focused: true,
url: AGENT_WINDOW_HOME,
width: 1280,
height: 800,
});
});
});
21 changes: 16 additions & 5 deletions apps/extension/src/session-manager/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentWindowApi } from "../agent-window";
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
import { SessionManager } from "../manager";

function fakeAgentWindow(): AgentWindowApi & {
Expand All @@ -8,7 +8,7 @@ function fakeAgentWindow(): AgentWindowApi & {
ensureActiveTabMock: ReturnType<typeof vi.fn>;
} {
let nextId = 100;
const createMock = vi.fn(async (_url: string) => {
const createMock = vi.fn(async (_url: string, _opts?: AgentWindowCreateOptions) => {
const id = nextId++;
return id;
});
Expand All @@ -30,7 +30,7 @@ describe("SessionManager", () => {
const sm = new SessionManager({ agentWindow: aw, now: () => 1700000000000 });
const ctx = await sm.start("aa11");
expect(aw.createMock).toHaveBeenCalledOnce();
expect(aw.createMock).toHaveBeenCalledWith("about:blank");
expect(aw.createMock).toHaveBeenCalledWith("about:blank", {});
expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce();
expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank");
expect(ctx.sessionId).toBe("aa11");
Expand All @@ -42,11 +42,22 @@ describe("SessionManager", () => {
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 });
const ctx = await sm.start("aa11", { size: { width: 1280, height: 800 } });
expect(aw.createMock).toHaveBeenCalledWith("about:blank", {
size: { width: 1280, height: 800 },
});
expect(ctx.agentWindowId).toBe(100);
});

it("forwards an explicit unfocused start to the Agent Window", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });

await sm.start("aa11", { focused: false });

expect(aw.createMock).toHaveBeenCalledWith("about:blank", { focused: false });
});

it("indexes the session by sessionId and agent window id", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
Expand Down
16 changes: 12 additions & 4 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, size?: { width: number; height: number }): Promise<number>;
create(url: string, opts?: AgentWindowCreateOptions): Promise<number>;
remove(windowId: number): Promise<void>;
/**
* Guarantee the Agent Window has an active, CDP-navigable tab.
Expand All @@ -18,16 +18,24 @@ export interface AgentWindowApi {
ensureActiveTab(windowId: number, url: string): Promise<void>;
}

/** Creation hints for a new Agent Window. */
export interface AgentWindowCreateOptions {
/** Optional outer size in CSS pixels. */
size?: { width: number; height: number };
/** Defaults to true so existing sessions keep visible Agent Windows. */
focused?: boolean;
}

/** Initial tab URL for every new session's Agent Window. */
export const AGENT_WINDOW_HOME = "about:blank";

export const chromeAgentWindowApi: AgentWindowApi = {
async create(url: string, size?: { width: number; height: number }): Promise<number> {
async create(url: string, opts: AgentWindowCreateOptions = {}): Promise<number> {
const win = await chrome.windows.create({
type: "normal",
focused: true,
focused: opts.focused ?? true,
url,
...(size ? { width: size.width, height: size.height } : {}),
...(opts.size ? { width: opts.size.width, height: opts.size.height } : {}),
});
if (typeof win?.id !== "number") {
throw new Error("[bh] chrome.windows.create returned no window id");
Expand Down
19 changes: 10 additions & 9 deletions apps/extension/src/session-manager/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export interface SessionManagerOptions {
now?: () => number;
}

/** Options for starting a session's Agent Window. */
export interface SessionStartOptions {
/** Optional Agent Window outer size in CSS pixels. */
size?: { width: number; height: number };
/** Defaults to true so existing clients keep visible Agent Windows. */
focused?: boolean;
}

/**
* Owner of all live agent sessions inside the extension.
*
Expand Down Expand Up @@ -128,18 +136,11 @@ 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,
size?: { width: number; height: number },
): Promise<SessionContext> {
async start(sessionId: string, opts: SessionStartOptions = {}): Promise<SessionContext> {
if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`);
}
// 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);
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, opts);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
const ctx: SessionContext = {
sessionId,
Expand Down
19 changes: 19 additions & 0 deletions apps/extension/src/tools/__tests__/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@ describe("ToolDispatcher", () => {
});
});

it("forwards an unfocused session start to the Agent Window", async () => {
const { transport, deliver } = fakeTransport();
const create = vi.fn(async () => 4242);
const sessions = new SessionManager({
agentWindow: {
create,
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();

deliver(makeRequest("tool.session_start", { session_id: "aa11", focused: false }));
await flushMicrotasks();

expect(create).toHaveBeenCalledWith("about:blank", { focused: false });
});

it("routes tool.session_stop and replies with empty result", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
Expand Down
12 changes: 10 additions & 2 deletions apps/extension/src/tools/__tests__/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,23 @@ describe("handleSessionStart window size", () => {
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 });
expect(aw.create).toHaveBeenCalledWith("about:blank", { size: { 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");
expect(aw.create).toHaveBeenCalledWith("about:blank", {});
});

it("forwards focused: false to the Agent Window", async () => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const result = await handleSessionStart(sm, { session_id: "aa11", focused: false });
expect(result).toEqual({ agent_window_id: 100 });
expect(aw.create).toHaveBeenCalledWith("about:blank", { focused: false });
});

it("rejects a lone width without height", async () => {
Expand Down
7 changes: 6 additions & 1 deletion apps/extension/src/tools/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface SessionStartParams {
width?: number;
/** Optional Agent Window outer height in CSS pixels (100..=7680). */
height?: number;
/** Defaults to true so existing clients preserve visible Agent Windows. */
focused?: boolean;
}

export interface SessionStartResult {
Expand Down Expand Up @@ -99,7 +101,10 @@ export async function handleSessionStart(
const sizeOrErr = validateWindowSize(params.width, params.height);
if (isRpcError(sizeOrErr)) return sizeOrErr;
try {
const ctx = await manager.start(params.session_id, sizeOrErr);
const ctx = await manager.start(params.session_id, {
size: sizeOrErr,
focused: params.focused,
});
return { agent_window_id: ctx.agentWindowId };
} catch (err) {
// chrome.windows.create / SessionManager failures are not CDP
Expand Down
3 changes: 2 additions & 1 deletion crates/bsk-cli/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Every automation task **must** follow this lifecycle. Do **not** rely on idle ti
3. bsk session stop <id> → REQUIRED when done (even on error paths)
```

Optional: `bsk session start --browser <instance-id-or-label>` when multiple browsers are connected (`bsk browsers` / error output lists them).
Optional: `bsk session start --browser <instance-id-or-label>` when multiple browsers are connected (`bsk browsers` / error output lists them). Add `--no-focus` to open the Agent Window in the background without stealing focus from the user's current window.

Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**.

Expand Down Expand Up @@ -129,6 +129,7 @@ Details and flags: **`bsk <cmd> --help`**
| Command | Summary |
|---------|---------|
| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** |
| `bsk session start --no-focus` | Open Agent Window in the background without stealing focus |
| `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 |
Expand Down
10 changes: 8 additions & 2 deletions crates/bsk-cli/src/cli/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::cli::business_rpc;
use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::{CliError, Format};
use crate::cli::record_state;
use crate::cli::session::{start_session, stop_session};
use crate::cli::session::{SessionStartOptions, start_session, stop_session};

/// Max wait for the user to click 结束 in the browser (24 hours).
const RECORD_AWAIT_TIMEOUT_MS: u32 = 86_400_000;
Expand Down Expand Up @@ -85,7 +85,13 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError>
}

let info = ensure_daemon().context("ensure daemon is running")?;
let session = start_session(info.sock_path.clone(), args.browser, None, None)?;
let session = start_session(
info.sock_path.clone(),
SessionStartOptions {
browser: args.browser,
..SessionStartOptions::default()
},
)?;

let start_params = RecordStartParams {
session_id: session.session_id.clone(),
Expand Down
41 changes: 31 additions & 10 deletions crates/bsk-cli/src/cli/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ pub struct SessionStartArgs {
/// `--width` and `--height` must be given to take effect.
#[arg(long, value_parser = window_size)]
pub height: Option<u32>,

/// Open the Agent Window in the background without stealing focus.
#[arg(long)]
pub no_focus: bool,
}

/// Parse a `--width` / `--height` Agent Window dimension (CSS pixels).
Expand Down Expand Up @@ -99,6 +103,8 @@ struct StartParams {
width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
focused: Option<bool>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -173,7 +179,15 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<()
}
});
}
let result = start_session(sock, args.browser, args.width, args.height);
let result = start_session(
sock,
SessionStartOptions {
browser: args.browser,
width: args.width,
height: args.height,
focused: args.no_focus.then_some(false),
},
);
waited.store(true, Ordering::SeqCst);
match result {
Ok(reply) => match format {
Expand All @@ -197,20 +211,27 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<()
Ok(())
}

/// Options for [`start_session`]: browser selection plus Agent Window
/// creation hints. `None` fields keep the extension-side defaults
/// (focused window, browser-chosen size).
#[derive(Debug, Default, Clone)]
pub struct SessionStartOptions {
pub browser: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub focused: Option<bool>,
}

/// Start a session and open the Agent Window. Used by `session start` and `record start`.
pub fn start_session(
sock: PathBuf,
browser: Option<String>,
width: Option<u32>,
height: Option<u32>,
) -> Result<StartReply, CliError> {
pub fn start_session(sock: PathBuf, opts: SessionStartOptions) -> Result<StartReply, CliError> {
call(
sock,
Method::SessionStart,
Some(StartParams {
browser_instance_id: browser,
width,
height,
browser_instance_id: opts.browser,
width: opts.width,
height: opts.height,
focused: opts.focused,
}),
SESSION_START_IPC_TIMEOUT,
)
Expand Down
Loading