narada 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narada
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Python client SDK for Narada
5
5
  Project-URL: Homepage, https://github.com/NaradaAI/narada-python-sdk/narada
6
6
  Project-URL: Repository, https://github.com/NaradaAI/narada-python-sdk
@@ -9,7 +9,7 @@ Author-email: Narada <support@narada.ai>
9
9
  License-Expression: Apache-2.0
10
10
  Requires-Python: >=3.12
11
11
  Requires-Dist: aiohttp>=3.12.13
12
- Requires-Dist: narada-core==0.1.0
12
+ Requires-Dist: narada-core==0.1.1
13
13
  Requires-Dist: packaging==24.2
14
14
  Requires-Dist: playwright>=1.53.0
15
15
  Requires-Dist: rich>=14.0.0
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "narada"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Python client SDK for Narada"
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
7
7
  authors = [{ name = "Narada", email = "support@narada.ai" }]
8
8
  requires-python = ">=3.12"
9
9
  dependencies = [
10
- "narada-core==0.1.0",
10
+ "narada-core==0.1.1",
11
11
  "aiohttp>=3.12.13",
12
12
  "playwright>=1.53.0",
13
13
  "rich>=14.0.0",
@@ -1,4 +1,8 @@
1
- from narada_core.actions.models import ActiveInputRequest, CriticResult
1
+ from narada_core.actions.models import (
2
+ ActiveInputRequest,
3
+ CriticResult,
4
+ PressKeyEventItem,
5
+ )
2
6
  from narada_core.errors import (
3
7
  NaradaError,
4
8
  NaradaExtensionMissingError,
@@ -52,6 +56,7 @@ __all__ = [
52
56
  "NaradaInitializationError",
53
57
  "NaradaTimeoutError",
54
58
  "NaradaUnsupportedBrowserError",
59
+ "PressKeyEventItem",
55
60
  "ProxyConfig",
56
61
  "ReasoningEffort",
57
62
  "RemoteBrowserEnvironment",
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import IO, Any, Generic, Literal, Mapping, TypeVar, overload
4
4
 
5
- from narada_core.actions.critic import run_critic
5
+ from narada_core.actions.critic import merge_critic_workflow_trace, run_critic
6
6
  from narada_core.actions.models import (
7
7
  DEFAULT_HITL_TIMEOUT_SECONDS,
8
8
  AgenticMatchingSelectorsFinderRequest,
@@ -16,6 +16,8 @@ from narada_core.actions.models import (
16
16
  AgentResponse,
17
17
  AgentUsage,
18
18
  CriticResult,
19
+ ExecuteJavaScriptOnPageRequest,
20
+ ExecuteJavaScriptOnPageResponse,
19
21
  GetFullHtmlRequest,
20
22
  GetFullHtmlResponse,
21
23
  GetScreenshotRequest,
@@ -25,7 +27,10 @@ from narada_core.actions.models import (
25
27
  GetUrlRequest,
26
28
  GetUrlResponse,
27
29
  GoToUrlRequest,
30
+ JsonValue,
28
31
  PrintMessageRequest,
32
+ PressKeyEventItem,
33
+ PressKeyRequest,
29
34
  PromptForUserInputRequest,
30
35
  PromptForUserInputResponse,
31
36
  PromptForUserInputVariable,
@@ -195,6 +200,10 @@ class Agent(Generic[_StructuredOutput]):
195
200
  time_zone=time_zone,
196
201
  timeout=timeout,
197
202
  )
203
+ workflow_trace = merge_critic_workflow_trace(
204
+ workflow_trace=workflow_trace,
205
+ critic_result=critic_result,
206
+ )
198
207
 
199
208
  return AgentResponse(
200
209
  request_id=remote_dispatch_response["requestId"],
@@ -352,6 +361,27 @@ class Agent(Generic[_StructuredOutput]):
352
361
  )
353
362
  return result.selectors
354
363
 
364
+ async def press_key(
365
+ self,
366
+ *,
367
+ events: list[PressKeyEventItem | Mapping[str, Any]],
368
+ timeout: int | None = 60,
369
+ ) -> None:
370
+ """Dispatch keyboard events on the active tab through the extension."""
371
+ if not events:
372
+ raise ValueError("press_key requires a non-empty events= list")
373
+
374
+ normalized_events = [
375
+ event
376
+ if isinstance(event, PressKeyEventItem)
377
+ else PressKeyEventItem.model_validate(event)
378
+ for event in events
379
+ ]
380
+ return await self._browser_environment()._run_extension_action(
381
+ PressKeyRequest(events=normalized_events),
382
+ timeout=timeout,
383
+ )
384
+
355
385
  async def agentic_mouse_action(
356
386
  self,
357
387
  *,
@@ -410,6 +440,17 @@ class Agent(Generic[_StructuredOutput]):
410
440
  timeout=timeout,
411
441
  )
412
442
 
443
+ async def execute_javascript_on_page(
444
+ self, *, code: str, timeout: int | None = None
445
+ ) -> JsonValue:
446
+ """Executes JavaScript on the current active page and returns its JSON result."""
447
+ result = await self._browser_environment()._run_extension_action(
448
+ ExecuteJavaScriptOnPageRequest(code=code),
449
+ ExecuteJavaScriptOnPageResponse,
450
+ timeout=timeout,
451
+ )
452
+ return result.result
453
+
413
454
  async def print_message(self, *, message: str, timeout: int | None = None) -> None:
414
455
  """Prints a message in the Narada extension side panel chat."""
415
456
  return await self._browser_environment()._run_extension_action(
@@ -100,6 +100,16 @@ _INITIALIZATION_ERROR_INDICATOR_SELECTOR = "#narada-initialization-error"
100
100
  type InputRequiredCallback = Callable[[ActiveInputRequest], Awaitable[None] | None]
101
101
 
102
102
 
103
+ def _load_execution_trace_context_from_env() -> dict[str, Any] | None:
104
+ raw = os.environ.get("NARADA_EXECUTION_TRACE_CONTEXT")
105
+ if not raw:
106
+ return None
107
+ value = json.loads(raw)
108
+ if not isinstance(value, dict):
109
+ raise ValueError("NARADA_EXECUTION_TRACE_CONTEXT must be a JSON object")
110
+ return value
111
+
112
+
103
113
  async def _notify_input_required_callback(
104
114
  callback: InputRequiredCallback | None,
105
115
  response: _RemoteDispatchPollResponse,
@@ -710,6 +720,9 @@ class Environment(ABC):
710
720
  browser_window_id = self._dispatch_browser_window_id
711
721
  if browser_window_id is not None:
712
722
  body["browserWindowId"] = browser_window_id
723
+ execution_trace_context = _load_execution_trace_context_from_env()
724
+ if execution_trace_context is not None:
725
+ body["executionTraceContext"] = execution_trace_context
713
726
  cloud_browser_session_id = self.cloud_browser_session_id
714
727
  if cloud_browser_session_id is not None:
715
728
  body["cloudBrowserSessionId"] = cloud_browser_session_id
@@ -843,8 +856,10 @@ class Environment(ABC):
843
856
  raise NaradaError(
844
857
  f"{type(self).__name__} does not support browser extension actions"
845
858
  )
859
+ action_execution_id = f"action_{uuid4().hex}"
846
860
  body = {
847
861
  "action": request.model_dump(),
862
+ "actionExecutionId": action_execution_id,
848
863
  "browserWindowId": browser_window_id,
849
864
  }
850
865
  remote_dispatch_request_id = os.environ.get(_REMOTE_DISPATCH_REQUEST_ID_ENV_VAR)
@@ -955,8 +970,7 @@ class BrowserEnvironment(BaseBrowserEnvironment):
955
970
  )
956
971
 
957
972
  async def _initialize(self) -> None:
958
- self._playwright_context_manager = async_playwright()
959
- self._playwright = await self._playwright_context_manager.__aenter__()
973
+ await self._start_playwright()
960
974
  if self._attach_to_existing:
961
975
  await self._initialize_in_existing_browser_window()
962
976
  else:
@@ -982,6 +996,10 @@ class BrowserEnvironment(BaseBrowserEnvironment):
982
996
  finally:
983
997
  await self._stop_playwright()
984
998
 
999
+ async def _start_playwright(self) -> None:
1000
+ self._playwright_context_manager = async_playwright()
1001
+ self._playwright = await self._playwright_context_manager.__aenter__()
1002
+
985
1003
  async def _stop_playwright(self) -> None:
986
1004
  if self._playwright_context_manager is None:
987
1005
  return
@@ -1465,8 +1483,7 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1465
1483
  ``#narada-browser-window-id`` (extension install retries apply). ``config`` controls
1466
1484
  interactive prompts and related behavior.
1467
1485
  """
1468
- self._playwright_context_manager = async_playwright()
1469
- self._playwright = await self._playwright_context_manager.__aenter__()
1486
+ await self._start_playwright()
1470
1487
 
1471
1488
  request_body = {
1472
1489
  "require_extension": True,
@@ -1542,6 +1559,10 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1542
1559
  # Re-raise the original connection error
1543
1560
  raise
1544
1561
 
1562
+ async def _start_playwright(self) -> None:
1563
+ self._playwright_context_manager = async_playwright()
1564
+ self._playwright = await self._playwright_context_manager.__aenter__()
1565
+
1545
1566
  async def _stop_playwright(self) -> None:
1546
1567
  if self._playwright_context_manager is None:
1547
1568
  return
@@ -1582,6 +1603,7 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1582
1603
  session_id: str,
1583
1604
  login_url: str,
1584
1605
  cdp_auth_headers: dict[str, str],
1606
+ expected_browser_window_id: str | None = None,
1585
1607
  ) -> None:
1586
1608
  assert self._playwright is not None
1587
1609
 
@@ -1593,6 +1615,23 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1593
1615
  # Navigate to login URL (provided by backend with custom token)
1594
1616
  context = browser.contexts[0]
1595
1617
  initialization_page = context.pages[0]
1618
+ if expected_browser_window_id is not None:
1619
+ # Put the backend-owned browser ID into sessionStorage before hydration
1620
+ # so AgentCore sessions use the right Firestore route when needed.
1621
+ expected_browser_window_id_json = json.dumps(expected_browser_window_id)
1622
+ await context.add_init_script(
1623
+ script=f"""
1624
+ (() => {{
1625
+ const expectedBrowserWindowId = {expected_browser_window_id_json};
1626
+ try {{
1627
+ sessionStorage.setItem(
1628
+ "naradaBrowserWindowId",
1629
+ expectedBrowserWindowId
1630
+ );
1631
+ }} catch (_error) {{}}
1632
+ }})();
1633
+ """
1634
+ )
1596
1635
  await initialization_page.goto(
1597
1636
  login_url, timeout=15_000, wait_until="domcontentloaded"
1598
1637
  )
@@ -1613,7 +1652,7 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1613
1652
  raise
1614
1653
  logging.info("Waiting for Narada extension to be installed...")
1615
1654
  await asyncio.sleep(1)
1616
- except NaradaTimeoutError:
1655
+ except (NaradaTimeoutError, NaradaExtensionUnauthenticatedError):
1617
1656
  if attempt == max_attempts - 1:
1618
1657
  raise
1619
1658
  # If browser window ID is not found, reload the page and try again
@@ -1622,6 +1661,15 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1622
1661
  login_url, timeout=15_000, wait_until="domcontentloaded"
1623
1662
  )
1624
1663
 
1664
+ if (
1665
+ expected_browser_window_id is not None
1666
+ and browser_window_id != expected_browser_window_id
1667
+ ):
1668
+ raise RuntimeError(
1669
+ "Initialized cloud session reported browserWindowId "
1670
+ f"{browser_window_id!r}, expected {expected_browser_window_id!r}."
1671
+ )
1672
+
1625
1673
  self._browser_window_id = browser_window_id
1626
1674
  self._session_id = session_id
1627
1675
  self._context = context
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import narada.environment as environment_module
4
+ import pytest
5
+ from narada import CloudBrowserEnvironment, RemoteBrowserEnvironment
6
+ from narada.config import BrowserConfig
7
+
8
+
9
+ class _FakePage:
10
+ def __init__(self) -> None:
11
+ self.goto_calls: list[dict[str, object]] = []
12
+ self.url = "about:blank"
13
+
14
+ async def goto(self, url: str, **kwargs: object) -> None:
15
+ self.url = url
16
+ self.goto_calls.append({"url": url, **kwargs})
17
+
18
+
19
+ class _FakeContext:
20
+ def __init__(self, page: _FakePage) -> None:
21
+ self.pages = [page]
22
+
23
+
24
+ class _FakeBrowser:
25
+ def __init__(self, page: _FakePage) -> None:
26
+ self.contexts = [_FakeContext(page)]
27
+ self.close_calls = 0
28
+
29
+ async def close(self) -> None:
30
+ self.close_calls += 1
31
+
32
+
33
+ class _FakeChromium:
34
+ def __init__(self, page: _FakePage) -> None:
35
+ self.browser = _FakeBrowser(page)
36
+
37
+ async def connect_over_cdp(
38
+ self, cdp_websocket_url: str, *, headers: dict[str, str]
39
+ ) -> _FakeBrowser:
40
+ return self.browser
41
+
42
+
43
+ class _FakePlaywright:
44
+ def __init__(self, page: _FakePage) -> None:
45
+ self.chromium = _FakeChromium(page)
46
+
47
+
48
+ class _FakeRemoteDispatchPostResponse:
49
+ ok = True
50
+ status = 200
51
+
52
+ async def __aenter__(self) -> _FakeRemoteDispatchPostResponse:
53
+ return self
54
+
55
+ async def __aexit__(self, *_args: object) -> None:
56
+ return None
57
+
58
+ def raise_for_status(self) -> None:
59
+ return None
60
+
61
+ async def json(self) -> dict[str, object]:
62
+ return {"requestId": "request-123"}
63
+
64
+
65
+ class _FakeRemoteDispatchGetResponse:
66
+ ok = True
67
+ status = 200
68
+
69
+ async def __aenter__(self) -> _FakeRemoteDispatchGetResponse:
70
+ return self
71
+
72
+ async def __aexit__(self, *_args: object) -> None:
73
+ return None
74
+
75
+ def raise_for_status(self) -> None:
76
+ return None
77
+
78
+ async def json(self) -> dict[str, object]:
79
+ return {
80
+ "status": "success",
81
+ "response": {
82
+ "text": "done",
83
+ "output": None,
84
+ "executionTraceContext": {
85
+ "type": "executionTraceContext",
86
+ "executionTraceS3Key": "user-test/execution-trace/index.json",
87
+ },
88
+ },
89
+ "createdAt": "2026-06-03T00:00:00Z",
90
+ "completedAt": "2026-06-03T00:00:01Z",
91
+ "usage": {"actions": 1, "credits": 0.1},
92
+ }
93
+
94
+
95
+ class _FakeRemoteDispatchSession:
96
+ post_calls: list[dict[str, object]] = []
97
+ get_calls: list[dict[str, object]] = []
98
+
99
+ async def __aenter__(self) -> _FakeRemoteDispatchSession:
100
+ return self
101
+
102
+ async def __aexit__(self, *_args: object) -> None:
103
+ return None
104
+
105
+ def post(self, url: str, **kwargs: object) -> _FakeRemoteDispatchPostResponse:
106
+ self.post_calls.append({"url": url, **kwargs})
107
+ return _FakeRemoteDispatchPostResponse()
108
+
109
+ def get(self, url: str, **kwargs: object) -> _FakeRemoteDispatchGetResponse:
110
+ self.get_calls.append({"url": url, **kwargs})
111
+ return _FakeRemoteDispatchGetResponse()
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_cloud_browser_initialization_uses_domcontentloaded_navigation(
116
+ monkeypatch: pytest.MonkeyPatch,
117
+ ) -> None:
118
+ page = _FakePage()
119
+ env = CloudBrowserEnvironment(
120
+ config=BrowserConfig(interactive=False),
121
+ auth_headers={"x-test": "true"},
122
+ )
123
+ env._playwright = _FakePlaywright(page)
124
+
125
+ async def wait_for_browser_window_id(*args: object, **kwargs: object) -> str:
126
+ return "window-123"
127
+
128
+ monkeypatch.setattr(
129
+ env, "_wait_for_cloud_browser_window_id", wait_for_browser_window_id
130
+ )
131
+
132
+ await env._initialize_cloud_browser_window(
133
+ cdp_websocket_url="wss://example.test/cdp",
134
+ session_id="session-123",
135
+ login_url="https://example.test/initialize",
136
+ cdp_auth_headers={"authorization": "Bearer test"},
137
+ )
138
+
139
+ assert env.browser_window_id == "window-123"
140
+ assert env.cloud_browser_session_id == "session-123"
141
+ assert page.goto_calls == [
142
+ {
143
+ "url": "https://example.test/initialize",
144
+ "timeout": 15_000,
145
+ "wait_until": "domcontentloaded",
146
+ }
147
+ ]
148
+
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_remote_dispatch_forwards_managed_cloud_browser_request(
152
+ monkeypatch: pytest.MonkeyPatch,
153
+ ) -> None:
154
+ _FakeRemoteDispatchSession.post_calls = []
155
+ _FakeRemoteDispatchSession.get_calls = []
156
+ monkeypatch.setenv("NARADA_API_BASE_URL", "https://api.example.test/fast/v2")
157
+ monkeypatch.setattr(
158
+ environment_module.aiohttp, "ClientSession", _FakeRemoteDispatchSession
159
+ )
160
+
161
+ env = RemoteBrowserEnvironment(
162
+ browser_window_id="sdk-managed-cloud-browser",
163
+ cloud_browser_session_id="cloud-session-123",
164
+ auth_headers={"x-test": "true"},
165
+ )
166
+
167
+ response = await env._dispatch_request(
168
+ prompt="Fill the RPA challenge form.",
169
+ timeout=30,
170
+ )
171
+
172
+ assert response["response"] is not None
173
+ assert response["response"]["executionTraceContext"] == {
174
+ "type": "executionTraceContext",
175
+ "executionTraceS3Key": "user-test/execution-trace/index.json",
176
+ }
177
+ assert len(_FakeRemoteDispatchSession.post_calls) == 1
178
+ post_call = _FakeRemoteDispatchSession.post_calls[0]
179
+ assert post_call["url"] == "https://api.example.test/fast/v2/remote-dispatch"
180
+ assert post_call["headers"] == {"x-test": "true"}
181
+ assert post_call["json"] == {
182
+ "prompt": "/Operator Fill the RPA challenge form.",
183
+ "browserWindowId": "sdk-managed-cloud-browser",
184
+ "timeZone": "America/Los_Angeles",
185
+ "cloudBrowserSessionId": "cloud-session-123",
186
+ }
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from types import SimpleNamespace
2
3
  from unittest.mock import AsyncMock, call
3
4
 
@@ -79,7 +80,9 @@ def _build_cloud_environment_with_page(page: AsyncMock) -> CloudBrowserEnvironme
79
80
  auth_headers={"x-api-key": "test-key"},
80
81
  config=BrowserConfig(interactive=False),
81
82
  )
82
- browser = SimpleNamespace(contexts=[SimpleNamespace(pages=[page])])
83
+ browser = SimpleNamespace(
84
+ contexts=[SimpleNamespace(pages=[page], add_init_script=AsyncMock())]
85
+ )
83
86
  env._playwright = SimpleNamespace(
84
87
  chromium=SimpleNamespace(connect_over_cdp=AsyncMock(return_value=browser))
85
88
  )
@@ -179,6 +182,91 @@ async def test_dispatch_request_calls_input_required_callback_once_per_input_id(
179
182
  assert sleep.await_count == 3
180
183
 
181
184
 
185
+ @pytest.mark.asyncio
186
+ async def test_dispatch_request_includes_execution_trace_context(
187
+ monkeypatch: pytest.MonkeyPatch,
188
+ ) -> None:
189
+ import narada.environment as environment_module
190
+
191
+ trace_context = {
192
+ "type": "executionTraceInheritanceContext",
193
+ "schemaVersion": 1,
194
+ "traceId": "trace-parent",
195
+ "parentSegmentId": "segment-local",
196
+ }
197
+ monkeypatch.setenv("NARADA_EXECUTION_TRACE_CONTEXT", json.dumps(trace_context))
198
+ fake_session = _RemoteDispatchFakeClientSession(
199
+ [
200
+ {
201
+ "status": "success",
202
+ "response": {"text": "ok"},
203
+ "usage": {"actions": 1, "credits": 1},
204
+ "createdAt": "2026-01-01T00:00:00Z",
205
+ "completedAt": "2026-01-01T00:00:01Z",
206
+ "activeInputRequest": None,
207
+ }
208
+ ]
209
+ )
210
+ monkeypatch.setattr(
211
+ environment_module.aiohttp, "ClientSession", lambda: fake_session
212
+ )
213
+
214
+ env = RemoteBrowserEnvironment(browser_window_id="bw-1", api_key="test-key")
215
+ response = await env._dispatch_request(prompt="Summarize", timeout=5)
216
+
217
+ assert response["status"] == "success"
218
+ assert fake_session.dispatched_body is not None
219
+ assert fake_session.dispatched_body["executionTraceContext"] == trace_context
220
+
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_extension_action_request_includes_action_execution_id(
224
+ monkeypatch: pytest.MonkeyPatch,
225
+ ) -> None:
226
+ import narada.environment as environment_module
227
+
228
+ fake_session = _FakeClientSession({"status": "success", "data": None})
229
+ monkeypatch.setattr(
230
+ environment_module.aiohttp, "ClientSession", lambda: fake_session
231
+ )
232
+
233
+ env = RemoteBrowserEnvironment(browser_window_id="bw-1", api_key="test-key")
234
+ await env.close()
235
+
236
+ assert fake_session.posts
237
+ action_execution_id = fake_session.posts[0]["json"]["actionExecutionId"]
238
+ assert action_execution_id.startswith("action_")
239
+
240
+
241
+ @pytest.mark.asyncio
242
+ async def test_remote_browser_environment_with_cloud_session_stops_session_by_default(
243
+ monkeypatch: pytest.MonkeyPatch,
244
+ ) -> None:
245
+ import narada.environment as environment_module
246
+
247
+ stop_cloud_browser_session = AsyncMock()
248
+ monkeypatch.setattr(
249
+ environment_module,
250
+ "_stop_cloud_browser_session",
251
+ stop_cloud_browser_session,
252
+ )
253
+
254
+ env = RemoteBrowserEnvironment(
255
+ browser_window_id="browser-window-123",
256
+ cloud_browser_session_id="session-123",
257
+ auth_headers={"x-api-key": "test-key"},
258
+ )
259
+
260
+ await env.close()
261
+
262
+ stop_cloud_browser_session.assert_awaited_once_with(
263
+ base_url=env._base_url,
264
+ auth_headers={"x-api-key": "test-key"},
265
+ session_id="session-123",
266
+ timeout=None,
267
+ )
268
+
269
+
182
270
  @pytest.mark.asyncio
183
271
  async def test_lambda_environment_uses_backend_initialization(
184
272
  monkeypatch: pytest.MonkeyPatch,
@@ -255,6 +343,7 @@ async def test_cloud_browser_environment_uses_domcontentloaded_for_login_navigat
255
343
  ) -> None:
256
344
  page = AsyncMock()
257
345
  env = _build_cloud_environment_with_page(page)
346
+ context = env._playwright.chromium.connect_over_cdp.return_value.contexts[0]
258
347
 
259
348
  wait_for_browser_window_id = AsyncMock(return_value="browser-window-123")
260
349
  monkeypatch.setattr(
@@ -278,10 +367,69 @@ async def test_cloud_browser_environment_uses_domcontentloaded_for_login_navigat
278
367
  BrowserConfig(interactive=False),
279
368
  timeout=30_000,
280
369
  )
370
+ context.add_init_script.assert_not_awaited()
281
371
  assert env.browser_window_id == "browser-window-123"
282
372
  assert env.cloud_browser_session_id == "session-123"
283
373
 
284
374
 
375
+ @pytest.mark.asyncio
376
+ async def test_cloud_browser_environment_seeds_expected_browser_window_id_before_navigation(
377
+ monkeypatch: pytest.MonkeyPatch,
378
+ ) -> None:
379
+ page = AsyncMock()
380
+ env = _build_cloud_environment_with_page(page)
381
+ context = env._playwright.chromium.connect_over_cdp.return_value.contexts[0]
382
+ events: list[str] = []
383
+
384
+ async def add_init_script(*args, **kwargs) -> None:
385
+ events.append("seed")
386
+
387
+ async def goto(*args, **kwargs) -> None:
388
+ events.append("goto")
389
+
390
+ context.add_init_script.side_effect = add_init_script
391
+ page.goto.side_effect = goto
392
+ wait_for_browser_window_id = AsyncMock(return_value="backend-window-123")
393
+ monkeypatch.setattr(
394
+ env, "_wait_for_cloud_browser_window_id", wait_for_browser_window_id
395
+ )
396
+
397
+ await env._initialize_cloud_browser_window(
398
+ cdp_websocket_url="wss://agentcore.example.test/session-123",
399
+ session_id="session-123",
400
+ login_url="https://app.narada.ai/chat?customToken=test-token",
401
+ cdp_auth_headers={"Authorization": "signed-cdp"},
402
+ expected_browser_window_id="backend-window-123",
403
+ )
404
+
405
+ assert events[:2] == ["seed", "goto"]
406
+ script = context.add_init_script.await_args.kwargs["script"]
407
+ assert "naradaBrowserWindowId" in script
408
+ assert "backend-window-123" in script
409
+ assert env.browser_window_id == "backend-window-123"
410
+
411
+
412
+ @pytest.mark.asyncio
413
+ async def test_cloud_browser_environment_rejects_unexpected_seeded_browser_window_id(
414
+ monkeypatch: pytest.MonkeyPatch,
415
+ ) -> None:
416
+ page = AsyncMock()
417
+ env = _build_cloud_environment_with_page(page)
418
+ wait_for_browser_window_id = AsyncMock(return_value="frontend-window-123")
419
+ monkeypatch.setattr(
420
+ env, "_wait_for_cloud_browser_window_id", wait_for_browser_window_id
421
+ )
422
+
423
+ with pytest.raises(RuntimeError, match="expected 'backend-window-123'"):
424
+ await env._initialize_cloud_browser_window(
425
+ cdp_websocket_url="wss://agentcore.example.test/session-123",
426
+ session_id="session-123",
427
+ login_url="https://app.narada.ai/chat?customToken=test-token",
428
+ cdp_auth_headers={"Authorization": "signed-cdp"},
429
+ expected_browser_window_id="backend-window-123",
430
+ )
431
+
432
+
285
433
  @pytest.mark.asyncio
286
434
  async def test_cloud_browser_environment_uses_domcontentloaded_for_retry_navigation(
287
435
  monkeypatch: pytest.MonkeyPatch,
@@ -355,3 +503,72 @@ async def test_agent_run_exposes_workflow_trace_alias(
355
503
 
356
504
  assert response.workflow_trace == workflow_trace
357
505
  assert response.model_dump(by_alias=True)["workflowTrace"] == workflow_trace
506
+
507
+
508
+ @pytest.mark.asyncio
509
+ async def test_agent_run_appends_critic_workflow_trace(
510
+ monkeypatch: pytest.MonkeyPatch,
511
+ ) -> None:
512
+ workflow_trace = {
513
+ "workflowId": "main-workflow",
514
+ "workflowName": "Main Workflow",
515
+ "runtime": "gui",
516
+ "status": "success",
517
+ "startTs": 100,
518
+ "children": [],
519
+ }
520
+ critic_workflow_trace = {
521
+ "workflowId": "critic-workflow",
522
+ "workflowName": "Critic Workflow",
523
+ "runtime": "gui",
524
+ "status": "success",
525
+ "startTs": 200,
526
+ "children": [],
527
+ }
528
+ env = CloudBrowserEnvironment(
529
+ auth_headers={"x-api-key": "test-key"},
530
+ )
531
+ agent = Agent(environment=env)
532
+ monkeypatch.setattr(
533
+ agent,
534
+ "_dispatch_request",
535
+ AsyncMock(
536
+ side_effect=[
537
+ {
538
+ "requestId": "request-123",
539
+ "status": "success",
540
+ "response": {
541
+ "text": "done",
542
+ "output": {"type": "text", "content": "done"},
543
+ "workflowTrace": workflow_trace,
544
+ },
545
+ "usage": {"actions": 0, "credits": 0},
546
+ },
547
+ {
548
+ "requestId": "critic-request-123",
549
+ "status": "success",
550
+ "response": {
551
+ "text": '{"narada_validation_passed":true}',
552
+ "output": {
553
+ "type": "structured",
554
+ "content": {"narada_validation_passed": True},
555
+ },
556
+ "structuredOutput": SimpleNamespace(
557
+ narada_validation_passed=True
558
+ ),
559
+ "workflowTrace": critic_workflow_trace,
560
+ },
561
+ "usage": {"actions": 0, "credits": 0},
562
+ },
563
+ ]
564
+ ),
565
+ )
566
+
567
+ response = await agent.run("return a trace", critic={})
568
+
569
+ assert response.critic_result is not None
570
+ assert response.critic_result.workflow_trace == critic_workflow_trace
571
+ assert response.workflow_trace == {
572
+ **workflow_trace,
573
+ "children": [{"kind": "sub_workflow", "trace": critic_workflow_trace}],
574
+ }
@@ -108,3 +108,35 @@ async def test_user_approval_respects_explicit_timeout(
108
108
 
109
109
  assert approved is True
110
110
  assert fake_session.post_bodies[0]["timeout"] == 600
111
+
112
+
113
+ @pytest.mark.asyncio
114
+ async def test_execute_javascript_on_page_dispatches_extension_action(
115
+ monkeypatch: pytest.MonkeyPatch,
116
+ ) -> None:
117
+ fake_session = _FakeSession(
118
+ [
119
+ {
120
+ "status": "success",
121
+ "data": '{"result":{"title":"Example Domain","count":3}}',
122
+ }
123
+ ]
124
+ )
125
+ monkeypatch.setattr(
126
+ "narada.environment.aiohttp.ClientSession", lambda: fake_session
127
+ )
128
+ agent = Agent(
129
+ environment=RemoteBrowserEnvironment(
130
+ browser_window_id="bw-1", api_key="test-key"
131
+ )
132
+ )
133
+
134
+ result = await agent.execute_javascript_on_page(
135
+ code="(() => ({ title: document.title, count: 3 }))()",
136
+ )
137
+
138
+ assert result == {"title": "Example Domain", "count": 3}
139
+ assert fake_session.post_bodies[0]["action"] == {
140
+ "name": "execute_javascript_on_page",
141
+ "code": "(() => ({ title: document.title, count: 3 }))()",
142
+ }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes