Skip to content
Draft
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
32 changes: 27 additions & 5 deletions browser-use-python/src/browser_use_sdk/v3/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import os
from collections.abc import Awaitable
from typing import Any, TypeVar, overload
from uuid import UUID

Expand Down Expand Up @@ -63,6 +62,7 @@ def run(
workspace_id: str | None = ...,
enable_recording: bool | None = ...,
cache_script: bool | None = ...,
auto_heal: bool | None = ...,
**extra: Any,
) -> SessionResult[T]: ...

Expand All @@ -81,6 +81,7 @@ def run(
workspace_id: str | None = ...,
enable_recording: bool | None = ...,
cache_script: bool | None = ...,
auto_heal: bool | None = ...,
**extra: Any,
) -> SessionResult[T]: ...

Expand All @@ -98,6 +99,7 @@ def run(
workspace_id: str | None = ...,
enable_recording: bool | None = ...,
cache_script: bool | None = ...,
auto_heal: bool | None = ...,
**extra: Any,
) -> SessionResult[str]: ...

Expand All @@ -116,6 +118,7 @@ def run(
workspace_id: str | None = None,
enable_recording: bool | None = None,
cache_script: bool | None = None,
auto_heal: bool | None = None,
**extra: Any,
) -> Any:
"""Run a task and block until complete. Returns a SessionResult.
Expand All @@ -127,7 +130,9 @@ def run(
- False: force-disable caching.

When active, the first call runs the full agent and saves a reusable script.
Subsequent calls with the same task template execute the script with $0 LLM cost.
Subsequent calls with the same task template execute the script. By default,
auto_heal may use lightweight validation or regenerate the script if output
looks wrong; set auto_heal=False to return the raw script output.
"""
if cache_script is True and not workspace_id:
raise ValueError("workspace_id is required when cache_script=True")
Expand Down Expand Up @@ -158,6 +163,7 @@ def run(
workspace_id=workspace_id,
enable_recording=enable_recording,
cache_script=cache_script,
auto_heal=auto_heal,
**extra,
)
return _poll_output(self.sessions, str(data.id), resolved_schema)
Expand All @@ -177,6 +183,7 @@ def stream(
workspace_id: str | None = None,
enable_recording: bool | None = None,
cache_script: bool | None = None,
auto_heal: bool | None = None,
**extra: Any,
) -> SessionStream[Any]:
"""Run a task and yield messages as they happen.
Expand Down Expand Up @@ -224,9 +231,12 @@ def stream(
workspace_id=workspace_id,
enable_recording=enable_recording,
cache_script=cache_script,
auto_heal=auto_heal,
**extra,
)
return SessionStream(data, self.sessions, resolved_schema, _start_cursor=start_cursor)
return SessionStream(
data, self.sessions, resolved_schema, _start_cursor=start_cursor
)

def close(self) -> None:
"""Close the underlying HTTP client."""
Expand Down Expand Up @@ -280,6 +290,7 @@ def run(
workspace_id: str | None = ...,
enable_recording: bool | None = ...,
cache_script: bool | None = ...,
auto_heal: bool | None = ...,
**extra: Any,
) -> AsyncSessionRun[T]: ...

Expand All @@ -298,6 +309,7 @@ def run(
workspace_id: str | None = ...,
enable_recording: bool | None = ...,
cache_script: bool | None = ...,
auto_heal: bool | None = ...,
**extra: Any,
) -> AsyncSessionRun[T]: ...

Expand All @@ -315,6 +327,7 @@ def run(
workspace_id: str | None = ...,
enable_recording: bool | None = ...,
cache_script: bool | None = ...,
auto_heal: bool | None = ...,
**extra: Any,
) -> AsyncSessionRun[str]: ...

Expand All @@ -333,6 +346,7 @@ def run(
workspace_id: str | None = None,
enable_recording: bool | None = None,
cache_script: bool | None = None,
auto_heal: bool | None = None,
**extra: Any,
) -> AsyncSessionRun[Any]:
"""Run a task. Await the result for a SessionResult.
Expand All @@ -344,7 +358,9 @@ def run(
- False: force-disable caching.

When active, the first call runs the full agent and saves a reusable script.
Subsequent calls with the same task template execute the script with $0 LLM cost.
Subsequent calls with the same task template execute the script. By default,
auto_heal may use lightweight validation or regenerate the script if output
looks wrong; set auto_heal=False to return the raw script output.
"""
if cache_script is True and not workspace_id:
raise ValueError("workspace_id is required when cache_script=True")
Expand Down Expand Up @@ -386,10 +402,16 @@ async def create_fn() -> SessionResponse:
workspace_id=workspace_id,
enable_recording=enable_recording,
cache_script=cache_script,
auto_heal=auto_heal,
**extra,
)

return AsyncSessionRun(create_fn, self.sessions, resolved_schema, _start_cursor_ref=lambda: start_cursor)
return AsyncSessionRun(
create_fn,
self.sessions,
resolved_schema,
_start_cursor_ref=lambda: start_cursor,
)

async def close(self) -> None:
"""Close the underlying HTTP client."""
Expand Down
26 changes: 22 additions & 4 deletions browser-use-python/src/browser_use_sdk/v3/resources/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def create(
enable_scheduled_tasks: bool | None = None,
enable_recording: bool | None = None,
cache_script: bool | None = None,
auto_heal: bool | None = None,
**extra: Any,
) -> SessionResponse:
"""Create a session and optionally dispatch a task."""
Expand All @@ -52,7 +53,11 @@ def create(
if profile_id is not None:
body["profileId"] = profile_id
if proxy_country_code is not _UNSET:
body["proxyCountryCode"] = proxy_country_code.lower() if isinstance(proxy_country_code, str) else proxy_country_code
body["proxyCountryCode"] = (
proxy_country_code.lower()
if isinstance(proxy_country_code, str)
else proxy_country_code
)
if output_schema is not None:
body["outputSchema"] = output_schema
if workspace_id is not None:
Expand All @@ -63,6 +68,8 @@ def create(
body["enableRecording"] = enable_recording
if cache_script is not None:
body["cacheScript"] = cache_script
if auto_heal is not None:
body["autoHeal"] = auto_heal
body.update(extra)
return SessionResponse.model_validate(
self._http.request("POST", "/sessions", json=body)
Expand Down Expand Up @@ -92,7 +99,9 @@ def get(self, session_id: str | UUID) -> SessionResponse:
self._http.request("GET", f"/sessions/{session_id}")
)

def stop(self, session_id: str | UUID, *, strategy: str | None = None, **extra: Any) -> SessionResponse:
def stop(
self, session_id: str | UUID, *, strategy: str | None = None, **extra: Any
) -> SessionResponse:
"""Stop a session or the running task."""
body: dict[str, Any] | None = None
if strategy is not None or extra:
Expand Down Expand Up @@ -172,6 +181,7 @@ async def create(
enable_scheduled_tasks: bool | None = None,
enable_recording: bool | None = None,
cache_script: bool | None = None,
auto_heal: bool | None = None,
**extra: Any,
) -> SessionResponse:
"""Create a session and optionally dispatch a task."""
Expand All @@ -189,7 +199,11 @@ async def create(
if profile_id is not None:
body["profileId"] = profile_id
if proxy_country_code is not _UNSET:
body["proxyCountryCode"] = proxy_country_code.lower() if isinstance(proxy_country_code, str) else proxy_country_code
body["proxyCountryCode"] = (
proxy_country_code.lower()
if isinstance(proxy_country_code, str)
else proxy_country_code
)
if output_schema is not None:
body["outputSchema"] = output_schema
if workspace_id is not None:
Expand All @@ -200,6 +214,8 @@ async def create(
body["enableRecording"] = enable_recording
if cache_script is not None:
body["cacheScript"] = cache_script
if auto_heal is not None:
body["autoHeal"] = auto_heal
body.update(extra)
return SessionResponse.model_validate(
await self._http.request("POST", "/sessions", json=body)
Expand Down Expand Up @@ -229,7 +245,9 @@ async def get(self, session_id: str | UUID) -> SessionResponse:
await self._http.request("GET", f"/sessions/{session_id}")
)

async def stop(self, session_id: str | UUID, *, strategy: str | None = None, **extra: Any) -> SessionResponse:
async def stop(
self, session_id: str | UUID, *, strategy: str | None = None, **extra: Any
) -> SessionResponse:
"""Stop a session or the running task."""
body: dict[str, Any] | None = None
if strategy is not None or extra:
Expand Down
94 changes: 74 additions & 20 deletions browser-use-python/tests/test_vibe.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import inspect
import json
import os
import typing
from pathlib import Path
from typing import Any, Dict, List, Set, Tuple
Expand All @@ -17,10 +18,15 @@
# ---------------------------------------------------------------------------
# Locate spec files via CLOUD_REPO_PATH in .env
# ---------------------------------------------------------------------------
_SDK_REPO = Path(__file__).resolve().parents[2] # browser-use-python/tests -> sdk repo root
_SDK_REPO = (
Path(__file__).resolve().parents[2]
) # browser-use-python/tests -> sdk repo root


def _get_cloud_repo_path() -> Path:
if env_path := os.environ.get("CLOUD_REPO_PATH"):
return Path(env_path)

env_file = _SDK_REPO / ".env"
for line in env_file.read_text().splitlines():
line = line.strip()
Expand Down Expand Up @@ -89,7 +95,10 @@ def _load_spec(path: Path) -> Dict[str, Any]:
("post", "/skills/{skill_id}/refine"): ("skills", "refine"),
("post", "/skills/{skill_id}/rollback"): ("skills", "rollback"),
("get", "/skills/{skill_id}/executions"): ("skills", "executions"),
("get", "/skills/{skill_id}/executions/{execution_id}/output"): ("skills", "execution_output"),
("get", "/skills/{skill_id}/executions/{execution_id}/output"): (
"skills",
"execution_output",
),
# marketplace
("get", "/marketplace/skills"): ("marketplace", "list"),
("get", "/marketplace/skills/{skill_slug}"): ("marketplace", "get"),
Expand Down Expand Up @@ -158,10 +167,6 @@ def test_all_spec_endpoints_mapped(self) -> None:
assert not missing, f"Unmapped v2 endpoints: {missing}"

def test_sdk_methods_exist(self) -> None:
from browser_use_sdk.v2.client import BrowserUse

client = BrowserUse.__new__(BrowserUse)
# Manually set up resource stubs so we can inspect
from browser_use_sdk.v2 import resources

for resource_attr, method_name in _V2_MAP.values():
Expand All @@ -181,9 +186,7 @@ def test_sdk_methods_exist(self) -> None:
f"{cls.__name__} missing method '{method_name}'"
)
method = getattr(cls, method_name)
assert callable(method), (
f"{cls.__name__}.{method_name} is not callable"
)
assert callable(method), f"{cls.__name__}.{method_name} is not callable"

def test_async_sdk_methods_exist(self) -> None:
from browser_use_sdk.v2 import resources
Expand Down Expand Up @@ -221,7 +224,13 @@ def test_all_spec_endpoints_mapped(self) -> None:
assert not missing, f"Unmapped v3 endpoints: {missing}"

def test_sdk_methods_exist(self) -> None:
from browser_use_sdk.v3.resources import billing, browsers, profiles, sessions, workspaces
from browser_use_sdk.v3.resources import (
billing,
browsers,
profiles,
sessions,
workspaces,
)

resource_classes = {
"billing": billing.Billing,
Expand All @@ -237,7 +246,13 @@ def test_sdk_methods_exist(self) -> None:
)

def test_async_sdk_methods_exist(self) -> None:
from browser_use_sdk.v3.resources import billing, browsers, profiles, sessions, workspaces
from browser_use_sdk.v3.resources import (
billing,
browsers,
profiles,
sessions,
workspaces,
)

async_classes = {
"billing": billing.AsyncBilling,
Expand Down Expand Up @@ -302,11 +317,7 @@ def _load(self) -> None:

def _get_query_params(self, method: str, path: str) -> Set[str]:
op = self.spec["paths"].get(path, {}).get(method, {})
return {
p["name"]
for p in op.get("parameters", [])
if p.get("in") == "query"
}
return {p["name"] for p in op.get("parameters", []) if p.get("in") == "query"}

def _resolve_ref(self, ref: str) -> Dict[str, Any]:
parts = ref.lstrip("#/").split("/")
Expand Down Expand Up @@ -408,10 +419,15 @@ def test_task_action_variants(self) -> None:
if not method_name:
missing.append(f"No SDK method mapping for action '{action}'")
continue
for label, classes_fn in [("sync", _get_resource_classes), ("async", _get_async_resource_classes)]:
for label, classes_fn in [
("sync", _get_resource_classes),
("async", _get_async_resource_classes),
]:
cls = classes_fn()["tasks"]
if not hasattr(cls, method_name):
missing.append(f"{cls.__name__} missing '{method_name}' for action '{action}'")
missing.append(
f"{cls.__name__} missing '{method_name}' for action '{action}'"
)

assert not missing, "Missing action variants:\n" + "\n".join(missing)

Expand All @@ -435,8 +451,14 @@ def test_v2_resources_attached(self) -> None:

client = BrowserUse(api_key="test-key")
expected = [
"billing", "tasks", "sessions", "files",
"profiles", "browsers", "skills", "marketplace",
"billing",
"tasks",
"sessions",
"files",
"profiles",
"browsers",
"skills",
"marketplace",
]
for attr in expected:
assert hasattr(client, attr), f"BrowserUse missing .{attr}"
Expand All @@ -449,3 +471,35 @@ def test_v3_resources_attached(self) -> None:
assert hasattr(client, "sessions")
assert hasattr(client, "workspaces")
client.close()


class TestV3SessionPayloads:
def test_sessions_create_serializes_auto_heal(self) -> None:
from browser_use_sdk.v3.resources.sessions import Sessions

class FakeHttp:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []

def request(self, method: str, path: str, *, json=None, params=None):
self.calls.append(
{"method": method, "path": path, "json": json, "params": params}
)
return {
"id": "00000000-0000-0000-0000-000000000001",
"status": "created",
"model": "gemini-3-flash",
"createdAt": "2026-05-26T00:00:00Z",
"updatedAt": "2026-05-26T00:00:00Z",
}

http = FakeHttp()
Sessions(http).create(
"Fetch https://httpbin.org/anything?item=@{{alpha}}",
workspace_id="00000000-0000-0000-0000-000000000002",
cache_script=True,
auto_heal=False,
)

assert http.calls[0]["json"]["cacheScript"] is True
assert http.calls[0]["json"]["autoHeal"] is False
Loading