agentenv-framework-protocol 0.1.448__tar.gz → 0.1.451__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 (124) hide show
  1. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/PKG-INFO +13 -2
  2. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/README.md +10 -1
  3. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/pyproject.toml +5 -3
  4. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/agent_env_environment.py +13 -4
  5. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/apps.py +1 -10
  6. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/fixture.py +57 -12
  7. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/driver.py +21 -13
  8. agentenv_framework_protocol-0.1.451/src/agentenv_protocol/computer/svc.py +182 -0
  9. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/website_browser.py +3 -0
  10. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_conformance.py +26 -1
  11. agentenv_framework_protocol-0.1.451/tests/test_computer_svc_forward.py +227 -0
  12. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_website_browser.py +2 -1
  13. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/.gitignore +0 -0
  14. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/LICENSE +0 -0
  15. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/NOTICE +0 -0
  16. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/THIRD_PARTY_NOTICES.md +0 -0
  17. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/examples/__init__.py +0 -0
  18. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/examples/advanced_agent.py +0 -0
  19. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/examples/basic_agent.py +0 -0
  20. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/examples/custom_extensions_agent.py +0 -0
  21. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/examples/multimodal_agent.py +0 -0
  22. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/examples/reference_model.py +0 -0
  23. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/examples/streaming_agent.py +0 -0
  24. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/__init__.py +0 -0
  25. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/__init__.py +0 -0
  26. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/_trajectory_log.py +0 -0
  27. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/_triggers.py +0 -0
  28. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/extensions.py +0 -0
  29. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/framework.py +0 -0
  30. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/peer_agents.py +0 -0
  31. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/proxy.py +0 -0
  32. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/registry.py +0 -0
  33. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/staging.py +0 -0
  34. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/tasks/__init__.py +0 -0
  35. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/a2a_agent/tasks/v1.py +0 -0
  36. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/client.py +0 -0
  37. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/__init__.py +0 -0
  38. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/_declare.py +0 -0
  39. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/_linux.py +0 -0
  40. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/_load.py +0 -0
  41. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/_with_fds.py +0 -0
  42. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/app.py +0 -0
  43. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/app_data.py +0 -0
  44. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/app_runtime.py +0 -0
  45. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/card.py +0 -0
  46. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/checks.py +0 -0
  47. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/__init__.py +0 -0
  48. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/__main__.py +0 -0
  49. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/checks.py +0 -0
  50. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/client.py +0 -0
  51. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/mp4.py +0 -0
  52. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/page_fixture.py +0 -0
  53. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/conformance/report.py +0 -0
  54. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/dataplane.py +0 -0
  55. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/errors.py +0 -0
  56. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/extensions.py +0 -0
  57. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/frames.py +0 -0
  58. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/keys.py +0 -0
  59. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/launch.py +0 -0
  60. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/manifest.py +0 -0
  61. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/operations.py +0 -0
  62. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/platform.py +0 -0
  63. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/queue.py +0 -0
  64. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/recording.py +0 -0
  65. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/results.py +0 -0
  66. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/run_install_step.py +0 -0
  67. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/runtime.py +0 -0
  68. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/seed.py +0 -0
  69. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/serve.py +0 -0
  70. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/serve_app.py +0 -0
  71. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/session.py +0 -0
  72. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/settings.py +0 -0
  73. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/skill.py +0 -0
  74. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/spaces.py +0 -0
  75. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/computer/tools.py +0 -0
  76. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/errors.py +0 -0
  77. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/manifest.py +0 -0
  78. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/preflight.py +0 -0
  79. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/tool_access.py +0 -0
  80. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/tool_client.py +0 -0
  81. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/tool_context.py +0 -0
  82. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/transfers.py +0 -0
  83. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/src/agentenv_protocol/types.py +0 -0
  84. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/computer_fake.py +0 -0
  85. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/probe_fake.py +0 -0
  86. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_agent.py +0 -0
  87. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_agent_examples.py +0 -0
  88. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_cdp_config.py +0 -0
  89. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_object_transfers.py +0 -0
  90. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_peer_agents.py +0 -0
  91. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_proxy.py +0 -0
  92. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_staging.py +0 -0
  93. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_a2a_trajectory_log.py +0 -0
  94. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_agent_env_environment.py +0 -0
  95. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_client.py +0 -0
  96. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_app_data.py +0 -0
  97. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_app_server.py +0 -0
  98. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_conformance_checks.py +0 -0
  99. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_conformance_page.py +0 -0
  100. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_conformance_recording.py +0 -0
  101. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_dataplane.py +0 -0
  102. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_declare.py +0 -0
  103. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_driver.py +0 -0
  104. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_extensions.py +0 -0
  105. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_frames.py +0 -0
  106. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_page.py +0 -0
  107. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_queue.py +0 -0
  108. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_recording.py +0 -0
  109. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_seed.py +0 -0
  110. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_serve.py +0 -0
  111. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_computer_spaces.py +0 -0
  112. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_control_token.py +0 -0
  113. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_data_objects.py +0 -0
  114. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_errors.py +0 -0
  115. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_extension_methods.py +0 -0
  116. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_intake.py +0 -0
  117. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_manifest.py +0 -0
  118. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_preflight.py +0 -0
  119. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_tool_access.py +0 -0
  120. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_tool_annotations.py +0 -0
  121. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_tool_client.py +0 -0
  122. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_tool_context.py +0 -0
  123. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_tool_context_fastmcp.py +0 -0
  124. {agentenv_framework_protocol-0.1.448 → agentenv_framework_protocol-0.1.451}/tests/test_tool_context_injection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentenv-framework-protocol
3
- Version: 0.1.448
3
+ Version: 0.1.451
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
@@ -42,6 +42,7 @@ Requires-Dist: av>=17; extra == 'computer'
42
42
  Requires-Dist: mcp<2,>=1.25; extra == 'computer'
43
43
  Requires-Dist: pillow>=10.4; extra == 'computer'
44
44
  Requires-Dist: uvicorn>=0.30; extra == 'computer'
45
+ Requires-Dist: websockets>=13; extra == 'computer'
45
46
  Provides-Extra: dev
46
47
  Requires-Dist: a2a-sdk[http-server]<0.4,>=0.3.26; extra == 'dev'
47
48
  Requires-Dist: av>=17; extra == 'dev'
@@ -52,6 +53,7 @@ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
52
53
  Requires-Dist: pytest>=9.0; extra == 'dev'
53
54
  Requires-Dist: python-multipart>=0.0.13; extra == 'dev'
54
55
  Requires-Dist: regex>=2023.12.25; extra == 'dev'
56
+ Requires-Dist: websockets>=13; extra == 'dev'
55
57
  Description-Content-Type: text/markdown
56
58
 
57
59
  # agentenv-framework-protocol
@@ -826,7 +828,16 @@ launches one at a time, runs a load and a reset with the app stopped and relaunc
826
828
  follows a failed load with a reset, and fails a call that runs past `data_timeout_s` (300 s); a sync
827
829
  hook runs in a thread, which the reset and relaunch after it wait for. The driver fronts its apps:
828
830
  its card lists each one's card as a child environment, rooted at `/svc/<app>/`, where it forwards
829
- requests to the app's server.
831
+ requests to the app's server, taking the control token:
832
+
833
+ - every method, its body and the answer's streamed both ways, so a large answer isn't held whole;
834
+ - headers passed on but the hop-by-hop ones, those a `Connection` header names and the control token's
835
+ `Authorization`; `Host` as the caller sent it, and in place of any address the caller forwarded,
836
+ the one it reached the driver at: `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Prefix`
837
+ (`/svc/<app>`), as the gateway sets them;
838
+ - WebSockets, relayed to the app's server both ways with the subprotocol it picks until either side
839
+ closes, the other then closed with its code; refused before they open without the token, or with no
840
+ such app or one not answering. The `computer` extra brings `websockets` for this.
830
841
 
831
842
  `MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
832
843
  env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
@@ -770,7 +770,16 @@ launches one at a time, runs a load and a reset with the app stopped and relaunc
770
770
  follows a failed load with a reset, and fails a call that runs past `data_timeout_s` (300 s); a sync
771
771
  hook runs in a thread, which the reset and relaunch after it wait for. The driver fronts its apps:
772
772
  its card lists each one's card as a child environment, rooted at `/svc/<app>/`, where it forwards
773
- requests to the app's server.
773
+ requests to the app's server, taking the control token:
774
+
775
+ - every method, its body and the answer's streamed both ways, so a large answer isn't held whole;
776
+ - headers passed on but the hop-by-hop ones, those a `Connection` header names and the control token's
777
+ `Authorization`; `Host` as the caller sent it, and in place of any address the caller forwarded,
778
+ the one it reached the driver at: `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Prefix`
779
+ (`/svc/<app>`), as the gateway sets them;
780
+ - WebSockets, relayed to the app's server both ways with the subprotocol it picks until either side
781
+ closes, the other then closed with its code; refused before they open without the token, or with no
782
+ such app or one not answering. The `computer` extra brings `websockets` for this.
774
783
 
775
784
  `MailApp.declaration()` returns what the class declares, an `AppDeclaration`, which a `computer_app`
776
785
  env stores (`browser: true` for a website browser), and `python -m agentenv_protocol.computer._declare <folder> <file> <class>` prints it as
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentenv-framework-protocol"
3
- version = "0.1.448"
3
+ version = "0.1.451"
4
4
  description = "Open data-plane protocol and server SDK for agent environments."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -41,13 +41,14 @@ app = [
41
41
  "mcp>=1.25,<2",
42
42
  "uvicorn>=0.30",
43
43
  ]
44
- # A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, and
45
- # records the display as H.264 with PyAV (computer-recording/v1).
44
+ # A computer driver: ComputerDriverEnvironment serves its tools over mcp's FastMCP, crops and encodes frames, records
45
+ # the display as H.264 with PyAV (computer-recording/v1), and relays WebSockets to its apps on /svc/<app>/.
46
46
  computer = [
47
47
  "av>=17",
48
48
  "mcp>=1.25,<2",
49
49
  "Pillow>=10.4",
50
50
  "uvicorn>=0.30",
51
+ "websockets>=13",
51
52
  ]
52
53
  dev = [
53
54
  "a2a-sdk[http-server]>=0.3.26,<0.4",
@@ -59,6 +60,7 @@ dev = [
59
60
  "fastmcp>=3.4,<4", # the standalone-fastmcp mount path
60
61
  "Pillow>=10.4", # the computer extra's frame crops and encodings
61
62
  "av>=17", # the computer extra's encoder, and the tests' decoder
63
+ "websockets>=13", # the computer extra's /svc/<app>/ relay
62
64
  ]
63
65
  [project.urls]
64
66
  Homepage = "https://www.agentenvframework.com"
@@ -1546,18 +1546,27 @@ def _error_response(error: AgentEnvError) -> Response:
1546
1546
  return _json_response(error_body(**error.fields()), error.status)
1547
1547
 
1548
1548
 
1549
+ def control_check() -> Callable[[Any], bool]:
1550
+ """Whether a call's headers carry the env's control token as their bearer, with ``CONTROL_TOKEN_ENV`` set; without
1551
+ it, any do."""
1552
+ token = os.environ.get(CONTROL_TOKEN_ENV)
1553
+ if not token:
1554
+ return lambda headers: True
1555
+ expected = f"Bearer {token}".encode()
1556
+ return lambda headers: hmac.compare_digest(headers.get("authorization", "").encode(), expected)
1557
+
1558
+
1549
1559
  def control_guard() -> Callable[[Callable], Callable]:
1550
1560
  """What wraps a framework-only route, everything an env serves but its MCP endpoint and its card: with
1551
1561
  ``CONTROL_TOKEN_ENV`` set, a call that doesn't carry that token as its bearer is refused before the route runs;
1552
1562
  without it, nothing."""
1553
- token = os.environ.get(CONTROL_TOKEN_ENV)
1554
- if not token:
1563
+ if not os.environ.get(CONTROL_TOKEN_ENV):
1555
1564
  return lambda handler: handler
1556
- expected = f"Bearer {token}".encode()
1565
+ allowed = control_check()
1557
1566
 
1558
1567
  def guard(handler: Callable) -> Callable:
1559
1568
  async def guarded(request: Request) -> Response:
1560
- if not hmac.compare_digest(request.headers.get("authorization", "").encode(), expected):
1569
+ if not allowed(request.headers):
1561
1570
  return _error_response(AgentEnvError(
1562
1571
  "unauthorized", "this route is agent-env's own: it takes the env's control token", status=401))
1563
1572
  return await handler(request)
@@ -3,7 +3,7 @@ Each app's own server answers on its folder's socket (``serve_app``): its card s
3
3
  paths it owns, and its ``computer-app/v1`` ``ready`` whether it's ready. Every app's server must answer before the
4
4
  computer is ready; an app that starts with the computer must also be ready, and a ``demand`` app is checked once
5
5
  something launches it. The driver lists each app as a child environment named for its folder, its card's paths under
6
- ``/svc/<name>/``, and forwards each request there to the app's server."""
6
+ ``/svc/<name>/``, where it forwards each request to the app's server (``svc``)."""
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
@@ -91,15 +91,6 @@ async def owned_paths(folders: list[Path]) -> list[str]:
91
91
  for path in client.extension_params(card, COMPUTER_APP).get("paths") or []]
92
92
 
93
93
 
94
- async def forward(folder: Path, method: str, path: str, query: str, body: bytes,
95
- content_type: Optional[str]) -> httpx.Response:
96
- """The app's server's answer to the request, sent on as it came; it takes as long as the app does."""
97
- async with httpx.AsyncClient(transport=httpx.AsyncHTTPTransport(uds=str(folder / SOCKET)), base_url="http://app",
98
- timeout=None) as http:
99
- return await http.request(method, f"/{path}" + (f"?{query}" if query else ""), content=body,
100
- headers={"content-type": content_type} if content_type else None)
101
-
102
-
103
94
  def endpoint(card: dict, method: str) -> str:
104
95
  """The path of a ``computer-app/v1`` method, as the app's card advertises it."""
105
96
  return client.find_extension_method(card, COMPUTER_APP, method)["endpoint"]
@@ -1,9 +1,11 @@
1
- """The Linux fixture the conformance probe stages with data/add and launches with computer-exec: a Tk window, the
2
- Fixture Desk, whose widgets' screen boxes are written to <state-dir>/layout.json and every effect that reaches it,
3
- with whatever lands in <state-dir>/inbox.txt, to <state-dir>/state.json, so the probe can aim at it and read back
4
- what arrived. Run as ``python3 fixture.py <state-dir>``; it needs a 1280x800 screen or larger."""
1
+ """The fixture the conformance probe stages with data/add and launches with computer-exec: a Tk window, the Fixture
2
+ Desk, whose widgets' screen boxes are written to <state-dir>/layout.json and every effect that reaches it, with
3
+ whatever lands in <state-dir>/inbox.txt, to <state-dir>/state.json, so the probe can aim at it and read back what
4
+ arrived. It records the same effects under X11, macOS's Aqua and Windows, whose Tk names keys, binds select-all and
5
+ reports the wheel differently. Run as ``python3 fixture.py <state-dir>``; it needs a 1280x800 screen or larger."""
5
6
  from __future__ import annotations
6
7
 
8
+ import ctypes
7
9
  import json
8
10
  import os
9
11
  import sys
@@ -19,6 +21,14 @@ MARKERS = ((11, 11, "#ff0000"), (12, 11, "#00ff00"), (11, 12, "#0000ff"), (12, 1
19
21
  (20, 37, "#00ffff"))
20
22
  CLIPBOARD = "agentenv-probe clipboard"
21
23
  MIN_SIZE = (1280, 800)
24
+ #: X11's keysyms for the keys Aqua's Tk names by their character.
25
+ KEYSYMS = {",": "comma", " ": "space"}
26
+ #: A wheel notch's <MouseWheel> delta where Tk scales the wheel: Windows', and Tk 9's everywhere.
27
+ NOTCH_DELTA = 120
28
+ #: Tk 9 packs a <TouchpadScroll>'s distances into its delta: y, a signed 16-bit number, in the low bits, x above.
29
+ Y_BITS = 16
30
+ #: The pixels of a scrolled line, macOS's for a scroll posted in lines.
31
+ LINE_PX = 10
22
32
 
23
33
 
24
34
  def main(state_dir: Path) -> None:
@@ -32,7 +42,7 @@ def main(state_dir: Path) -> None:
32
42
  state: dict = {"name": "", "notes": "", "keys": [], "saved_count": 0, "subscribed": False, "selected": None,
33
43
  "scroll_top": 0.0, "cells": {}, "right_clicks": 0, "double_clicks": 0, "drags": [],
34
44
  "wheel": {"up": 0, "down": 0, "left": 0, "right": 0}, "clipboard": None, "loaded": None,
35
- "screen": [width, height]}
45
+ "screen": [width, height], "windowing": root.tk.call("tk", "windowingsystem")}
36
46
 
37
47
  def save() -> None:
38
48
  part = state_dir / ".state.json.part"
@@ -52,12 +62,20 @@ def main(state_dir: Path) -> None:
52
62
 
53
63
  def key(event) -> None:
54
64
  state["name"] = name.get()
55
- state["keys"] = (state["keys"] + [event.keysym])[-100:]
65
+ state["keys"] = (state["keys"] + [KEYSYMS.get(event.keysym, event.keysym)])[-100:]
56
66
  state["clipboard"] = _clipboard(root)
57
67
  save()
58
68
 
59
69
  name.bind("<KeyRelease>", key)
60
70
 
71
+ def select_all(event) -> str:
72
+ name.selection_range(0, "end")
73
+ name.icursor("end")
74
+ return "break"
75
+
76
+ # Tk binds Ctrl+/ to select-all on X11 alone; Aqua's is Cmd+A.
77
+ name.bind("<Control-slash>", select_all)
78
+
61
79
  def on_save() -> None:
62
80
  state["saved_count"] += 1
63
81
  save()
@@ -157,14 +175,25 @@ def main(state_dir: Path) -> None:
157
175
  wheel = tk.Canvas(root, width=200, height=120, bg="#e6f0ff", highlightthickness=0)
158
176
  wheel.place(x=840, y=500)
159
177
 
160
- def wheeled(event, button: int) -> None:
161
- # Tk 8.6 delivers X buttons 6 and 7 (horizontal) as 4 and 5 with Shift held.
162
- horizontal = bool(event.state & 1)
163
- state["wheel"][("left" if horizontal else "up") if button == 4 else ("right" if horizontal else "down")] += 1
178
+ def wheeled(horizontal: bool, steps: int) -> None:
179
+ way = ("left" if horizontal else "up") if steps > 0 else ("right" if horizontal else "down")
180
+ state["wheel"][way] += abs(steps)
164
181
  save()
165
182
 
166
- wheel.bind("<Button-4>", lambda e: wheeled(e, 4))
167
- wheel.bind("<Button-5>", lambda e: wheeled(e, 5))
183
+ def scrolled(delta: int) -> None:
184
+ if steps := touchpad_steps(delta):
185
+ wheeled(*steps)
186
+
187
+ # Tk 8.6 on X11 reports the wheel as buttons 4 and 5, Aqua, Windows and Tk 9 as <MouseWheel>, a horizontal scroll
188
+ # (X's buttons 6 and 7) as a vertical one with Shift held. Tk 9 reports a scroll in pixels, a trackpad's or one a
189
+ # driver posts as Aqua's do, as <TouchpadScroll>.
190
+ if root.tk.call("tk", "windowingsystem") == "x11" and tk.TkVersion < 8.7:
191
+ wheel.bind("<Button-4>", lambda e: wheeled(bool(e.state & 1), 1))
192
+ wheel.bind("<Button-5>", lambda e: wheeled(bool(e.state & 1), -1))
193
+ else:
194
+ wheel.bind("<MouseWheel>", lambda e: wheeled(bool(e.state & 1), wheel_steps(e.delta)))
195
+ if tk.TkVersion >= 8.7:
196
+ wheel.bind("<TouchpadScroll>", lambda e: scrolled(e.delta))
168
197
 
169
198
  field = tk.Canvas(root, width=48, height=48, bg="#000000", highlightthickness=0, bd=0)
170
199
  field.place(x=1080, y=500)
@@ -198,6 +227,22 @@ def main(state_dir: Path) -> None:
198
227
  root.mainloop()
199
228
 
200
229
 
230
+ def wheel_steps(delta: int) -> int:
231
+ """The wheel steps a <MouseWheel> event's ``delta`` holds, up positive: whole notches of 120 where Tk scales them
232
+ so (Windows, and Tk 9 everywhere), else one a unit (Aqua's Tk 8.6)."""
233
+ return int(delta / NOTCH_DELTA) if abs(delta) >= NOTCH_DELTA else delta
234
+
235
+
236
+ def touchpad_steps(delta: int) -> tuple[bool, int] | None:
237
+ """The steps a <TouchpadScroll> event's ``delta`` holds, as horizontal or not and up or left positive, or None
238
+ when it moves nothing. A step is a line, LINE_PX, and macOS may coalesce several into one event."""
239
+ dx, dy = delta >> Y_BITS, ctypes.c_int16(delta).value
240
+ if not (dx or dy):
241
+ return None
242
+ distance = dy or dx
243
+ return not dy, (1 if distance > 0 else -1) * max(1, round(abs(distance) / LINE_PX))
244
+
245
+
201
246
  def _clipboard(root: tk.Tk) -> str | None:
202
247
  try:
203
248
  return root.clipboard_get()
@@ -4,21 +4,22 @@ computer's apps as child environments, each reached at ``/svc/<name>/`` through
4
4
  from __future__ import annotations
5
5
 
6
6
  import asyncio
7
- from typing import Any, Callable, ClassVar, Literal
7
+ from pathlib import Path
8
+ from typing import Any, Callable, ClassVar, Literal, Optional
8
9
 
9
- import httpx
10
10
  from pydantic import BaseModel, ConfigDict
11
11
  from starlette.requests import Request
12
12
  from starlette.responses import Response
13
+ from starlette.websockets import WebSocket
13
14
 
14
15
  from ..agent_env_environment import (_CARD_CONFIG_ATTR, AgentEnvEnvironment, AgentEnvFastMCPApplication,
15
16
  _error_response, control_guard, environment_card)
16
17
  from ..tool_context import ToolContext
17
18
  from ..types import EnvironmentCard, EnvironmentExtension, EnvironmentTool
18
- from . import _linux, apps, card
19
+ from . import _linux, apps, card, svc
19
20
  from .checks import check_features, check_names
20
21
  from .dataplane import DataPlane
21
- from .errors import DeliveryFailed, TargetUnavailable
22
+ from .errors import TargetUnavailable
22
23
  from .extensions import Extensions
23
24
  from .operations import ReadyCheck, check_class, default_for
24
25
  from .platform import Platform, detect
@@ -138,21 +139,28 @@ class ComputerDriverEnvironment(Tools, Extensions, DataPlane, AgentEnvEnvironmen
138
139
  return await apps.children(self._computer.apps) if self._computer.apps else None
139
140
 
140
141
  def _computer_front_apps(self, app: Any) -> Any:
141
- app.custom_route("/svc/{name}/{path:path}", methods=["GET", "POST"])(control_guard()(self._computer_forward))
142
+ app.custom_route("/svc/{name}/{path:path}", methods=svc.METHODS)(control_guard()(self._computer_forward))
143
+ svc.websocket_route(app, "/svc/{name}/{path:path}", self._computer_relay)
142
144
  return app
143
145
 
146
+ def _computer_app_folder(self, name: str) -> Optional[Path]:
147
+ return next((folder for folder in self._computer.apps if folder.name == name), None)
148
+
144
149
  async def _computer_forward(self, request: Request) -> Response:
145
150
  """A request under ``/svc/<name>/``, sent on to the app named so: its own server answers it."""
146
151
  name = request.path_params["name"]
147
- folder = next((folder for folder in self._computer.apps if folder.name == name), None)
148
- if folder is None:
152
+ if (folder := self._computer_app_folder(name)) is None:
149
153
  return _error_response(TargetUnavailable(f"this computer has no app named {name!r}"))
150
- try:
151
- answer = await apps.forward(folder, request.method, request.path_params["path"], request.url.query,
152
- await request.body(), request.headers.get("content-type"))
153
- except (httpx.HTTPError, OSError) as e:
154
- return _error_response(DeliveryFailed(f"app {name}'s server isn't answering: {type(e).__name__}: {e}"))
155
- return Response(answer.content, status_code=answer.status_code, media_type=answer.headers.get("content-type"))
154
+ return await svc.forward(folder, name, request)
155
+
156
+ async def _computer_relay(self, websocket: WebSocket) -> None:
157
+ """A WebSocket under ``/svc/<name>/``, relayed both ways to the app named so; refused, before it opens, with no
158
+ such app."""
159
+ name = websocket.path_params["name"]
160
+ if (folder := self._computer_app_folder(name)) is None:
161
+ await websocket.close(code=1008)
162
+ return
163
+ await svc.relay(folder, name, websocket)
156
164
 
157
165
  # The card: what the driver's operations support.
158
166
 
@@ -0,0 +1,182 @@
1
+ """The driver's ``/svc/<name>/`` route to each app on its computer: a request sent on to the app's server with its body
2
+ and its answer streamed, and a WebSocket relayed to it both ways. It needs the ``computer`` extra's ``websockets``, so
3
+ only the driver imports it; an app's venv, with the ``app`` extra, imports ``apps`` and never this."""
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import contextlib
8
+ import re
9
+ from collections.abc import AsyncIterator, Iterable
10
+ from pathlib import Path
11
+ from typing import Any, Callable
12
+ from urllib.parse import quote, unquote
13
+
14
+ import httpx
15
+ import websockets
16
+ from starlette.background import BackgroundTask
17
+ from starlette.requests import Request
18
+ from starlette.responses import Response, StreamingResponse
19
+ from starlette.routing import WebSocketRoute
20
+ from starlette.websockets import WebSocket, WebSocketDisconnect
21
+ from websockets.asyncio.client import ClientConnection, unix_connect
22
+
23
+ from ..agent_env_environment import _error_response, control_check
24
+ from .app import SOCKET
25
+ from .errors import DeliveryFailed
26
+
27
+ #: The methods the route forwards.
28
+ METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
29
+ #: The headers that belong to one connection, which a proxy doesn't pass on.
30
+ HOP_HEADERS = frozenset({"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection",
31
+ "te", "trailer", "transfer-encoding", "upgrade"})
32
+ #: The address a request reached the driver at, which the driver sets for the app in place of any the caller sent.
33
+ FORWARDED = ("x-forwarded-host", "x-forwarded-proto", "x-forwarded-prefix")
34
+ #: A WebSocket close code that means the connection ended without one, which a close can't send on.
35
+ UNSENDABLE_CLOSES = (1005, 1006, 1015)
36
+ #: A Host header that names a host, and its port or not, and nothing else.
37
+ HOST = re.compile(r"(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?")
38
+ #: What a path's segments may hold unescaped, besides letters, digits and ``_.-~``.
39
+ PATH_SAFE = "/:@!$&'()*+,;="
40
+
41
+
42
+ def passed_on(headers: Iterable[tuple[str, str]], *dropped: str) -> list[tuple[str, str]]:
43
+ """``headers`` as a proxy passes them on: without the hop-by-hop ones, those a ``Connection`` header names, and
44
+ ``dropped``."""
45
+ headers = list(headers)
46
+ named = {token.strip().lower() for name, value in headers if name.lower() == "connection"
47
+ for token in value.split(",")}
48
+ skipped = HOP_HEADERS | named | set(dropped)
49
+ return [(name, value) for name, value in headers if name.lower() not in skipped]
50
+
51
+
52
+ def to_app(headers: Iterable[tuple[str, str]], host: str, scheme: str, prefix: str) -> list[tuple[str, str]]:
53
+ """A caller's headers as the app gets them: passed on without the control token's ``Authorization`` or any address
54
+ the caller forwarded, and with the one it reached the driver at, its ``host``, ``scheme`` and the app's
55
+ ``prefix``."""
56
+ return passed_on(headers, "authorization", *FORWARDED) + [
57
+ ("x-forwarded-host", host), ("x-forwarded-proto", scheme), ("x-forwarded-prefix", prefix)]
58
+
59
+
60
+ def target(connection: Request | WebSocket) -> str:
61
+ """The path and query a request under ``/svc/<name>/`` asks its app for, as it came: still percent-encoded, so an
62
+ escaped ``?``, ``#`` or ``/`` stays one. The raw path's segments past any the driver is mounted under must be
63
+ ``svc`` and the app's name, else the routed path is escaped again."""
64
+ mounted = len([segment for segment in connection.scope.get("root_path", "").split("/") if segment])
65
+ parts = (connection.scope.get("raw_path") or b"").decode("latin-1").split("/")[1 + mounted:]
66
+ if len(parts) > 2 and [unquote(part) for part in parts[:2]] == ["svc", connection.path_params["name"]]:
67
+ tail = "/".join(parts[2:])
68
+ else:
69
+ tail = quote(connection.path_params["path"], safe=PATH_SAFE)
70
+ query = connection.scope.get("query_string", b"").decode("latin-1")
71
+ return f"/{tail}" + (f"?{query}" if query else "")
72
+
73
+
74
+ def _bytes(headers: Iterable[tuple[str, str]]) -> list[tuple[bytes, bytes]]:
75
+ """Headers decoded as Latin-1, as Starlette and the helpers here hold them, as the bytes they came as."""
76
+ return [(name.encode("latin-1"), value.encode("latin-1")) for name, value in headers]
77
+
78
+
79
+ def websocket_route(app: Any, path: str, endpoint: Callable) -> None:
80
+ """``endpoint`` served at ``path`` as a WebSocket route of ``app``, an mcp or a standalone fastmcp FastMCP, which
81
+ have no API for one."""
82
+ for routes in ("_custom_starlette_routes", "_additional_http_routes"):
83
+ if isinstance(getattr(app, routes, None), list):
84
+ getattr(app, routes).append(WebSocketRoute(path, endpoint))
85
+ return
86
+ raise TypeError(f"a driver's app takes WebSocket routes on an mcp or a fastmcp FastMCP, not a {type(app).__name__}")
87
+
88
+
89
+ async def forward(folder: Path, name: str, request: Request) -> Response:
90
+ """``request`` sent on to the app ``name``'s server in ``folder`` with the caller's headers and none of the
91
+ client's own, its body and its answer streamed; it takes as long as the app does."""
92
+ headers = to_app(request.headers.items(), request.headers.get("host", ""), request.url.scheme, f"/svc/{name}")
93
+ has_body = request.headers.get("content-length", "0") != "0" or "transfer-encoding" in request.headers
94
+ http = httpx.AsyncClient(transport=httpx.AsyncHTTPTransport(uds=str(folder / SOCKET)), base_url="http://app",
95
+ timeout=None)
96
+ http.headers.clear()
97
+ try:
98
+ answer = await http.send(http.build_request(request.method, target(request), headers=_bytes(headers),
99
+ content=request.stream() if has_body else None), stream=True)
100
+ except (httpx.HTTPError, OSError) as e:
101
+ await http.aclose()
102
+ return _error_response(DeliveryFailed(f"app {name}'s server isn't answering: {type(e).__name__}: {e}"))
103
+ except BaseException:
104
+ await http.aclose()
105
+ raise
106
+
107
+ async def close() -> None:
108
+ await answer.aclose()
109
+ await http.aclose()
110
+
111
+ async def streamed() -> AsyncIterator[bytes]:
112
+ try:
113
+ async for chunk in answer.aiter_raw():
114
+ yield chunk
115
+ finally:
116
+ await close()
117
+
118
+ response = StreamingResponse(streamed(), status_code=answer.status_code, background=BackgroundTask(close))
119
+ response.raw_headers = _bytes(passed_on((name.decode("latin-1"), value.decode("latin-1"))
120
+ for name, value in answer.headers.raw))
121
+ return response
122
+
123
+
124
+ async def relay(folder: Path, name: str, websocket: WebSocket) -> None:
125
+ """``websocket`` relayed both ways to the app ``name``'s server in ``folder`` until either side closes; refused,
126
+ before it opens, without the control token, with a Host that isn't a host and port, or with the app's server not
127
+ answering."""
128
+ host = websocket.headers.get("host", "")
129
+ if not control_check()(websocket.headers) or (host and not HOST.fullmatch(host)):
130
+ await websocket.close(code=1008)
131
+ return
132
+ headers = [(key, _utf8(value)) for key, value in to_app(
133
+ websocket.headers.items(), host, "https" if websocket.url.scheme == "wss" else "http", f"/svc/{name}")
134
+ if key.lower() != "host" and not key.lower().startswith("sec-websocket-")]
135
+ offered = [p.strip() for p in websocket.headers.get("sec-websocket-protocol", "").split(",") if p.strip()]
136
+ try:
137
+ upstream = await unix_connect(str(folder / SOCKET), f"ws://{host or 'app'}{target(websocket)}",
138
+ additional_headers=headers, subprotocols=offered or None, max_size=None,
139
+ compression=None, ping_interval=None, user_agent_header=None)
140
+ except (OSError, ValueError, asyncio.TimeoutError, websockets.WebSocketException):
141
+ await websocket.close(code=1011)
142
+ return
143
+ async with upstream:
144
+ await websocket.accept(subprotocol=upstream.subprotocol)
145
+ await _both_ways(websocket, upstream)
146
+
147
+
148
+ def _utf8(value: str) -> str:
149
+ """A header value decoded as Latin-1 as the string ``websockets`` sends as the same bytes, which it encodes as
150
+ UTF-8; bytes that aren't UTF-8 become replacement characters."""
151
+ return value.encode("latin-1").decode("utf-8", "replace")
152
+
153
+
154
+ async def _both_ways(client: WebSocket, app: ClientConnection) -> None:
155
+ """Each message from either side sent to the other until one side closes, then the other closed with its code."""
156
+ async def inbound() -> None:
157
+ while True:
158
+ message = await client.receive()
159
+ if message["type"] == "websocket.disconnect":
160
+ code = message.get("code") or 1000
161
+ await app.close(1000 if code in UNSENDABLE_CLOSES else code)
162
+ return
163
+ await app.send(message["text"] if message.get("text") is not None else message["bytes"])
164
+
165
+ async def outbound() -> None:
166
+ with contextlib.suppress(websockets.ConnectionClosed):
167
+ async for message in app:
168
+ if isinstance(message, str):
169
+ await client.send_text(message)
170
+ else:
171
+ await client.send_bytes(message)
172
+ code = app.close_code or 1000
173
+ with contextlib.suppress(RuntimeError, WebSocketDisconnect):
174
+ await client.close(1000 if code in UNSENDABLE_CLOSES else code)
175
+
176
+ tasks = [asyncio.ensure_future(inbound()), asyncio.ensure_future(outbound())]
177
+ try:
178
+ await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
179
+ finally:
180
+ for task in tasks:
181
+ task.cancel()
182
+ await asyncio.gather(*tasks, return_exceptions=True)
@@ -430,7 +430,10 @@ class BrowserControl:
430
430
  return {**({"png": png} if png is not None else {"blank": True}), "page": self._state_of(target)}
431
431
 
432
432
  async def _capture(self, session: str) -> Optional[str]:
433
+ """The tab's page as it shows now; with a dialog open on it, which keeps it from painting, the last it took."""
433
434
  target = self._sessions[session]
435
+ if session in self._open_dialogs:
436
+ return self._shots.get(target)
434
437
  navigated = self.tabs.get(target, {}).get("url")
435
438
  try:
436
439
  shot = await self.devtools.call("Page.captureScreenshot", {"format": "png", "optimizeForSpeed": True},
@@ -4,8 +4,10 @@ full needs gets those checks unverified; a driver broken one way fails the check
4
4
  fronts that exports is asked for its export once; the report's verdicts and the CLI."""
5
5
  from __future__ import annotations
6
6
 
7
+ import importlib
7
8
  import json
8
- from types import SimpleNamespace
9
+ import sys
10
+ from types import ModuleType, SimpleNamespace
9
11
 
10
12
  import pytest
11
13
 
@@ -284,6 +286,29 @@ async def test_a_fixture_that_doesnt_start_leaves_full_unverified_and_is_cleaned
284
286
  assert (["kill", "4243"] in probed.commands) is (driver is SilentFixture)
285
287
 
286
288
 
289
+ @pytest.mark.parametrize("delta, steps", [(1, 1), (-3, -3), (120, 1), (-360, -3), (240, 2)],
290
+ ids=["aqua-up", "aqua-down-3", "scaled-up", "scaled-down-3", "scaled-up-2"])
291
+ def test_the_fixture_counts_a_wheel_events_steps_whether_tk_scales_them_by_120_or_not(delta, steps, monkeypatch):
292
+ """Aqua's Tk 8.6 reports a step as a delta of 1; Windows, and Tk 9 everywhere, as 120."""
293
+ assert _fixture(monkeypatch).wheel_steps(delta) == steps
294
+
295
+
296
+ @pytest.mark.parametrize("delta, steps", [(10, (False, 1)), (65526, (False, -1)), (65516, (False, -2)),
297
+ (655360, (True, 1)), (-655360, (True, -1)), (0, None)],
298
+ ids=["up", "down", "down-2-coalesced", "left", "right", "still"])
299
+ def test_the_fixture_counts_a_scroll_tk_9_reports_in_pixels_by_the_lines_it_moves(delta, steps, monkeypatch):
300
+ """The deltas Tk 9 on macOS gave cua-driver's scrolls: 10 pixels a line, two lines at once where macOS coalesced
301
+ them."""
302
+ assert _fixture(monkeypatch).touchpad_steps(delta) == steps
303
+
304
+
305
+ def _fixture(monkeypatch):
306
+ """The fixture module, which runs in the computer, read with Tk stood in for, as this Python may have none."""
307
+ monkeypatch.setitem(sys.modules, "tkinter", ModuleType("tkinter"))
308
+ monkeypatch.delitem(sys.modules, "agentenv_protocol.computer.conformance.fixture", raising=False)
309
+ return importlib.import_module("agentenv_protocol.computer.conformance.fixture")
310
+
311
+
287
312
  def test_a_refusal_must_carry_the_code_and_applied_no():
288
313
  def result(error: bool, **data) -> CallToolResult:
289
314
  return CallToolResult(content=[TextContent(type="text", text="x")], structuredContent=data, isError=error)