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
9 changes: 9 additions & 0 deletions src/reverse_api/auto_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class AutoPromptEngineer(Protocol):
agent_provider: str
mcp_run_id: str
headless: bool
start_url: str | None
prompt: str
scripts_dir: Path
har_path: Path
Expand All @@ -91,6 +92,7 @@ def __init__(
# `headless` is auto-engineer specific: for MCP providers it configures the MCP
# server's browser launch; for `agent-browser` it only adjusts prompt wording.
headless = kwargs.pop("headless", False)
start_url = kwargs.pop("start_url", None)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -105,6 +107,7 @@ def __init__(
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless
self.start_url = start_url

def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]:
"""Build (system_prompt, user_message) for auto mode.
Expand Down Expand Up @@ -145,6 +148,7 @@ def _build_auto_prompts(self: AutoPromptEngineer) -> tuple[str, str]:
template_kwargs = {
"prompt": self.prompt,
"scripts_dir": str(self.scripts_dir),
"start_url_block": "\n" + load("auto/start_url", start_url=self.start_url) if self.start_url else "",
}
if self.agent_provider != "chrome-mcp":
template_kwargs["har_path"] = str(self.har_path)
Expand Down Expand Up @@ -367,6 +371,7 @@ class OpenCodeAutoEngineer(OpenCodeEngineer):
def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agent_provider: str = "auto", **kwargs: Any) -> None:
"""Initialize OpenCode-backed agent engineer."""
headless = kwargs.pop("headless", False)
start_url = kwargs.pop("start_url", None)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -381,6 +386,7 @@ def __init__(self, run_id: str, prompt: str, output_dir: str | None = None, agen
self.agent_provider = agent_provider
self.mcp_name: str | None = None
self.headless = headless
self.start_url = start_url

def _get_active_prompts(self) -> tuple[str, str]:
return ClaudeAutoEngineer._build_auto_prompts(self)
Expand Down Expand Up @@ -618,6 +624,7 @@ def __init__(
from .copilot_engineer import CopilotEngineer

headless = kwargs.pop("headless", False)
start_url = kwargs.pop("start_url", None)
har_dir = get_har_dir(run_id, output_dir)
har_path = har_dir / "recording.har"

Expand All @@ -632,6 +639,7 @@ def __init__(
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless
self.start_url = start_url

def start_sync(self) -> None:
self._engineer.start_sync()
Expand All @@ -658,6 +666,7 @@ async def analyze_and_generate(self) -> dict[str, Any] | None:
eng.agent_provider = self.agent_provider
eng.mcp_run_id = self.mcp_run_id
eng.headless = self.headless
eng.start_url = self.start_url

if self.agent_provider == "agent-browser":
ab_setup = ensure_agent_browser_runtime()
Expand Down
13 changes: 2 additions & 11 deletions src/reverse_api/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from rich.console import Console
from rich.status import Status

from .utils import get_har_dir, get_timestamp
from .utils import get_har_dir, get_timestamp, normalize_url

console = Console()

Expand Down Expand Up @@ -233,15 +233,6 @@ def _inject_stealth(self, page: Page) -> None:
"""Inject stealth scripts into page before any other scripts run."""
page.add_init_script(STEALTH_JS)

@staticmethod
def _normalize_url(url: str) -> str:
"""Prepend https:// when the user typed a bare host (e.g.
``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs."""
url = url.strip()
if url and "://" not in url:
return "https://" + url
return url

def _abort_playwright(self) -> None:
"""Quietly tear down the browser/Playwright after a failed start.

Expand Down Expand Up @@ -440,7 +431,7 @@ def start(self, start_url: str | None = None) -> Path:
console.print()

if start_url:
start_url = self._normalize_url(start_url)
start_url = normalize_url(start_url)

self._playwright = sync_playwright().start()

Expand Down
11 changes: 11 additions & 0 deletions src/reverse_api/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
get_history_path,
get_messages_path,
get_timestamp,
normalize_url,
resolve_run,
)

Expand Down Expand Up @@ -1222,6 +1223,9 @@ def agent(

Agent mode runs an integrated capture + reverse-engineering pipeline.
"""
if url:
url = normalize_url(url)

if dry_run:
# --dry-run is fundamentally about emitting machine-parseable validation
# results, so it implies --json (and therefore --no-interactive).
Expand Down Expand Up @@ -1539,6 +1543,9 @@ def run_auto_capture(
if not prompt:
raise click.UsageError("a prompt is required")

if url:
url = normalize_url(url)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

if agent_provider == "chrome-mcp" and not headless:
from .chrome_mcp import check_auto_connect

Expand Down Expand Up @@ -1636,6 +1643,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
start_url=url,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
)
elif sdk == "copilot":
from .auto_engineer import CopilotAutoEngineer
Expand All @@ -1651,6 +1659,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
start_url=url,
)
elif sdk == "cursor":
from .cursor_engineer import CursorAutoEngineer
Expand All @@ -1667,6 +1676,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
start_url=url,
cursor_model=model or config_manager.get("cursor_model", "composer-2.5"),
cursor_web_search=bool(config_manager.get("cursor_web_search", True)),
cursor_setting_sources=_cursor_src,
Expand All @@ -1685,6 +1695,7 @@ def run_auto_capture(
output_language=output_language,
interactive=interactive,
headless=headless,
start_url=url,
)

# CopilotAutoEngineer wraps the CopilotEngineer that owns the session.
Expand Down
1 change: 1 addition & 0 deletions src/reverse_api/copilot_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class CopilotEngineer(BaseEngineer):
agent_provider: str
mcp_run_id: str
headless: bool
start_url: str | None

def __init__(
self,
Expand Down
2 changes: 2 additions & 0 deletions src/reverse_api/cursor_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ def __init__(
**kwargs: Any,
) -> None:
headless = kwargs.pop("headless", False)
start_url = kwargs.pop("start_url", None)
from .utils import get_har_dir

har_dir = get_har_dir(run_id, output_dir)
Expand All @@ -512,6 +513,7 @@ def __init__(
self.mcp_run_id = run_id
self.agent_provider = agent_provider
self.headless = headless
self.start_url = start_url

def _cursor_mcp_servers(self) -> dict[str, Any]:
if self.agent_provider == "agent-browser":
Expand Down
5 changes: 5 additions & 0 deletions src/reverse_api/prompts/auto/start_url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<start_url>
{start_url}
</start_url>

Start the mission by opening this URL.
4 changes: 2 additions & 2 deletions src/reverse_api/prompts/auto/user_agent_browser.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<mission>
{prompt}
</mission>

{start_url_block}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
<output_directory>
{scripts_dir}
</output_directory>
Expand Down Expand Up @@ -59,7 +59,7 @@ Start HAR recording **before** navigation and confirm it succeeded (abort if thi
export AGENT_BROWSER_SESSION="{agent_browser_session}"
{agent_browser_shell} network har start
# verify exit code 0; if non-zero, diagnose with `{agent_browser_shell} doctor` and stop
{agent_browser_shell} open https://example.com
{agent_browser_shell} open "<target-url>" # the start URL, or the site the mission names
{agent_browser_shell} snapshot -i --json
# … iterate …
```
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/prompts/auto/user_chrome_mcp.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<mission>
{prompt}
</mission>

{start_url_block}
<output_directory>
{scripts_dir}
</output_directory>
Expand Down
2 changes: 1 addition & 1 deletion src/reverse_api/prompts/auto/user_playwright.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<mission>
{prompt}
</mission>

{start_url_block}
<output_directory>
{scripts_dir}
</output_directory>
Expand Down
9 changes: 9 additions & 0 deletions src/reverse_api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@
)


def normalize_url(url: str) -> str:
"""Prepend https:// when the user typed a bare host (e.g.
``jobs.example.com/x``); Playwright's goto rejects scheme-less URLs."""
url = url.strip()
if url and "://" not in url:
return "https://" + url
return url


def build_sdk_env() -> dict[str, str]:
"""Environment overrides for Claude Agent SDK subprocesses.

Expand Down
49 changes: 49 additions & 0 deletions tests/test_auto_engineer.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,55 @@ def test_opencode_delegates_prompts(self, tmp_path):
assert "npx" in user_p


class TestStartUrlPrompt:
"""The agent --url value reaches the user message."""

URL = "https://jobs.ashbyhq.com/openai"

def _user_prompt(self, tmp_path, agent_provider, **kwargs):
with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"):
with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"):
with patch("reverse_api.base_engineer.MessageStore"):
with patch("reverse_api.agent_browser.agent_browser_npx_package", return_value="agent-browser@test"):
eng = ClaudeAutoEngineer(
run_id="test123",
prompt="fetch all open job postings",
model="claude-sonnet-4-6",
output_dir=str(tmp_path),
agent_provider=agent_provider,
**kwargs,
)
return eng._build_auto_prompts()[1]

@pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"])
def test_start_url_follows_mission(self, tmp_path, agent_provider):
user_p = self._user_prompt(tmp_path, agent_provider, start_url=self.URL)
assert f"<start_url>\n{self.URL}\n</start_url>" in user_p
assert user_p.index("</mission>") < user_p.index("<start_url>") < user_p.index("<output_directory>")

@pytest.mark.parametrize("agent_provider", ["auto", "chrome-mcp", "agent-browser"])
def test_no_start_url_block_without_url(self, tmp_path, agent_provider):
user_p = self._user_prompt(tmp_path, agent_provider)
assert "<start_url>" not in user_p
assert "</mission>\n\n<output_directory>" in user_p

def test_opencode_renders_start_url(self, tmp_path):
with patch("reverse_api.auto_engineer.get_har_dir", return_value=tmp_path / "har"):
with patch("reverse_api.base_engineer.get_scripts_dir", return_value=tmp_path / "scripts"):
with patch("reverse_api.base_engineer.MessageStore"):
with patch("reverse_api.opencode_engineer.OpenCodeUI"):
eng = OpenCodeAutoEngineer(
run_id="test123",
prompt="fetch all open job postings",
output_dir=str(tmp_path),
opencode_provider="anthropic",
opencode_model="claude-opus-4-6",
start_url=self.URL,
)
_sys_p, user_p = eng._get_active_prompts()
assert f"<start_url>\n{self.URL}\n</start_url>" in user_p


class TestChromeMcpConfig:
"""Test MCP configuration selection."""

Expand Down
52 changes: 52 additions & 0 deletions tests/test_cli_agent_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -699,3 +699,55 @@ def test_direct_interrupt_after_claude_result_keeps_partial_run(tmp_path, output
payload = json.loads(result.stdout.strip().splitlines()[-1])
assert payload["error"] == "interrupted"
assert payload["script_path"] == "completed.py"


@pytest.mark.parametrize(
"sdk,engine_path",
[
("claude", "reverse_api.auto_engineer.ClaudeAutoEngineer"),
("opencode", "reverse_api.auto_engineer.OpenCodeAutoEngineer"),
("copilot", "reverse_api.auto_engineer.CopilotAutoEngineer"),
("cursor", "reverse_api.cursor_engineer.CursorAutoEngineer"),
],
)
def test_agent_url_reaches_engine(tmp_path, sdk, engine_path):
from unittest.mock import AsyncMock

from reverse_api.config import ConfigManager

config = ConfigManager(tmp_path / "config.json")
config.config.update({"sdk": sdk, "agent_provider": "auto", "real_time_sync": False})
engine = MagicMock()
engine.analyze_and_generate = AsyncMock(return_value=None)
with (
patch("reverse_api.cli.config_manager", config),
patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")),
patch(engine_path, return_value=engine) as engine_cls,
):
CliRunner().invoke(
main,
["agent", "-p", "fetch job postings", "-u", "https://jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"],
)
assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai"


def test_agent_bare_host_url_gets_https_scheme(tmp_path):
from unittest.mock import AsyncMock

from reverse_api.config import ConfigManager

config = ConfigManager(tmp_path / "config.json")
config.config.update({"sdk": "claude", "agent_provider": "auto", "real_time_sync": False})
engine = MagicMock()
engine.analyze_and_generate = AsyncMock(return_value=None)
with (
patch("reverse_api.cli.config_manager", config),
patch("reverse_api.cli.session_manager", SessionManager(tmp_path / "history.json")),
patch("reverse_api.auto_engineer.ClaudeAutoEngineer", return_value=engine) as engine_cls,
):
result = CliRunner().invoke(
main,
["agent", "-p", "fetch job postings", "-u", "jobs.ashbyhq.com/openai", "--headless", "-o", str(tmp_path), "--json"],
)
assert engine_cls.call_args.kwargs["start_url"] == "https://jobs.ashbyhq.com/openai"
assert json.loads(result.stdout.strip().splitlines()[-1])["url"] == "https://jobs.ashbyhq.com/openai"
17 changes: 17 additions & 0 deletions tests/test_cli_followups.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,23 @@ def test_dry_run_bad_url_is_misuse(self, tmp_path):
assert payload["error_kind"] == "misuse"
assert any(c["name"] == "url" and c["status"] == "error" for c in payload["checks"])

def test_dry_run_bare_host_gets_https_scheme(self, tmp_path):
"""A bare host passes the url check, as it does on a real run."""
from reverse_api.cli import agent as agent_cmd

runner = CliRunner()
with patch("reverse_api.cli.config_manager") as cm:
cm.get.side_effect = lambda key, default=None: {
"agent_provider": "auto",
"sdk": "claude",
"output_dir": str(tmp_path),
}.get(key, default)
result = runner.invoke(agent_cmd, ["--dry-run", "-p", "x", "-u", "jobs.example.com/x"])

payload = json.loads(result.stdout.strip())
assert payload["url"] == "https://jobs.example.com/x"
assert any(c["name"] == "url" and c["status"] == "ok" for c in payload["checks"])

def test_dry_run_unwritable_output_dir_is_config_invalid(self, tmp_path):
"""Unwritable output_dir → error_kind=config_invalid, not misuse."""
from reverse_api.cli import agent as agent_cmd
Expand Down
5 changes: 0 additions & 5 deletions tests/test_manual_browser_startup_failure.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,3 @@ def test_playwright_stopped_when_start_raises(self, tmp_path):

fake_pw.stop.assert_called_once()
assert browser._playwright is None

def test_scheme_added_to_bare_host(self):
assert ManualBrowser._normalize_url("jobs.example.com/x") == "https://jobs.example.com/x"
assert ManualBrowser._normalize_url("http://a.com") == "http://a.com"
assert ManualBrowser._normalize_url(" https://b.com ") == "https://b.com"
Loading
Loading