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.
- {screenforge-0.6.0 → screenforge-0.7.0}/PKG-INFO +13 -5
- {screenforge-0.6.0 → screenforge-0.7.0}/README.md +12 -4
- screenforge-0.7.0/cli/_version.py +1 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/dispatch.py +1 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/doctor.py +0 -4
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/action.py +54 -16
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/default.py +9 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/workflow.py +66 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/session.py +1 -5
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/shared.py +12 -9
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/tool_protocol_handlers.py +60 -6
- {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/base_adapter.py +5 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/ai.py +13 -8
- {screenforge-0.6.0 → screenforge-0.7.0}/common/ai_autonomous.py +7 -27
- {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_stats.py +0 -5
- {screenforge-0.6.0 → screenforge-0.7.0}/common/executor.py +134 -125
- {screenforge-0.6.0 → screenforge-0.7.0}/common/run_reporter.py +5 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/tool_protocol.py +5 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/config/config.py +2 -4
- screenforge-0.7.0/config/env_loader.py +23 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/pyproject.toml +15 -1
- {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/PKG-INFO +13 -5
- {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/SOURCES.txt +17 -0
- screenforge-0.7.0/tests/test_action_mode_observation_stash.py +117 -0
- screenforge-0.7.0/tests/test_cache_hash.py +52 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_cli_action_json.py +22 -1
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_dispatch.py +1 -1
- screenforge-0.7.0/tests/test_mcp_execute_observation.py +165 -0
- screenforge-0.7.0/tests/test_mcp_execute_parity_live.py +87 -0
- screenforge-0.7.0/tests/test_mode_error_code_threading.py +234 -0
- screenforge-0.7.0/tests/test_review_cases.py +98 -0
- screenforge-0.7.0/tests/test_review_conftest_gating.py +24 -0
- screenforge-0.7.0/tests/test_review_filmstrip.py +46 -0
- screenforge-0.7.0/tests/test_review_html.py +73 -0
- screenforge-0.7.0/tests/test_review_json.py +26 -0
- screenforge-0.7.0/tests/test_review_locate_frame.py +32 -0
- screenforge-0.7.0/tests/test_review_patching.py +87 -0
- screenforge-0.7.0/tests/test_review_recorder.py +66 -0
- screenforge-0.7.0/tests/test_review_screenshot.py +41 -0
- screenforge-0.7.0/tests/test_review_smoke_live.py +89 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_run_reporter.py +18 -0
- screenforge-0.7.0/tests/test_shared_observation_stash.py +27 -0
- screenforge-0.7.0/tests/test_workflow_mode_observation_stash.py +113 -0
- screenforge-0.6.0/cli/_version.py +0 -1
- screenforge-0.6.0/config/env_loader.py +0 -76
- {screenforge-0.6.0 → screenforge-0.7.0}/LICENSE +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/__init__.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/__init__.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/demo.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/dry_run.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/init.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/modes/plan.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/parser.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/playground_sink.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/reporter.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/cli/shorthand.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/__init__.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/__init__.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/android_adapter.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/ios_adapter.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/adapters/web_adapter.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/ai_heal.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/__init__.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_hash.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_manager.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/cache_storage.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/cache/embedding_loader.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/capabilities.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/case_memory.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/error_codes.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/exceptions.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/failure_diagnosis.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/history_manager.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/logs.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/mcp_server.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/preflight.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/progress.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/run_resume.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/runtime_modes.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/visual_fallback.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/common/workflow_schema.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/config/__init__.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/dependency_links.txt +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/entry_points.txt +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/requires.txt +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/screenforge.egg-info/top_level.txt +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/setup.cfg +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ai_autonomous.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ai_brain.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ai_heal.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_android_smoke_live.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_cache_manager.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_capabilities.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_codegen_quality.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_doctor_orphan_browser.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_dom_capture.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_error_codes.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_executor.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_failure_diagnosis.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_interaction_actions.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ios_smoke_live.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_mcp_ref_cache.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_ml_optional.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_parser.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_playground_app.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_playground_sink.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_playground_sink_integration.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_run_resume.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_runtime_modes.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_screenshot_annotator.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_shorthand.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_tool_protocol_diagnosis.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_utils_ios.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_utils_web.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_utils_xml.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_visual_fallback.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_web_adapter.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_web_dom_complex_live.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/tests/test_web_smoke_live.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/utils/__init__.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/utils/screenshot_annotator.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/utils/utils_ios.py +0 -0
- {screenforge-0.6.0 → screenforge-0.7.0}/utils/utils_web.py +0 -0
- {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.
|
|
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
|
-
>
|
|
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
|
-

|
|
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
|
-
- **
|
|
111
|
-
- **Self-healing engine**: When
|
|
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
|
-
>
|
|
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
|
-

|
|
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
|
-
- **
|
|
66
|
-
- **Self-healing engine**: When
|
|
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"
|
|
@@ -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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
246
|
-
"
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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).
|
|
333
|
-
#
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 {}
|