screenforge 0.6.0__tar.gz → 0.7.0__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. {screenforge-0.6.0 → screenforge-0.7.0}/PKG-INFO +13 -5
  2. {screenforge-0.6.0 → screenforge-0.7.0}/README.md +12 -4
  3. screenforge-0.7.0/cli/_version.py +1 -0
  4. {screenforge-0.6.0 → screenforge-0.7.0}/cli/dispatch.py +1 -0
  5. {screenforge-0.6.0 → screenforge-0.7.0}/cli/doctor.py +0 -4
  6. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/action.py +54 -16
  7. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/default.py +9 -0
  8. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/workflow.py +66 -0
  9. {screenforge-0.6.0 → screenforge-0.7.0}/cli/session.py +1 -5
  10. {screenforge-0.6.0 → screenforge-0.7.0}/cli/shared.py +12 -9
  11. {screenforge-0.6.0 → screenforge-0.7.0}/cli/tool_protocol_handlers.py +60 -6
  12. {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/base_adapter.py +5 -0
  13. {screenforge-0.6.0 → screenforge-0.7.0}/common/ai.py +13 -8
  14. {screenforge-0.6.0 → screenforge-0.7.0}/common/ai_autonomous.py +7 -27
  15. {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_stats.py +0 -5
  16. {screenforge-0.6.0 → screenforge-0.7.0}/common/executor.py +134 -125
  17. {screenforge-0.6.0 → screenforge-0.7.0}/common/run_reporter.py +5 -0
  18. {screenforge-0.6.0 → screenforge-0.7.0}/common/tool_protocol.py +5 -0
  19. {screenforge-0.6.0 → screenforge-0.7.0}/config/config.py +2 -4
  20. screenforge-0.7.0/config/env_loader.py +23 -0
  21. {screenforge-0.6.0 → screenforge-0.7.0}/pyproject.toml +15 -1
  22. {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/PKG-INFO +13 -5
  23. {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/SOURCES.txt +17 -0
  24. screenforge-0.7.0/tests/test_action_mode_observation_stash.py +117 -0
  25. screenforge-0.7.0/tests/test_cache_hash.py +52 -0
  26. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_cli_action_json.py +22 -1
  27. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_dispatch.py +1 -1
  28. screenforge-0.7.0/tests/test_mcp_execute_observation.py +165 -0
  29. screenforge-0.7.0/tests/test_mcp_execute_parity_live.py +87 -0
  30. screenforge-0.7.0/tests/test_mode_error_code_threading.py +234 -0
  31. screenforge-0.7.0/tests/test_review_cases.py +98 -0
  32. screenforge-0.7.0/tests/test_review_conftest_gating.py +24 -0
  33. screenforge-0.7.0/tests/test_review_filmstrip.py +46 -0
  34. screenforge-0.7.0/tests/test_review_html.py +73 -0
  35. screenforge-0.7.0/tests/test_review_json.py +26 -0
  36. screenforge-0.7.0/tests/test_review_locate_frame.py +32 -0
  37. screenforge-0.7.0/tests/test_review_patching.py +87 -0
  38. screenforge-0.7.0/tests/test_review_recorder.py +66 -0
  39. screenforge-0.7.0/tests/test_review_screenshot.py +41 -0
  40. screenforge-0.7.0/tests/test_review_smoke_live.py +89 -0
  41. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_run_reporter.py +18 -0
  42. screenforge-0.7.0/tests/test_shared_observation_stash.py +27 -0
  43. screenforge-0.7.0/tests/test_workflow_mode_observation_stash.py +113 -0
  44. screenforge-0.6.0/cli/_version.py +0 -1
  45. screenforge-0.6.0/config/env_loader.py +0 -76
  46. {screenforge-0.6.0 → screenforge-0.7.0}/LICENSE +0 -0
  47. {screenforge-0.6.0 → screenforge-0.7.0}/cli/__init__.py +0 -0
  48. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/__init__.py +0 -0
  49. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/demo.py +0 -0
  50. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/dry_run.py +0 -0
  51. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/init.py +0 -0
  52. {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/plan.py +0 -0
  53. {screenforge-0.6.0 → screenforge-0.7.0}/cli/parser.py +0 -0
  54. {screenforge-0.6.0 → screenforge-0.7.0}/cli/playground_sink.py +0 -0
  55. {screenforge-0.6.0 → screenforge-0.7.0}/cli/reporter.py +0 -0
  56. {screenforge-0.6.0 → screenforge-0.7.0}/cli/shorthand.py +0 -0
  57. {screenforge-0.6.0 → screenforge-0.7.0}/common/__init__.py +0 -0
  58. {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/__init__.py +0 -0
  59. {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/android_adapter.py +0 -0
  60. {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/ios_adapter.py +0 -0
  61. {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/web_adapter.py +0 -0
  62. {screenforge-0.6.0 → screenforge-0.7.0}/common/ai_heal.py +0 -0
  63. {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/__init__.py +0 -0
  64. {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_hash.py +0 -0
  65. {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_manager.py +0 -0
  66. {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_storage.py +0 -0
  67. {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/embedding_loader.py +0 -0
  68. {screenforge-0.6.0 → screenforge-0.7.0}/common/capabilities.py +0 -0
  69. {screenforge-0.6.0 → screenforge-0.7.0}/common/case_memory.py +0 -0
  70. {screenforge-0.6.0 → screenforge-0.7.0}/common/error_codes.py +0 -0
  71. {screenforge-0.6.0 → screenforge-0.7.0}/common/exceptions.py +0 -0
  72. {screenforge-0.6.0 → screenforge-0.7.0}/common/failure_diagnosis.py +0 -0
  73. {screenforge-0.6.0 → screenforge-0.7.0}/common/history_manager.py +0 -0
  74. {screenforge-0.6.0 → screenforge-0.7.0}/common/logs.py +0 -0
  75. {screenforge-0.6.0 → screenforge-0.7.0}/common/mcp_server.py +0 -0
  76. {screenforge-0.6.0 → screenforge-0.7.0}/common/preflight.py +0 -0
  77. {screenforge-0.6.0 → screenforge-0.7.0}/common/progress.py +0 -0
  78. {screenforge-0.6.0 → screenforge-0.7.0}/common/run_resume.py +0 -0
  79. {screenforge-0.6.0 → screenforge-0.7.0}/common/runtime_modes.py +0 -0
  80. {screenforge-0.6.0 → screenforge-0.7.0}/common/visual_fallback.py +0 -0
  81. {screenforge-0.6.0 → screenforge-0.7.0}/common/workflow_schema.py +0 -0
  82. {screenforge-0.6.0 → screenforge-0.7.0}/config/__init__.py +0 -0
  83. {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/dependency_links.txt +0 -0
  84. {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/entry_points.txt +0 -0
  85. {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/requires.txt +0 -0
  86. {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/top_level.txt +0 -0
  87. {screenforge-0.6.0 → screenforge-0.7.0}/setup.cfg +0 -0
  88. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ai_autonomous.py +0 -0
  89. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ai_brain.py +0 -0
  90. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ai_heal.py +0 -0
  91. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_android_smoke_live.py +0 -0
  92. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_cache_manager.py +0 -0
  93. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_capabilities.py +0 -0
  94. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_codegen_quality.py +0 -0
  95. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_doctor_orphan_browser.py +0 -0
  96. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_dom_capture.py +0 -0
  97. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_error_codes.py +0 -0
  98. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_executor.py +0 -0
  99. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_failure_diagnosis.py +0 -0
  100. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_interaction_actions.py +0 -0
  101. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ios_smoke_live.py +0 -0
  102. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_mcp_ref_cache.py +0 -0
  103. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ml_optional.py +0 -0
  104. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_parser.py +0 -0
  105. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_playground_app.py +0 -0
  106. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_playground_sink.py +0 -0
  107. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_playground_sink_integration.py +0 -0
  108. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_run_resume.py +0 -0
  109. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_runtime_modes.py +0 -0
  110. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_screenshot_annotator.py +0 -0
  111. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_shorthand.py +0 -0
  112. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_tool_protocol_diagnosis.py +0 -0
  113. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_utils_ios.py +0 -0
  114. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_utils_web.py +0 -0
  115. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_utils_xml.py +0 -0
  116. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_visual_fallback.py +0 -0
  117. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_web_adapter.py +0 -0
  118. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_web_dom_complex_live.py +0 -0
  119. {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_web_smoke_live.py +0 -0
  120. {screenforge-0.6.0 → screenforge-0.7.0}/utils/__init__.py +0 -0
  121. {screenforge-0.6.0 → screenforge-0.7.0}/utils/screenshot_annotator.py +0 -0
  122. {screenforge-0.6.0 → screenforge-0.7.0}/utils/utils_ios.py +0 -0
  123. {screenforge-0.6.0 → screenforge-0.7.0}/utils/utils_web.py +0 -0
  124. {screenforge-0.6.0 → screenforge-0.7.0}/utils/utils_xml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: AI-driven cross-platform UI automation engine with test script generation
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
@@ -51,11 +51,13 @@ Dynamic: license-file
51
51
 
52
52
  **[中文](https://github.com/jhinzzz/ScreenForge/blob/main/README_CN.md)** | English
53
53
 
54
- > Describe what to test. Watch it happen. Get a pytest script.
54
+ > **Your AI agent runs the test. You keep the pytest file.**
55
+ >
56
+ > Describe the flow in plain language. Watch the agent click through a live site. Walk away with a self-healing pytest script that survives UI changes — and runs in CI. _(Drives real iOS + Android devices too, not just Chrome.)_
55
57
 
56
58
  ScreenForge is an AI-driven UI automation engine that turns natural language into executable test scripts. Unlike record-and-replay tools, you don't perform the actions yourself — the AI does it for you.
57
59
 
58
- ![ScreenForge Demo](https://raw.githubusercontent.com/jhinzzz/ScreenForge/main/docs/assets/demo.gif)
60
+ ![ScreenForge Live Mirror — the generated pytest grows beside the live page as the agent runs](https://raw.githubusercontent.com/jhinzzz/ScreenForge/main/docs/assets/hero.gif)
59
61
 
60
62
  ## Why ScreenForge?
61
63
 
@@ -65,6 +67,7 @@ ScreenForge is an AI-driven UI automation engine that turns natural language int
65
67
  | Generates replayable test scripts? | Yes | No | No | **Yes (pytest)** |
66
68
  | Self-healing when UI changes? | No | No | No | **Yes** |
67
69
  | Works as AI Agent tool (MCP)? | No | Yes | No | **Yes** |
70
+ | Drives real iOS / Android devices? | No | No | No | **Yes** |
68
71
 
69
72
  **Core architecture**: Your AI Agent is the brain (understands requirements, makes decisions). ScreenForge is the hands (executes UI actions, generates code).
70
73
 
@@ -107,13 +110,16 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
107
110
 
108
111
  ## Features
109
112
 
110
- - **Cross-platform**: Android (uiautomator2), iOS (wda), Web (Playwright)
111
- - **Self-healing engine**: When tests break due to UI changes, the engine auto-repairs locators with confidence scoring and AST validation
113
+ - **Keep a real pytest file**: Every run emits a replayable `pytest` script (Allure-instrumented) you can drop straight into CI — not a black-box agent run.
114
+ - **Self-healing engine**: When the UI changes and a locator breaks, the engine auto-repairs it with confidence scoring and AST validation, so your committed test survives.
115
+ - **The agent does the clicking**: You (or your AI agent) describe intent; ScreenForge inspects the DOM, acts, and verifies — you never record by hand.
116
+ - **Real devices, not just browsers**: Drives Android (uiautomator2) and iOS (wda) physical devices over the same protocol — the one thing pure-web agents (Playwright MCP, Browser Use) can't do.
112
117
  - **L1/L2 semantic cache**: Same page + same instruction = instant response, no LLM call needed
113
118
  - **Visual fallback**: When DOM can't locate elements (Canvas, games), VLM parses screenshots
114
119
  - **MCP server**: Any MCP-compatible Agent can drive ScreenForge natively
115
120
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
116
121
  - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
122
+ - **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
117
123
 
118
124
  ## Agent Integration (Claude Code / Cursor / Codex)
119
125
 
@@ -206,6 +212,8 @@ If ScreenForge generates tests for your project, add this badge to your README:
206
212
  | [MCP Setup (3 min)](https://github.com/jhinzzz/ScreenForge/blob/main/docs/mcp-setup.md) | Connect to Claude Desktop / Cursor / Cline / Claude Code |
207
213
  | [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md) | Integration protocol for AI Agents |
208
214
  | [Capability Matrix](https://github.com/jhinzzz/ScreenForge/blob/main/docs/capability-matrix.md) | Supported platforms, actions, and locators |
215
+ | [Architecture Deep-Dive](https://github.com/jhinzzz/ScreenForge/blob/main/docs/architecture-deep-dive.md) | Brain/hands split, semantic cache, self-heal AST gates, hygiene-as-feature |
216
+ | [Examples](https://github.com/jhinzzz/ScreenForge/tree/main/docs/examples) | Real committed workflows + the green pytest they generated |
209
217
  | [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md) | Live Mirror — watch code + screenshots grow as the test runs |
210
218
  | [Workflow Examples](https://github.com/jhinzzz/ScreenForge/tree/main/docs/workflows) | YAML workflow templates |
211
219
  | [CHANGELOG](https://github.com/jhinzzz/ScreenForge/blob/main/CHANGELOG.md) | Version history |
@@ -6,11 +6,13 @@
6
6
 
7
7
  **[中文](https://github.com/jhinzzz/ScreenForge/blob/main/README_CN.md)** | English
8
8
 
9
- > Describe what to test. Watch it happen. Get a pytest script.
9
+ > **Your AI agent runs the test. You keep the pytest file.**
10
+ >
11
+ > Describe the flow in plain language. Watch the agent click through a live site. Walk away with a self-healing pytest script that survives UI changes — and runs in CI. _(Drives real iOS + Android devices too, not just Chrome.)_
10
12
 
11
13
  ScreenForge is an AI-driven UI automation engine that turns natural language into executable test scripts. Unlike record-and-replay tools, you don't perform the actions yourself — the AI does it for you.
12
14
 
13
- ![ScreenForge Demo](https://raw.githubusercontent.com/jhinzzz/ScreenForge/main/docs/assets/demo.gif)
15
+ ![ScreenForge Live Mirror — the generated pytest grows beside the live page as the agent runs](https://raw.githubusercontent.com/jhinzzz/ScreenForge/main/docs/assets/hero.gif)
14
16
 
15
17
  ## Why ScreenForge?
16
18
 
@@ -20,6 +22,7 @@ ScreenForge is an AI-driven UI automation engine that turns natural language int
20
22
  | Generates replayable test scripts? | Yes | No | No | **Yes (pytest)** |
21
23
  | Self-healing when UI changes? | No | No | No | **Yes** |
22
24
  | Works as AI Agent tool (MCP)? | No | Yes | No | **Yes** |
25
+ | Drives real iOS / Android devices? | No | No | No | **Yes** |
23
26
 
24
27
  **Core architecture**: Your AI Agent is the brain (understands requirements, makes decisions). ScreenForge is the hands (executes UI actions, generates code).
25
28
 
@@ -62,13 +65,16 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
62
65
 
63
66
  ## Features
64
67
 
65
- - **Cross-platform**: Android (uiautomator2), iOS (wda), Web (Playwright)
66
- - **Self-healing engine**: When tests break due to UI changes, the engine auto-repairs locators with confidence scoring and AST validation
68
+ - **Keep a real pytest file**: Every run emits a replayable `pytest` script (Allure-instrumented) you can drop straight into CI — not a black-box agent run.
69
+ - **Self-healing engine**: When the UI changes and a locator breaks, the engine auto-repairs it with confidence scoring and AST validation, so your committed test survives.
70
+ - **The agent does the clicking**: You (or your AI agent) describe intent; ScreenForge inspects the DOM, acts, and verifies — you never record by hand.
71
+ - **Real devices, not just browsers**: Drives Android (uiautomator2) and iOS (wda) physical devices over the same protocol — the one thing pure-web agents (Playwright MCP, Browser Use) can't do.
67
72
  - **L1/L2 semantic cache**: Same page + same instruction = instant response, no LLM call needed
68
73
  - **Visual fallback**: When DOM can't locate elements (Canvas, games), VLM parses screenshots
69
74
  - **MCP server**: Any MCP-compatible Agent can drive ScreenForge natively
70
75
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
71
76
  - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
77
+ - **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
72
78
 
73
79
  ## Agent Integration (Claude Code / Cursor / Codex)
74
80
 
@@ -161,6 +167,8 @@ If ScreenForge generates tests for your project, add this badge to your README:
161
167
  | [MCP Setup (3 min)](https://github.com/jhinzzz/ScreenForge/blob/main/docs/mcp-setup.md) | Connect to Claude Desktop / Cursor / Cline / Claude Code |
162
168
  | [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md) | Integration protocol for AI Agents |
163
169
  | [Capability Matrix](https://github.com/jhinzzz/ScreenForge/blob/main/docs/capability-matrix.md) | Supported platforms, actions, and locators |
170
+ | [Architecture Deep-Dive](https://github.com/jhinzzz/ScreenForge/blob/main/docs/architecture-deep-dive.md) | Brain/hands split, semantic cache, self-heal AST gates, hygiene-as-feature |
171
+ | [Examples](https://github.com/jhinzzz/ScreenForge/tree/main/docs/examples) | Real committed workflows + the green pytest they generated |
164
172
  | [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md) | Live Mirror — watch code + screenshots grow as the test runs |
165
173
  | [Workflow Examples](https://github.com/jhinzzz/ScreenForge/tree/main/docs/workflows) | YAML workflow templates |
166
174
  | [CHANGELOG](https://github.com/jhinzzz/ScreenForge/blob/main/CHANGELOG.md) | Version history |
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -57,6 +57,7 @@ def _dispatch_execution(
57
57
  args,
58
58
  output_script_path,
59
59
  resume_context,
60
+ shared_adapter_manager=shared_adapter_manager,
60
61
  )
61
62
  if args.action and execution_mode == MODE_PLAN_ONLY:
62
63
  return run_action_plan_only_mode(
@@ -373,10 +373,6 @@ def _build_doctor_summary(checks: list[dict]) -> dict:
373
373
  }
374
374
 
375
375
 
376
- def _build_doctor_remediation_items(checks: list[dict]) -> list[str]:
377
- return _build_doctor_summary(checks).get("top_items", [])
378
-
379
-
380
376
  def _build_doctor_check_failure_message(check: dict) -> str:
381
377
  details = []
382
378
 
@@ -75,6 +75,30 @@ def build_failure_payload(
75
75
  return payload
76
76
 
77
77
 
78
+ def build_success_payload(
79
+ *,
80
+ action_name: str,
81
+ platform: str,
82
+ ui_tree: dict,
83
+ current_url: str,
84
+ output_script: str,
85
+ ) -> dict:
86
+ """Assemble the --action --json success payload.
87
+
88
+ Single source of truth shared by the shell --json stdout write and the MCP
89
+ observation stash, so the two can never drift. Pure: no I/O, no device.
90
+ """
91
+ return {
92
+ "ok": True,
93
+ "action": action_name,
94
+ "platform": platform,
95
+ "ui_tree": ui_tree,
96
+ "element_count": len(ui_tree.get("ui_elements", []) or []),
97
+ "output_script": output_script,
98
+ "current_url": current_url,
99
+ }
100
+
101
+
78
102
  def run_action_default_mode(
79
103
  args,
80
104
  output_script_path: str,
@@ -88,6 +112,7 @@ def run_action_default_mode(
88
112
  exit_code = 1
89
113
  final_status = "failed"
90
114
  final_error = ""
115
+ final_error_code = ""
91
116
  steps_executed = 0
92
117
  _emit_run_started(reporter, args, output_script_path, MODE_RUN)
93
118
  _apply_resume_summary(reporter, resume_context)
@@ -155,6 +180,9 @@ def run_action_default_mode(
155
180
  result = executor.execute_and_record(action_data)
156
181
  if not result.get("success"):
157
182
  assertion_failed = bool(result.get("assertion_failed"))
183
+ # Record the executor's code (E0xx) so finalize writes it into
184
+ # summary.json — assertion verdicts carry none, so this stays "".
185
+ final_error_code = result.get("error_code", "") or ""
158
186
  if assertion_failed:
159
187
  final_error = f"Assertion failed: {action_data['name']}"
160
188
  else:
@@ -166,7 +194,7 @@ def run_action_default_mode(
166
194
  action_description=action_data["name"],
167
195
  )
168
196
  log.error(f"❌ [Action] Failed: {action_data['name']}")
169
- if json_mode:
197
+ if json_mode or shared_adapter_manager is not None:
170
198
  # Distinguish a verification verdict (assertion_failed=true: the
171
199
  # SUT did not meet the assertion — do NOT retry) from a real
172
200
  # engine error. Only engine errors get the did-you-mean
@@ -200,9 +228,14 @@ def run_action_default_mode(
200
228
  ui_tree=ui_tree,
201
229
  current_url=page_url,
202
230
  )
203
- json.dump(payload, sys.stdout, ensure_ascii=False)
204
- sys.stdout.write("\n")
205
- sys.stdout.flush()
231
+ # Stash for the MCP handler (manager present); write stdout for
232
+ # shell (--json). MCP keeps stdout clean: stashes but never writes.
233
+ if shared_adapter_manager is not None:
234
+ shared_adapter_manager.set_last_observation(payload)
235
+ if json_mode:
236
+ json.dump(payload, sys.stdout, ensure_ascii=False)
237
+ sys.stdout.write("\n")
238
+ sys.stdout.flush()
206
239
  return 1
207
240
 
208
241
  history_manager.add_step(result["code_lines"], result["action_description"])
@@ -235,24 +268,28 @@ def run_action_default_mode(
235
268
  final_status = "success"
236
269
  exit_code = 0
237
270
 
238
- if json_mode:
271
+ if json_mode or shared_adapter_manager is not None:
239
272
  _ensure_ui_compressors()
240
273
  ui_json, _ = _capture_ui_state(args, adapter, reporter, 1)
241
274
  try:
242
275
  ui_tree = json.loads(ui_json)
243
276
  except (json.JSONDecodeError, TypeError):
244
277
  ui_tree = {}
245
- json.dump({
246
- "ok": True,
247
- "action": action_data["name"],
248
- "platform": args.platform,
249
- "ui_tree": ui_tree,
250
- "element_count": len(ui_tree.get("ui_elements", [])),
251
- "output_script": output_script_path,
252
- "current_url": current_url(adapter, args.platform),
253
- }, sys.stdout, ensure_ascii=False)
254
- sys.stdout.write("\n")
255
- sys.stdout.flush()
278
+ payload = build_success_payload(
279
+ action_name=action_data["name"],
280
+ platform=args.platform,
281
+ ui_tree=ui_tree,
282
+ current_url=current_url(adapter, args.platform),
283
+ output_script=output_script_path,
284
+ )
285
+ # Stash for the MCP handler (manager present); write stdout for shell
286
+ # (--json). MCP keeps stdout clean: it stashes but never writes.
287
+ if shared_adapter_manager is not None:
288
+ shared_adapter_manager.set_last_observation(payload)
289
+ if json_mode:
290
+ json.dump(payload, sys.stdout, ensure_ascii=False)
291
+ sys.stdout.write("\n")
292
+ sys.stdout.flush()
256
293
 
257
294
  return 0
258
295
  except Exception as e:
@@ -266,6 +303,7 @@ def run_action_default_mode(
266
303
  exit_code=exit_code,
267
304
  steps_executed=steps_executed,
268
305
  last_error=final_error,
306
+ error_code=final_error_code,
269
307
  )
270
308
  log.info(f"🏁 Done. Generated script saved to: {output_script_path}")
271
309
  if owns_adapter and adapter:
@@ -30,6 +30,7 @@ def run_default_mode(
30
30
  exit_code = 1
31
31
  final_status = "failed"
32
32
  final_error = ""
33
+ final_error_code = ""
33
34
  steps_executed = 0
34
35
  _emit_run_started(reporter, args, output_script_path, MODE_RUN)
35
36
  _apply_resume_summary(reporter, resume_context)
@@ -89,6 +90,9 @@ def run_default_mode(
89
90
  status = decision_data.get("status")
90
91
  action_data = decision_data.get("result", {})
91
92
  last_error = ""
93
+ # Reset per iteration so a recovered-then-succeeded run finalizes with
94
+ # error_code="" — only THIS step's failure (set below) should persist.
95
+ final_error_code = ""
92
96
  reporter.emit_event(
93
97
  "decision_received",
94
98
  step=step_count,
@@ -121,6 +125,7 @@ def run_default_mode(
121
125
  )
122
126
  else:
123
127
  final_error = "Final action/assertion failed — task verification did not pass"
128
+ final_error_code = result.get("error_code", "") or ""
124
129
  reporter.emit_event(
125
130
  "action_executed",
126
131
  step=step_count,
@@ -193,6 +198,9 @@ def run_default_mode(
193
198
  )
194
199
  else:
195
200
  consecutive_failures += 1
201
+ # Retain the executor's code (E0xx) so if the circuit
202
+ # breaker trips below, finalize records the real last code.
203
+ final_error_code = result.get("error_code", "") or ""
196
204
  action_repr = f"{action_data.get('action')} - {action_data.get('locator_type')}={action_data.get('locator_value')}"
197
205
  last_error = f"Action [{action_repr}] failed — element not found or not interactable on the current page."
198
206
  reporter.emit_event(
@@ -239,6 +247,7 @@ def run_default_mode(
239
247
  exit_code=exit_code,
240
248
  steps_executed=steps_executed,
241
249
  last_error=final_error,
250
+ error_code=final_error_code,
242
251
  )
243
252
  log.info(f"🏁 Done. Generated script saved to: {output_script_path}")
244
253
  if adapter:
@@ -1,5 +1,6 @@
1
1
  """Workflow execution modes."""
2
2
 
3
+ import json
3
4
  from pathlib import Path
4
5
 
5
6
  import cli.shared as _shared
@@ -11,10 +12,14 @@ from cli.reporter import (
11
12
  _emit_run_started,
12
13
  )
13
14
  from cli.shared import (
15
+ _capture_ui_state,
14
16
  _connect_adapter,
15
17
  _ensure_executor_runtime,
16
18
  _ensure_history_manager,
19
+ _ensure_ui_compressors,
17
20
  _ensure_workflow_loader,
21
+ _SharedAdapterManager,
22
+ current_url,
18
23
  get_initial_header,
19
24
  log,
20
25
  save_to_disk,
@@ -242,12 +247,14 @@ def run_workflow_default_mode(
242
247
  args,
243
248
  output_script_path: str,
244
249
  resume_context: dict,
250
+ shared_adapter_manager: _SharedAdapterManager | None = None,
245
251
  ) -> int:
246
252
  adapter = None
247
253
  reporter = _build_reporter(args, output_script_path, MODE_RUN)
248
254
  exit_code = 1
249
255
  final_status = "failed"
250
256
  final_error = ""
257
+ final_error_code = ""
251
258
  steps_executed = 0
252
259
  _emit_run_started(reporter, args, output_script_path, MODE_RUN)
253
260
  _apply_resume_summary(reporter, resume_context)
@@ -297,6 +304,7 @@ def run_workflow_default_mode(
297
304
 
298
305
  steps_executed = index
299
306
  action_data = _workflow_step_to_action_data(step, index)
307
+ last_action_name = action_data["name"]
300
308
  reporter.emit_event(
301
309
  "step_started",
302
310
  step=index,
@@ -306,6 +314,9 @@ def run_workflow_default_mode(
306
314
  result = executor.execute_and_record(action_data)
307
315
  if not result.get("success"):
308
316
  final_error = f"Workflow step failed: {action_data['name']}"
317
+ # Record the failing step's code (E0xx) for summary.json; assertion
318
+ # verdicts carry none, so this stays "".
319
+ final_error_code = result.get("error_code", "") or ""
309
320
  reporter.emit_event(
310
321
  "action_executed",
311
322
  step=index,
@@ -313,6 +324,36 @@ def run_workflow_default_mode(
313
324
  action_description=action_data["name"],
314
325
  )
315
326
  log.error(f"❌ [Workflow] Step failed: {action_data['name']}")
327
+ # ★ MCP parity: stash the FAILING step's observation (single-point
328
+ # failure) with its index/name so the agent knows where the batch
329
+ # stopped. Gated on a manager (MCP/session path) — bare shell pays
330
+ # nothing. NEVER a per-step array (see design doc, decision §).
331
+ if shared_adapter_manager is not None:
332
+ # Local import dodges an action↔workflow cycle (both import cli.shared).
333
+ from cli.modes.action import build_failure_payload
334
+ assertion_failed = bool(result.get("assertion_failed"))
335
+ page_url = current_url(adapter, args.platform)
336
+ fail_ui_tree = {}
337
+ if not assertion_failed:
338
+ _ensure_ui_compressors()
339
+ try:
340
+ ui_json, _ = _capture_ui_state(args, adapter, reporter, index)
341
+ fail_ui_tree = json.loads(ui_json)
342
+ except Exception as e:
343
+ log.warning(f"⚠️ [Workflow] Failed to capture post-failure UI state: {e}")
344
+ fail_ui_tree = {}
345
+ observation = build_failure_payload(
346
+ action_name=action_data["name"],
347
+ platform=args.platform,
348
+ assertion_failed=assertion_failed,
349
+ error_code=result.get("error_code", "") or "",
350
+ locator_value=action_data.get("locator_value", "") or "",
351
+ ui_tree=fail_ui_tree,
352
+ current_url=page_url,
353
+ )
354
+ observation["failed_step_index"] = index
355
+ observation["failed_step_name"] = action_data["name"]
356
+ shared_adapter_manager.set_last_observation(observation)
316
357
  return 1
317
358
 
318
359
  history_manager.add_step(
@@ -344,6 +385,30 @@ def run_workflow_default_mode(
344
385
  )
345
386
  )
346
387
  reporter.update_control_summary(executed_steps=executed_steps)
388
+ # ★ MCP parity: stash ONE live observation — the FINAL step's end-state,
389
+ # marked with executed_steps so the agent knows it's the end, not step 1.
390
+ # Gated on a manager; bare shell workflow pays nothing. NEVER per-step.
391
+ if shared_adapter_manager is not None and executed_steps:
392
+ # Local import dodges an action↔workflow cycle (both import cli.shared).
393
+ from cli.modes.action import build_success_payload
394
+ _ensure_ui_compressors()
395
+ # steps_executed (raw loop index) is used ONLY for screenshot artifact
396
+ # naming under --vision; the captured ui_json content is independent of it.
397
+ try:
398
+ ui_json, _ = _capture_ui_state(args, adapter, reporter, steps_executed)
399
+ final_ui_tree = json.loads(ui_json)
400
+ except Exception as e:
401
+ log.warning(f"⚠️ [Workflow] Failed to capture final UI state: {e}")
402
+ final_ui_tree = {}
403
+ observation = build_success_payload(
404
+ action_name=last_action_name,
405
+ platform=args.platform,
406
+ ui_tree=final_ui_tree,
407
+ current_url=current_url(adapter, args.platform),
408
+ output_script=output_script_path,
409
+ )
410
+ observation["executed_steps"] = executed_steps
411
+ shared_adapter_manager.set_last_observation(observation)
347
412
  final_status = "success"
348
413
  exit_code = 0
349
414
  return 0
@@ -360,6 +425,7 @@ def run_workflow_default_mode(
360
425
  exit_code=exit_code,
361
426
  steps_executed=steps_executed,
362
427
  last_error=final_error,
428
+ error_code=final_error_code,
363
429
  )
364
430
  log.info(f"🏁 Done. Generated script saved to: {output_script_path}")
365
431
  if adapter:
@@ -18,10 +18,6 @@ def _session_file(session_id: str) -> str:
18
18
  return os.path.join(_SESSION_DIR, f"{session_id}.json")
19
19
 
20
20
 
21
- def session_exists(session_id: str) -> bool:
22
- return os.path.exists(_session_file(session_id))
23
-
24
-
25
21
  def load_session(session_id: str) -> dict | None:
26
22
  path = _session_file(session_id)
27
23
  if not os.path.exists(path):
@@ -125,7 +121,7 @@ def stop_session_recording(session_id: str) -> str:
125
121
  pgid = os.getpgid(pid)
126
122
  os.killpg(pgid, signal.SIGINT)
127
123
  os.waitpid(pid, 0)
128
- except (OSError, ChildProcessError):
124
+ except OSError:
129
125
  try:
130
126
  os.kill(pid, signal.SIGINT)
131
127
  time.sleep(2)
@@ -3,12 +3,6 @@
3
3
  import base64
4
4
  import time
5
5
 
6
- from common.capabilities import (
7
- ACTIONS_REQUIRING_EXTRA_VALUE,
8
- GLOBAL_ACTIONS,
9
- SUPPORTED_ACTIONS,
10
- )
11
-
12
6
 
13
7
  class _LazyProxy:
14
8
  def __init__(self, loader):
@@ -62,9 +56,6 @@ load_workflow_file = None
62
56
  WorkflowLoadError = None
63
57
  parse_workflow_var_overrides = None
64
58
  resolve_workflow_definition = None
65
- SUPPORTED_INLINE_ACTIONS = SUPPORTED_ACTIONS
66
- GLOBAL_INLINE_ACTIONS = GLOBAL_ACTIONS
67
- INLINE_ACTIONS_REQUIRING_EXTRA_VALUE = ACTIONS_REQUIRING_EXTRA_VALUE
68
59
 
69
60
 
70
61
  def _slug_to_test_name(label: str) -> str:
@@ -382,6 +373,18 @@ class _SharedAdapterManager:
382
373
  # an inspect_ui and a follow-up `ref @N` action in the same MCP session
383
374
  # share state via get_executor() — without any process-global.
384
375
  self._executors: dict[str, object] = {}
376
+ # ONE live post-action observation, stashed by the execute mode
377
+ # (--action / --workflow) when this manager is present (MCP/session
378
+ # path only), read once by the MCP handler after dispatch. Cleared on
379
+ # read so a later execute can't serve a stale prior step's observation.
380
+ self._last_observation: dict | None = None
381
+
382
+ def set_last_observation(self, payload: dict) -> None:
383
+ self._last_observation = payload
384
+
385
+ def take_last_observation(self) -> dict | None:
386
+ observation, self._last_observation = self._last_observation, None
387
+ return observation
385
388
 
386
389
  def get_or_create(self, platform: str, env: str = "dev"):
387
390
  if platform in self._adapters:
@@ -30,6 +30,31 @@ from common.tool_protocol import (
30
30
  load_tool_request_from_stdin,
31
31
  )
32
32
 
33
+ # Agent-facing fields projected from the live execute observation onto the MCP
34
+ # response — the exact superset of --action --json success + engine_error shapes,
35
+ # plus the single-observation workflow markers. Curated allowlist so it never
36
+ # clobbers the response envelope (ok/operation/mode/exit_code/run_dir/...).
37
+ _OBSERVATION_FIELDS = (
38
+ "ui_tree",
39
+ "element_count",
40
+ "current_url",
41
+ "output_script",
42
+ "executed_steps",
43
+ "result",
44
+ "assertion_failed",
45
+ "error_code",
46
+ "message",
47
+ "fix",
48
+ "candidates",
49
+ "recommended_next_step",
50
+ "failed_step_index",
51
+ "failed_step_name",
52
+ )
53
+
54
+
55
+ def _project_observation_fields(observation: dict) -> dict:
56
+ return {key: observation[key] for key in _OBSERVATION_FIELDS if key in observation}
57
+
33
58
 
34
59
  class _NullRunReporter:
35
60
  def emit_event(self, event: str, **payload) -> None:
@@ -329,11 +354,12 @@ def build_tool_response_payload(request, shared_adapter_manager: _SharedAdapterM
329
354
 
330
355
  # Minimal MCP-execute enrichment: error_code + fix from the single-source
331
356
  # table (NO did-you-mean candidates — this run-report path has no live
332
- # ui_elements). NOTE: this stays {} until the autonomous run reporter
333
- # propagates the executor's error_code into summary.json; today
334
- # run_reporter writes category/stage/last_error but not error_code, so the
335
- # `if code:` guard is the honest no-op never a fabricated code. Wiring it
336
- # live is a follow-up (propagate error_code through the run summary).
357
+ # ui_elements). The execution modes now thread the executor's error_code into
358
+ # summary.json (run_reporter.finalize(error_code=...)), so this is LIVE for the
359
+ # --goal autonomous path the one execute path that produces no observation to
360
+ # fold below. For action/workflow the live observation overrides this with the
361
+ # richer engine_error diagnosis. The `if code:` guard keeps it honest: an empty
362
+ # summary error_code (e.g. assertion verdict) yields {}, never a fabricated code.
337
363
  failure_diagnosis = {}
338
364
  if exit_code != 0:
339
365
  from common.error_codes import lookup
@@ -343,7 +369,7 @@ def build_tool_response_payload(request, shared_adapter_manager: _SharedAdapterM
343
369
  msg, fix = lookup(code)
344
370
  failure_diagnosis = {"error_code": code, "message": msg, "fix": fix}
345
371
 
346
- return {
372
+ response = {
347
373
  "ok": exit_code == 0,
348
374
  "operation": "execute",
349
375
  "mode": execution_mode,
@@ -358,6 +384,34 @@ def build_tool_response_payload(request, shared_adapter_manager: _SharedAdapterM
358
384
  "failure_diagnosis": failure_diagnosis,
359
385
  }
360
386
 
387
+ # Fold in the live post-action observation (MCP parity with --action --json).
388
+ # The execute mode stashed it on the manager during dispatch; project its
389
+ # agent-facing fields ON TOP of the run-report response so live data wins
390
+ # (e.g. recommended_next_step from the live page beats the run_assets one).
391
+ # This is what makes failure_diagnosis REAL on MCP — sourced from the live
392
+ # executor result, not the lossy summary.json. take_* clears the stash.
393
+ observation = (
394
+ shared_adapter_manager.take_last_observation()
395
+ if shared_adapter_manager is not None
396
+ else None
397
+ )
398
+ if observation:
399
+ response.update(_project_observation_fields(observation))
400
+ if observation.get("result") == "engine_error":
401
+ response["failure_diagnosis"] = {
402
+ "error_code": observation.get("error_code", ""),
403
+ "message": observation.get("message", ""),
404
+ "fix": observation.get("fix", ""),
405
+ }
406
+ elif observation.get("result") == "assertion_failed":
407
+ # A failed assertion is a terminal verdict, not a recoverable error.
408
+ # The bare verdict omits recommended_next_step, so without this the
409
+ # run_assets-derived value (set above) would survive and bait a retry
410
+ # on a legitimately-failed assertion. Null it to keep the verdict bare.
411
+ response["recommended_next_step"] = None
412
+
413
+ return response
414
+
361
415
 
362
416
  def build_load_run_payload(run_id: str) -> dict:
363
417
  run_id = str(run_id).strip()
@@ -17,6 +17,11 @@ class BasePlatformAdapter(ABC):
17
17
  def take_screenshot(self) -> bytes:
18
18
  pass
19
19
 
20
+ # 录像 seam(诚实边界):
21
+ # - android/ios:start_record/stop_record_and_get_path 落真视频文件(scrcpy / simctl)。
22
+ # - web:start_record 是 no-op(CDP attach 无法用 Playwright 录像);web 的"录像"
23
+ # 由 review 层用逐操作截图 ffmpeg 拼胶片(review/render.py make_filmstrip)实现,
24
+ # 不走本 seam。调用方须把返回 "" / None 当作"无视频",非错误。
20
25
  def start_record(self, video_name: str):
21
26
  pass
22
27
 
@@ -9,6 +9,15 @@ from common.logs import log
9
9
  from common.progress import ai_status
10
10
 
11
11
 
12
+ def _strip_json_fences(text: str) -> str:
13
+ """Unwrap markdown code fences the model may add despite being told not to."""
14
+ if "```json" in text:
15
+ return text.split("```json")[1].split("```")[0].strip()
16
+ if "```" in text:
17
+ return text.replace("```", "").strip()
18
+ return text
19
+
20
+
12
21
  class AIBrain:
13
22
  def __init__(self):
14
23
  # 实例化文本专属客户端
@@ -251,6 +260,7 @@ class AIBrain:
251
260
  """
252
261
  封装底层的 LLM 网络调用
253
262
  """
263
+ result_text = ""
254
264
  try:
255
265
  with ai_status(f"Thinking ({model_name})..."):
256
266
  response = client.chat.completions.create(
@@ -262,16 +272,11 @@ class AIBrain:
262
272
  temperature=0.1,
263
273
  )
264
274
 
265
- result_text = response.choices[0].message.content.strip()
266
-
267
- if "```json" in result_text:
268
- result_text = result_text.split("```json")[1].split("```")[0].strip()
269
- elif "```" in result_text:
270
- result_text = result_text.replace("```", "").strip()
271
-
275
+ result_text = _strip_json_fences(response.choices[0].message.content.strip())
272
276
  parsed_json = json.loads(result_text)
273
277
  return parsed_json.get("result", {})
274
278
 
275
279
  except Exception as e:
276
- log.error(f"[Error] Model ({model_name}) request or parse failed: {e}")
280
+ # {} = "retry without cache" per caller contract; log raw text so parse-fail network-fail.
281
+ log.error(f"[Error] Model ({model_name}) request or parse failed: {e}\nRaw response: {result_text}")
277
282
  return {}