screenforge 0.10.0__tar.gz → 0.11.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.10.0 → screenforge-0.11.0}/PKG-INFO +1 -1
- screenforge-0.11.0/cli/_version.py +1 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/default.py +17 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/session.py +25 -1
- {screenforge-0.10.0 → screenforge-0.11.0}/common/ai.py +71 -8
- {screenforge-0.10.0 → screenforge-0.11.0}/common/ai_autonomous.py +5 -3
- screenforge-0.11.0/common/cache/cache_hash.py +88 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/cache_manager.py +39 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/config/config.py +10 -5
- {screenforge-0.10.0 → screenforge-0.11.0}/pyproject.toml +1 -1
- {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/PKG-INFO +1 -1
- {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/SOURCES.txt +2 -0
- screenforge-0.11.0/tests/test_ai_brain.py +210 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_cache_hash.py +28 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_cache_manager.py +41 -0
- screenforge-0.11.0/tests/test_config_app_target.py +23 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mode_error_code_threading.py +28 -0
- screenforge-0.11.0/tests/test_session_reap.py +43 -0
- screenforge-0.10.0/cli/_version.py +0 -1
- screenforge-0.10.0/common/cache/cache_hash.py +0 -57
- screenforge-0.10.0/tests/test_ai_brain.py +0 -106
- {screenforge-0.10.0 → screenforge-0.11.0}/LICENSE +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/README.md +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/__init__.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/dispatch.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/doctor.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/__init__.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/action.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/demo.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/dry_run.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/init.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/plan.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/workflow.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/parser.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/playground_sink.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/reporter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/shared.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/shorthand.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/cli/tool_protocol_handlers.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/__init__.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/__init__.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/android_adapter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/base_adapter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/ios_adapter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/web_adapter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/ai_heal.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/__init__.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/cache_stats.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/cache_storage.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/embedding_loader.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/capabilities.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/case_memory.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/error_codes.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/exceptions.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/executor.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/failure_diagnosis.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/history_manager.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/logs.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/mcp_server.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/observation.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/preflight.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/progress.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/run_reporter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/run_resume.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/runtime_modes.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/tool_protocol.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/visual_fallback.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/common/workflow_schema.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/config/__init__.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/config/env_loader.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/dependency_links.txt +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/entry_points.txt +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/requires.txt +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/top_level.txt +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/setup.cfg +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_action_mode_observation_stash.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ai_autonomous.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ai_heal.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_android_smoke_live.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_capabilities.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_case_memory_identity.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_cli_action_json.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_codegen_quality.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_dispatch.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_doctor_orphan_browser.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_dom_capture.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_error_codes.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_executor.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_failure_diagnosis.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_failure_diagnosis_ios.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_failure_diagnosis_mobile.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_forward_candidates.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_inspect_ui_candidates.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_inspect_ui_envelope.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_inspect_ui_memory.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_interaction_actions.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ios_smoke_live.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_execute_observation.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_execute_parity_live.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_ref_cache.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_tool_result.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ml_optional.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_observation.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_observation_action_parity.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_parser.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_playground_app.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_playground_sink.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_playground_sink_integration.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_cases.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_conftest_gating.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_filmstrip.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_html.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_json.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_locate_frame.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_patching.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_recorder.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_screenshot.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_smoke_live.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_run_reporter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_run_resume.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_runtime_modes.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_screenshot_annotator.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_shared_observation_stash.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_shorthand.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_tool_protocol_diagnosis.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_utils_ios.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_utils_web.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_utils_xml.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_visual_fallback.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_web_adapter.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_web_dom_complex_live.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_web_smoke_live.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_workflow_mode_observation_stash.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/utils/__init__.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/utils/screenshot_annotator.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/utils/utils_ios.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/utils/utils_web.py +0 -0
- {screenforge-0.10.0 → screenforge-0.11.0}/utils/utils_xml.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.11.0"
|
|
@@ -58,6 +58,7 @@ def run_default_mode(
|
|
|
58
58
|
step_count = 0
|
|
59
59
|
last_error = ""
|
|
60
60
|
consecutive_failures = 0
|
|
61
|
+
consecutive_not_found = 0
|
|
61
62
|
last_ui_json = ""
|
|
62
63
|
|
|
63
64
|
while step_count < args.max_steps:
|
|
@@ -177,11 +178,27 @@ def run_default_mode(
|
|
|
177
178
|
success=False,
|
|
178
179
|
action_description="not_found",
|
|
179
180
|
)
|
|
181
|
+
# not_found is exempt from the failure circuit breaker so vision
|
|
182
|
+
# gets a chance, but a model stuck returning it makes no progress
|
|
183
|
+
# and burns one LLM call per step up to max_steps (token bleed).
|
|
184
|
+
# Cap consecutive not_found at the same threshold and stop.
|
|
185
|
+
consecutive_not_found += 1
|
|
186
|
+
if consecutive_not_found >= args.max_retries:
|
|
187
|
+
final_error = (
|
|
188
|
+
f"Stopped after {consecutive_not_found} consecutive 'not_found' — "
|
|
189
|
+
f"target never appeared in the UI tree."
|
|
190
|
+
)
|
|
191
|
+
log.error(
|
|
192
|
+
f"[E025] {consecutive_not_found} consecutive 'not_found'. "
|
|
193
|
+
f"Fix: re-run with --vision, or refine --goal / scroll the target into view."
|
|
194
|
+
)
|
|
195
|
+
return 1
|
|
180
196
|
continue
|
|
181
197
|
else:
|
|
182
198
|
result = executor.execute_and_record(action_data)
|
|
183
199
|
if result.get("success"):
|
|
184
200
|
consecutive_failures = 0
|
|
201
|
+
consecutive_not_found = 0
|
|
185
202
|
history_manager.add_step(
|
|
186
203
|
result["code_lines"], result["action_description"]
|
|
187
204
|
)
|
|
@@ -108,6 +108,30 @@ def start_session_recording(session_id: str, platform: str, udid: str = "") -> s
|
|
|
108
108
|
return video_path
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
def _reap_with_timeout(pid: int, timeout: float) -> None:
|
|
112
|
+
"""Wait for a signalled recording process to exit, then SIGKILL if it won't.
|
|
113
|
+
|
|
114
|
+
Bare os.waitpid(pid, 0) blocks forever — if `xcrun simctl recordVideo` ever
|
|
115
|
+
ignores SIGINT, --session-end would hang the whole CLI. Poll with WNOHANG up
|
|
116
|
+
to `timeout`, then SIGKILL and reap so we never block indefinitely.
|
|
117
|
+
"""
|
|
118
|
+
deadline = time.monotonic() + timeout
|
|
119
|
+
while time.monotonic() < deadline:
|
|
120
|
+
try:
|
|
121
|
+
reaped, _ = os.waitpid(pid, os.WNOHANG)
|
|
122
|
+
except ChildProcessError:
|
|
123
|
+
return # already reaped elsewhere
|
|
124
|
+
if reaped:
|
|
125
|
+
return
|
|
126
|
+
time.sleep(0.1)
|
|
127
|
+
# Still alive after the grace period — kill hard and reap the zombie.
|
|
128
|
+
try:
|
|
129
|
+
os.kill(pid, signal.SIGKILL)
|
|
130
|
+
os.waitpid(pid, 0)
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
111
135
|
def stop_session_recording(session_id: str) -> str:
|
|
112
136
|
session = load_session(session_id)
|
|
113
137
|
if not session:
|
|
@@ -120,7 +144,7 @@ def stop_session_recording(session_id: str) -> str:
|
|
|
120
144
|
try:
|
|
121
145
|
pgid = os.getpgid(pid)
|
|
122
146
|
os.killpg(pgid, signal.SIGINT)
|
|
123
|
-
|
|
147
|
+
_reap_with_timeout(pid, timeout=10.0)
|
|
124
148
|
except OSError:
|
|
125
149
|
try:
|
|
126
150
|
os.kill(pid, signal.SIGINT)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
import time
|
|
3
4
|
|
|
4
5
|
from openai import OpenAI
|
|
@@ -18,6 +19,39 @@ def _strip_json_fences(text: str) -> str:
|
|
|
18
19
|
return text
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
# Endpoints (base_url) that 400'd on response_format — never retried with it.
|
|
23
|
+
# response_format={"type":"json_object"} cuts malformed responses (fewer retries,
|
|
24
|
+
# fewer tokens), but is NOT universal across OpenAI-compatible endpoints, which
|
|
25
|
+
# this tool promises to support. So: try once per endpoint, remember a rejection,
|
|
26
|
+
# and fall back to a plain call forever after. Prompts already say "output JSON".
|
|
27
|
+
_JSON_MODE_UNSUPPORTED: set = set()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def chat_completion_json(client, **kwargs):
|
|
31
|
+
"""chat.completions.create that requests JSON mode when the endpoint allows.
|
|
32
|
+
|
|
33
|
+
Detect-once per endpoint: on the first 400-style rejection of response_format
|
|
34
|
+
we record the base_url and never send it again, so an endpoint without JSON
|
|
35
|
+
mode pays one failed call total, not one per request.
|
|
36
|
+
"""
|
|
37
|
+
endpoint = str(getattr(client, "base_url", ""))
|
|
38
|
+
if endpoint not in _JSON_MODE_UNSUPPORTED:
|
|
39
|
+
try:
|
|
40
|
+
return client.chat.completions.create(
|
|
41
|
+
response_format={"type": "json_object"}, **kwargs
|
|
42
|
+
)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
# Only json-mode-shaped rejections fall back; real network/auth errors
|
|
45
|
+
# must surface to the caller's existing try/except unchanged.
|
|
46
|
+
msg = str(e).lower()
|
|
47
|
+
if "response_format" in msg or "json" in msg or "not support" in msg:
|
|
48
|
+
log.warning(f"[AI] Endpoint rejected JSON mode, falling back for {endpoint}")
|
|
49
|
+
_JSON_MODE_UNSUPPORTED.add(endpoint)
|
|
50
|
+
else:
|
|
51
|
+
raise
|
|
52
|
+
return client.chat.completions.create(**kwargs)
|
|
53
|
+
|
|
54
|
+
|
|
21
55
|
class AIBrain:
|
|
22
56
|
def __init__(self):
|
|
23
57
|
# 实例化文本专属客户端
|
|
@@ -39,20 +73,48 @@ class AIBrain:
|
|
|
39
73
|
|
|
40
74
|
def _verify_locator_in_ui(self, decision: dict, ui_dict: dict) -> bool:
|
|
41
75
|
"""
|
|
42
|
-
校验缓存中推荐的动作,其元素是否真实存在于当前的 UI
|
|
76
|
+
校验缓存中推荐的动作,其元素是否真实存在于当前的 UI 树中。
|
|
77
|
+
|
|
78
|
+
L2 是跨页面的全局语义缓存,一条决策可能命中一个并不存在该元素的页面。
|
|
79
|
+
本方法是那道安全网:能确认元素不存在时返回 False 丢弃该命中;无法确认
|
|
80
|
+
(元素无该属性、选择器无法解析)时放行,绝不误伤有效缓存。
|
|
43
81
|
"""
|
|
44
82
|
loc_type = decision.get("locator_type")
|
|
45
83
|
loc_val = decision.get("locator_value")
|
|
46
84
|
|
|
47
|
-
#
|
|
48
|
-
if
|
|
49
|
-
not loc_type
|
|
50
|
-
or not loc_val
|
|
51
|
-
or loc_type not in ["text", "description", "resourceId", "id"]
|
|
52
|
-
):
|
|
85
|
+
# 无定位需求 (swipe / press 等全局动作) 直接放行
|
|
86
|
+
if not loc_type or not loc_val:
|
|
53
87
|
return True
|
|
54
88
|
|
|
55
89
|
elements = ui_dict.get("ui_elements", [])
|
|
90
|
+
|
|
91
|
+
# css 是 Web 端最常用的定位器 (Prompt 铁律排第一),且 L2 是跨页面全局缓存,
|
|
92
|
+
# 因此必须校验而非放行。压缩后的 Web 树只能确认执行器自己产出的两种形态:
|
|
93
|
+
# `#id` -> 元素 id,`[name="x"]` -> 元素 name (见 executor.LocatorBuilder)。
|
|
94
|
+
# 其它选择器 (class/标签/后代) 树里无法还原,判为无法确认 -> 放行。
|
|
95
|
+
if loc_type == "css":
|
|
96
|
+
# Only a SIMPLE selector round-trips to a tree attribute. The charset
|
|
97
|
+
# excludes CSS combinators/escapes (space > + ~ , . [ : \) on purpose:
|
|
98
|
+
# `#form input` and `#user\.email` are compound or escaped, so the raw
|
|
99
|
+
# string never equals an `id` value — comparing anyway would discard a
|
|
100
|
+
# VALID L2 hit and force a fresh paid LLM call, inverting this method's
|
|
101
|
+
# purpose. Unverifiable → pass through.
|
|
102
|
+
simple = r"[^\s>+~,.\[\]:\\#]+"
|
|
103
|
+
m = re.fullmatch(rf"#({simple})", loc_val) or re.fullmatch(
|
|
104
|
+
rf'\[name="({simple})"\]', loc_val
|
|
105
|
+
)
|
|
106
|
+
if not m:
|
|
107
|
+
return True
|
|
108
|
+
attr = "id" if loc_val.startswith("#") else "name"
|
|
109
|
+
target = m.group(1)
|
|
110
|
+
values = [el.get(attr) for el in elements if el.get(attr)]
|
|
111
|
+
if not values: # 树里没有任何该属性 -> 无从确认 -> 放行
|
|
112
|
+
return True
|
|
113
|
+
return target in values
|
|
114
|
+
|
|
115
|
+
if loc_type not in ["text", "description", "resourceId", "id"]:
|
|
116
|
+
return True
|
|
117
|
+
|
|
56
118
|
for el in elements:
|
|
57
119
|
if loc_type == "text" and el.get("text") == loc_val:
|
|
58
120
|
return True
|
|
@@ -263,7 +325,8 @@ class AIBrain:
|
|
|
263
325
|
result_text = ""
|
|
264
326
|
try:
|
|
265
327
|
with ai_status(f"Thinking ({model_name})..."):
|
|
266
|
-
response =
|
|
328
|
+
response = chat_completion_json(
|
|
329
|
+
client,
|
|
267
330
|
model=model_name,
|
|
268
331
|
messages=[
|
|
269
332
|
{"role": "system", "content": system_prompt},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
3
|
import config.config as config
|
|
4
|
-
from common.ai import AIBrain, _strip_json_fences
|
|
4
|
+
from common.ai import AIBrain, _strip_json_fences, chat_completion_json
|
|
5
5
|
from common.logs import log
|
|
6
6
|
from common.progress import ai_status
|
|
7
7
|
|
|
@@ -80,7 +80,8 @@ class AutonomousBrain(AIBrain):
|
|
|
80
80
|
result_text = ""
|
|
81
81
|
try:
|
|
82
82
|
with ai_status("Planning execution steps..."):
|
|
83
|
-
response =
|
|
83
|
+
response = chat_completion_json(
|
|
84
|
+
active_client,
|
|
84
85
|
model=active_model,
|
|
85
86
|
messages=[
|
|
86
87
|
{"role": "system", "content": system_prompt},
|
|
@@ -225,7 +226,8 @@ class AutonomousBrain(AIBrain):
|
|
|
225
226
|
result_text = ""
|
|
226
227
|
try:
|
|
227
228
|
with ai_status("Reasoning about next action..."):
|
|
228
|
-
response =
|
|
229
|
+
response = chat_completion_json(
|
|
230
|
+
active_client,
|
|
229
231
|
model=active_model,
|
|
230
232
|
messages=[
|
|
231
233
|
{"role": "system", "content": system_prompt},
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
_BLOCKLIST = frozenset(
|
|
7
|
+
[
|
|
8
|
+
"加密货币",
|
|
9
|
+
"比特币",
|
|
10
|
+
"meme币",
|
|
11
|
+
"美股代币",
|
|
12
|
+
"贵金属代币",
|
|
13
|
+
"热门资产",
|
|
14
|
+
"已上线",
|
|
15
|
+
"已上架",
|
|
16
|
+
"上架",
|
|
17
|
+
"下架",
|
|
18
|
+
"公告",
|
|
19
|
+
"活动",
|
|
20
|
+
]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _latin_anchor(raw_text: str) -> str:
|
|
25
|
+
"""A Latin/non-CJK anchor from an element's text, or '' if not anchor-like.
|
|
26
|
+
|
|
27
|
+
Mirrors the CJK path's dynamic-data immunity: digits and symbols are
|
|
28
|
+
stripped so a changing price/balance/count doesn't move the skeleton hash
|
|
29
|
+
("Balance 100" and "Balance 9999" both reduce to "balance"). Whitespace is
|
|
30
|
+
collapsed and case is folded so render noise doesn't fork the fingerprint.
|
|
31
|
+
|
|
32
|
+
ponytail: 2..24 chars keeps button/nav labels ("login", "delete account")
|
|
33
|
+
and drops long description sentences — the Latin analogue of the CJK 2..6
|
|
34
|
+
window. Raise the ceiling only if a real multi-word control exceeds it.
|
|
35
|
+
"""
|
|
36
|
+
letters_only = re.sub(r"[^a-zA-Z\s]", "", raw_text)
|
|
37
|
+
normalized = " ".join(letters_only.split()).lower()
|
|
38
|
+
return normalized if 2 <= len(normalized) <= 24 else ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_semantic_fingerprint(ui_json: Dict[str, Any]) -> list:
|
|
42
|
+
"""
|
|
43
|
+
提取页面锚点指纹,免疫动态数据和渲染顺序波动。
|
|
44
|
+
"""
|
|
45
|
+
fingerprint_features = set()
|
|
46
|
+
elements = ui_json.get("ui_elements", [])
|
|
47
|
+
|
|
48
|
+
for el in elements:
|
|
49
|
+
raw_text = el.get("text", "") or el.get("desc", "")
|
|
50
|
+
# 抹除所有数字、字母、符号,只保留纯汉字
|
|
51
|
+
cn_text = re.sub(r"[^\u4e00-\u9fa5]", "", raw_text)
|
|
52
|
+
|
|
53
|
+
# 标准的 UI 导航或按钮,通常在 2 到 6 个汉字之间
|
|
54
|
+
if 2 <= len(cn_text) <= 6 and cn_text not in _BLOCKLIST:
|
|
55
|
+
fingerprint_features.add(f"{el.get('class')}|{cn_text}")
|
|
56
|
+
|
|
57
|
+
# Non-CJK pages (English etc.) carry NO Chinese anchors, so the loop above
|
|
58
|
+
# yields nothing and every such page collapsed to one constant hash — L1's
|
|
59
|
+
# exact key then replayed an action recorded on a DIFFERENT page. Fall back
|
|
60
|
+
# to Latin anchors so those pages get distinct identities too. Page-level,
|
|
61
|
+
# not per-element: a page WITH usable CJK anchors keeps exactly its old
|
|
62
|
+
# fingerprint (Chinese behavior frozen); only a fully-anchorless page falls
|
|
63
|
+
# through here.
|
|
64
|
+
if not fingerprint_features:
|
|
65
|
+
for el in elements:
|
|
66
|
+
anchor = _latin_anchor(el.get("text", "") or el.get("desc", ""))
|
|
67
|
+
if anchor:
|
|
68
|
+
fingerprint_features.add(f"{el.get('class')}|{anchor}")
|
|
69
|
+
|
|
70
|
+
# 强制排序并转为列表,保证哈希的绝对一致性
|
|
71
|
+
return sorted(list(fingerprint_features))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_ui_hash(ui_json: Dict[str, Any]) -> str:
|
|
75
|
+
"""计算用于混合缓存匹配的页面骨架 Hash"""
|
|
76
|
+
fingerprint = _extract_semantic_fingerprint(ui_json)
|
|
77
|
+
fingerprint_str = json.dumps(fingerprint)
|
|
78
|
+
hash_obj = hashlib.sha256()
|
|
79
|
+
hash_obj.update(fingerprint_str.encode("utf-8"))
|
|
80
|
+
return hash_obj.hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_instruction_hash(instruction: str) -> str:
|
|
84
|
+
"""计算用于混合缓存 O(1) 精确匹配的指令 Hash"""
|
|
85
|
+
normalized_inst = re.sub(r"\s+", " ", instruction).strip().lower()
|
|
86
|
+
hash_obj = hashlib.sha256()
|
|
87
|
+
hash_obj.update(normalized_inst.encode("utf-8"))
|
|
88
|
+
return hash_obj.hexdigest()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
|
|
3
4
|
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
|
@@ -30,6 +31,7 @@ class CacheManager:
|
|
|
30
31
|
self._cache_dir = cache_dir
|
|
31
32
|
self._enabled = enabled
|
|
32
33
|
self._ttl_seconds = ttl_days * 24 * 60 * 60
|
|
34
|
+
self._max_size_bytes = max_size_mb * 1024 * 1024
|
|
33
35
|
self._stats = CacheStats(cache_dir)
|
|
34
36
|
self._model_loader = EmbeddingModelLoader()
|
|
35
37
|
|
|
@@ -178,6 +180,10 @@ class CacheManager:
|
|
|
178
180
|
return False
|
|
179
181
|
try:
|
|
180
182
|
cache_data = load_cache(self._cache_dir)
|
|
183
|
+
# Prune expired entries here: the write path reloads and re-saves the
|
|
184
|
+
# whole file, so without this it re-persists dead entries forever. The
|
|
185
|
+
# read path only saves on a hit, so misses can't be relied on to prune.
|
|
186
|
+
cache_data = cleanup_expired_entries(cache_data, self._ttl_seconds)
|
|
181
187
|
entries = cache_data.setdefault("entries", {})
|
|
182
188
|
current_vector = self._get_embedding(instruction)
|
|
183
189
|
keys_to_delete = []
|
|
@@ -219,12 +225,45 @@ class CacheManager:
|
|
|
219
225
|
if ui_hash is not None:
|
|
220
226
|
entry["ui_hash"] = ui_hash
|
|
221
227
|
entries[exact_key] = entry
|
|
228
|
+
self._evict_to_fit(cache_data, exact_key)
|
|
222
229
|
save_cache(self._cache_dir, cache_data)
|
|
223
230
|
return True
|
|
224
231
|
except Exception as e:
|
|
225
232
|
log.error(f"[Cache Error] Write failed: {e}")
|
|
226
233
|
return False
|
|
227
234
|
|
|
235
|
+
def _evict_to_fit(self, cache_data: Dict, keep_key: str) -> None:
|
|
236
|
+
"""Evict least-recently-accessed entries until the cache fits max_size_bytes.
|
|
237
|
+
|
|
238
|
+
TTL bounds age; this bounds size within the TTL window. The just-written
|
|
239
|
+
key is never evicted (a single entry larger than the cap still persists —
|
|
240
|
+
the cap is a soft ceiling, not a hard reject of the current write).
|
|
241
|
+
"""
|
|
242
|
+
entries = cache_data.get("entries", {})
|
|
243
|
+
|
|
244
|
+
# ponytail: re-serializes on each eviction (O(n²) worst case). Fine —
|
|
245
|
+
# TTL already bounds entry count and eviction is rare; switch to a running
|
|
246
|
+
# byte tally if a cache ever evicts hundreds of entries in one write.
|
|
247
|
+
def size() -> int:
|
|
248
|
+
return len(json.dumps(cache_data, ensure_ascii=False).encode("utf-8"))
|
|
249
|
+
|
|
250
|
+
if size() <= self._max_size_bytes:
|
|
251
|
+
return
|
|
252
|
+
# Oldest access first; the current write is pinned last so it survives.
|
|
253
|
+
ordered = sorted(
|
|
254
|
+
entries.keys(),
|
|
255
|
+
key=lambda k: (
|
|
256
|
+
k == keep_key,
|
|
257
|
+
entries[k].get("metadata", {}).get("last_accessed", ""),
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
for k in ordered:
|
|
261
|
+
if k == keep_key or len(entries) <= 1:
|
|
262
|
+
break
|
|
263
|
+
del entries[k]
|
|
264
|
+
if size() <= self._max_size_bytes:
|
|
265
|
+
break
|
|
266
|
+
|
|
228
267
|
def get(self, instruction: str, ui_json: Dict[str, Any], platform: str) -> Optional[Dict[str, Any]]:
|
|
229
268
|
try:
|
|
230
269
|
ui_hash = compute_ui_hash(ui_json)
|
|
@@ -51,12 +51,17 @@ DEFAULT_TIMEOUT = float(os.getenv("DEFAULT_TIMEOUT", 30.0))
|
|
|
51
51
|
# ==========================================
|
|
52
52
|
# 用户填入各环境/平台的 App 包名或 URL 后,launch_app 会自动拉起;默认留空=不自动启动。
|
|
53
53
|
# 这是文档化的用户旋钮(见 docs/capability-matrix.md),不是死代码。
|
|
54
|
+
# Auto-launch target per (env, platform). Env-driven so a pip-installed user can
|
|
55
|
+
# set an Android package / iOS bundle / web start-URL without editing the source.
|
|
56
|
+
# Empty (the default) means "don't auto-launch" — correct for web (the brain
|
|
57
|
+
# issues goto) and for driving an already-open mobile app. Env vars follow the
|
|
58
|
+
# pattern APP_TARGET_<ENV>_<PLATFORM>, e.g. APP_TARGET_DEV_ANDROID=com.foo.bar.
|
|
54
59
|
APP_ENV_CONFIG = {
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
"ios"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
env: {
|
|
61
|
+
platform: os.getenv(f"APP_TARGET_{env.upper()}_{platform.upper()}", "")
|
|
62
|
+
for platform in ("android", "ios", "web")
|
|
63
|
+
}
|
|
64
|
+
for env in ("dev", "prod", "us_dev", "us_prod")
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
# ==========================================
|
|
@@ -73,6 +73,7 @@ tests/test_capabilities.py
|
|
|
73
73
|
tests/test_case_memory_identity.py
|
|
74
74
|
tests/test_cli_action_json.py
|
|
75
75
|
tests/test_codegen_quality.py
|
|
76
|
+
tests/test_config_app_target.py
|
|
76
77
|
tests/test_dispatch.py
|
|
77
78
|
tests/test_doctor_orphan_browser.py
|
|
78
79
|
tests/test_dom_capture.py
|
|
@@ -113,6 +114,7 @@ tests/test_run_reporter.py
|
|
|
113
114
|
tests/test_run_resume.py
|
|
114
115
|
tests/test_runtime_modes.py
|
|
115
116
|
tests/test_screenshot_annotator.py
|
|
117
|
+
tests/test_session_reap.py
|
|
116
118
|
tests/test_shared_observation_stash.py
|
|
117
119
|
tests/test_shorthand.py
|
|
118
120
|
tests/test_tool_protocol_diagnosis.py
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Tests for common/ai.py — AIBrain action decision and cache interaction."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def mock_config(monkeypatch):
|
|
11
|
+
monkeypatch.setattr("config.config.OPENAI_API_KEY", "test-key")
|
|
12
|
+
monkeypatch.setattr("config.config.OPENAI_BASE_URL", "http://localhost")
|
|
13
|
+
monkeypatch.setattr("config.config.VISION_API_KEY", "test-key")
|
|
14
|
+
monkeypatch.setattr("config.config.VISION_BASE_URL", "http://localhost")
|
|
15
|
+
monkeypatch.setattr("config.config.VISION_MODEL_NAME", "gpt-4o")
|
|
16
|
+
monkeypatch.setattr("config.config.MODEL_NAME", "gpt-4o")
|
|
17
|
+
monkeypatch.setattr("config.config.CACHE_DIR", "/tmp/test_cache")
|
|
18
|
+
monkeypatch.setattr("config.config.CACHE_ENABLED", False)
|
|
19
|
+
monkeypatch.setattr("config.config.CACHE_TTL_DAYS", 1)
|
|
20
|
+
monkeypatch.setattr("config.config.CACHE_MAX_SIZE_MB", 10)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def brain(mock_config):
|
|
25
|
+
with patch("common.ai.OpenAI") as mock_openai_cls:
|
|
26
|
+
mock_openai_cls.side_effect = [MagicMock(), MagicMock()]
|
|
27
|
+
from common.ai import AIBrain
|
|
28
|
+
return AIBrain()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestVerifyLocatorInUi:
|
|
32
|
+
def test_no_locator_type_passes(self, brain):
|
|
33
|
+
decision = {"action": "swipe", "locator_type": "", "locator_value": ""}
|
|
34
|
+
assert brain._verify_locator_in_ui(decision, {"ui_elements": []}) is True
|
|
35
|
+
|
|
36
|
+
def test_text_locator_found(self, brain):
|
|
37
|
+
decision = {"locator_type": "text", "locator_value": "Login"}
|
|
38
|
+
ui = {"ui_elements": [{"text": "Login", "id": ""}]}
|
|
39
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
40
|
+
|
|
41
|
+
def test_text_locator_not_found(self, brain):
|
|
42
|
+
decision = {"locator_type": "text", "locator_value": "Login"}
|
|
43
|
+
ui = {"ui_elements": [{"text": "Register", "id": ""}]}
|
|
44
|
+
assert brain._verify_locator_in_ui(decision, ui) is False
|
|
45
|
+
|
|
46
|
+
def test_id_locator_found(self, brain):
|
|
47
|
+
decision = {"locator_type": "resourceId", "locator_value": "btn_submit"}
|
|
48
|
+
ui = {"ui_elements": [{"text": "", "id": "btn_submit"}]}
|
|
49
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
50
|
+
|
|
51
|
+
def test_description_locator_found(self, brain):
|
|
52
|
+
decision = {"locator_type": "description", "locator_value": "Close dialog"}
|
|
53
|
+
ui = {"ui_elements": [{"desc": "Close dialog", "text": ""}]}
|
|
54
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
55
|
+
|
|
56
|
+
def test_css_locator_passthrough(self, brain):
|
|
57
|
+
# Empty tree → nothing to verify against → pass through (don't discard).
|
|
58
|
+
decision = {"locator_type": "css", "locator_value": "#btn"}
|
|
59
|
+
ui = {"ui_elements": []}
|
|
60
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
61
|
+
|
|
62
|
+
def test_css_id_present_is_accepted(self, brain):
|
|
63
|
+
decision = {"locator_type": "css", "locator_value": "#username"}
|
|
64
|
+
ui = {"ui_elements": [{"id": "username", "class": "input"}]}
|
|
65
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
66
|
+
|
|
67
|
+
def test_css_id_absent_on_a_page_that_has_ids_is_rejected(self, brain):
|
|
68
|
+
# P1: L2 is a GLOBAL cross-page cache. A `#username` decision learned on
|
|
69
|
+
# page A must NOT replay on page B where ids exist but none match — the
|
|
70
|
+
# old allowlist waved ALL css through, so this returned True (wrong).
|
|
71
|
+
decision = {"locator_type": "css", "locator_value": "#username"}
|
|
72
|
+
ui = {"ui_elements": [{"id": "search", "class": "input"}]}
|
|
73
|
+
assert brain._verify_locator_in_ui(decision, ui) is False
|
|
74
|
+
|
|
75
|
+
def test_css_id_passes_through_when_tree_carries_no_ids(self, brain):
|
|
76
|
+
# Can't confirm presence or absence → pass through, never false-reject.
|
|
77
|
+
decision = {"locator_type": "css", "locator_value": "#username"}
|
|
78
|
+
ui = {"ui_elements": [{"text": "Login", "class": "button"}]}
|
|
79
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
80
|
+
|
|
81
|
+
def test_css_name_attribute_verified(self, brain):
|
|
82
|
+
present = {"locator_type": "css", "locator_value": '[name="email"]'}
|
|
83
|
+
absent = {"locator_type": "css", "locator_value": '[name="email"]'}
|
|
84
|
+
ui = {"ui_elements": [{"name": "email", "class": "input"}]}
|
|
85
|
+
other = {"ui_elements": [{"name": "phone", "class": "input"}]}
|
|
86
|
+
assert brain._verify_locator_in_ui(present, ui) is True
|
|
87
|
+
assert brain._verify_locator_in_ui(absent, other) is False
|
|
88
|
+
|
|
89
|
+
def test_css_complex_selector_passes_through(self, brain):
|
|
90
|
+
# A selector the compressed tree can't resolve (class/tag/descendant)
|
|
91
|
+
# is unverifiable → pass through rather than discard a valid hit.
|
|
92
|
+
decision = {"locator_type": "css", "locator_value": "div.card > button.primary"}
|
|
93
|
+
ui = {"ui_elements": [{"id": "search", "class": "button"}]}
|
|
94
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
"selector",
|
|
98
|
+
[
|
|
99
|
+
"#login-form input", # descendant
|
|
100
|
+
"#login-form > .btn", # child combinator
|
|
101
|
+
"#username.form-control", # id + class compound
|
|
102
|
+
'#login-form input[name="u"]', # descendant + attribute
|
|
103
|
+
"#a, #b", # selector list
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
def test_css_compound_selector_starting_with_id_passes_through(self, brain, selector):
|
|
107
|
+
# A compound selector only STARTS with #id — the rest narrows to a
|
|
108
|
+
# descendant/class. Comparing the whole string against `id` values always
|
|
109
|
+
# fails, which DISCARDS a valid L2 hit and forces a fresh paid LLM call —
|
|
110
|
+
# the opposite of why this verification exists. Unverifiable → pass through.
|
|
111
|
+
ui = {"ui_elements": [{"id": "login-form", "class": "form"}]}
|
|
112
|
+
assert brain._verify_locator_in_ui({"locator_type": "css", "locator_value": selector}, ui) is True
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
("raw_id", "selector"),
|
|
116
|
+
[
|
|
117
|
+
("user.email", r"#user\.email"), # Angular/JSF-style dotted id
|
|
118
|
+
("form:submit", r"#form\:submit"), # JSF colon id
|
|
119
|
+
("2fa_code", "#\\32 fa_code"), # leading digit, CSS.escape form
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
def test_css_escaped_id_selector_is_not_false_rejected(self, brain, raw_id, selector):
|
|
123
|
+
# executor._escape_css_ident backslash-escapes special chars, so the
|
|
124
|
+
# selector text never equals the raw `id` in the tree. A literal compare
|
|
125
|
+
# rejects a locator whose element is RIGHT THERE. Must not return False.
|
|
126
|
+
ui = {"ui_elements": [{"id": raw_id, "class": "input"}]}
|
|
127
|
+
assert brain._verify_locator_in_ui({"locator_type": "css", "locator_value": selector}, ui) is True
|
|
128
|
+
|
|
129
|
+
def test_css_escaped_name_selector_is_not_false_rejected(self, brain):
|
|
130
|
+
# Same for [name="..."]: a quote in the name is escaped when built.
|
|
131
|
+
ui = {"ui_elements": [{"name": 'we"ird', "class": "input"}]}
|
|
132
|
+
decision = {"locator_type": "css", "locator_value": '[name="we\\"ird"]'}
|
|
133
|
+
assert brain._verify_locator_in_ui(decision, ui) is True
|
|
134
|
+
|
|
135
|
+
def test_css_plain_id_rejection_still_works(self, brain):
|
|
136
|
+
# The safety net must stay armed for the simple case it was built for.
|
|
137
|
+
ui = {"ui_elements": [{"id": "search", "class": "input"}]}
|
|
138
|
+
decision = {"locator_type": "css", "locator_value": "#username"}
|
|
139
|
+
assert brain._verify_locator_in_ui(decision, ui) is False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestGetAction:
|
|
143
|
+
def _make_llm_response(self, content: str):
|
|
144
|
+
response = MagicMock()
|
|
145
|
+
response.choices = [MagicMock()]
|
|
146
|
+
response.choices[0].message.content = content
|
|
147
|
+
return response
|
|
148
|
+
|
|
149
|
+
def test_returns_parsed_result(self, brain):
|
|
150
|
+
result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "Login"}})
|
|
151
|
+
brain.text_client.chat.completions.create.return_value = self._make_llm_response(result_json)
|
|
152
|
+
|
|
153
|
+
result = brain.get_action("click login button", '{"ui_elements": []}', "web", skip_cache=True)
|
|
154
|
+
assert result["action"] == "click"
|
|
155
|
+
assert result["locator_value"] == "Login"
|
|
156
|
+
|
|
157
|
+
def test_handles_markdown_wrapped_json(self, brain):
|
|
158
|
+
content = '```json\n{"result": {"action": "input", "locator_type": "css", "locator_value": "#email"}}\n```'
|
|
159
|
+
brain.text_client.chat.completions.create.return_value = self._make_llm_response(content)
|
|
160
|
+
|
|
161
|
+
result = brain.get_action("type in email", '{"ui_elements": []}', "web", skip_cache=True)
|
|
162
|
+
assert result["action"] == "input"
|
|
163
|
+
|
|
164
|
+
def test_returns_empty_on_parse_failure(self, brain):
|
|
165
|
+
brain.text_client.chat.completions.create.return_value = self._make_llm_response("not json at all")
|
|
166
|
+
|
|
167
|
+
result = brain.get_action("do something", '{"ui_elements": []}', "web", skip_cache=True)
|
|
168
|
+
assert result == {}
|
|
169
|
+
|
|
170
|
+
def test_returns_empty_on_api_error(self, brain):
|
|
171
|
+
brain.text_client.chat.completions.create.side_effect = Exception("connection timeout")
|
|
172
|
+
|
|
173
|
+
result = brain.get_action("do something", '{"ui_elements": []}', "web", skip_cache=True)
|
|
174
|
+
assert result == {}
|
|
175
|
+
|
|
176
|
+
def test_requests_json_mode_when_supported(self, brain):
|
|
177
|
+
result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "Login"}})
|
|
178
|
+
brain.text_client.chat.completions.create.return_value = self._make_llm_response(result_json)
|
|
179
|
+
|
|
180
|
+
brain.get_action("click login", '{"ui_elements": []}', "web", skip_cache=True)
|
|
181
|
+
_, kwargs = brain.text_client.chat.completions.create.call_args
|
|
182
|
+
assert kwargs.get("response_format") == {"type": "json_object"}
|
|
183
|
+
|
|
184
|
+
def test_falls_back_when_endpoint_rejects_json_mode(self, brain):
|
|
185
|
+
import common.ai as ai_mod
|
|
186
|
+
ai_mod._JSON_MODE_UNSUPPORTED.clear()
|
|
187
|
+
result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "Go"}})
|
|
188
|
+
good = self._make_llm_response(result_json)
|
|
189
|
+
|
|
190
|
+
def create(**kwargs):
|
|
191
|
+
if "response_format" in kwargs:
|
|
192
|
+
raise Exception("400: response_format is not supported by this model")
|
|
193
|
+
return good
|
|
194
|
+
|
|
195
|
+
brain.text_client.chat.completions.create.side_effect = create
|
|
196
|
+
result = brain.get_action("click go", '{"ui_elements": []}', "web", skip_cache=True)
|
|
197
|
+
assert result["action"] == "click" # fallback (no response_format) succeeded
|
|
198
|
+
ai_mod._JSON_MODE_UNSUPPORTED.clear()
|
|
199
|
+
|
|
200
|
+
def test_uses_vision_client_when_screenshot_provided(self, brain):
|
|
201
|
+
result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "OK"}})
|
|
202
|
+
brain.vision_client.chat.completions.create.return_value = self._make_llm_response(result_json)
|
|
203
|
+
|
|
204
|
+
result = brain.get_action(
|
|
205
|
+
"click ok", '{"ui_elements": []}', "web",
|
|
206
|
+
screenshot_base64="base64data", skip_cache=True,
|
|
207
|
+
)
|
|
208
|
+
assert result["action"] == "click"
|
|
209
|
+
brain.vision_client.chat.completions.create.assert_called_once()
|
|
210
|
+
brain.text_client.chat.completions.create.assert_not_called()
|