snapshot: WIP 5j replay reliability (B1 watchdog + dialog handlers + grounding drift)
Snapshot avant correction du blocage relance Léa (3 incidents 24h: SSH refusé, polls morts ×2). Point de rollback stable. Contenu: - agent_v1/core/executor.py: 5 patchs dialog handling (saveas drift, close_tab hotkey fallback, confirm_save Unicode apostrophe, foreground dialog recontextualization, runtime_dialog in-loop) + helpers normalize_window_hint, requires_post_verify_window_transition - agent_v1/core/grounding.py: garde drift template fix (fallback_x/y plumbed) - server_v1/replay_watchdog.py (NEW): orphan watchdog B1, scan 10s timeout 30s - server_v1/api_stream.py: dispatched_action plumbing, watchdog lifespan, metrics endpoint - server_v1/replay_engine.py: _schedule_retry préserve original_action + dispatched_action - stream_processor.py: gardes _infer_tab_switch_target (no false switch_tab on save_as dialog open) + _attach_expected_window_before - tests/integration: test_replay_watchdog.py (8 cas), test_stream_processor.py - tests/unit: test_executor_verify_window_guard.py (start_button, close_tab, runtime_dialog, post_verify, transition fallbacks) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,31 @@ class TestStreamerEndpoints:
|
||||
_, kwargs = finalize_calls[0]
|
||||
assert kwargs["params"]["session_id"] == "sess_test_002"
|
||||
|
||||
def test_finalize_callback_receives_server_payload(self):
|
||||
"""Le payload enrichi de /finalize est remonté au callback client."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
payload = {
|
||||
"status": "queued_for_processing",
|
||||
"replay_ready": True,
|
||||
"replay_request": {
|
||||
"endpoint": "/api/v1/traces/stream/replay-session",
|
||||
"session_id": "sess_test_008",
|
||||
"machine_id": "pc-alpha",
|
||||
},
|
||||
}
|
||||
seen = []
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True, json=lambda: payload)
|
||||
streamer = TraceStreamer("sess_test_008")
|
||||
streamer.set_on_finalize_result(seen.append)
|
||||
streamer._server_available = True
|
||||
streamer.running = False
|
||||
streamer._finalize_session()
|
||||
|
||||
assert seen == [payload]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Payload formats
|
||||
|
||||
134
tests/integration/test_finalize_replay_chain.py
Normal file
134
tests/integration/test_finalize_replay_chain.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Tests du chainage produit finalize -> replay-session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
class TestFinalizeReplayChain:
|
||||
_TEST_API_TOKEN = "test_finalize_replay_chain_token_0123456789"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_api_token(self, monkeypatch):
|
||||
monkeypatch.setenv("RPA_API_TOKEN", self._TEST_API_TOKEN)
|
||||
api_stream_mod = sys.modules.get("agent_v0.server_v1.api_stream")
|
||||
if api_stream_mod is not None:
|
||||
monkeypatch.setattr(api_stream_mod, "API_TOKEN", self._TEST_API_TOKEN)
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, tmp_path, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from agent_v0.server_v1 import api_stream
|
||||
from agent_v0.server_v1.stream_processor import StreamProcessor
|
||||
from agent_v0.server_v1.worker_stream import StreamWorker
|
||||
|
||||
original_processor = api_stream.processor
|
||||
original_worker = api_stream.worker
|
||||
test_processor = StreamProcessor(data_dir=str(tmp_path))
|
||||
api_stream.processor = test_processor
|
||||
api_stream.worker = StreamWorker(
|
||||
live_dir=str(tmp_path),
|
||||
processor=test_processor,
|
||||
)
|
||||
monkeypatch.setattr(api_stream, "_enqueue_to_worker", lambda session_id: None)
|
||||
|
||||
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
||||
yield client, api_stream, test_processor, api_stream.API_TOKEN
|
||||
|
||||
api_stream.processor = original_processor
|
||||
api_stream.worker = original_worker
|
||||
|
||||
def test_finalize_exposes_replay_request_without_launch(self, client):
|
||||
c, _, proc, token = client
|
||||
proc.session_manager.register_session("sess_final_001", machine_id="pc-alpha")
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/traces/stream/finalize",
|
||||
params={"session_id": "sess_final_001"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "queued_for_processing"
|
||||
assert data["replay_ready"] is True
|
||||
assert data["replay_request"] == {
|
||||
"endpoint": "/api/v1/traces/stream/replay-session",
|
||||
"session_id": "sess_final_001",
|
||||
"machine_id": "pc-alpha",
|
||||
}
|
||||
assert "replay_launch" not in data
|
||||
|
||||
def test_finalize_can_launch_replay_session(self, client, monkeypatch):
|
||||
c, api_stream, proc, token = client
|
||||
proc.session_manager.register_session("sess_final_002", machine_id="pc-beta")
|
||||
calls = []
|
||||
|
||||
async def fake_replay_from_session(session_id: str, machine_id: str = "default"):
|
||||
calls.append((session_id, machine_id))
|
||||
return {
|
||||
"replay_id": "replay_sess_1234abcd",
|
||||
"status": "running",
|
||||
"source_session_id": session_id,
|
||||
"target_session_id": "agent_demo",
|
||||
"machine_id": machine_id,
|
||||
"total_actions": 7,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(api_stream, "replay_from_session", fake_replay_from_session)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/traces/stream/finalize",
|
||||
params={
|
||||
"session_id": "sess_final_002",
|
||||
"launch_replay": "true",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert calls == [("sess_final_002", "pc-beta")]
|
||||
assert data["replay_launch"]["status"] == "started"
|
||||
assert data["replay_launch"]["replay"]["replay_id"] == "replay_sess_1234abcd"
|
||||
assert data["replay_launch"]["replay"]["source_session_id"] == "sess_final_002"
|
||||
assert data["replay_launch"]["replay"]["machine_id"] == "pc-beta"
|
||||
|
||||
def test_finalize_remains_successful_if_auto_replay_fails(self, client, monkeypatch):
|
||||
c, api_stream, proc, token = client
|
||||
proc.session_manager.register_session("sess_final_003", machine_id="pc-gamma")
|
||||
|
||||
async def fake_replay_from_session(session_id: str, machine_id: str = "default"):
|
||||
raise api_stream.HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Aucune session Agent V1 active sur {machine_id}",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(api_stream, "replay_from_session", fake_replay_from_session)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/traces/stream/finalize",
|
||||
params={
|
||||
"session_id": "sess_final_003",
|
||||
"launch_replay": "true",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "queued_for_processing"
|
||||
assert data["replay_launch"] == {
|
||||
"status": "failed",
|
||||
"status_code": 404,
|
||||
"detail": "Aucune session Agent V1 active sur pc-gamma",
|
||||
}
|
||||
assert data["replay_request"]["machine_id"] == "pc-gamma"
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests intégration : /replay/resume doit réinjecter l'action complète en pause."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
class TestReplayResumePreservesOriginalAction:
|
||||
_TEST_API_TOKEN = "test_replay_resume_preserves_original_action_token"
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_api_token(self, monkeypatch):
|
||||
monkeypatch.setenv("RPA_API_TOKEN", self._TEST_API_TOKEN)
|
||||
api_stream_mod = sys.modules.get("agent_v0.server_v1.api_stream")
|
||||
if api_stream_mod is not None:
|
||||
monkeypatch.setattr(api_stream_mod, "API_TOKEN", self._TEST_API_TOKEN)
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
from agent_v0.server_v1 import api_stream
|
||||
|
||||
monkeypatch.setattr(api_stream, "API_TOKEN", self._TEST_API_TOKEN)
|
||||
|
||||
saved_states = dict(api_stream._replay_states)
|
||||
saved_queues = dict(api_stream._replay_queues)
|
||||
saved_retry = dict(api_stream._retry_pending)
|
||||
|
||||
api_stream._replay_states.clear()
|
||||
api_stream._replay_queues.clear()
|
||||
api_stream._retry_pending.clear()
|
||||
|
||||
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
||||
yield client, api_stream, self._TEST_API_TOKEN
|
||||
|
||||
api_stream._replay_states.clear()
|
||||
api_stream._replay_states.update(saved_states)
|
||||
api_stream._replay_queues.clear()
|
||||
api_stream._replay_queues.update(saved_queues)
|
||||
api_stream._retry_pending.clear()
|
||||
api_stream._retry_pending.update(saved_retry)
|
||||
|
||||
def test_resume_reinjects_full_original_action_from_failed_action(self, client):
|
||||
http_client, api_stream, token = client
|
||||
|
||||
original_action = {
|
||||
"action_id": "act_raw_75272d22",
|
||||
"type": "click",
|
||||
"visual_mode": True,
|
||||
"x_pct": 0.8781,
|
||||
"y_pct": 0.9856,
|
||||
"expected_window_before": "http192.168.1.408765dossier.htmlid=.txt – Bloc-notes",
|
||||
"target_spec": {
|
||||
"window_title": "http192.168.1.408765dossier.htmlid=.txt – Bloc-notes",
|
||||
"by_role": "yolo",
|
||||
},
|
||||
}
|
||||
|
||||
api_stream._replay_states["replay_xyz"] = {
|
||||
"replay_id": "replay_xyz",
|
||||
"session_id": "sess_resume_xyz",
|
||||
"machine_id": "pc-alpha",
|
||||
"status": "paused_need_help",
|
||||
"failed_action": {
|
||||
"action_id": "act_raw_75272d22",
|
||||
"type": "click",
|
||||
"reason": "wrong_window",
|
||||
"target_spec": original_action["target_spec"],
|
||||
"original_action": original_action,
|
||||
},
|
||||
"pause_message": "Replay en pause",
|
||||
"safety_checks": [],
|
||||
"checks_acknowledged": [],
|
||||
"params": {},
|
||||
}
|
||||
api_stream._replay_queues["sess_resume_xyz"] = []
|
||||
|
||||
resp = http_client.post(
|
||||
"/api/v1/traces/stream/replay/replay_xyz/resume",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "resumed"
|
||||
|
||||
reinjected = api_stream._replay_queues["sess_resume_xyz"][0]
|
||||
assert reinjected["action_id"] == "act_raw_75272d22_resume"
|
||||
assert reinjected["x_pct"] == pytest.approx(0.8781)
|
||||
assert reinjected["y_pct"] == pytest.approx(0.9856)
|
||||
assert reinjected["expected_window_before"] == (
|
||||
"http192.168.1.408765dossier.htmlid=.txt – Bloc-notes"
|
||||
)
|
||||
assert reinjected["target_spec"]["window_title"] == (
|
||||
"http192.168.1.408765dossier.htmlid=.txt – Bloc-notes"
|
||||
)
|
||||
|
||||
def test_resume_dispatch_backfills_retry_pending_for_watchdog(self, client):
|
||||
http_client, api_stream, token = client
|
||||
|
||||
original_action = {
|
||||
"action_id": "act_resume_01",
|
||||
"type": "click",
|
||||
"visual_mode": True,
|
||||
"x_pct": 0.41,
|
||||
"y_pct": 0.52,
|
||||
"target_spec": {"window_title": "test - Bloc-notes"},
|
||||
}
|
||||
|
||||
api_stream._replay_states["replay_resume_watchdog"] = {
|
||||
"replay_id": "replay_resume_watchdog",
|
||||
"session_id": "sess_resume_watchdog",
|
||||
"machine_id": "pc-watchdog",
|
||||
"status": "paused_need_help",
|
||||
"failed_action": {
|
||||
"action_id": "act_resume_01",
|
||||
"type": "click",
|
||||
"reason": "wrong_window",
|
||||
"target_spec": original_action["target_spec"],
|
||||
"original_action": original_action,
|
||||
},
|
||||
"pause_message": "Replay en pause",
|
||||
"safety_checks": [],
|
||||
"checks_acknowledged": [],
|
||||
"params": {},
|
||||
}
|
||||
api_stream._replay_queues["sess_resume_watchdog"] = []
|
||||
|
||||
resume_resp = http_client.post(
|
||||
"/api/v1/traces/stream/replay/replay_resume_watchdog/resume",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert resume_resp.status_code == 200
|
||||
|
||||
next_resp = http_client.get(
|
||||
"/api/v1/traces/stream/replay/next",
|
||||
params={"session_id": "sess_resume_watchdog", "machine_id": "pc-watchdog"},
|
||||
)
|
||||
|
||||
assert next_resp.status_code == 200
|
||||
payload = next_resp.json()
|
||||
dispatched = payload["action"]
|
||||
assert dispatched["action_id"] == "act_resume_01_resume"
|
||||
|
||||
retry_info = api_stream._retry_pending["act_resume_01_resume"]
|
||||
assert retry_info["action"]["action_id"] == "act_resume_01"
|
||||
assert retry_info["dispatched_action"]["action_id"] == "act_resume_01_resume"
|
||||
assert retry_info["session_id"] == "sess_resume_watchdog"
|
||||
assert retry_info["machine_id"] == "pc-watchdog"
|
||||
assert retry_info["replay_id"] == "replay_resume_watchdog"
|
||||
assert retry_info["first_dispatched_at"] > 0
|
||||
assert retry_info["dispatched_at"] >= retry_info["first_dispatched_at"]
|
||||
151
tests/integration/test_replay_session_trim_neutral.py
Normal file
151
tests/integration/test_replay_session_trim_neutral.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Non-régression — trim du préambule redondant pour /replay-session.
|
||||
|
||||
Bug fixé le 2026-05-20 (cf. ``docs/AUDIT_FINALIZE_CONTRACT_INTEGRATION_2026-05-20.md``
|
||||
et ``CR_AUDIT_PAUSED_RESUME_BUS_2026-05-22.md``) : sur la session source
|
||||
``sess_20260520T102916_066851``, le premier event raw rejoué après le
|
||||
setup auto Windows était un clic intra-Notepad sur la barre d'onglets
|
||||
qui basculait de ``http...txt – Bloc-notes`` vers ``Sans titre – Bloc-notes``.
|
||||
Comme le setup amène déjà Notepad dans ``Sans titre``, ce clic ne
|
||||
modifiait rien à l'écran → `retry_threshold`.
|
||||
|
||||
Ce test reproduit la chaîne complète d'``api_stream.replay-session``
|
||||
côté serveur (sans HTTP) sur une fixture synthétique correspondante,
|
||||
et vérifie que la première action utile post-setup est bien la
|
||||
saisie de texte ``test`` — pas un clic de bascule d'onglet ``Sans titre``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_v0.server_v1.replay_engine import ( # noqa: E402
|
||||
_extract_required_apps_from_events,
|
||||
_generate_setup_actions,
|
||||
_trim_redundant_setup_events,
|
||||
)
|
||||
from agent_v0.server_v1.stream_processor import ( # noqa: E402
|
||||
build_replay_from_raw_events,
|
||||
)
|
||||
|
||||
|
||||
def _make_session_events() -> list:
|
||||
"""Reproduit le pattern de ``sess_20260520T102916_066851`` :
|
||||
Démarrer → Rechercher → Notepad ouvre un fichier .txt → l'utilisateur
|
||||
clique sur l'onglet ``Sans titre`` → tape ``test`` → Ctrl+S.
|
||||
|
||||
L'enregistrement initial passe par un titre non-neutre puis bascule
|
||||
sur un titre neutre — c'est le scénario qui piégeait le trim."""
|
||||
return [
|
||||
# Démarrer
|
||||
{"event": {
|
||||
"type": "window_focus_change",
|
||||
"to": {"app_name": "explorer.exe", "title": "Explorateur"},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "mouse_click", "pos": [50, 1430], "timestamp": 1.0,
|
||||
"window": {"app_name": "explorer.exe", "title": "Explorateur"},
|
||||
}},
|
||||
# SearchHost
|
||||
{"event": {
|
||||
"type": "window_focus_change",
|
||||
"to": {"app_name": "SearchHost.exe", "title": "Rechercher"},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "text_input", "text": "bloc", "timestamp": 2.0,
|
||||
"window": {"app_name": "SearchHost.exe", "title": "Rechercher"},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "mouse_click", "pos": [681, 448], "timestamp": 2.5,
|
||||
"window": {"app_name": "SearchHost.exe", "title": "Rechercher"},
|
||||
}},
|
||||
# Notepad ouvre un fichier .txt existant (non-neutre)
|
||||
{"event": {
|
||||
"type": "window_focus_change",
|
||||
"to": {
|
||||
"app_name": "Notepad.exe",
|
||||
"title": "http192.168.1.408765dossier.htmlid=.txt – Bloc-notes",
|
||||
},
|
||||
}},
|
||||
# Clic dans la barre d'onglets (y=40) → bascule vers Sans titre
|
||||
{"event": {
|
||||
"type": "mouse_click", "pos": [1191, 40], "timestamp": 4.0,
|
||||
"window": {
|
||||
"app_name": "Notepad.exe",
|
||||
"title": "http192.168.1.408765dossier.htmlid=.txt – Bloc-notes",
|
||||
},
|
||||
"window_capture": {"click_relative": [1191, 40]},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "window_focus_change",
|
||||
"to": {"app_name": "Notepad.exe", "title": "Sans titre – Bloc-notes"},
|
||||
}},
|
||||
# Saisie réelle de l'utilisateur — c'est la première action utile
|
||||
{"event": {
|
||||
"type": "text_input", "text": "test", "timestamp": 5.0,
|
||||
"window": {"app_name": "Notepad.exe",
|
||||
"title": "Sans titre – Bloc-notes"},
|
||||
}},
|
||||
]
|
||||
|
||||
|
||||
def test_replay_session_pipeline_skips_redundant_tab_switch(tmp_path):
|
||||
"""Pipeline complet replay-session : setup auto + trim + build doit
|
||||
produire un replay dont la première action post-setup est la saisie
|
||||
``test``, pas le clic de bascule d'onglet ``Sans titre``.
|
||||
"""
|
||||
raw_events = _make_session_events()
|
||||
app_info = _extract_required_apps_from_events(raw_events)
|
||||
|
||||
# 1) Setup auto reconnaît Notepad et génère ses actions
|
||||
assert app_info.get("primary_app") == "Notepad.exe"
|
||||
setup_actions = _generate_setup_actions(app_info, setup_id_prefix="setup_sess")
|
||||
assert setup_actions, "le setup auto doit injecter des actions Notepad"
|
||||
action_ids = {a.get("action_id", "") for a in setup_actions}
|
||||
assert any("click_start" in aid for aid in action_ids)
|
||||
assert any("click_result" in aid for aid in action_ids)
|
||||
|
||||
# 2) Trim : le clic intra-Notepad redondant doit disparaître
|
||||
trimmed = _trim_redundant_setup_events(raw_events, app_info)
|
||||
click_titles = [
|
||||
(ev.get("event") or ev).get("window", {}).get("title", "")
|
||||
for ev in trimmed
|
||||
if (ev.get("event") or ev).get("type") == "mouse_click"
|
||||
]
|
||||
assert not any(
|
||||
"http192.168.1.40" in t for t in click_titles
|
||||
), "le clic intra-Notepad redondant doit être coupé par le trim"
|
||||
|
||||
# 3) Build replay propre : la première action utile post-trim est
|
||||
# la saisie 'test' — pas un click "Sans titre" issu de
|
||||
# _infer_tab_switch_target.
|
||||
actions = build_replay_from_raw_events(
|
||||
trimmed, session_id="sess_synthetic", session_dir=str(tmp_path),
|
||||
)
|
||||
actionable = [a for a in actions if a.get("type") in ("click", "type", "key_combo")]
|
||||
assert actionable, "le replay doit contenir au moins une action utile"
|
||||
|
||||
first = actionable[0]
|
||||
assert first.get("type") == "type", (
|
||||
f"première action utile doit être 'type', pas '{first.get('type')}' "
|
||||
f"(target_spec={first.get('target_spec')})"
|
||||
)
|
||||
assert first.get("text") == "test"
|
||||
|
||||
# Sanity : aucune action click ne doit cibler "Sans titre" (= la
|
||||
# bascule d'onglet inférée par _infer_tab_switch_target) dans le
|
||||
# replay nettoyé.
|
||||
sans_titre_clicks = [
|
||||
a for a in actions
|
||||
if a.get("type") == "click"
|
||||
and a.get("target_spec", {}).get("by_text", "").strip().lower() == "sans titre"
|
||||
]
|
||||
assert not sans_titre_clicks, (
|
||||
"le replay ne doit plus contenir de click ciblant 'Sans titre' "
|
||||
f"(trouvés : {sans_titre_clicks})"
|
||||
)
|
||||
352
tests/integration/test_replay_watchdog.py
Normal file
352
tests/integration/test_replay_watchdog.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Integration tests for the replay orphan watchdog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import importlib
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def fake_lock():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_watchdog_singleton():
|
||||
import agent_v0.server_v1.replay_watchdog as wd_mod
|
||||
|
||||
wd_mod._singleton = None
|
||||
for key in list(wd_mod._metrics.keys()):
|
||||
if isinstance(wd_mod._metrics[key], (int, float)):
|
||||
wd_mod._metrics[key] = 0
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_short_timeout(monkeypatch):
|
||||
monkeypatch.setenv("RPA_WATCHDOG_ENABLED", "1")
|
||||
monkeypatch.setenv("RPA_WATCHDOG_SCAN_INTERVAL_S", "0.1")
|
||||
monkeypatch.setenv("RPA_WATCHDOG_ORPHAN_TIMEOUT_S", "0.2")
|
||||
monkeypatch.setenv("RPA_WATCHDOG_MAX_RESENDS", "2")
|
||||
|
||||
import agent_v0.server_v1.replay_watchdog as wd_mod
|
||||
|
||||
importlib.reload(wd_mod)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_orphan_below_timeout(env_short_timeout):
|
||||
from agent_v0.server_v1.replay_watchdog import ReplayWatchdog
|
||||
|
||||
now = time.time()
|
||||
retry_pending: Dict[str, Dict[str, Any]] = {
|
||||
"act1": {
|
||||
"action": {"action_id": "act1", "type": "click"},
|
||||
"dispatched_action": {"action_id": "act1", "type": "click"},
|
||||
"session_id": "sess1",
|
||||
"machine_id": "m1",
|
||||
"dispatched_at": now,
|
||||
"first_dispatched_at": now,
|
||||
"resent_count": 0,
|
||||
}
|
||||
}
|
||||
replay_queues: Dict[str, List[Dict[str, Any]]] = {"sess1": []}
|
||||
watchdog = ReplayWatchdog(retry_pending, replay_queues, fake_lock)
|
||||
|
||||
result = await watchdog._scan_once()
|
||||
|
||||
assert result == {
|
||||
"orphans": 0,
|
||||
"resent": 0,
|
||||
"gaveup": 0,
|
||||
"skipped": 0,
|
||||
"in_flight": 1,
|
||||
}
|
||||
assert replay_queues["sess1"] == []
|
||||
assert retry_pending["act1"]["resent_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orphan_above_timeout_resent_in_head(env_short_timeout):
|
||||
from agent_v0.server_v1.replay_watchdog import ReplayWatchdog
|
||||
|
||||
action = {"action_id": "act1", "type": "click"}
|
||||
other = {"action_id": "act_next", "type": "click"}
|
||||
retry_pending = {
|
||||
"act1": {
|
||||
"action": {"action_id": "original", "type": "click"},
|
||||
"dispatched_action": action,
|
||||
"session_id": "sess1",
|
||||
"machine_id": "m1",
|
||||
"dispatched_at": time.time() - 5.0,
|
||||
"first_dispatched_at": time.time() - 5.0,
|
||||
"resent_count": 0,
|
||||
}
|
||||
}
|
||||
replay_queues = {"sess1": [other]}
|
||||
watchdog = ReplayWatchdog(retry_pending, replay_queues, fake_lock)
|
||||
|
||||
result = await watchdog._scan_once()
|
||||
|
||||
assert result["resent"] == 1
|
||||
assert replay_queues["sess1"] == [action, other]
|
||||
assert retry_pending["act1"]["resent_count"] == 1
|
||||
assert retry_pending["act1"]["dispatched_at"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveup_after_max_resends(env_short_timeout):
|
||||
from agent_v0.server_v1.replay_watchdog import ReplayWatchdog
|
||||
|
||||
retry_pending = {
|
||||
"act1": {
|
||||
"action": {"action_id": "act1", "type": "click"},
|
||||
"dispatched_action": {"action_id": "act1", "type": "click"},
|
||||
"session_id": "sess1",
|
||||
"machine_id": "m1",
|
||||
"dispatched_at": time.time() - 5.0,
|
||||
"first_dispatched_at": time.time() - 90.0,
|
||||
"resent_count": 2,
|
||||
}
|
||||
}
|
||||
replay_queues = {"sess1": []}
|
||||
watchdog = ReplayWatchdog(retry_pending, replay_queues, fake_lock)
|
||||
|
||||
result = await watchdog._scan_once()
|
||||
|
||||
assert result["gaveup"] == 1
|
||||
assert result["resent"] == 0
|
||||
assert "act1" not in retry_pending
|
||||
assert replay_queues["sess1"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_race_report_arrives_during_scan(env_short_timeout):
|
||||
from agent_v0.server_v1.replay_watchdog import ReplayWatchdog
|
||||
|
||||
retry_pending = {
|
||||
"act1": {
|
||||
"action": {"action_id": "act1", "type": "click"},
|
||||
"dispatched_action": {"action_id": "act1", "type": "click"},
|
||||
"session_id": "sess1",
|
||||
"machine_id": "m1",
|
||||
"dispatched_at": time.time() - 5.0,
|
||||
"first_dispatched_at": time.time() - 5.0,
|
||||
"resent_count": 0,
|
||||
}
|
||||
}
|
||||
replay_queues = {"sess1": []}
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lock_that_pops_before_resend():
|
||||
count = getattr(lock_that_pops_before_resend, "_count", 0) + 1
|
||||
lock_that_pops_before_resend._count = count
|
||||
if count == 2:
|
||||
retry_pending.pop("act1", None)
|
||||
yield
|
||||
|
||||
watchdog = ReplayWatchdog(retry_pending, replay_queues, lock_that_pops_before_resend)
|
||||
result = await watchdog._scan_once()
|
||||
|
||||
assert result["orphans"] == 1
|
||||
assert result["resent"] == 0
|
||||
assert replay_queues["sess1"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_via_env(monkeypatch):
|
||||
monkeypatch.setenv("RPA_WATCHDOG_ENABLED", "0")
|
||||
|
||||
import agent_v0.server_v1.replay_watchdog as wd_mod
|
||||
|
||||
importlib.reload(wd_mod)
|
||||
watchdog = wd_mod.ReplayWatchdog({}, {}, fake_lock)
|
||||
|
||||
await watchdog.start()
|
||||
|
||||
assert watchdog._task is None
|
||||
await watchdog.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifecycle_start_stop_clean(env_short_timeout):
|
||||
from agent_v0.server_v1.replay_watchdog import ReplayWatchdog
|
||||
|
||||
watchdog = ReplayWatchdog({}, {}, fake_lock)
|
||||
await watchdog.start()
|
||||
|
||||
assert watchdog._task is not None
|
||||
assert not watchdog._task.done()
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
await watchdog.stop(timeout_s=2.0)
|
||||
|
||||
assert watchdog._task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_orphan_with_repush_tail(monkeypatch, env_short_timeout):
|
||||
monkeypatch.setenv("RPA_WATCHDOG_REPUSH_POSITION", "tail")
|
||||
|
||||
import agent_v0.server_v1.replay_watchdog as wd_mod
|
||||
|
||||
importlib.reload(wd_mod)
|
||||
from agent_v0.server_v1.replay_watchdog import ReplayWatchdog
|
||||
|
||||
action = {"action_id": "act1", "type": "click"}
|
||||
other = {"action_id": "act_next", "type": "click"}
|
||||
retry_pending = {
|
||||
"act1": {
|
||||
"action": {"action_id": "original", "type": "click"},
|
||||
"dispatched_action": action,
|
||||
"session_id": "sess1",
|
||||
"machine_id": "m1",
|
||||
"dispatched_at": time.time() - 5.0,
|
||||
"first_dispatched_at": time.time() - 5.0,
|
||||
"resent_count": 0,
|
||||
}
|
||||
}
|
||||
replay_queues = {"sess1": [other]}
|
||||
watchdog = ReplayWatchdog(retry_pending, replay_queues, fake_lock)
|
||||
|
||||
await watchdog._scan_once()
|
||||
|
||||
assert replay_queues["sess1"] == [other, action]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_snapshot(env_short_timeout):
|
||||
from agent_v0.server_v1.replay_watchdog import ReplayWatchdog, get_metrics_snapshot
|
||||
|
||||
retry_pending = {
|
||||
"act1": {
|
||||
"action": {"action_id": "act1", "type": "click"},
|
||||
"dispatched_action": {"action_id": "act1", "type": "click"},
|
||||
"session_id": "sess1",
|
||||
"machine_id": "m1",
|
||||
"dispatched_at": time.time() - 5.0,
|
||||
"first_dispatched_at": time.time() - 5.0,
|
||||
"resent_count": 0,
|
||||
}
|
||||
}
|
||||
watchdog = ReplayWatchdog(retry_pending, {"sess1": []}, fake_lock)
|
||||
|
||||
await watchdog._scan_once()
|
||||
snapshot = get_metrics_snapshot()
|
||||
|
||||
assert snapshot["scans_total"] >= 1
|
||||
assert snapshot["orphans_detected_total"] >= 1
|
||||
assert snapshot["orphans_resent_total"] >= 1
|
||||
|
||||
|
||||
def test_default_orphan_timeout_matches_spec(monkeypatch):
|
||||
monkeypatch.delenv("RPA_WATCHDOG_ORPHAN_TIMEOUT_S", raising=False)
|
||||
|
||||
import agent_v0.server_v1.replay_watchdog as wd_mod
|
||||
|
||||
importlib.reload(wd_mod)
|
||||
|
||||
assert wd_mod.WATCHDOG_ORPHAN_TIMEOUT_S == 45.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_late_report_clears_resent_duplicate_from_queue(monkeypatch):
|
||||
monkeypatch.setenv("RPA_API_TOKEN", "test_replay_watchdog_token")
|
||||
|
||||
from agent_v0.server_v1 import api_stream
|
||||
|
||||
monkeypatch.setattr(api_stream, "API_TOKEN", "test_replay_watchdog_token")
|
||||
|
||||
saved_states = dict(api_stream._replay_states)
|
||||
saved_queues = dict(api_stream._replay_queues)
|
||||
saved_retry = dict(api_stream._retry_pending)
|
||||
|
||||
api_stream._replay_states.clear()
|
||||
api_stream._replay_queues.clear()
|
||||
api_stream._retry_pending.clear()
|
||||
|
||||
try:
|
||||
action = {
|
||||
"action_id": "act_setup_sess_click_start",
|
||||
"type": "click",
|
||||
"visual_mode": True,
|
||||
"x_pct": 0.387891,
|
||||
"y_pct": 0.974375,
|
||||
"_setup_phase": True,
|
||||
"target_spec": {"by_role": "start_button"},
|
||||
}
|
||||
next_action = {"action_id": "act_setup_sess_wait_start", "type": "wait"}
|
||||
replay_id = "replay_watchdog_dup"
|
||||
session_id = "sess_watchdog_dup"
|
||||
now = time.time()
|
||||
|
||||
api_stream._replay_states[replay_id] = {
|
||||
"replay_id": replay_id,
|
||||
"workflow_id": "session_replay:test",
|
||||
"session_id": session_id,
|
||||
"machine_id": "pc-watchdog",
|
||||
"status": "running",
|
||||
"total_actions": 2,
|
||||
"completed_actions": 0,
|
||||
"failed_actions": 0,
|
||||
"current_action_index": 0,
|
||||
"params": {},
|
||||
"results": [],
|
||||
"actions": [action, next_action],
|
||||
"retried_actions": 0,
|
||||
"unverified_actions": 0,
|
||||
"error_log": [],
|
||||
"last_screenshot": None,
|
||||
"failed_action": None,
|
||||
"pause_message": None,
|
||||
"variables": {},
|
||||
"safety_checks": [],
|
||||
"checks_acknowledged": [],
|
||||
"pause_reason": "",
|
||||
"pause_payload": None,
|
||||
}
|
||||
api_stream._replay_queues[session_id] = [dict(action), dict(next_action)]
|
||||
api_stream._retry_pending[action["action_id"]] = {
|
||||
"action": dict(action),
|
||||
"dispatched_action": dict(action),
|
||||
"retry_count": 0,
|
||||
"replay_id": replay_id,
|
||||
"session_id": session_id,
|
||||
"machine_id": "pc-watchdog",
|
||||
"dispatched_at": now,
|
||||
"first_dispatched_at": now - 5.0,
|
||||
"resent_count": 1,
|
||||
"last_resent_at": now - 1.0,
|
||||
}
|
||||
|
||||
report = api_stream.ReplayResultReport(
|
||||
session_id=session_id,
|
||||
action_id=action["action_id"],
|
||||
success=True,
|
||||
warning="start_button_hotkey_fallback",
|
||||
resolution_method="semantic_start_button_hotkey",
|
||||
resolution_score=1.0,
|
||||
)
|
||||
|
||||
result = await api_stream.report_action_result(report)
|
||||
|
||||
assert result["status"] == "recorded"
|
||||
assert [a["action_id"] for a in api_stream._replay_queues[session_id]] == [
|
||||
"act_setup_sess_wait_start"
|
||||
]
|
||||
assert action["action_id"] not in api_stream._retry_pending
|
||||
assert api_stream._replay_states[replay_id]["completed_actions"] == 1
|
||||
assert api_stream._replay_states[replay_id]["current_action_index"] == 1
|
||||
finally:
|
||||
api_stream._replay_states.clear()
|
||||
api_stream._replay_states.update(saved_states)
|
||||
api_stream._replay_queues.clear()
|
||||
api_stream._replay_queues.update(saved_queues)
|
||||
api_stream._retry_pending.clear()
|
||||
api_stream._retry_pending.update(saved_retry)
|
||||
@@ -112,6 +112,58 @@ class TestLiveSessionManager:
|
||||
assert len(raw["screenshots"]) == 1
|
||||
assert raw["screenshots"][0]["screenshot_id"] == "shot_full_001"
|
||||
|
||||
def test_discovers_bg_session_machine_id_from_root_folder(self, tmp_path):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
|
||||
live_dir = tmp_path / "live_sessions"
|
||||
session_dir = live_dir / "bg_DESKTOP-58D5CAC_windows"
|
||||
session_dir.mkdir(parents=True)
|
||||
(session_dir / "live_events.jsonl").write_text("{}", encoding="utf-8")
|
||||
|
||||
mgr = LiveSessionManager(
|
||||
persist_dir=str(tmp_path / "persist"),
|
||||
live_sessions_dir=str(live_dir),
|
||||
)
|
||||
|
||||
session = mgr.get_session("bg_DESKTOP-58D5CAC_windows")
|
||||
assert session is not None
|
||||
assert session.machine_id == "DESKTOP-58D5CAC_windows"
|
||||
|
||||
def test_loads_persisted_bg_session_with_machine_id_inferred(self, tmp_path):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
|
||||
persist_dir = tmp_path / "persist"
|
||||
persist_dir.mkdir()
|
||||
(persist_dir / "bg_DESKTOP-58D5CAC_windows.json").write_text(
|
||||
'{"session_id":"bg_DESKTOP-58D5CAC_windows","machine_id":"default",'
|
||||
'"events":[],"shot_paths":{},"last_window_info":{"title":"Unknown","app_name":"unknown"},'
|
||||
'"created_at":"2026-05-20T14:00:00","last_activity":"2026-05-20T14:00:00",'
|
||||
'"finalized":false,"window_titles_seen":{},"app_names_seen":{}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
mgr = LiveSessionManager(persist_dir=str(persist_dir))
|
||||
|
||||
session = mgr.get_session("bg_DESKTOP-58D5CAC_windows")
|
||||
assert session is not None
|
||||
assert session.machine_id == "DESKTOP-58D5CAC_windows"
|
||||
|
||||
def test_find_active_agent_session_falls_back_to_bg_machine_session(self, tmp_path):
|
||||
from agent_v0.server_v1.live_session_manager import LiveSessionManager
|
||||
from agent_v0.server_v1.replay_engine import _find_active_agent_session
|
||||
|
||||
mgr = LiveSessionManager(persist_dir=str(tmp_path / "persist"))
|
||||
mgr.register_session(
|
||||
"sess_20260520T102916_066851",
|
||||
machine_id="DESKTOP-58D5CAC_windows",
|
||||
)
|
||||
mgr.finalize("sess_20260520T102916_066851")
|
||||
mgr.register_session("bg_DESKTOP-58D5CAC_windows")
|
||||
|
||||
active = _find_active_agent_session(mgr, machine_id="DESKTOP-58D5CAC_windows")
|
||||
|
||||
assert active == "bg_DESKTOP-58D5CAC_windows"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# StreamProcessor
|
||||
@@ -195,6 +247,238 @@ class TestStreamProcessor:
|
||||
assert stats["total_workflows"] == 0
|
||||
assert stats["initialized"] is False
|
||||
|
||||
def test_build_replay_does_not_compile_save_dialog_open_as_switch_tab(
|
||||
self, tmp_path, monkeypatch,
|
||||
):
|
||||
"""`Enregistrer sous` same-app n'est pas un onglet.
|
||||
|
||||
Régression live 2026-05-23 : un clic menu dans Notepad était
|
||||
recompilé en faux `switch_tab`, ce qui injectait un clic parasite
|
||||
avant la vraie ouverture de dialog.
|
||||
"""
|
||||
from agent_v0.server_v1 import stream_processor as sp
|
||||
|
||||
session_dir = tmp_path / "sess"
|
||||
(session_dir / "shots").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(sp, "_load_crop_for_event", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
sp,
|
||||
"enrich_click_from_screenshot",
|
||||
lambda *args, **kwargs: {"anchor_image_base64": "abc123", "by_role": "yolo"},
|
||||
)
|
||||
monkeypatch.setattr(sp, "_attach_expected_screenshots", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(sp, "_enrich_actions_with_intentions", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(sp, "_unload_gemma4", lambda *args, **kwargs: None)
|
||||
|
||||
events = [
|
||||
{"event": {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1.0,
|
||||
"pos": [820, 630],
|
||||
"button": "left",
|
||||
"screenshot_id": "shot_001",
|
||||
"window": {"title": "*test – Bloc-notes", "app_name": "Notepad.exe"},
|
||||
"window_capture": {
|
||||
"rect": [320, 520, 2240, 1636],
|
||||
"click_relative": [500, 110],
|
||||
"window_size": [1920, 1116],
|
||||
},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1.2,
|
||||
"pos": [860, 562],
|
||||
"button": "left",
|
||||
"screenshot_id": "shot_002",
|
||||
"window": {"title": "*test – Bloc-notes", "app_name": "Notepad.exe"},
|
||||
"window_capture": {
|
||||
"rect": [320, 520, 2240, 1636],
|
||||
"click_relative": [540, 40],
|
||||
"window_size": [1920, 1116],
|
||||
},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "window_focus_change",
|
||||
"timestamp": 1.35,
|
||||
"from": {"title": "*test – Bloc-notes", "app_name": "Notepad.exe"},
|
||||
"to": {"title": "Enregistrer sous", "app_name": "Notepad.exe"},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1.6,
|
||||
"pos": [997, 743],
|
||||
"button": "left",
|
||||
"screenshot_id": "shot_003",
|
||||
"window": {"title": "Enregistrer sous", "app_name": "Notepad.exe"},
|
||||
}},
|
||||
]
|
||||
|
||||
actions = sp.build_replay_from_raw_events(
|
||||
events, session_id="sess_save_dialog", session_dir=str(session_dir),
|
||||
)
|
||||
|
||||
clicks = [a for a in actions if a.get("type") == "click"]
|
||||
assert len(clicks) == 3
|
||||
assert all(
|
||||
(c.get("target_spec", {}).get("context_hints") or {}).get("interaction") != "switch_tab"
|
||||
for c in clicks
|
||||
)
|
||||
assert clicks[1].get("expected_window_title") == "Enregistrer sous"
|
||||
assert clicks[2].get("expected_window_before") == "Enregistrer sous"
|
||||
|
||||
def test_build_replay_tab_switch_focus_belongs_to_latest_click_only(
|
||||
self, tmp_path, monkeypatch,
|
||||
):
|
||||
"""Le focus d'onglet doit être rattaché au dernier clic causal."""
|
||||
from agent_v0.server_v1 import stream_processor as sp
|
||||
|
||||
session_dir = tmp_path / "sess"
|
||||
(session_dir / "shots").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(sp, "_load_crop_for_event", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
sp,
|
||||
"enrich_click_from_screenshot",
|
||||
lambda *args, **kwargs: {"anchor_image_base64": "abc123", "by_role": "yolo"},
|
||||
)
|
||||
monkeypatch.setattr(sp, "_attach_expected_screenshots", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(sp, "_enrich_actions_with_intentions", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(sp, "_unload_gemma4", lambda *args, **kwargs: None)
|
||||
|
||||
events = [
|
||||
{"event": {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1.0,
|
||||
"pos": [1410, 562],
|
||||
"button": "left",
|
||||
"screenshot_id": "shot_001",
|
||||
"window": {
|
||||
"title": "http192.168.1.408765dossier.htmlid=.txt – Bloc-notes",
|
||||
"app_name": "Notepad.exe",
|
||||
},
|
||||
"window_capture": {
|
||||
"rect": [323, 522, 2243, 1638],
|
||||
"click_relative": [1087, 40],
|
||||
"window_size": [1920, 1116],
|
||||
},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1.1,
|
||||
"pos": [1514, 562],
|
||||
"button": "left",
|
||||
"screenshot_id": "shot_002",
|
||||
"window": {
|
||||
"title": "http192.168.1.408765dossier.htmlid=.txt – Bloc-notes",
|
||||
"app_name": "Notepad.exe",
|
||||
},
|
||||
"window_capture": {
|
||||
"rect": [323, 522, 2243, 1638],
|
||||
"click_relative": [1191, 40],
|
||||
"window_size": [1920, 1116],
|
||||
},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "window_focus_change",
|
||||
"timestamp": 1.2,
|
||||
"from": {
|
||||
"title": "http192.168.1.408765dossier.htmlid=.txt – Bloc-notes",
|
||||
"app_name": "Notepad.exe",
|
||||
},
|
||||
"to": {
|
||||
"title": "Sans titre – Bloc-notes",
|
||||
"app_name": "Notepad.exe",
|
||||
},
|
||||
}},
|
||||
]
|
||||
|
||||
actions = sp.build_replay_from_raw_events(
|
||||
events,
|
||||
session_id="sess_intervening_click",
|
||||
session_dir=str(session_dir),
|
||||
)
|
||||
|
||||
assert len(actions) == 2
|
||||
first_hints = actions[0].get("target_spec", {}).get("context_hints") or {}
|
||||
second_hints = actions[1].get("target_spec", {}).get("context_hints") or {}
|
||||
|
||||
assert first_hints.get("interaction") != "switch_tab"
|
||||
assert actions[1]["target_spec"]["by_text"] == "Sans titre"
|
||||
assert actions[1]["target_spec"]["by_role"] == "tab"
|
||||
assert second_hints.get("interaction") == "switch_tab"
|
||||
|
||||
def test_build_replay_infers_close_tab_before_save_dialog(
|
||||
self, tmp_path, monkeypatch,
|
||||
):
|
||||
"""Le clic sur le x d'onglet actif doit être sémantisé comme close_tab."""
|
||||
from agent_v0.server_v1 import stream_processor as sp
|
||||
|
||||
session_dir = tmp_path / "sess"
|
||||
(session_dir / "shots").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(sp, "_load_crop_for_event", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
sp,
|
||||
"enrich_click_from_screenshot",
|
||||
lambda *args, **kwargs: {"anchor_image_base64": "abc123", "by_role": "yolo"},
|
||||
)
|
||||
monkeypatch.setattr(sp, "_attach_expected_screenshots", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(sp, "_enrich_actions_with_intentions", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(sp, "_unload_gemma4", lambda *args, **kwargs: None)
|
||||
|
||||
events = [
|
||||
{"event": {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1.0,
|
||||
"pos": [1814, 560],
|
||||
"button": "left",
|
||||
"screenshot_id": "shot_001",
|
||||
"window": {"title": "*test – Bloc-notes", "app_name": "Notepad.exe"},
|
||||
"window_capture": {
|
||||
"rect": [323, 522, 2243, 1638],
|
||||
"click_relative": [1491, 38],
|
||||
"window_size": [1920, 1116],
|
||||
},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "mouse_click",
|
||||
"timestamp": 1.3,
|
||||
"pos": [1183, 1156],
|
||||
"button": "left",
|
||||
"screenshot_id": "shot_002",
|
||||
"window": {"title": "*test – Bloc-notes", "app_name": "Notepad.exe"},
|
||||
"window_capture": {
|
||||
"rect": [323, 522, 2243, 1638],
|
||||
"click_relative": [860, 634],
|
||||
"window_size": [1920, 1116],
|
||||
},
|
||||
}},
|
||||
{"event": {
|
||||
"type": "window_focus_change",
|
||||
"timestamp": 1.5,
|
||||
"from": {"title": "*test – Bloc-notes", "app_name": "Notepad.exe"},
|
||||
"to": {"title": "Enregistrer sous", "app_name": "Notepad.exe"},
|
||||
}},
|
||||
]
|
||||
|
||||
actions = sp.build_replay_from_raw_events(
|
||||
events,
|
||||
session_id="sess_close_tab",
|
||||
session_dir=str(session_dir),
|
||||
)
|
||||
|
||||
clicks = [a for a in actions if a.get("type") == "click"]
|
||||
assert len(clicks) == 2
|
||||
first_spec = clicks[0].get("target_spec", {})
|
||||
first_hints = first_spec.get("context_hints") or {}
|
||||
|
||||
assert first_spec.get("by_role") == "tab_close_button"
|
||||
assert first_spec.get("by_text", "") == ""
|
||||
assert first_hints.get("interaction") == "close_tab"
|
||||
assert first_hints.get("active_tab_label") == "test"
|
||||
assert "fermer l'onglet actif 'test'" in first_spec.get("vlm_description", "")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# StreamWorker
|
||||
|
||||
Reference in New Issue
Block a user