Skip to content
Open
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
36 changes: 36 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,39 @@ 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, false);

expect(create).toHaveBeenCalledWith({
type: "normal",
focused: false,
url: AGENT_WINDOW_HOME,
});
});
});
13 changes: 11 additions & 2 deletions apps/extension/src/session-manager/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, _focused?: boolean) => {
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", true);
expect(aw.ensureActiveTabMock).toHaveBeenCalledOnce();
expect(aw.ensureActiveTabMock).toHaveBeenCalledWith(100, "about:blank");
expect(ctx.sessionId).toBe("aa11");
Expand All @@ -40,6 +40,15 @@ describe("SessionManager", () => {
expect(ctx.borrowedTabs.size).toBe(0);
});

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

await sm.start("aa11", false);

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

it("indexes the session by sessionId and agent window id", async () => {
const aw = fakeAgentWindow();
const sm = new SessionManager({ agentWindow: aw });
Expand Down
6 changes: 3 additions & 3 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, focused?: boolean): Promise<number>;
remove(windowId: number): Promise<void>;
/**
* Guarantee the Agent Window has an active, CDP-navigable tab.
Expand All @@ -22,10 +22,10 @@ export interface AgentWindowApi {
export const AGENT_WINDOW_HOME = "about:blank";

export const chromeAgentWindowApi: AgentWindowApi = {
async create(url: string): Promise<number> {
async create(url: string, focused = true): Promise<number> {
const win = await chrome.windows.create({
type: "normal",
focused: true,
focused,
url,
});
if (typeof win?.id !== "number") {
Expand Down
4 changes: 2 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,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): Promise<SessionContext> {
async start(sessionId: string, focused = true): Promise<SessionContext> {
if (this.sessions.has(sessionId)) {
throw new Error(`[bh] session ${sessionId} already exists`);
}
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME);
const windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, focused);
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 @@ -67,6 +67,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", false);
});

it("routes tool.session_stop and replies with empty result", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
Expand Down
4 changes: 3 additions & 1 deletion apps/extension/src/tools/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
export interface SessionStartParams {
session_id: string;
browser_instance_id?: string;
/** Defaults to true so existing clients preserve visible Agent Windows. */
focused?: boolean;
}

export interface SessionStartResult {
Expand Down Expand Up @@ -53,7 +55,7 @@ export async function handleSessionStart(
};
}
try {
const ctx = await manager.start(params.session_id);
const ctx = await manager.start(params.session_id, params.focused ?? true);
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 @@ -124,6 +124,7 @@ Details and flags: **`bsk <cmd> --help`**
| Command | Summary |
|---------|---------|
| `bsk session start` | Open Agent Window; 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
17 changes: 16 additions & 1 deletion crates/bsk-cli/src/cli/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ pub struct SessionStartArgs {
/// are connected).
#[arg(long)]
pub browser: Option<String>,

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

#[derive(Debug, Clone, Args)]
Expand All @@ -71,6 +75,8 @@ pub struct SessionStopArgs {
struct StartParams {
#[serde(skip_serializing_if = "Option::is_none")]
browser_instance_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
focused: Option<bool>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -140,7 +146,7 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<()
}
});
}
let result = start_session(sock, args.browser);
let result = start_session_with_focus(sock, args.browser, args.no_focus.then_some(false));
waited.store(true, Ordering::SeqCst);
match result {
Ok(reply) => match format {
Expand All @@ -166,11 +172,20 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<()

/// Start a session and open the Agent Window. Used by `session start` and `record start`.
pub fn start_session(sock: PathBuf, browser: Option<String>) -> Result<StartReply, CliError> {
start_session_with_focus(sock, browser, None)
}

fn start_session_with_focus(
sock: PathBuf,
browser: Option<String>,
focused: Option<bool>,
) -> Result<StartReply, CliError> {
call(
sock,
Method::SessionStart,
Some(StartParams {
browser_instance_id: browser,
focused,
}),
SESSION_START_IPC_TIMEOUT,
)
Expand Down
10 changes: 7 additions & 3 deletions crates/bsk-cli/src/daemon/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ use tracing::{debug, warn};
use super::abort::AbortRegistry;
use super::queue::{DEFAULT_TOOL_TIMEOUT, DispatchError};
use super::sessions::{
SessionId, StartSessionError, StopSessionError, snapshot_status_entries, start_session,
stop_session,
SessionId, StartSessionError, StopSessionError, snapshot_status_entries,
start_session_with_focus, stop_session,
};
use super::state::{DAEMON_VERSION, DaemonState, PROTOCOL_VERSION};

Expand Down Expand Up @@ -538,6 +538,8 @@ fn tool_dispatch_timeout(params: &Value) -> Result<Duration, RpcError> {
struct CliSessionStartParams {
#[serde(default)]
pub browser_instance_id: Option<String>,
#[serde(default)]
pub focused: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -629,6 +631,7 @@ async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result
let params: CliSessionStartParams = if params.is_null() {
CliSessionStartParams {
browser_instance_id: None,
focused: None,
}
} else {
serde_json::from_value(params).map_err(|err| RpcError {
Expand All @@ -637,11 +640,12 @@ async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result
data: None,
})?
};
match start_session(
match start_session_with_focus(
&state.browsers,
&state.sessions,
&state.tool_queues,
params.browser_instance_id.as_deref(),
params.focused,
state.config.extension_connect_wait,
DEFAULT_RPC_TIMEOUT,
)
Expand Down
22 changes: 22 additions & 0 deletions crates/bsk-cli/src/daemon/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,27 @@ pub async fn start_session(
requested: Option<&str>,
connect_wait: Duration,
timeout_dur: Duration,
) -> Result<Session, StartSessionError> {
start_session_with_focus(
registry,
sessions,
queues,
requested,
None,
connect_wait,
timeout_dur,
)
.await
}

pub async fn start_session_with_focus(
registry: &Arc<BrowserRegistry>,
sessions: &Arc<SessionRegistry>,
queues: &Arc<ToolQueueRegistry>,
requested: Option<&str>,
focused: Option<bool>,
connect_wait: Duration,
timeout_dur: Duration,
) -> Result<Session, StartSessionError> {
let client: Arc<BrowserClient> = registry
.select_with_connect_wait(requested, connect_wait)
Expand All @@ -427,6 +448,7 @@ pub async fn start_session(
let params = SessionStartParams {
session_id: session_id.0.clone(),
browser_instance_id: Some(client.id.0.clone()),
focused,
};
let rpc_id = next_rpc_id("sess-start");
let request = RequestFrame {
Expand Down
13 changes: 13 additions & 0 deletions crates/bsk-cli/tests/cli_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::time::Duration;
use bsk::cli::daemon::{DaemonCmd, parse_duration};
use bsk::cli::navigate::NavigateCmd;
use bsk::cli::record::{RecordCmd, RecordSub};
use bsk::cli::session::{SessionCmd, SessionSub};
use bsk::{Cli, Command};
use clap::Parser;

Expand Down Expand Up @@ -227,3 +228,15 @@ fn parses_record_start_without_url() {
assert_eq!(args.browser.as_deref(), Some("022ca8ac"));
assert!(args.url.is_none());
}

#[test]
fn parses_session_start_no_focus() {
let cli = parse(&["bsk", "session", "start", "--no-focus"]);
let Command::Session(SessionCmd {
sub: SessionSub::Start(args),
}) = cli.command
else {
panic!("expected session start subcommand");
};
assert!(args.no_focus);
}
5 changes: 4 additions & 1 deletion crates/bsk-cli/tests/sessions_ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ async fn session_start_stop_round_trip_via_ipc() {
if let Frame::Request(req) = frame {
let reply = match req.method {
Method::ToolSessionStart => {
let _: SessionStartParams =
let params: SessionStartParams =
serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(params.focused, Some(false));
let result = SessionStartResult {
agent_window_id: Some(4242),
};
Expand Down Expand Up @@ -177,6 +178,7 @@ async fn session_start_stop_round_trip_via_ipc() {
#[derive(serde::Serialize)]
struct StartParams {
browser_instance_id: Option<String>,
focused: Option<bool>,
}
#[derive(serde::Deserialize, Debug)]
struct StartReply {
Expand All @@ -191,6 +193,7 @@ async fn session_start_stop_round_trip_via_ipc() {
Method::SessionStart,
Some(StartParams {
browser_instance_id: None,
focused: Some(false),
}),
Duration::from_secs(5),
)
Expand Down
7 changes: 7 additions & 0 deletions crates/bsk-protocol/schema/tool_session_start_params.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
"null"
]
},
"focused": {
"description": "Whether the new Agent Window should take focus. Omitted means the extension's default (`true`) for compatibility with older clients.",
"type": [
"boolean",
"null"
]
},
"session_id": {
"type": "string"
}
Expand Down
21 changes: 21 additions & 0 deletions crates/bsk-protocol/src/tools/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ pub struct SessionStartParams {
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_instance_id: Option<String>,
/// Whether the new Agent Window should take focus. Omitted means the
/// extension's default (`true`) for compatibility with older clients.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focused: Option<bool>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -43,6 +47,23 @@ mod tests {
use super::*;
use serde_json::json;

#[test]
fn session_start_focus_is_optional_and_round_trips_false() {
let defaulted: SessionStartParams = serde_json::from_value(json!({
"session_id": "aa11"
}))
.unwrap();
assert_eq!(defaulted.focused, None);

let background: SessionStartParams = serde_json::from_value(json!({
"session_id": "aa11",
"focused": false
}))
.unwrap();
assert_eq!(background.focused, Some(false));
assert_eq!(serde_json::to_value(background).unwrap()["focused"], false);
}

#[test]
fn session_stop_result_round_trips_auto_return_payload() {
let result: SessionStopResult = serde_json::from_value(json!({
Expand Down
Loading