feat(qw1): MonitorRouter — résolution de l'écran cible pour le replay

Module isolé qui choisit l'écran cible avec stratégie en cascade :
1. action.monitor_index (session source) → cible explicite
2. session.last_focused_monitor → fallback focus actif
3. composite (offset 0,0) → backward compat (comportement actuel)

Backward 100% : actions sans monitor_index → fallback composite identique
au comportement mss.monitors[0] actuel.

Tests : 4 cas (cible OK, fallback focus, fallback composite, index invalide).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-05-05 22:50:22 +02:00
parent 5543e25f9d
commit 6582a69d31
2 changed files with 139 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# agent_v0/server_v1/monitor_router.py
"""MonitorRouter — résolution de l'écran cible pour le replay (QW1).
Stratégie en cascade :
1. action.monitor_index (hérité de la session source) → cible cet écran
2. session.last_focused_monitor (focus actif vu en dernier heartbeat) → fallback
3. composite (offset 0, 0) → backward compat
Émet sur le bus lea:* l'event monitor_routed avec la source de la décision.
"""
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
@dataclass
class MonitorTarget:
"""Représente l'écran cible résolu pour une action de replay."""
idx: int
offset_x: int
offset_y: int
w: int
h: int
source: str # "action" | "focus" | "composite_fallback"
_COMPOSITE_FALLBACK = MonitorTarget(
idx=-1,
offset_x=0,
offset_y=0,
w=0,
h=0,
source="composite_fallback",
)
def _find_monitor(geometry: List[Dict[str, Any]], idx: int) -> Optional[Dict[str, Any]]:
"""Retourne le monitor d'index donné, ou None si absent."""
for m in geometry:
if m.get("idx") == idx:
return m
return None
def _to_target(monitor: Dict[str, Any], source: str) -> MonitorTarget:
return MonitorTarget(
idx=int(monitor["idx"]),
offset_x=int(monitor.get("x", 0)),
offset_y=int(monitor.get("y", 0)),
w=int(monitor.get("w", 0)),
h=int(monitor.get("h", 0)),
source=source,
)
def resolve_target_monitor(
action: Dict[str, Any],
session_state: Dict[str, Any],
) -> MonitorTarget:
"""Résout l'écran cible d'une action de replay.
Args:
action: Dict de l'action (peut contenir `monitor_index`).
session_state: État de la session (doit contenir `monitors_geometry`
et `last_focused_monitor`).
Returns:
MonitorTarget avec l'offset à appliquer aux coordonnées de grounding.
"""
geometry: List[Dict[str, Any]] = session_state.get("monitors_geometry") or []
# 1. Cible explicite via action
explicit_idx = action.get("monitor_index")
if explicit_idx is not None and geometry:
m = _find_monitor(geometry, int(explicit_idx))
if m is not None:
return _to_target(m, source="action")
# Index invalide → on tombe sur le fallback focus
# 2. Fallback focus actif
focused_idx = session_state.get("last_focused_monitor")
if focused_idx is not None and geometry:
m = _find_monitor(geometry, int(focused_idx))
if m is not None:
return _to_target(m, source="focus")
# 3. Fallback composite (backward compat — comportement actuel mss.monitors[0])
return _COMPOSITE_FALLBACK

View File

@@ -0,0 +1,51 @@
# tests/unit/test_monitor_router.py
"""Tests unitaires pour MonitorRouter (QW1)."""
import pytest
from agent_v0.server_v1.monitor_router import resolve_target_monitor, MonitorTarget
# Geometry de référence pour les 3 tests : 2 écrans côte à côte
TWO_MONITORS = [
{"idx": 0, "x": 0, "y": 0, "w": 1920, "h": 1080, "primary": True},
{"idx": 1, "x": 1920, "y": 0, "w": 1920, "h": 1080, "primary": False},
]
def test_resolve_uses_action_monitor_index_when_present():
"""Si action.monitor_index présent et valide → cible cet écran."""
action = {"monitor_index": 1}
session_state = {"monitors_geometry": TWO_MONITORS, "last_focused_monitor": 0}
result = resolve_target_monitor(action, session_state)
assert result.idx == 1
assert result.offset_x == 1920
assert result.offset_y == 0
assert result.source == "action"
def test_resolve_falls_back_to_focused_monitor_when_action_missing():
"""Si action.monitor_index absent → fallback focus actif."""
action = {} # pas de monitor_index
session_state = {"monitors_geometry": TWO_MONITORS, "last_focused_monitor": 1}
result = resolve_target_monitor(action, session_state)
assert result.idx == 1
assert result.source == "focus"
def test_resolve_falls_back_to_composite_when_geometry_empty():
"""Si geometry vide (vieux Agent V1) → fallback composite (idx=-1, offset=0)."""
action = {}
session_state = {"monitors_geometry": [], "last_focused_monitor": None}
result = resolve_target_monitor(action, session_state)
assert result.source == "composite_fallback"
assert result.offset_x == 0
assert result.offset_y == 0
def test_resolve_falls_back_when_action_index_out_of_range():
"""Si action.monitor_index hors limites (écran débranché) → fallback focus."""
action = {"monitor_index": 5} # n'existe pas
session_state = {"monitors_geometry": TWO_MONITORS, "last_focused_monitor": 0}
result = resolve_target_monitor(action, session_state)
assert result.idx == 0
assert result.source == "focus"