From 430c11bac9d47490e5674899140d65fca2793caa Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 14 Nov 2026 18:30:53 -0800 Subject: [PATCH 1/5] fix(browser): a start tab already on its URL's page is reloaded there, so a load of cookies alone reaches it A load of cookies or local storage without tabs loads the start tabs again so their pages read it. A tab already on its start URL's document, as a hash-routed page's is, only moved within it: the navigation named no loader, loaded nothing, and timed out. Such a tab now moves to the URL within its document if it's elsewhere there, then reloads, and the load takes the reload's first commit as its document. Co-Authored-By: Claude Opus 5.5 --- .../website_browser/control.py | 53 +++++++++++++++-- .../tests/test_website_browser.py | 57 ++++++++++++++++++- tst/integration/computer/browser_data.py | 7 ++- tst/integration/computer/chromium_app_test.py | 42 +++++++++++++- 4 files changed, 147 insertions(+), 12 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py index de05263b3..37c417db6 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Awaitable, Callable, Optional -from urllib.parse import urlsplit +from urllib.parse import urldefrag, urlsplit from .log import ACTOR, KEY_FIELDS, TYPING_METHODS, BrowserLog, Causes, caused, log_time, redact_url from ..computer.errors import DeviceUnavailable, InvalidArgument @@ -326,7 +326,8 @@ async def load_start_tabs(self, urls: list[str], site_of: Callable[[str], Option as ``site_of`` names them, an error status is too, or once it has been waited on ``own_timeout_s`` on such a site, else ``live_timeout_s``. Each tab's outcome, in order, kept though the browser has gone and the first can't come to the front. A page already loaded doesn't read cookies set after it, so a tab a load signed in - shows it only once it loads again.""" + shows it only once it loads again: a tab already on its URL's document, as a hash-routed page, is reloaded + there, since going to the URL alone would only move within the document (``_start_load``).""" if not urls: return [] targets = [target for target, tab in self.tabs.items() @@ -357,7 +358,8 @@ async def _first_tab(self) -> str: async def _start_load(self, target: str, url: str, site: Optional[str]) -> _StartLoad: """The tab ``target`` sent to ``url``, its navigation followed by what the browser says of it. The browser answers a navigation once it commits, which a page that never answers never does, so its answer is awaited - apart, for as long as the tab is.""" + apart, for as long as the tab is. A tab already on ``url``'s document is reloaded there instead + (``_reload_start``).""" session = next((s for s, t in self._sessions.items() if t == target), None) if session is None: raise DeviceUnavailable(f"the browser's tab {target} has no session to load a start tab in") @@ -368,6 +370,8 @@ async def _start_load(self, target: str, url: str, site: Optional[str]) -> _Star load.error = f"{type(e).__name__}: {e}" load.check() return load + if _same_document(self.tabs.get(target, {}).get("url", ""), url): + return await self._reload_start(target, load, session) landed = asyncio.get_running_loop().create_future() self.causes.navigating(target, landed, _cause()) load.navigating = asyncio.ensure_future(self.devtools.request("Page.navigate", {"url": url}, @@ -376,6 +380,28 @@ async def _start_load(self, target: str, url: str, site: Optional[str]) -> _Star load.navigating.add_done_callback(lambda done: _landed_at(landed, done)) return load + async def _reload_start(self, target: str, load: _StartLoad, session: str) -> _StartLoad: + """``load``'s tab, already on its URL's document, moved to the URL within it when it's elsewhere there, then + reloaded, so its page loads again and reads what a load just set; a navigation to the URL alone would only move + within the document, which loads nothing and names no loader. A reload names none either, so the load's + document is the first its main frame commits after it, and the log takes that navigation as the reload's.""" + load.reloading = True + if self.tabs.get(target, {}).get("url") != load.url: + try: + moved = await self._navigate(target, "Page.navigate", {"url": load.url}, session) + except (ConnectionError, DevToolsError, asyncio.TimeoutError) as e: + moved = {"errorText": f"{type(e).__name__}: {e}"} + if moved.get("errorText"): + load.error = moved["errorText"] + load.check() + return load + cause = _cause() + self.causes.navigates_next(target, cause) + load.navigating = asyncio.ensure_future(self.devtools.request("Page.reload", {}, session_id=session)) + load.navigating.add_done_callback(load.answered) + load.navigating.add_done_callback(lambda done: _reloaded(self.causes, target, cause, done)) + return load + async def _settled(self, target: str, load: _StartLoad, timeout_s: float) -> dict: """``load``'s outcome once it settles, or once it has been waited on ``timeout_s``.""" try: @@ -1039,6 +1065,8 @@ def _on_Page_frameNavigated(self, params: dict, session: Optional[str]) -> None: self._spawn(self._log_navigated(self.log.reserve(), log_time(), target, frame.get("url", ""), frame.get("loaderId"))) if (load := self._loading.get(session)) is not None and not frame.get("parentId") and frame.get("loaderId"): + if load.reloading and load.loader is None: + load.loader = frame["loaderId"] load.commits.append(frame["loaderId"]) load.check() @@ -1228,7 +1256,8 @@ class _StartLoad: commits in order, the loads that fired after a commit, and its documents' requests, final responses and failures. Its document is the last its main frame committed from its own navigation on, which a page that sends itself elsewhere before it loads replaces. It's ``settled`` once its navigation or its document has failed, its document - has loaded or, on one of the env's ``site``, answered an error.""" + has loaded or, on one of the env's ``site``, answered an error. A ``reloading`` one's navigation is a reload, whose + answer names no loader: its loader is its main frame's first commit.""" session: str url: str site: Optional[str] @@ -1242,6 +1271,7 @@ class _StartLoad: failures: dict[str, str] = field(default_factory=dict) settled: asyncio.Event = field(default_factory=asyncio.Event) navigating: Optional[asyncio.Future] = None + reloading: bool = False def answered(self, navigating: asyncio.Future) -> None: """The browser's answer to its navigation: its loader, or why it couldn't navigate.""" @@ -1255,7 +1285,7 @@ def answered(self, navigating: asyncio.Future) -> None: self.error = str(answer["error"].get("message", answer["error"])) else: self.error = answer.get("result", {}).get("errorText") or None - self.loader = answer.get("result", {}).get("loaderId") + self.loader = answer.get("result", {}).get("loaderId") or self.loader self.check() def current(self) -> Optional[str]: @@ -1286,6 +1316,19 @@ def _landed_at(landed: asyncio.Future, navigating: asyncio.Future) -> None: landed.cancel() +def _same_document(current: str, url: str) -> bool: + """Whether a tab at ``current`` sent to ``url`` stays in its document, as a browser keeps a navigation to a URL + with a fragment that differs from where it is in its fragment alone, or not at all.""" + return "#" in url and urldefrag(url)[0] == urldefrag(current)[0] + + +def _reloaded(causes: Causes, target: str, cause: dict, reloading: asyncio.Future) -> None: + """A start tab's reload answered: one that failed navigates nowhere, so the log doesn't take its tab's next + navigation for it.""" + if reloading.cancelled() or reloading.exception() is not None or "error" in reloading.result(): + causes.navigated_nowhere(target, cause) + + def _types(event: dict) -> bool: """Whether a page input event types something.""" return event.get("method") in TYPING_METHODS and any(field in (event.get("params") or {}) for field in KEY_FIELDS) diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py index 816337d61..05c7e56c1 100644 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -13,6 +13,7 @@ import os import signal import sys +from urllib.parse import urldefrag import pytest @@ -611,11 +612,18 @@ class Navigating(StubDevTools): navigation as ``pages`` says by URL: a status and the URL it ends on, then committed and, unless ``no-load`` follows, loaded, or with ``("redirect", status, url)`` following, sending itself there by script before it loads; ``refused``, a network error; ``silent``, never answered, as a page that never answers isn't; or ``stale``, a load - event with no commit.""" + event with no commit. A navigation that stays in a tab's document, to a URL with a fragment that differs from + where it is (``at``) in its fragment alone, moves within it and names no loader, as a browser's does; a reload + loads where the tab is as a navigation there would, naming no loader either.""" def __init__(self, pages): super().__init__({"Target.getTargets": {"targetInfos": []}}) - self.pages, self.opened, self.loaders = pages, 0, 0 + self.pages, self.opened, self.loaders, self.urls = pages, 0, 0, {} + + def at(self, target, url): + """The tab ``target`` at ``url``, as the browser tells the control.""" + self.urls[target] = url + self.send("Target.targetInfoChanged", _page(target, url)) async def call(self, method, params=None, *, session_id=None, timeout_s=None): if method == "Target.createTarget": @@ -626,10 +634,23 @@ async def call(self, method, params=None, *, session_id=None, timeout_s=None): self.send("Target.attachedToTarget", {"sessionId": f"s-{target}", "waitingForDebugger": True, **_page(target)}) return {"targetId": target} + if method == "Page.reload": + self.calls.append((method, params, session_id)) + await self._load(self.urls[session_id[2:]], session_id) + return {} if method != "Page.navigate": return await super().call(method, params, session_id=session_id, timeout_s=timeout_s) self.calls.append((method, params, session_id)) - page, target = self.pages[params["url"]], session_id[2:] + url, target = params["url"], session_id[2:] + if "#" in url and urldefrag(url)[0] == urldefrag(self.urls.get(target, ""))[0]: + self.send("Page.navigatedWithinDocument", {"frameId": target, "url": url}, session_id) + self.at(target, url) + return {"frameId": target} + return await self._load(url, session_id) + + async def _load(self, url, session_id): + page, target = self.pages[url], session_id[2:] + self.urls[target] = url self.loaders += 1 loader, request = f"L{self.loaders}", f"r{self.loaders}" if page == "refused": @@ -736,6 +757,36 @@ async def test_a_start_page_that_sends_itself_elsewhere_before_it_loads_is_judge assert (tab["final_url"], tab["status"], tab["outcome"]) == (final, status, outcome) +@pytest.mark.asyncio +@pytest.mark.parametrize("at, moved", [("https://shop.test/#/orders", False), ("https://shop.test/#/cart", True)], + ids=["at-its-url", "at-another-route-of-its-page"]) +async def test_a_start_tab_already_on_its_pages_document_is_reloaded_there_and_loads_again(at, moved): + url = "https://shop.test/#/orders" + devtools = Navigating({url: (200, url)}) + control = await _started(devtools) + devtools.at("start", at) + + (tab,) = await control.load_start_tabs([url], _shop, own_timeout_s=2) + + assert (tab["outcome"], tab["status"], tab["final_url"], tab["error"]) == ("loaded", 200, url, None) + assert _sent(devtools, "Page.navigate") == ([({"url": url}, "s-start")] if moved else []) + assert _sent(devtools, "Page.reload") == [({}, "s-start")] + assert not control._loading + + +@pytest.mark.asyncio +async def test_a_start_tab_on_another_page_of_its_site_navigates_and_isnt_reloaded(): + url = "https://shop.test/#/orders" + devtools = Navigating({url: (200, url)}) + control = await _started(devtools) + devtools.at("start", "https://shop.test/account") + + (tab,) = await control.load_start_tabs([url], _shop, own_timeout_s=2) + + assert (tab["outcome"], tab["final_url"]) == ("loaded", url) + assert (_sent(devtools, "Page.navigate"), _sent(devtools, "Page.reload")) == ([({"url": url}, "s-start")], []) + + @pytest.mark.asyncio async def test_a_start_tab_still_loading_when_the_browser_ends_fails_naming_that_and_is_kept_with_its_pipe_gone(): def gone(params, session): diff --git a/tst/integration/computer/browser_data.py b/tst/integration/computer/browser_data.py index e0561ece4..1f9af0757 100644 --- a/tst/integration/computer/browser_data.py +++ b/tst/integration/computer/browser_data.py @@ -33,7 +33,8 @@ #: How long the site serves, an effect may take to show and how often it's asked meanwhile. SERVE_S, WAIT_S, POLL_S = 900, 90.0, 0.5 # Serves, on loopback for the seconds it's given: / as "signed in as theme " for a -# request with the sid cookie, else a redirect to /login, titled "login"; and /other, titled "other". +# request with the sid cookie, else a redirect to /login, titled "login"; /app, a page that routes by its URL's +# fragment, as "app ", else "app signed out"; and /other, titled "other". SERVE_SITE = r"""import http.cookies, http.server, json, sys, threading, time class Page(http.server.BaseHTTPRequestHandler): def do_GET(self): @@ -44,7 +45,9 @@ def do_GET(self): self.send_header("Content-Length", "0") self.end_headers() return - if self.path == "/": + if self.path == "/app": + body = ("app %s" % (sid.value if sid is not None else "signed out")).encode() + elif self.path == "/": body = ("loading" % json.dumps(sid.value)).encode() else: diff --git a/tst/integration/computer/chromium_app_test.py b/tst/integration/computer/chromium_app_test.py index 7972eb42f..fb643314e 100644 --- a/tst/integration/computer/chromium_app_test.py +++ b/tst/integration/computer/chromium_app_test.py @@ -10,7 +10,8 @@ its control connection keeps lists a tab an agent opened and closed, a download with its size and sha256, a confirm() accepted for it, and each launch, exit and stop with its cause, across a kill and a restart of its server; and browser/v1 reads and sets its cookies, local storage and downloads; and its data plane signs it in, snapshots it, resets it and loads a -snapshot back; and the browser task steps set its cookies and storage and open, activate and close its tabs by URL. +snapshot back, a load of cookies alone reloading a start tab on a hash route so its page reads them; and the browser +task steps set its cookies and storage and open, activate and close its tabs by URL. Its start tabs open in order, the first active, a deploy recording each one's outcome and going on past a live page that never answers; and with the run's sites routed to it, a start tab on one must load, judged by the page it lands on when it sends itself elsewhere before it loads, so a site answering 503 fails the deploy, naming it. Needs Docker and the local registry on localhost:5000.""" @@ -20,6 +21,7 @@ import json import platform import re +import time import uuid import httpx @@ -32,7 +34,9 @@ from agent_env.env import Env from agentenv_protocol.types import WELL_KNOWN_PATH from agentenv_protocol.website_browser import BROWSER, SHM_MIN_BYTES -from tst.integration.computer.browser_data import SITE as DATA_SITE, check_browser_data +from agentenv_protocol.website_browser.control import LIVE_START_TAB_TIMEOUT_S +from tst.integration.computer.browser_data import (SERVE_S, SERVE_SITE, SITE as DATA_SITE, _client, _load, _state, + _universe, check_browser_data) from tst.integration.computer.browser_steps import check_browser_steps from tst.integration.computer.browser_state import check_browser_state from tst.integration.computer.start_tabs import host_pages, tabs_of @@ -394,6 +398,40 @@ async def act(): assert not report.failed +@pytest.mark.asyncio +async def test_a_load_of_cookies_alone_reloads_a_start_tab_on_a_hash_route_so_its_page_reads_them(chromium_desktop): + start = f"{DATA_SITE}app#/trips" + with namespace_routing(): + deployed = await Env.get(chromium_desktop.id).deploy( + env_params={"app_settings": {"chromium": {"start_urls": [start]}}}) + context = TaskStepContext(deployed_envs=[deployed]) + serving = asyncio.ensure_future(inside.run(deployed, ["python3", "-c", SERVE_SITE, str(SERVE_S)], + timeout_s=SERVE_S + 60)) + try: + card = await _app(deployed, WELL_KNOWN_PATH, "chromium") + await _ready(deployed, card, "chromium") + call, titled = _client(deployed, lambda op: f"{deployed.environment_url}/svc/chromium" + f"{protocol_v1.find_extension_method(card, BROWSER, op)['endpoint']}") + await asyncio.sleep(SERVE_START_S) + await call("navigate", {"url": f"{DATA_SITE}other"}) + await titled("other") + await call("navigate", {"url": start}) + signed_out = await titled("app signed out") + cookies = _universe(chromium_desktop.id, {"browser-state.json": _state( + cookies=[{"name": "sid", "value": "ada", "url": DATA_SITE, "max_age": 3600}])}) + loading = time.monotonic() + context = await _load(context, chromium_desktop.id, "cookies", artifact_id=cookies.id) + loaded_s = time.monotonic() - loading + signed_in = await titled("app ada") + finally: + serving.cancel() + report = await teardown_run(context) + + assert signed_out["url"] == start and signed_in["url"] == start, (signed_out, signed_in) + assert loaded_s < LIVE_START_TAB_TIMEOUT_S, loaded_s + assert not report.failed + + @pytest.mark.asyncio async def test_its_start_tabs_open_in_order_the_first_active_and_a_live_one_that_never_answers_is_recorded_not_fatal( chromium_desktop): From aa71b1a148f74ec487d9ffe7e5b4c1c5a02b2135 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 14 Nov 2026 18:37:56 -0800 Subject: [PATCH 2/5] fix(browser): a start tab reloads when the browser stays in its document, and the log keeps that reload's cause Comparing the start URL with the tab's left out URLs the browser spells otherwise (https://shop.test#/orders is https://shop.test/#/orders in the tab). The start tab now navigates as before and reloads when the browser's answer names no loader, which is the browser saying it stayed in the document. A move within a document no longer uses up a reload asked for after it, so the log takes the reload's navigation for agentenv's. Co-Authored-By: Claude Opus 5.5 --- .../website_browser/control.py | 88 +++++++------------ .../agentenv_protocol/website_browser/log.py | 5 +- .../tests/test_browser_log.py | 15 ++++ .../tests/test_website_browser.py | 29 +++--- 4 files changed, 67 insertions(+), 70 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py index 37c417db6..1ebbd5227 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Awaitable, Callable, Optional -from urllib.parse import urldefrag, urlsplit +from urllib.parse import urlsplit from .log import ACTOR, KEY_FIELDS, TYPING_METHODS, BrowserLog, Causes, caused, log_time, redact_url from ..computer.errors import DeviceUnavailable, InvalidArgument @@ -327,7 +327,7 @@ async def load_start_tabs(self, urls: list[str], site_of: Callable[[str], Option site, else ``live_timeout_s``. Each tab's outcome, in order, kept though the browser has gone and the first can't come to the front. A page already loaded doesn't read cookies set after it, so a tab a load signed in shows it only once it loads again: a tab already on its URL's document, as a hash-routed page, is reloaded - there, since going to the URL alone would only move within the document (``_start_load``).""" + there, since going to the URL alone only moves within the document (``_start_navigation``).""" if not urls: return [] targets = [target for target, tab in self.tabs.items() @@ -358,8 +358,8 @@ async def _first_tab(self) -> str: async def _start_load(self, target: str, url: str, site: Optional[str]) -> _StartLoad: """The tab ``target`` sent to ``url``, its navigation followed by what the browser says of it. The browser answers a navigation once it commits, which a page that never answers never does, so its answer is awaited - apart, for as long as the tab is. A tab already on ``url``'s document is reloaded there instead - (``_reload_start``).""" + apart, for as long as the tab is, and a navigation that stays in the tab's document is followed by a reload + (``_start_navigation``).""" session = next((s for s, t in self._sessions.items() if t == target), None) if session is None: raise DeviceUnavailable(f"the browser's tab {target} has no session to load a start tab in") @@ -370,37 +370,35 @@ async def _start_load(self, target: str, url: str, site: Optional[str]) -> _Star load.error = f"{type(e).__name__}: {e}" load.check() return load - if _same_document(self.tabs.get(target, {}).get("url", ""), url): - return await self._reload_start(target, load, session) - landed = asyncio.get_running_loop().create_future() - self.causes.navigating(target, landed, _cause()) - load.navigating = asyncio.ensure_future(self.devtools.request("Page.navigate", {"url": url}, - session_id=session)) + load.navigating = asyncio.ensure_future(self._start_navigation(target, load, session)) load.navigating.add_done_callback(load.answered) - load.navigating.add_done_callback(lambda done: _landed_at(landed, done)) return load - async def _reload_start(self, target: str, load: _StartLoad, session: str) -> _StartLoad: - """``load``'s tab, already on its URL's document, moved to the URL within it when it's elsewhere there, then - reloaded, so its page loads again and reads what a load just set; a navigation to the URL alone would only move - within the document, which loads nothing and names no loader. A reload names none either, so the load's - document is the first its main frame commits after it, and the log takes that navigation as the reload's.""" + async def _start_navigation(self, target: str, load: _StartLoad, session: str) -> dict: + """``load``'s tab sent to its URL, which the log says who asked for: the browser's answer. A tab already on the + URL's document, as a hash-routed page is, only moves within it, which loads nothing and names no loader, so the + page wouldn't read what a load just set: it's reloaded there, the load taking its main frame's first commit + after for its document, and the log that navigation for the reload.""" + cause, landed = _cause(), asyncio.get_running_loop().create_future() + self.causes.navigating(target, landed, cause) + try: + answer = await self.devtools.request("Page.navigate", {"url": load.url}, session_id=session) + except BaseException: + landed.cancel() + raise + result = answer.get("result") or {} + if "error" in answer or result.get("errorText"): + landed.cancel() + return answer + landed.set_result(result.get("loaderId")) + if result.get("loaderId"): + return answer load.reloading = True - if self.tabs.get(target, {}).get("url") != load.url: - try: - moved = await self._navigate(target, "Page.navigate", {"url": load.url}, session) - except (ConnectionError, DevToolsError, asyncio.TimeoutError) as e: - moved = {"errorText": f"{type(e).__name__}: {e}"} - if moved.get("errorText"): - load.error = moved["errorText"] - load.check() - return load - cause = _cause() self.causes.navigates_next(target, cause) - load.navigating = asyncio.ensure_future(self.devtools.request("Page.reload", {}, session_id=session)) - load.navigating.add_done_callback(load.answered) - load.navigating.add_done_callback(lambda done: _reloaded(self.causes, target, cause, done)) - return load + reloaded = await self.devtools.request("Page.reload", {}, session_id=session) + if "error" in reloaded: + self.causes.navigated_nowhere(target, cause) + return reloaded async def _settled(self, target: str, load: _StartLoad, timeout_s: float) -> dict: """``load``'s outcome once it settles, or once it has been waited on ``timeout_s``.""" @@ -1256,8 +1254,9 @@ class _StartLoad: commits in order, the loads that fired after a commit, and its documents' requests, final responses and failures. Its document is the last its main frame committed from its own navigation on, which a page that sends itself elsewhere before it loads replaces. It's ``settled`` once its navigation or its document has failed, its document - has loaded or, on one of the env's ``site``, answered an error. A ``reloading`` one's navigation is a reload, whose - answer names no loader: its loader is its main frame's first commit.""" + has loaded or, on one of the env's ``site``, answered an error. A ``reloading`` one's navigation stayed in its + document and was followed by a reload, whose answer names no loader either: its loader is its main frame's first + commit after.""" session: str url: str site: Optional[str] @@ -1304,31 +1303,6 @@ def check(self) -> None: self.settled.set() -def _landed_at(landed: asyncio.Future, navigating: asyncio.Future) -> None: - """``landed`` given the loader a start tab's navigation answered with; cancelled when it failed or named none.""" - if landed.done(): - return - answer = None if navigating.cancelled() or navigating.exception() is not None else navigating.result() - loader = ((answer or {}).get("result") or {}).get("loaderId") - if isinstance(loader, str): - landed.set_result(loader) - else: - landed.cancel() - - -def _same_document(current: str, url: str) -> bool: - """Whether a tab at ``current`` sent to ``url`` stays in its document, as a browser keeps a navigation to a URL - with a fragment that differs from where it is in its fragment alone, or not at all.""" - return "#" in url and urldefrag(url)[0] == urldefrag(current)[0] - - -def _reloaded(causes: Causes, target: str, cause: dict, reloading: asyncio.Future) -> None: - """A start tab's reload answered: one that failed navigates nowhere, so the log doesn't take its tab's next - navigation for it.""" - if reloading.cancelled() or reloading.exception() is not None or "error" in reloading.result(): - causes.navigated_nowhere(target, cause) - - def _types(event: dict) -> bool: """Whether a page input event types something.""" return event.get("method") in TYPING_METHODS and any(field in (event.get("params") or {}) for field in KEY_FIELDS) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/log.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/log.py index f9c2ce00d..bdda38735 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/log.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/log.py @@ -563,12 +563,13 @@ async def navigated(self, target: str, loader: Optional[str]) -> Optional[dict]: async def navigated_within(self, target: str) -> Optional[dict]: """What moved ``target`` within its document, once its navigations in flight have answered: a navigation - answered with no loader, else a reload or move in history, which it uses up.""" + answered with no loader, else a reload or move in history, which it uses up; one asked for after that + navigation, as a start tab's reload is, is left for the navigation it makes.""" if target not in self._same_document and self._navigating.get(target): await asyncio.wait(set(self._navigating[target]), timeout=CAUSE_WAIT_S) - pending = self._next.pop(target, None) if (cause := self._same_document.pop(target, None)) is not None: return cause + pending = self._next.pop(target, None) if pending is not None and pending[1] >= asyncio.get_running_loop().time(): return pending[0] return None diff --git a/packages/agentenv-protocol/tests/test_browser_log.py b/packages/agentenv-protocol/tests/test_browser_log.py index 32a2f61ed..eb1e05607 100644 --- a/packages/agentenv-protocol/tests/test_browser_log.py +++ b/packages/agentenv-protocol/tests/test_browser_log.py @@ -281,6 +281,21 @@ async def test_a_move_within_a_document_is_the_commands_that_asked_for_it_and_us assert await causes.navigated_within("T1") is None +@pytest.mark.asyncio +async def test_a_reload_asked_for_once_a_move_within_a_document_answered_is_left_for_the_navigation_it_makes(): + causes = browser_log.Causes() + moved, reload = {"seq": 3, "via": "agentenv"}, {"seq": 3, "via": "agentenv", "reload": True} + answer = asyncio.get_running_loop().create_future() + causes.navigating("T1", answer, moved) + within = asyncio.ensure_future(causes.navigated_within("T1")) + await asyncio.sleep(0) + answer.set_result(None) + causes.navigates_next("T1", reload) + + assert await within == moved + assert await causes.navigated("T1", "L-reload") == reload + + @pytest.mark.asyncio async def test_a_move_back_with_nowhere_to_go_leaves_no_cause_behind(tmp_path): pipe = Pipe() diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py index 05c7e56c1..6c23f2e6c 100644 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -13,7 +13,7 @@ import os import signal import sys -from urllib.parse import urldefrag +from urllib.parse import urldefrag, urlsplit, urlunsplit import pytest @@ -612,9 +612,9 @@ class Navigating(StubDevTools): navigation as ``pages`` says by URL: a status and the URL it ends on, then committed and, unless ``no-load`` follows, loaded, or with ``("redirect", status, url)`` following, sending itself there by script before it loads; ``refused``, a network error; ``silent``, never answered, as a page that never answers isn't; or ``stale``, a load - event with no commit. A navigation that stays in a tab's document, to a URL with a fragment that differs from - where it is (``at``) in its fragment alone, moves within it and names no loader, as a browser's does; a reload - loads where the tab is as a navigation there would, naming no loader either.""" + event with no commit. A navigation that stays in a tab's document, to a URL with a fragment that differs from where + it is (``at``) in its fragment alone, as a browser spells both (``_document``), moves within it and names no loader, + as a browser's does; a reload loads where the tab is as a navigation there would, naming no loader either.""" def __init__(self, pages): super().__init__({"Target.getTargets": {"targetInfos": []}}) @@ -642,7 +642,7 @@ async def call(self, method, params=None, *, session_id=None, timeout_s=None): return await super().call(method, params, session_id=session_id, timeout_s=timeout_s) self.calls.append((method, params, session_id)) url, target = params["url"], session_id[2:] - if "#" in url and urldefrag(url)[0] == urldefrag(self.urls.get(target, ""))[0]: + if "#" in url and _document(url) == _document(self.urls.get(target, "")): self.send("Page.navigatedWithinDocument", {"frameId": target, "url": url}, session_id) self.at(target, url) return {"frameId": target} @@ -676,6 +676,12 @@ def _document(self, session, target, loader, request, status, url): self.send("Page.frameNavigated", {"frame": {"id": target, "loaderId": loader, "url": url}}, session) +def _document(url): + """The document ``url`` names, as a browser spells it: without its fragment, an empty path as "/".""" + parts = urlsplit(urldefrag(url)[0]) + return urlunsplit(parts._replace(path=parts.path or "/")) + + async def _started(devtools, viewport=(1280, 800)) -> BrowserControl: """A control following a browser just launched, on its first tab, ``start``.""" control = BrowserControl(devtools, "agent", viewport) @@ -758,10 +764,11 @@ async def test_a_start_page_that_sends_itself_elsewhere_before_it_loads_is_judge @pytest.mark.asyncio -@pytest.mark.parametrize("at, moved", [("https://shop.test/#/orders", False), ("https://shop.test/#/cart", True)], - ids=["at-its-url", "at-another-route-of-its-page"]) -async def test_a_start_tab_already_on_its_pages_document_is_reloaded_there_and_loads_again(at, moved): - url = "https://shop.test/#/orders" +@pytest.mark.parametrize("at, url", [("https://shop.test/#/orders", "https://shop.test/#/orders"), + ("https://shop.test/#/cart", "https://shop.test/#/orders"), + ("https://shop.test/#/orders", "https://shop.test#/orders")], + ids=["at-its-url", "at-another-route-of-its-page", "at-its-url-spelled-otherwise"]) +async def test_a_start_tab_already_on_its_pages_document_is_reloaded_there_and_loads_again(at, url): devtools = Navigating({url: (200, url)}) control = await _started(devtools) devtools.at("start", at) @@ -769,8 +776,8 @@ async def test_a_start_tab_already_on_its_pages_document_is_reloaded_there_and_l (tab,) = await control.load_start_tabs([url], _shop, own_timeout_s=2) assert (tab["outcome"], tab["status"], tab["final_url"], tab["error"]) == ("loaded", 200, url, None) - assert _sent(devtools, "Page.navigate") == ([({"url": url}, "s-start")] if moved else []) - assert _sent(devtools, "Page.reload") == [({}, "s-start")] + assert [(method, params) for method, params, on in devtools.calls if on == "s-start" and method in ( + "Page.navigate", "Page.reload")] == [("Page.navigate", {"url": url}), ("Page.reload", {})] assert not control._loading From 3e6e520a93c38cb3dfbb787dd2789e195efa92b3 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 14 Nov 2026 18:47:15 -0800 Subject: [PATCH 3/5] fix(browser): a start tab's reload that fails before it commits fails the load at once A reload's loader is now its main frame's first document request as well as its first commit, so a reload the browser aborts before committing fails the start tab instead of waiting out its time. The test browser answers a reload before it commits, as Chrome does, and the Chromium test also loads cookies with the tab on another route of its start page. Co-Authored-By: Claude Opus 5.5 --- .../website_browser/control.py | 4 ++- .../tests/test_website_browser.py | 31 +++++++++++++++++-- tst/integration/computer/chromium_app_test.py | 12 +++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py index 1ebbd5227..c7072d12a 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py @@ -1084,6 +1084,8 @@ def _on_Page_navigatedWithinDocument(self, params: dict, session: Optional[str]) def _on_Network_requestWillBeSent(self, params: dict, session: Optional[str]) -> None: if (load := self._loading.get(session)) is not None and params.get("type") == "Document": load.requests[params["requestId"]] = params.get("loaderId", "") + if load.reloading and load.loader is None and params.get("frameId") == self._sessions.get(session): + load.loader = params.get("loaderId") or None def _on_Network_responseReceived(self, params: dict, session: Optional[str]) -> None: if (load := self._loading.get(session)) is not None and params.get("type") == "Document" and \ @@ -1256,7 +1258,7 @@ class _StartLoad: elsewhere before it loads replaces. It's ``settled`` once its navigation or its document has failed, its document has loaded or, on one of the env's ``site``, answered an error. A ``reloading`` one's navigation stayed in its document and was followed by a reload, whose answer names no loader either: its loader is its main frame's first - commit after.""" + document request or commit after, so a reload that fails before it commits fails the load.""" session: str url: str site: Optional[str] diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py index 6c23f2e6c..8e0e42ef0 100644 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -611,14 +611,14 @@ class Navigating(StubDevTools): """A browser whose first tab is ``start`` and whose new tabs are t1, t2, …, each attached as it opens, answering a navigation as ``pages`` says by URL: a status and the URL it ends on, then committed and, unless ``no-load`` follows, loaded, or with ``("redirect", status, url)`` following, sending itself there by script before it loads; - ``refused``, a network error; ``silent``, never answered, as a page that never answers isn't; or ``stale``, a load + ``refused``, a network error; ``aborted``, a document request that fails before it commits; ``silent``, never answered, as a page that never answers isn't; or ``stale``, a load event with no commit. A navigation that stays in a tab's document, to a URL with a fragment that differs from where it is (``at``) in its fragment alone, as a browser spells both (``_document``), moves within it and names no loader, as a browser's does; a reload loads where the tab is as a navigation there would, naming no loader either.""" def __init__(self, pages): super().__init__({"Target.getTargets": {"targetInfos": []}}) - self.pages, self.opened, self.loaders, self.urls = pages, 0, 0, {} + self.pages, self.opened, self.loaders, self.urls, self.reloading = pages, 0, 0, {}, None def at(self, target, url): """The tab ``target`` at ``url``, as the browser tells the control.""" @@ -636,7 +636,7 @@ async def call(self, method, params=None, *, session_id=None, timeout_s=None): return {"targetId": target} if method == "Page.reload": self.calls.append((method, params, session_id)) - await self._load(self.urls[session_id[2:]], session_id) + self.reloading = asyncio.ensure_future(self._reload(self.urls[session_id[2:]], session_id)) return {} if method != "Page.navigate": return await super().call(method, params, session_id=session_id, timeout_s=timeout_s) @@ -648,11 +648,21 @@ async def call(self, method, params=None, *, session_id=None, timeout_s=None): return {"frameId": target} return await self._load(url, session_id) + async def _reload(self, url, session_id): + """A reload, which the browser answers before it commits.""" + await asyncio.sleep(0.01) + await self._load(url, session_id) + async def _load(self, url, session_id): page, target = self.pages[url], session_id[2:] self.urls[target] = url self.loaders += 1 loader, request = f"L{self.loaders}", f"r{self.loaders}" + if page == "aborted": + document = {"requestId": request, "loaderId": loader, "type": "Document", "frameId": target} + self.send("Network.requestWillBeSent", document, session_id) + self.send("Network.loadingFailed", {"requestId": request, "errorText": "net::ERR_ABORTED"}, session_id) + return {"frameId": target, "loaderId": loader, "errorText": "net::ERR_ABORTED"} if page == "refused": return {"frameId": target, "loaderId": loader, "errorText": "net::ERR_CONNECTION_REFUSED"} if page == "silent": @@ -781,6 +791,21 @@ async def test_a_start_tab_already_on_its_pages_document_is_reloaded_there_and_l assert not control._loading +@pytest.mark.asyncio +async def test_a_start_tab_whose_reload_fails_before_it_commits_fails_at_once_naming_why(): + url = "https://shop.test/#/orders" + devtools = Navigating({url: "aborted"}) + control = await _started(devtools) + devtools.at("start", url) + loop = asyncio.get_running_loop() + started = loop.time() + + (tab,) = await control.load_start_tabs([url], _shop, own_timeout_s=2) + + assert (tab["outcome"], tab["error"]) == ("failed", "net::ERR_ABORTED") + assert loop.time() - started < 1 + + @pytest.mark.asyncio async def test_a_start_tab_on_another_page_of_its_site_navigates_and_isnt_reloaded(): url = "https://shop.test/#/orders" diff --git a/tst/integration/computer/chromium_app_test.py b/tst/integration/computer/chromium_app_test.py index fb643314e..547876498 100644 --- a/tst/integration/computer/chromium_app_test.py +++ b/tst/integration/computer/chromium_app_test.py @@ -10,8 +10,8 @@ its control connection keeps lists a tab an agent opened and closed, a download with its size and sha256, a confirm() accepted for it, and each launch, exit and stop with its cause, across a kill and a restart of its server; and browser/v1 reads and sets its cookies, local storage and downloads; and its data plane signs it in, snapshots it, resets it and loads a -snapshot back, a load of cookies alone reloading a start tab on a hash route so its page reads them; and the browser -task steps set its cookies and storage and open, activate and close its tabs by URL. +snapshot back, a load of cookies alone reloading a start tab on a hash route so its page reads them, the tab on its +start URL or on another route of its page; and the browser task steps set its cookies and storage and open, activate and close its tabs by URL. Its start tabs open in order, the first active, a deploy recording each one's outcome and going on past a live page that never answers; and with the run's sites routed to it, a start tab on one must load, judged by the page it lands on when it sends itself elsewhere before it loads, so a site answering 503 fails the deploy, naming it. Needs Docker and the local registry on localhost:5000.""" @@ -423,12 +423,20 @@ async def test_a_load_of_cookies_alone_reloads_a_start_tab_on_a_hash_route_so_it context = await _load(context, chromium_desktop.id, "cookies", artifact_id=cookies.id) loaded_s = time.monotonic() - loading signed_in = await titled("app ada") + await call("navigate", {"url": f"{DATA_SITE}app#/home"}) + elsewhere = (await call("tabs/list", method="GET"))["tabs"] + bob = _universe(chromium_desktop.id, {"browser-state.json": _state( + cookies=[{"name": "sid", "value": "bob", "url": DATA_SITE, "max_age": 3600}])}) + context = await _load(context, chromium_desktop.id, "cookies-elsewhere", artifact_id=bob.id) + moved_back = await titled("app bob") finally: serving.cancel() report = await teardown_run(context) assert signed_out["url"] == start and signed_in["url"] == start, (signed_out, signed_in) assert loaded_s < LIVE_START_TAB_TIMEOUT_S, loaded_s + assert [(tab["url"], tab["title"]) for tab in elsewhere] == [(f"{DATA_SITE}app#/home", "app ada")], elsewhere + assert moved_back["url"] == start, moved_back assert not report.failed From ef64893915063cd74595b35e13c8bf307df3ad47 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 14 Nov 2026 18:56:43 -0800 Subject: [PATCH 4/5] fix(browser): a start tab reloads once its move within its document has landed Chrome answers a navigation that stays in the document before the move lands, so a reload sent at once loaded the route the tab was on: a cookie load with the tab on another route of its start page reloaded that route. The start tab now waits for its main frame's move within the document, up to MOVE_WITHIN_S, before it reloads, and the test browser answers such a move before it lands, as Chrome does. Co-Authored-By: Claude Opus 5.5 --- .../website_browser/control.py | 14 +++++++++--- .../tests/test_website_browser.py | 22 ++++++++++++------- tst/integration/computer/chromium_app_test.py | 21 +++++++++--------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py index c7072d12a..2a8d16e11 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py @@ -33,6 +33,8 @@ ARRIVAL_POLL_S = 0.05 #: How long a start tab on a site the env doesn't serve may take to load before it's left loading. LIVE_START_TAB_TIMEOUT_S = 30.0 +#: How long a start tab's move within its document may take to land after the browser answers it, before its reload. +MOVE_WITHIN_S = 5.0 #: How a start tab settled. LOADED, FAILED, TIMED_OUT = "loaded", "failed", "timed_out" #: How much of a download each read hashes. @@ -377,8 +379,9 @@ async def _start_load(self, target: str, url: str, site: Optional[str]) -> _Star async def _start_navigation(self, target: str, load: _StartLoad, session: str) -> dict: """``load``'s tab sent to its URL, which the log says who asked for: the browser's answer. A tab already on the URL's document, as a hash-routed page is, only moves within it, which loads nothing and names no loader, so the - page wouldn't read what a load just set: it's reloaded there, the load taking its main frame's first commit - after for its document, and the log that navigation for the reload.""" + page wouldn't read what a load just set: it's reloaded there once the move has landed, which the browser + answers before it does (else the reload loads where the tab was), the load taking its main frame's first + document after for its own, and the log that navigation for the reload.""" cause, landed = _cause(), asyncio.get_running_loop().create_future() self.causes.navigating(target, landed, cause) try: @@ -393,6 +396,8 @@ async def _start_navigation(self, target: str, load: _StartLoad, session: str) - landed.set_result(result.get("loaderId")) if result.get("loaderId"): return answer + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(load.moved.wait(), MOVE_WITHIN_S) load.reloading = True self.causes.navigates_next(target, cause) reloaded = await self.devtools.request("Page.reload", {}, session_id=session) @@ -1076,8 +1081,10 @@ def _on_Page_frameAttached(self, params: dict, session: Optional[str]) -> None: def _on_Page_navigatedWithinDocument(self, params: dict, session: Optional[str]) -> None: """A tab's page moving to another URL of its document, a fragment or its history API: a command's, when one - asked for it, else its page's.""" + asked for it, else its page's; a start tab's move before its reload.""" target = self._sessions.get(session) + if (load := self._loading.get(session)) is not None and params.get("frameId") == target: + load.moved.set() if self.log is not None and target in self.tabs and params.get("frameId") == target: self._spawn(self._log_navigated(self.log.reserve(), log_time(), target, params.get("url", ""), False)) @@ -1273,6 +1280,7 @@ class _StartLoad: settled: asyncio.Event = field(default_factory=asyncio.Event) navigating: Optional[asyncio.Future] = None reloading: bool = False + moved: asyncio.Event = field(default_factory=asyncio.Event) def answered(self, navigating: asyncio.Future) -> None: """The browser's answer to its navigation: its loader, or why it couldn't navigate.""" diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py index 8e0e42ef0..233a47f3c 100644 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -611,14 +611,15 @@ class Navigating(StubDevTools): """A browser whose first tab is ``start`` and whose new tabs are t1, t2, …, each attached as it opens, answering a navigation as ``pages`` says by URL: a status and the URL it ends on, then committed and, unless ``no-load`` follows, loaded, or with ``("redirect", status, url)`` following, sending itself there by script before it loads; - ``refused``, a network error; ``aborted``, a document request that fails before it commits; ``silent``, never answered, as a page that never answers isn't; or ``stale``, a load - event with no commit. A navigation that stays in a tab's document, to a URL with a fragment that differs from where - it is (``at``) in its fragment alone, as a browser spells both (``_document``), moves within it and names no loader, - as a browser's does; a reload loads where the tab is as a navigation there would, naming no loader either.""" + ``refused``, a network error; ``aborted``, a document request that fails before it commits; ``silent``, never + answered, as a page that never answers isn't; or ``stale``, a load event with no commit. A navigation that stays in + a tab's document, to a URL with a fragment that differs from where it is (``at``) in its fragment alone, as a + browser spells both (``_document``), moves within it and names no loader, as a browser's does; a reload loads where + the tab is as a navigation there would, naming no loader either.""" def __init__(self, pages): super().__init__({"Target.getTargets": {"targetInfos": []}}) - self.pages, self.opened, self.loaders, self.urls, self.reloading = pages, 0, 0, {}, None + self.pages, self.opened, self.loaders, self.urls, self.pending = pages, 0, 0, {}, [] def at(self, target, url): """The tab ``target`` at ``url``, as the browser tells the control.""" @@ -636,18 +637,23 @@ async def call(self, method, params=None, *, session_id=None, timeout_s=None): return {"targetId": target} if method == "Page.reload": self.calls.append((method, params, session_id)) - self.reloading = asyncio.ensure_future(self._reload(self.urls[session_id[2:]], session_id)) + self.pending.append(asyncio.ensure_future(self._reload(self.urls[session_id[2:]], session_id))) return {} if method != "Page.navigate": return await super().call(method, params, session_id=session_id, timeout_s=timeout_s) self.calls.append((method, params, session_id)) url, target = params["url"], session_id[2:] if "#" in url and _document(url) == _document(self.urls.get(target, "")): - self.send("Page.navigatedWithinDocument", {"frameId": target, "url": url}, session_id) - self.at(target, url) + self.pending.append(asyncio.ensure_future(self._move_within(target, url, session_id))) return {"frameId": target} return await self._load(url, session_id) + async def _move_within(self, target, url, session_id): + """A move within a tab's document, which the browser answers before it lands.""" + await asyncio.sleep(0.05) + self.send("Page.navigatedWithinDocument", {"frameId": target, "url": url}, session_id) + self.at(target, url) + async def _reload(self, url, session_id): """A reload, which the browser answers before it commits.""" await asyncio.sleep(0.01) diff --git a/tst/integration/computer/chromium_app_test.py b/tst/integration/computer/chromium_app_test.py index 547876498..8f477d618 100644 --- a/tst/integration/computer/chromium_app_test.py +++ b/tst/integration/computer/chromium_app_test.py @@ -5,16 +5,17 @@ website browser it runs Chrome itself with agentenv's flags on its debugging pipe, its computer's /dev/shm the 1 GiB it asks for, as a container or a disk, so Chrome keeps its shared memory there, a reset_env too, answers location prompts and leaves notification ones to ask as the deploy says, and its menu launcher, run while it runs, opens another window -of it with no second copy, its cache kept and it still ready; and the -desktop that gives it French defaults deploys with them, a deploy's time zone over them, each recorded; and the record -its control connection keeps lists a tab an agent opened and closed, a download with its size and sha256, a confirm() -accepted for it, and each launch, exit and stop with its cause, across a kill and a restart of its server; and browser/v1 reads and sets its -cookies, local storage and downloads; and its data plane signs it in, snapshots it, resets it and loads a -snapshot back, a load of cookies alone reloading a start tab on a hash route so its page reads them, the tab on its -start URL or on another route of its page; and the browser task steps set its cookies and storage and open, activate and close its tabs by URL. -Its start tabs open in order, the first active, a deploy recording each one's outcome and going on past a live page -that never answers; and with the run's sites routed to it, a start tab on one must load, judged by the page it -lands on when it sends itself elsewhere before it loads, so a site answering 503 fails the deploy, naming it. Needs Docker and the local registry on localhost:5000.""" +of it with no second copy, its cache kept and it still ready; and the desktop that gives it French defaults deploys with +them, a deploy's time zone over them, each recorded; and the record its control connection keeps lists a tab an agent +opened and closed, a download with its size and sha256, a confirm() accepted for it, and each launch, exit and stop with +its cause, across a kill and a restart of its server; and browser/v1 reads and sets its cookies, local storage and +downloads; and its data plane signs it in, snapshots it, resets it and loads a snapshot back, a load of cookies alone +reloading a start tab on a hash route so its page reads them, the tab on its start URL or on another route of its page; +and the browser task steps set its cookies and storage and open, activate and close its tabs by URL. Its start tabs open +in order, the first active, a deploy recording each one's outcome and going on past a live page that never answers; and +with the run's sites routed to it, a start tab on one must load, judged by the page it lands on when it sends itself +elsewhere before it loads, so a site answering 503 fails the deploy, naming it. Needs Docker and the local registry on +localhost:5000.""" import asyncio import hashlib From 703b01c432d9ba2fa97b1effd8903ac58b35840d Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Sat, 14 Nov 2026 19:06:41 -0800 Subject: [PATCH 5/5] fix(browser): a start tab's reload cut short forgets its cause, and the test browser names its delays A start tab's wait can cancel its reload before the browser answers it; its pending cause in the log is now cleared, so a later navigation of the page isn't taken for agentenv's. Co-Authored-By: Claude Opus 5.5 --- .../website_browser/control.py | 6 ++++- .../tests/test_website_browser.py | 26 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py index 2a8d16e11..c4367f80f 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/website_browser/control.py @@ -400,7 +400,11 @@ async def _start_navigation(self, target: str, load: _StartLoad, session: str) - await asyncio.wait_for(load.moved.wait(), MOVE_WITHIN_S) load.reloading = True self.causes.navigates_next(target, cause) - reloaded = await self.devtools.request("Page.reload", {}, session_id=session) + try: + reloaded = await self.devtools.request("Page.reload", {}, session_id=session) + except BaseException: + self.causes.navigated_nowhere(target, cause) + raise if "error" in reloaded: self.causes.navigated_nowhere(target, cause) return reloaded diff --git a/packages/agentenv-protocol/tests/test_website_browser.py b/packages/agentenv-protocol/tests/test_website_browser.py index 233a47f3c..111a72247 100644 --- a/packages/agentenv-protocol/tests/test_website_browser.py +++ b/packages/agentenv-protocol/tests/test_website_browser.py @@ -617,9 +617,13 @@ class Navigating(StubDevTools): browser spells both (``_document``), moves within it and names no loader, as a browser's does; a reload loads where the tab is as a navigation there would, naming no loader either.""" - def __init__(self, pages): + #: How long after the browser answers them a move within a document lands, and a reload starts loading. + MOVE_LANDS_S, RELOAD_LOADS_S = 0.05, 0.01 + + def __init__(self, pages, reload_answers=True): super().__init__({"Target.getTargets": {"targetInfos": []}}) self.pages, self.opened, self.loaders, self.urls, self.pending = pages, 0, 0, {}, [] + self.reload_answers = reload_answers def at(self, target, url): """The tab ``target`` at ``url``, as the browser tells the control.""" @@ -637,6 +641,8 @@ async def call(self, method, params=None, *, session_id=None, timeout_s=None): return {"targetId": target} if method == "Page.reload": self.calls.append((method, params, session_id)) + if not self.reload_answers: + await asyncio.Event().wait() self.pending.append(asyncio.ensure_future(self._reload(self.urls[session_id[2:]], session_id))) return {} if method != "Page.navigate": @@ -650,13 +656,13 @@ async def call(self, method, params=None, *, session_id=None, timeout_s=None): async def _move_within(self, target, url, session_id): """A move within a tab's document, which the browser answers before it lands.""" - await asyncio.sleep(0.05) + await asyncio.sleep(self.MOVE_LANDS_S) self.send("Page.navigatedWithinDocument", {"frameId": target, "url": url}, session_id) self.at(target, url) async def _reload(self, url, session_id): """A reload, which the browser answers before it commits.""" - await asyncio.sleep(0.01) + await asyncio.sleep(self.RELOAD_LOADS_S) await self._load(url, session_id) async def _load(self, url, session_id): @@ -812,6 +818,20 @@ async def test_a_start_tab_whose_reload_fails_before_it_commits_fails_at_once_na assert loop.time() - started < 1 +@pytest.mark.asyncio +async def test_a_start_tab_whose_reload_never_answers_times_out_and_leaves_no_cause_for_a_later_navigation(): + url = "https://shop.test/#/orders" + devtools = Navigating({url: (200, url)}, reload_answers=False) + control = await _started(devtools) + devtools.at("start", url) + + (tab,) = await control.load_start_tabs([url], _shop, own_timeout_s=0.5) + await asyncio.sleep(0) + + assert tab["outcome"] == "timed_out" + assert "start" not in control.causes._next + + @pytest.mark.asyncio async def test_a_start_tab_on_another_page_of_its_site_navigates_and_isnt_reloaded(): url = "https://shop.test/#/orders"