diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 89b72c6..432c6fc 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 4822167..55978a6 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, @@ -821,7 +822,7 @@ async def prompt_for_user_input( step_id: str, variables: list[PromptForUserInputVariable], prompt_message: str | None = None, - 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( @@ -840,7 +841,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 3a058a1..75561d3 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -534,6 +534,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 49d9465..bf0663a 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, @@ -770,7 +771,7 @@ async def prompt_for_user_input( step_id: str, variables: list[PromptForUserInputVariable], prompt_message: str | None = None, - 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( @@ -789,7 +790,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