agentenv-framework-protocol 0.1.453__tar.gz → 0.1.456__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.
Files changed (126) hide show
  1. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/PKG-INFO +11 -1
  2. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/README.md +10 -0
  3. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/pyproject.toml +1 -1
  4. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/client.py +80 -0
  5. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/_linux.py +3 -2
  6. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/app.py +9 -0
  7. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/app_runtime.py +4 -2
  8. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/checks.py +3 -1
  9. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/driver.py +3 -3
  10. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/platform.py +6 -4
  11. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/probe_fake.py +6 -5
  12. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_client.py +52 -0
  13. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_app_server.py +11 -0
  14. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_conformance.py +1 -1
  15. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_declare.py +5 -2
  16. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_driver.py +9 -5
  17. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_recording.py +16 -3
  18. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/.gitignore +0 -0
  19. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/LICENSE +0 -0
  20. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/NOTICE +0 -0
  21. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/THIRD_PARTY_NOTICES.md +0 -0
  22. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/examples/__init__.py +0 -0
  23. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/examples/advanced_agent.py +0 -0
  24. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/examples/basic_agent.py +0 -0
  25. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/examples/custom_extensions_agent.py +0 -0
  26. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/examples/multimodal_agent.py +0 -0
  27. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/examples/reference_model.py +0 -0
  28. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/examples/streaming_agent.py +0 -0
  29. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/__init__.py +0 -0
  30. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  31. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  32. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  33. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  34. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  35. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  36. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  37. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  38. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  39. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  40. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  41. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/agent_env_environment.py +0 -0
  42. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/browser_cdp.py +0 -0
  43. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/__init__.py +0 -0
  44. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/_declare.py +0 -0
  45. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/_load.py +0 -0
  46. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/_with_fds.py +0 -0
  47. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/app_data.py +0 -0
  48. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/apps.py +0 -0
  49. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/card.py +0 -0
  50. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/checks.py +0 -0
  51. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
  52. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
  53. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/client.py +0 -0
  54. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/fixture.py +0 -0
  55. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
  56. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
  57. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  58. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/dataplane.py +0 -0
  59. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/errors.py +0 -0
  60. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/extensions.py +0 -0
  61. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/frames.py +0 -0
  62. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/keys.py +0 -0
  63. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/launch.py +0 -0
  64. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/manifest.py +0 -0
  65. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/operations.py +0 -0
  66. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/queue.py +0 -0
  67. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/recording.py +0 -0
  68. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/results.py +0 -0
  69. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/run_install_step.py +0 -0
  70. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/runtime.py +0 -0
  71. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/seed.py +0 -0
  72. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/serve.py +0 -0
  73. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/serve_app.py +0 -0
  74. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/session.py +0 -0
  75. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/settings.py +0 -0
  76. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/skill.py +0 -0
  77. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/spaces.py +0 -0
  78. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/svc.py +0 -0
  79. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/computer/tools.py +0 -0
  80. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/errors.py +0 -0
  81. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/manifest.py +0 -0
  82. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/preflight.py +0 -0
  83. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/tool_access.py +0 -0
  84. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/tool_client.py +0 -0
  85. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/tool_context.py +0 -0
  86. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/transfers.py +0 -0
  87. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/types.py +0 -0
  88. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/src/agentenv_protocol/website_browser.py +0 -0
  89. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/computer_fake.py +0 -0
  90. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_agent.py +0 -0
  91. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_agent_examples.py +0 -0
  92. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_cdp_config.py +0 -0
  93. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_object_transfers.py +0 -0
  94. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_peer_agents.py +0 -0
  95. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_proxy.py +0 -0
  96. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_staging.py +0 -0
  97. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_a2a_trajectory_log.py +0 -0
  98. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_agent_env_environment.py +0 -0
  99. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_browser_cdp.py +0 -0
  100. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_app_data.py +0 -0
  101. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_conformance_checks.py +0 -0
  102. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_conformance_page.py +0 -0
  103. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_conformance_recording.py +0 -0
  104. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_dataplane.py +0 -0
  105. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_extensions.py +0 -0
  106. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_frames.py +0 -0
  107. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_page.py +0 -0
  108. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_queue.py +0 -0
  109. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_seed.py +0 -0
  110. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_serve.py +0 -0
  111. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_spaces.py +0 -0
  112. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_computer_svc_forward.py +0 -0
  113. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_control_token.py +0 -0
  114. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_data_objects.py +0 -0
  115. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_errors.py +0 -0
  116. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_extension_methods.py +0 -0
  117. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_intake.py +0 -0
  118. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_manifest.py +0 -0
  119. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_preflight.py +0 -0
  120. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_tool_access.py +0 -0
  121. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_tool_annotations.py +0 -0
  122. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_tool_client.py +0 -0
  123. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_tool_context.py +0 -0
  124. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_tool_context_fastmcp.py +0 -0
  125. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_tool_context_injection.py +0 -0
  126. {agentenv_framework_protocol-0.1.453 → agentenv_framework_protocol-0.1.456}/tests/test_website_browser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.453
3
+ Version: 0.1.456
4
4
  Summary: Open data-plane protocol and server SDK for agent environments.
5
5
  Project-URL: Homepage, https://www.agentenvframework.com
6
6
  Project-URL: Documentation, https://www.agentenvframework.com/docs
@@ -258,6 +258,16 @@ with any other transport are kept beside the SDK's entry. Card URLs are paths, r
258
258
  address the card was fetched from. On the client side, `client.mcp_path(card)` returns the
259
259
  declared path, or `/mcp` for a card without one.
260
260
 
261
+ A composed card, such as a gateway's, nests each backing environment's card under
262
+ `children_environments`, its `url` and endpoints rooted at the route the composer serves it at.
263
+ `client.children(card)` returns each as a `ChildEnv` (its `name`, `url` and `card`, with its
264
+ `extensions`, `operations` and `serves(method)`, and `base_url(address)`, its data plane resolved
265
+ against the composed card's address). Two children may share a name, as a website and an MCP
266
+ server can, so `client.child_at(card, url)` finds one by its url or route; `client.child_named(card,
267
+ name)` raises `AmbiguousChildError` for a shared name, and `client.find_child` returns the first.
268
+ `client.serves(card, method)` says whether a card serves a data-plane method by its declared
269
+ `operations`, None for a card that declares none.
270
+
261
271
  Extensions are invoked from what the card advertises. `client.find_extension_method(card, uri,
262
272
  method)` returns one advertised method, whose `endpoint` is the method's own or else the
263
273
  extension's, and `client.invoke_extension(base_url, card, uri, params, method=...)` calls it with
@@ -199,6 +199,16 @@ with any other transport are kept beside the SDK's entry. Card URLs are paths, r
199
199
  address the card was fetched from. On the client side, `client.mcp_path(card)` returns the
200
200
  declared path, or `/mcp` for a card without one.
201
201
 
202
+ A composed card, such as a gateway's, nests each backing environment's card under
203
+ `children_environments`, its `url` and endpoints rooted at the route the composer serves it at.
204
+ `client.children(card)` returns each as a `ChildEnv` (its `name`, `url` and `card`, with its
205
+ `extensions`, `operations` and `serves(method)`, and `base_url(address)`, its data plane resolved
206
+ against the composed card's address). Two children may share a name, as a website and an MCP
207
+ server can, so `client.child_at(card, url)` finds one by its url or route; `client.child_named(card,
208
+ name)` raises `AmbiguousChildError` for a shared name, and `client.find_child` returns the first.
209
+ `client.serves(card, method)` says whether a card serves a data-plane method by its declared
210
+ `operations`, None for a card that declares none.
211
+
202
212
  Extensions are invoked from what the card advertises. `client.find_extension_method(card, uri,
203
213
  method)` returns one advertised method, whose `endpoint` is the method's own or else the
204
214
  extension's, and `client.invoke_extension(base_url, card, uri, params, method=...)` calls it with
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.453"
3
+ version = "0.1.456"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -7,6 +7,7 @@ import contextlib
7
7
  import logging
8
8
  from collections.abc import Iterator
9
9
  from contextvars import ContextVar
10
+ from dataclasses import dataclass, field
10
11
 
11
12
  import httpx
12
13
 
@@ -210,6 +211,83 @@ def find_tool(card: dict, name: str) -> dict | None:
210
211
  return None
211
212
 
212
213
 
214
+ def serves(card: dict, method: str) -> bool | None:
215
+ """Whether a card serves the data-plane ``method`` (``data/get`` say): True or False by its declared
216
+ ``capabilities.operations``, or None when it declares none, as an older card doesn't, so the caller decides as it
217
+ did before cards declared them (a probe, say)."""
218
+ declared = operations(card)
219
+ return None if declared is None else method in declared
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class ChildEnv:
224
+ """One child env a composed card nests under ``children_environments``: its ``name`` and ``url`` as its card gives
225
+ them, and that ``card``, which says what it offers. A composer roots each child's ``url`` (and its endpoints) at
226
+ the route it serves the child at, so two children sharing a name have distinct urls; ``url`` is None for a child
227
+ whose card names none."""
228
+
229
+ name: str | None
230
+ url: str | None
231
+ card: dict = field(hash=False, repr=False)
232
+
233
+ @property
234
+ def extensions(self) -> list[dict]:
235
+ """The extensions the child's card advertises (``capabilities.extensions``)."""
236
+ return list((self.card.get("capabilities") or {}).get("extensions") or [])
237
+
238
+ @property
239
+ def operations(self) -> list[str] | None:
240
+ """The data-plane methods the child's card declares (``operations``), or None when it declares none."""
241
+ return operations(self.card)
242
+
243
+ def find_extension(self, uri: str) -> dict | None:
244
+ """The extension the child's card advertises under ``uri``, or None (``find_extension``)."""
245
+ return find_extension(self.card, uri)
246
+
247
+ def serves(self, method: str) -> bool | None:
248
+ """Whether the child's card serves the data-plane ``method``, None when it declares no operations (``serves``)."""
249
+ return serves(self.card, method)
250
+
251
+ def base_url(self, address: str) -> str:
252
+ """Where the child's data plane is, its ``url`` without the RPC path, resolved against ``address``, the composed
253
+ card's: a path joins onto it, a full URL stands alone, and a child naming no url is at ``address`` itself."""
254
+ url = (self.url or RPC_PATH).removesuffix(RPC_PATH)
255
+ return url if "://" in url else f"{address.rstrip('/')}{url}"
256
+
257
+
258
+ def children(card: dict) -> list[ChildEnv]:
259
+ """Each child env a composed card nests under ``children_environments``, in its order; none for a leaf card, or
260
+ one whose ``children_environments`` is missing or null. An entry that isn't an object is skipped."""
261
+ return [ChildEnv(name=child.get("name"), url=child.get("url"), card=child)
262
+ for child in card.get("children_environments") or [] if isinstance(child, dict)]
263
+
264
+
265
+ def child_at(card: dict, url: str) -> ChildEnv | None:
266
+ """The child env of a composed card at ``url``: the one whose ``url`` is ``url``, or whose data plane is there
267
+ (``url`` without the RPC path, ``/svc/mcp-slack`` for ``/svc/mcp-slack/agentenv`` say), a trailing slash aside;
268
+ None when no child is. A composer serves each child at its own route, so this is the lookup two children sharing a
269
+ name can't confuse."""
270
+ wanted = url.rstrip("/")
271
+ for child in children(card):
272
+ if child.url is not None and wanted in (child.url.rstrip("/"), child.url.removesuffix(RPC_PATH).rstrip("/")):
273
+ return child
274
+ return None
275
+
276
+
277
+ class AmbiguousChildError(LookupError):
278
+ """More than one child env of a composed card has the name asked for, so the name can't say which is meant."""
279
+
280
+
281
+ def child_named(card: dict, name: str) -> ChildEnv | None:
282
+ """The one child env of a composed card named ``name``, or None when none is; raises ``AmbiguousChildError`` when
283
+ several are, as a website and an MCP server sharing a name are, which ``child_at`` tells apart by their urls."""
284
+ found = [child for child in children(card) if child.name == name]
285
+ if len(found) > 1:
286
+ raise AmbiguousChildError(f"{len(found)} child envs are named {name!r} ({', '.join(str(c.url) for c in found)}); "
287
+ "look one up by its url")
288
+ return found[0] if found else None
289
+
290
+
213
291
  def find_child(card: dict, name: str) -> dict | None:
214
292
  """Return the nested child EnvironmentCard named `name`, or None.
215
293
 
@@ -218,6 +296,8 @@ def find_child(card: dict, name: str) -> dict | None:
218
296
  rewritten to be reachable from the composed card's base_url. Callers resolve a service by
219
297
  name here, then use the child card with `find_extension`/`invoke_extension`. Tolerates a
220
298
  missing/null `children_environments` so single/leaf cards return None rather than raising.
299
+ Two children sharing `name` return the first; `child_named` refuses that, and `child_at`
300
+ finds a child by its url, which tells them apart.
221
301
  """
222
302
  for child in card.get("children_environments") or []:
223
303
  if child.get("name") == name:
@@ -1,5 +1,6 @@
1
- """The base class's Linux defaults for the OS operations. The driver runs as the session user inside the desktop
2
- session, so both act directly: a command inherits the session's environment, a file lands as that user."""
1
+ """The base class's defaults for the OS operations on Linux and macOS, which use POSIX alone. The driver runs as the
2
+ session user inside the desktop session, so both act directly: a command inherits the session's environment, a file
3
+ lands as that user."""
3
4
  from __future__ import annotations
4
5
 
5
6
  import asyncio
@@ -251,6 +251,15 @@ class AppDeclaration(BaseModel):
251
251
  ready_checks: list[AppReadyCheck]
252
252
  browser: Optional[bool] = None
253
253
 
254
+ @model_validator(mode="after")
255
+ def _no_window_wait_on_macos(self) -> AppDeclaration:
256
+ """A window's readiness is read from X11's windows, which macOS hasn't."""
257
+ launch = self.os["macos"].launch if "macos" in self.os else None
258
+ if launch is not None and launch.ready is not None and launch.ready.window:
259
+ raise ValueError("on macos an app's launch can't wait on its window, which is read from X11; wait on a "
260
+ "port, a command or a @ready_check")
261
+ return self
262
+
254
263
  @model_validator(mode="before")
255
264
  @classmethod
256
265
  def _stored_before_each_os(cls, data: Any) -> Any:
@@ -9,6 +9,7 @@ import json
9
9
  import os
10
10
  import shlex
11
11
  import signal
12
+ import sys
12
13
  import time
13
14
  from collections.abc import Awaitable, Callable
14
15
  from datetime import datetime, timezone
@@ -22,8 +23,9 @@ from . import session
22
23
  if TYPE_CHECKING:
23
24
  from .app import Launch
24
25
 
25
- #: Where an app's server marks that it launched the app since the computer booted, at boot or on demand.
26
- LAUNCHED = Path("/run/agentenv/apps")
26
+ #: Where an app's server marks that it launched the app since the computer booted, at boot or on demand: a folder a
27
+ #: boot clears, under /tmp on macOS, which has no /run and lets the session user write nothing at its root.
28
+ LAUNCHED = Path("/tmp/agentenv/apps" if sys.platform == "darwin" else "/run/agentenv/apps")
27
29
  #: How long one readiness check may take; an app's checks run at once.
28
30
  CHECK_TIMEOUT_S = 10.0
29
31
  #: Where an app's server keeps the pid of the app it launched last, in the app's folder, so a server that restarts can
@@ -48,6 +48,8 @@ MINIMUM_TOOLS = ("computer_screenshot", "computer_click", "computer_drag", "comp
48
48
  DATA_PLANE = (INTAKE_EXTENSION_URI, DATA_OBJECTS_EXTENSION_URI)
49
49
  #: Where the fixture is staged and keeps its state, relative to the session user's home.
50
50
  STAGE = ".agentenv-probe"
51
+ #: How the probe reads a staged file's owner: through the python3 the fixture runs on, the same on every OS.
52
+ OWNER = ("python3", "-c", "import os, sys; print(os.stat(sys.argv[1]).st_uid)")
51
53
  #: Where a page driver's computer_upload takes files from, relative to the session user's home.
52
54
  UPLOADS = "Uploads"
53
55
  #: What the probe answers the page fixture's prompt with.
@@ -605,7 +607,7 @@ class Probe:
605
607
  async def _security(self) -> None:
606
608
  async def one_user() -> str | None:
607
609
  uid = (await self.driver.run("id", "-u"))["stdout"].strip()
608
- owner = (await self.driver.run("stat", "-c", "%u", f"{STAGE}/{self.fixture}"))["stdout"].strip()
610
+ owner = (await self.driver.run(*OWNER, f"{STAGE}/{self.fixture}"))["stdout"].strip()
609
611
  if uid == "0":
610
612
  return "commands run as root"
611
613
  return None if owner == uid else f"data/add wrote as uid {owner}, commands run as uid {uid}"
@@ -205,13 +205,13 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
205
205
  if self._computer is None:
206
206
  raise TypeError(f"{type(self).__qualname__}.__init__ must call super().__init__()")
207
207
 
208
- # The Linux defaults for the OS operations.
208
+ # The defaults for the OS operations on Linux and macOS, POSIX alike.
209
209
 
210
- @default_for("run_in_session", os="linux")
210
+ @default_for("run_in_session", os=["linux", "macos"])
211
211
  async def _computer_linux_run_in_session(self, argv: list, cwd: str | None, env: dict | None, timeout_s: float):
212
212
  return await _linux.run_in_session(argv, cwd, env, timeout_s, output_bytes=self.settings.exec_output_bytes)
213
213
 
214
- @default_for("write_files", os="linux")
214
+ @default_for("write_files", os=["linux", "macos"])
215
215
  async def _computer_linux_write_files(self, files: dict) -> None:
216
216
  await asyncio.to_thread(_linux.write_files, files)
217
217
 
@@ -27,13 +27,15 @@ class Platform:
27
27
 
28
28
 
29
29
  def detect(environ: dict | None = None, os_release: Path = Path("/etc/os-release")) -> Platform:
30
- """The running platform: the OS, ``/etc/os-release``'s ID and VERSION_ID on Linux, the machine, and the
31
- display from ``XDG_SESSION_TYPE``, ``none`` for a computer with no display, else ``DISPLAY`` (x11) or
32
- ``WAYLAND_DISPLAY`` (wayland)."""
30
+ """The running platform: the OS, ``/etc/os-release``'s ID and VERSION_ID on Linux and the macOS version on macOS,
31
+ the machine, and the display: on Linux from ``XDG_SESSION_TYPE``, ``none`` for a computer with no display, else
32
+ ``DISPLAY`` (x11) or ``WAYLAND_DISPLAY`` (wayland); on macOS Aqua."""
33
33
  environ = os.environ if environ is None else environ
34
34
  name = _OS.get(sys.platform, sys.platform)
35
35
  release = _os_release(os_release) if name == "linux" else {}
36
- display = None
36
+ if name == "macos":
37
+ release = {"VERSION_ID": _platform.mac_ver()[0] or None}
38
+ display = "aqua" if name == "macos" else None
37
39
  if name == "linux":
38
40
  session = (environ.get("XDG_SESSION_TYPE") or "").strip().lower()
39
41
  if session in ("x11", "wayland", "none"):
@@ -39,7 +39,7 @@ from agentenv_protocol.computer import (
39
39
  upload,
40
40
  )
41
41
  from agentenv_protocol.computer import _linux as linux
42
- from agentenv_protocol.computer.conformance.checks import STAGE
42
+ from agentenv_protocol.computer.conformance.checks import OWNER, STAGE
43
43
  from agentenv_protocol.computer.conformance.page_fixture import QUESTION, TITLE, TWO, TWO_TITLE
44
44
 
45
45
  CLIPBOARD = "agentenv-probe clipboard"
@@ -56,6 +56,7 @@ DESK_URL = "http://127.0.0.1:4343/"
56
56
  TITLES = {DESK_URL: TITLE, DESK_URL + TWO.lstrip("/"): TWO_TITLE}
57
57
 
58
58
 
59
+
59
60
  class FakeDesk:
60
61
  """What the Fixture Desk would record of the input the fake screen gets."""
61
62
 
@@ -202,7 +203,7 @@ class FixtureDriver(FakeDriver):
202
203
  if f"python3 {STAGE}/fixture.py" in text:
203
204
  self.desk.start()
204
205
  return ExecResult(0, b"4242\n")
205
- if argv in (["id", "-u"], ["stat", "-c", "%u", f"{STAGE}/fixture.py"]):
206
+ if argv in (["id", "-u"], [*OWNER, f"{STAGE}/fixture.py"]):
206
207
  return ExecResult(0, b"1000\n")
207
208
  if argv[:1] in (["kill"], ["tail"]) or f"rm -rf {STAGE}/state" in text:
208
209
  return ExecResult(0)
@@ -320,10 +321,10 @@ class PageFixtureDriver(FixtureDriver):
320
321
  return ExecResult(0, b"4343") if self.desk.started else ExecResult(1)
321
322
  if argv[:1] == ["cat"] and argv[1].endswith("layout.json") and self.desk.started:
322
323
  return ExecResult(0, json.dumps(PAGE_LAYOUT).encode())
323
- if argv == ["stat", "-c", "%u", f"{STAGE}/page_fixture.py"]:
324
+ if argv == [*OWNER, f"{STAGE}/page_fixture.py"]:
324
325
  return ExecResult(0, b"1000\n")
325
- if argv == ["stat", "-c", "%u", f"{STAGE}/fixture.py"]:
326
- return ExecResult(1, b"", b"stat: cannot statx: No such file or directory")
326
+ if argv == [*OWNER, f"{STAGE}/fixture.py"]:
327
+ return ExecResult(1, b"", b"FileNotFoundError: [Errno 2] No such file or directory")
327
328
  return super()._fixture(argv)
328
329
 
329
330
 
@@ -190,6 +190,58 @@ def test_find_child_tolerates_missing_children():
190
190
  assert protocol_v1.find_child({"children_environments": None}, "slack") is None
191
191
 
192
192
 
193
+ _SHARED_NAME = {"name": "gw", "children_environments": [
194
+ {"name": "mail", "url": "/svc/mcp-mail/agentenv", "capabilities": {"operations": ["data/get"], "extensions": [
195
+ {"uri": "urn:agentenv:clock/v1"}]}},
196
+ {"name": "mail", "url": "/svc/mail/agentenv", "capabilities": {"operations": []}},
197
+ {"name": "docs", "capabilities": {}},
198
+ "not a card",
199
+ ]}
200
+
201
+
202
+ def test_children_are_each_nested_card_by_its_name_url_and_card():
203
+ found = protocol_v1.children(_SHARED_NAME)
204
+
205
+ assert [(c.name, c.url) for c in found] == [("mail", "/svc/mcp-mail/agentenv"), ("mail", "/svc/mail/agentenv"), ("docs", None)]
206
+ assert found[0].card is _SHARED_NAME["children_environments"][0]
207
+ assert [c.extensions for c in found] == [[{"uri": "urn:agentenv:clock/v1"}], [], []]
208
+ assert [c.operations for c in found] == [["data/get"], [], None]
209
+ assert [c.serves("data/get") for c in found] == [True, False, None]
210
+ assert found[0].find_extension("urn:agentenv:clock/v1") is not None and found[1].find_extension("urn:agentenv:clock/v1") is None
211
+ assert protocol_v1.children({"name": "leaf"}) == [] and protocol_v1.children({"children_environments": None}) == []
212
+
213
+
214
+ def test_a_childs_base_url_is_its_url_without_the_rpc_path_against_the_composed_cards_address():
215
+ mcp, website, docs = protocol_v1.children(_SHARED_NAME)
216
+
217
+ assert mcp.base_url("https://env.example/") == "https://env.example/svc/mcp-mail"
218
+ assert website.base_url("https://env.example") == "https://env.example/svc/mail"
219
+ assert docs.base_url("https://env.example") == "https://env.example"
220
+ assert protocol_v1.ChildEnv("x", "https://x.example/agentenv", {}).base_url("https://env.example") == "https://x.example"
221
+
222
+
223
+ def test_child_at_finds_a_child_by_its_url_or_data_plane_route_where_two_share_a_name():
224
+ assert protocol_v1.child_at(_SHARED_NAME, "/svc/mail").url == "/svc/mail/agentenv"
225
+ assert protocol_v1.child_at(_SHARED_NAME, "/svc/mcp-mail/").url == "/svc/mcp-mail/agentenv"
226
+ assert protocol_v1.child_at(_SHARED_NAME, "/svc/mail/agentenv").url == "/svc/mail/agentenv"
227
+ assert protocol_v1.child_at(_SHARED_NAME, "/svc/docs") is None
228
+ assert protocol_v1.child_at({"name": "leaf"}, "/svc/mail") is None
229
+
230
+
231
+ def test_child_named_refuses_a_name_two_children_share():
232
+ assert protocol_v1.child_named(_SHARED_NAME, "docs").name == "docs"
233
+ assert protocol_v1.child_named(_SHARED_NAME, "gmail") is None
234
+ with pytest.raises(protocol_v1.AmbiguousChildError, match="2 child envs are named 'mail'"):
235
+ protocol_v1.child_named(_SHARED_NAME, "mail")
236
+ assert protocol_v1.find_child(_SHARED_NAME, "mail")["url"] == "/svc/mcp-mail/agentenv"
237
+
238
+
239
+ def test_serves_is_what_a_card_declares_of_a_method_and_none_when_it_declares_no_operations():
240
+ assert protocol_v1.serves({"capabilities": {"operations": ["data/get"]}}, "data/get") is True
241
+ assert protocol_v1.serves({"capabilities": {"operations": ["data/add"]}}, "data/get") is False
242
+ assert protocol_v1.serves({"capabilities": {}}, "data/get") is None
243
+
244
+
193
245
  def test_find_extension_ignores_children():
194
246
  # set-errors/v1 is advertised only on the children, never at the top level
195
247
  assert protocol_v1.find_extension(_CARD_WITH_CHILDREN, "urn:agentenv:set-errors/v1") is None
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import asyncio
8
8
  import contextlib
9
+ import importlib.util
9
10
  import os
10
11
  import shlex
11
12
  import signal
@@ -109,6 +110,16 @@ async def test_an_apps_server_is_ready_once_it_launched_the_app_and_each_check_p
109
110
  assert after["ready"] is True and after["problems"] == []
110
111
 
111
112
 
113
+
114
+ @pytest.mark.parametrize("platform, folder", [("linux", "/run/agentenv/apps"), ("darwin", "/tmp/agentenv/apps")])
115
+ def test_a_launch_is_marked_in_a_folder_a_boot_clears_and_the_session_user_writes(monkeypatch, platform, folder):
116
+ monkeypatch.setattr(sys, "platform", platform)
117
+ spec = importlib.util.spec_from_file_location(f"agentenv_protocol.computer._app_runtime_on_{platform}",
118
+ app_runtime.__file__)
119
+ loaded = importlib.util.module_from_spec(spec)
120
+ spec.loader.exec_module(loaded)
121
+ assert loaded.LAUNCHED == Path(folder)
122
+
112
123
  @pytest.mark.asyncio
113
124
  async def test_checks_that_fail_hang_or_raise_say_so_at_once_without_holding_up_the_server(desktop, monkeypatch):
114
125
  monkeypatch.setattr(app_runtime, "CHECK_TIMEOUT_S", 1.0)
@@ -167,7 +167,7 @@ async def test_each_app_a_driver_fronts_that_exports_is_asked_for_its_export_onc
167
167
 
168
168
  @pytest.mark.asyncio
169
169
  async def test_a_driver_without_exec_or_data_add_passes_basic_and_leaves_full_unverified():
170
- report = await _probe(MinimalDriver(FakeScreen(640, 400), platform=Platform(os="macos")))
170
+ report = await _probe(MinimalDriver(FakeScreen(640, 400), platform=Platform(os="windows")))
171
171
  assert report.passed, _failed(report)
172
172
  unverified = [c for c in report.checks if c.status == "unverified"]
173
173
  assert {c.check for c in unverified} == {"P3", "P4", "P5", "P6", "P7", "P9"}
@@ -264,6 +264,9 @@ def test_an_app_declaring_its_skill_twice_is_refused(folder):
264
264
  "Changed.fonts runs on 'windows', which Changed declares no install for; it declares freebsd, linux"),
265
265
  ('@ready_check(os="macos")\n def window_shown(self): return True',
266
266
  "Changed.window_shown runs on 'macos', which Changed declares no install for"),
267
+ ('@install("macos", FreeBSD(), launch=Launch(command=["mail"], window={"class": "Mail"}, '
268
+ 'ready=Ready(window=True)))',
269
+ "on macos an app's launch can't wait on its window, which is read from X11"),
267
270
  ('class BuildSettings(BaseModel): key: SecretStr = SecretStr("")',
268
271
  "Changed.BuildSettings holds a secret at properties.key, which its computer's image would keep; declare it in "
269
272
  "Settings"),
@@ -273,9 +276,9 @@ def test_an_app_declaring_its_skill_twice_is_refused(folder):
273
276
  "Changed.BuildSettings holds a secret at $defs.Settings.properties.password, which"),
274
277
  ], ids=["no-wait", "no-window", "window-key", "download-sha", "script-escape", "os-skill-twice", "os-build-secret",
275
278
  "step-os",
276
- "check-os", "build-secret", "build-secrets-listed", "build-secret-nested"])
279
+ "check-os", "macos-window-wait", "build-secret", "build-secrets-listed", "build-secret-nested"])
277
280
  def test_an_app_whose_declaration_cant_be_checked_is_refused_saying_why(folder, capsys, change, problem):
278
- decorator, body = (change, "pass") if change.startswith("@linux") else ("", change)
281
+ decorator, body = (change, "pass") if change.startswith(("@linux", "@install(")) else ("", change)
279
282
  (folder / "app.py").write_text(APP.format(supports=LINUX, sha='a' * 64) + f"""
280
283
 
281
284
  @computer_app(supports={LINUX})
@@ -45,6 +45,8 @@ from agentenv_protocol.computer.settings import load_settings # noqa: E402
45
45
  from agentenv_protocol.computer.tools import _modifiers # noqa: E402
46
46
 
47
47
  MACOS = Platform(os="macos", arch="arm64")
48
+ #: A platform the base class has no OS defaults for.
49
+ WINDOWS = Platform(os="windows", arch="amd64")
48
50
 
49
51
 
50
52
  def _required(namespace: dict) -> dict:
@@ -130,7 +132,8 @@ def test_set_display_without_modes_is_refused():
130
132
  def test_a_driver_method_replaces_the_linux_default():
131
133
  Driver = _define({"run": run_in_session(lambda self, *a: None)})
132
134
  assert declared(Driver)["run_in_session"] == [("run", {})]
133
- assert declared(MinimalDriver)["run_in_session"] == [("_computer_linux_run_in_session", {"os": frozenset({"linux"})})]
135
+ assert declared(MinimalDriver)["run_in_session"] == [("_computer_linux_run_in_session",
136
+ {"os": frozenset({"linux", "macos"})})]
134
137
 
135
138
 
136
139
  def test_the_platform_is_detected(tmp_path, monkeypatch):
@@ -143,7 +146,8 @@ def test_the_platform_is_detected(tmp_path, monkeypatch):
143
146
  assert detect({"WAYLAND_DISPLAY": "wayland-0"}, release).display == "wayland"
144
147
  assert detect({}, tmp_path / "missing").as_dict() == {"os": "linux", "arch": "arm64"}
145
148
  monkeypatch.setattr("agentenv_protocol.computer.platform.sys.platform", "darwin")
146
- assert detect({"DISPLAY": ":0"}, release) == Platform("macos", None, None, "arm64", None)
149
+ monkeypatch.setattr("agentenv_protocol.computer.platform._platform.mac_ver", lambda: ("26.5.2", ("", "", ""), ""))
150
+ assert detect({"DISPLAY": ":0"}, release) == Platform("macos", None, "26.5.2", "arm64", "aqua")
147
151
 
148
152
 
149
153
  def test_settings_come_from_one_json_value():
@@ -222,7 +226,7 @@ async def test_a_driver_whose_software_ships_a_skill_leads_with_it_and_the_compu
222
226
 
223
227
  @pytest.mark.asyncio
224
228
  async def test_a_minimal_driver_declares_only_what_it_has():
225
- async with http_client(MinimalDriver(platform=MACOS)) as http:
229
+ async with http_client(MinimalDriver(platform=WINDOWS)) as http:
226
230
  card = (await http.get("/.well-known/agent-env.json")).json()
227
231
  display_set = await http.post("/agentenv/ext/computer-display/set", json={"width": 1, "height": 1})
228
232
  exec_run = await http.post("/agentenv/ext/computer-exec/run", json={"argv": ["true"], "request_id": "r"})
@@ -449,14 +453,14 @@ def test_a_driver_class_declares_what_it_serves_on_each_platform(monkeypatch):
449
453
  raise AssertionError("declaration builds the card without the subclass's __init__")
450
454
 
451
455
  linux = Declared.declaration(LINUX)
452
- elsewhere = MinimalDriver.declaration(Platform(os="macos"))
456
+ elsewhere = MinimalDriver.declaration(Platform(os="windows"))
453
457
  uris = lambda card: {e.uri for e in card.capabilities.extensions} # noqa: E731
454
458
  assert linux.name == "declared" and len(linux.capabilities.tools) == 14
455
459
  assert {"urn:agentenv:computer-pointer/v1", "urn:agentenv:computer-exec/v1"} <= uris(linux)
456
460
  assert elsewhere.name == "MinimalDriver" and len(elsewhere.capabilities.tools) == 7
457
461
  assert "urn:agentenv:computer-exec/v1" not in uris(elsewhere)
458
462
  computer = next(e for e in elsewhere.capabilities.extensions if e.uri == "urn:agentenv:computer/v1")
459
- assert computer.params["platform"] == {"os": "macos"}
463
+ assert computer.params["platform"] == {"os": "windows"}
460
464
 
461
465
 
462
466
  def test_a_supports_entry_names_each_platform_it_covers():
@@ -94,12 +94,16 @@ def _grant() -> dict:
94
94
  "file_field": "file"}}
95
95
 
96
96
 
97
- async def _record(driver, seconds: float, start: dict | None = None, end: dict | None = None) -> tuple[dict, dict]:
98
- """A recording that runs ``seconds`` from its first frame, however long that took to come, and then ends."""
97
+ async def _record(driver, seconds: float, start: dict | None = None, end: dict | None = None,
98
+ frames: int | None = None) -> tuple[dict, dict]:
99
+ """A recording that runs ``seconds`` from its first frame, however long that took to come, and, given ``frames``,
100
+ until its encoder holds that many, and then ends."""
99
101
  async with http_client(driver) as http:
100
102
  started = (await http.post(START, json={"request_id": "r", "fps": 10, **(start or {})})).json()
101
103
  await _first_frame(driver, started)
102
104
  await asyncio.sleep(seconds)
105
+ if frames is not None:
106
+ await _encoded(driver, started, frames)
103
107
  manifest = (await http.post(END, json={"recording_id": started["recording_id"], **(end or {})})).json()
104
108
  return started, manifest
105
109
 
@@ -237,7 +241,7 @@ async def test_a_stream_that_keeps_failing_ends_the_recording_with_its_error(hom
237
241
 
238
242
  @pytest.mark.asyncio
239
243
  async def test_a_stream_of_frames_keeps_each_frames_capture_time(home):
240
- _, manifest = await _record(Streamed(), 0.8, start={"fps": 20, "max_edge": 640})
244
+ _, manifest = await _record(Streamed(), 0.8, start={"fps": 20, "max_edge": 640}, frames=10)
241
245
  (segment,) = manifest["segments"]
242
246
  assert manifest["source"] == "stream" and (segment["width"], segment["height"]) == (640, 400)
243
247
  frames = _decoded(home / ".agentenv-recordings" / segment["path"])
@@ -511,3 +515,12 @@ async def test_frames_in_thread_keeps_the_latest_frames_for_a_slow_reader_and_cl
511
515
  await endless.aclose()
512
516
  assert first == 0 and rest == [26, 27, 28, 29]
513
517
  assert await asyncio.to_thread(stopped.wait, 5)
518
+
519
+
520
+ async def _encoded(driver, started: dict, frames: int) -> None:
521
+ """Until the recording's encoder holds ``frames`` frames: a busy machine captures fewer a second."""
522
+ recording = driver._computer.recordings.running[started["recording_id"]]
523
+ for _ in range(3000):
524
+ if recording.encoder and recording.encoder.frames >= frames:
525
+ return
526
+ await asyncio.sleep(0.01)