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:
Dom
2026-05-24 16:48:37 +02:00
parent 5ea4960e65
commit 7df51d2c79
47 changed files with 9811 additions and 451 deletions

View 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})"
)