From 69b8bc1b8455d11591854e42b699436bd67bbdc0 Mon Sep 17 00:00:00 2001 From: Arnav Brahmasandra Date: Wed, 13 May 2026 17:56:00 -0700 Subject: [PATCH] Use longer default timeout for HITL actions --- .../src/narada_core/actions/models.py | 2 + packages/narada-pyodide/src/narada/window.py | 5 +- .../tests/test_cloud_browser.py | 64 ++++++++++++ packages/narada/src/narada/window.py | 5 +- .../tests/test_window_human_interaction.py | 98 +++++++++++++++++++ 5 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 packages/narada/tests/test_window_human_interaction.py diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 25b5cbc..8287221 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -22,6 +22,8 @@ # There is no `AgentRequest` because the `agent` action delegates to the `dispatch_request` method # under the hood. +DEFAULT_HITL_TIMEOUT_SECONDS = 300 + _StructuredOutputT = TypeVar("_StructuredOutputT") diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index 0bf4d3b..08654c8 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -22,6 +22,7 @@ from js import AbortController, setTimeout # type: ignore from narada_core.actions.critic import run_critic from narada_core.actions.models import ( + DEFAULT_HITL_TIMEOUT_SECONDS, AgenticMouseAction, AgenticMouseActionRequest, AgenticSelectorAction, @@ -818,7 +819,7 @@ async def prompt_for_user_input( *, step_id: str, variables: list[PromptForUserInputVariable], - timeout: int | None = None, + timeout: int | None = DEFAULT_HITL_TIMEOUT_SECONDS, ) -> dict[str, Any]: """Prompts the user for one or more input values in the extension UI.""" result = await self._run_extension_action( @@ -835,7 +836,7 @@ async def user_approval( prompt_message: str, approve_label: str, reject_label: str, - timeout: int | None = None, + timeout: int | None = DEFAULT_HITL_TIMEOUT_SECONDS, ) -> bool: """Prompts the user to approve or reject in the extension UI.""" result = await self._run_extension_action( diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index eecedc8..b5a7138 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -477,6 +477,70 @@ async def test_cloud_browser_window_get_downloaded_files_returns_presigned_urls( assert "key=downloads%2Fsession-123%2Freport.pdf" in second_call.args[0] +@pytest.mark.asyncio +async def test_remote_browser_window_prompt_for_user_input_uses_hitl_default_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + return_value=_FakeResponse( + json_data={ + "status": "success", + "data": '{"values_by_name":{"name":"Narada"}}', + } + ) + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + window = window_module.RemoteBrowserWindow( + browser_window_id="browser-window-123", + api_key="test-api-key", + ) + values = await window.prompt_for_user_input( + step_id="input-step", + variables=[ + window_module.PromptForUserInputVariable( + name="name", type="string", required=True + ), + ], + ) + + assert values == {"name": "Narada"} + call = pyfetch.await_args + assert call is not None + payload = json.loads(call.kwargs["body"]) + assert payload["timeout"] == window_module.DEFAULT_HITL_TIMEOUT_SECONDS + + +@pytest.mark.asyncio +async def test_remote_browser_window_user_approval_respects_explicit_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + return_value=_FakeResponse( + json_data={"status": "success", "data": '{"approved":true}'} + ) + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + window = window_module.RemoteBrowserWindow( + browser_window_id="browser-window-123", + api_key="test-api-key", + ) + approved = await window.user_approval( + step_id="approval-step", + prompt_message="Proceed?", + approve_label="Approve", + reject_label="Reject", + timeout=600, + ) + + assert approved is True + call = pyfetch.await_args + assert call is not None + payload = json.loads(call.kwargs["body"]) + assert payload["timeout"] == 600 + + @pytest.mark.asyncio async def test_remote_browser_window_without_cloud_session_keeps_extension_action_close( monkeypatch: pytest.MonkeyPatch, diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index c577951..c601d30 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -23,6 +23,7 @@ import aiohttp from narada_core.actions.critic import run_critic from narada_core.actions.models import ( + DEFAULT_HITL_TIMEOUT_SECONDS, AgenticMouseAction, AgenticMouseActionRequest, AgenticSelectorAction, @@ -769,7 +770,7 @@ async def prompt_for_user_input( *, step_id: str, variables: list[PromptForUserInputVariable], - timeout: int | None = None, + timeout: int | None = DEFAULT_HITL_TIMEOUT_SECONDS, ) -> dict[str, Any]: """Prompts the user for one or more input values in the extension UI.""" result = await self._run_extension_action( @@ -786,7 +787,7 @@ async def user_approval( prompt_message: str, approve_label: str, reject_label: str, - timeout: int | None = None, + timeout: int | None = DEFAULT_HITL_TIMEOUT_SECONDS, ) -> bool: """Prompts the user to approve or reject in the extension UI.""" result = await self._run_extension_action( diff --git a/packages/narada/tests/test_window_human_interaction.py b/packages/narada/tests/test_window_human_interaction.py new file mode 100644 index 0000000..093b8c9 --- /dev/null +++ b/packages/narada/tests/test_window_human_interaction.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from http import HTTPStatus +from typing import Any + +import pytest +from narada.window import RemoteBrowserWindow +from narada_core.actions.models import ( + DEFAULT_HITL_TIMEOUT_SECONDS, + PromptForUserInputVariable, +) + + +class _FakeResponse: + def __init__(self, payload: dict[str, Any], status: int = HTTPStatus.OK) -> None: + self._payload = payload + self.status = status + + async def __aenter__(self) -> "_FakeResponse": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def raise_for_status(self) -> None: + return None + + async def json(self) -> dict[str, Any]: + return self._payload + + +class _FakeSession: + def __init__(self, responses: list[dict[str, Any]]) -> None: + self._responses = iter(responses) + self.post_bodies: list[dict[str, Any]] = [] + + async def __aenter__(self) -> "_FakeSession": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, _url: str, **kwargs: Any) -> _FakeResponse: + self.post_bodies.append(kwargs["json"]) + return _FakeResponse(next(self._responses)) + + +@pytest.mark.asyncio +async def test_prompt_for_user_input_uses_hitl_default_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_session = _FakeSession( + [ + { + "status": "success", + "data": '{"values_by_name":{"name":"Narada"}}', + } + ] + ) + monkeypatch.setattr("narada.window.aiohttp.ClientSession", lambda: fake_session) + window = RemoteBrowserWindow(browser_window_id="bw-1", api_key="test-key") + + values = await window.prompt_for_user_input( + step_id="input-step", + variables=[ + PromptForUserInputVariable(name="name", type="string", required=True), + ], + ) + + assert values == {"name": "Narada"} + assert fake_session.post_bodies[0]["timeout"] == DEFAULT_HITL_TIMEOUT_SECONDS + + +@pytest.mark.asyncio +async def test_user_approval_respects_explicit_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_session = _FakeSession( + [ + { + "status": "success", + "data": '{"approved":true}', + } + ] + ) + monkeypatch.setattr("narada.window.aiohttp.ClientSession", lambda: fake_session) + window = RemoteBrowserWindow(browser_window_id="bw-1", api_key="test-key") + + approved = await window.user_approval( + step_id="approval-step", + prompt_message="Proceed?", + approve_label="Approve", + reject_label="Reject", + timeout=600, + ) + + assert approved is True + assert fake_session.post_bodies[0]["timeout"] == 600