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
2 changes: 2 additions & 0 deletions packages/narada-core/src/narada_core/actions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
5 changes: 3 additions & 2 deletions packages/narada-pyodide/src/narada/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
64 changes: 64 additions & 0 deletions packages/narada-pyodide/tests/test_cloud_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/narada/src/narada/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
98 changes: 98 additions & 0 deletions packages/narada/tests/test_window_human_interaction.py
Original file line number Diff line number Diff line change
@@ -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
Loading