diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index 5dbada0f0..c24792a41 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -2853,6 +2853,35 @@ async def get_next_action(session_id: str, machine_id: str = "default"): # Peek à la prochaine action SANS la retirer (pour le pre-check) action = queue[0] + # ── pause_for_human : interception avant exécution ── + # Cette action n'est jamais transmise à l'Agent V1. Elle bascule + # le replay en paused_need_help avec le message custom, ce qui + # déclenche la bulle interactive ChatWindow (J3.5). + if action.get("type") == "pause_for_human" and owning_replay is not None: + params = action.get("parameters") or {} + message = params.get("message") or "Validation requise" + queue.pop(0) + _replay_queues[session_id] = queue + owning_replay["status"] = "paused_need_help" + owning_replay["pause_message"] = message + owning_replay["failed_action"] = { + "action_id": action.get("action_id", ""), + "type": "pause_for_human", + "reason": "user_request", + } + logger.info( + f"Replay {owning_replay['replay_id']} pause supervisée demandée " + f"par le workflow : {message[:80]}" + ) + return { + "action": None, + "session_id": session_id, + "machine_id": machine_id, + "replay_paused": True, + "pause_message": message, + "replay_id": owning_replay["replay_id"], + } + # ---- Pre-check écran (optionnel, non bloquant) ---- # Ne s'applique qu'aux actions qui ont un from_node (actions de workflow, # pas les wait/retry auto-injectés ni les actions Copilot/Agent Libre) diff --git a/agent_v0/server_v1/replay_engine.py b/agent_v0/server_v1/replay_engine.py index c745b4b82..0ba4721c7 100644 --- a/agent_v0/server_v1/replay_engine.py +++ b/agent_v0/server_v1/replay_engine.py @@ -32,7 +32,8 @@ _ALLOWED_ACTION_TYPES = { "click", "type", "key_combo", "scroll", "wait", "file_open", "file_save", "file_close", "file_new", "file_dialog", "double_click", "right_click", "drag", - "verify_screen", # Replay hybride : vérification visuelle entre groupes + "verify_screen", # Replay hybride : vérification visuelle entre groupes + "pause_for_human", # Pause supervisée explicite (interceptée par /replay/next) } _MAX_ACTION_TEXT_LENGTH = 10000 _MAX_KEYS_PER_COMBO = 10 @@ -852,6 +853,13 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str, keys = [action_params["key"]] normalized["keys"] = keys + elif action_type == "pause_for_human": + normalized["type"] = "pause_for_human" + normalized["parameters"] = { + "message": action_params.get("message", "Validation requise"), + } + return [normalized] # pas de target/coords pour cette action logique + else: logger.warning(f"Type d'action inconnu : {action_type}") return [] diff --git a/tests/integration/test_pause_for_human.py b/tests/integration/test_pause_for_human.py new file mode 100644 index 000000000..47ffb1a68 --- /dev/null +++ b/tests/integration/test_pause_for_human.py @@ -0,0 +1,131 @@ +"""Tests de l'action pause_for_human (C.5). + +Vérifie la chaîne : +- Validation côté replay_engine accepte le nouveau type +- Conversion edge → action normalisée préserve le message +- Bridge VWB → core mappe correctement +- Le bridge VWB construit bien un edge avec action.type='pause_for_human' +""" + +from agent_v0.server_v1.replay_engine import ( + _ALLOWED_ACTION_TYPES, + _validate_replay_action, + _edge_to_normalized_actions, +) +from visual_workflow_builder.backend.services.learned_workflow_bridge import ( + VWB_ACTION_TO_CORE, + convert_vwb_to_core_workflow, + _vwb_params_to_core, +) + + +# ---------------------------------------------------------------------- +# Validation pipeline (replay_engine) +# ---------------------------------------------------------------------- + +def test_pause_for_human_in_allowed_types(): + assert "pause_for_human" in _ALLOWED_ACTION_TYPES + + +def test_validate_pause_for_human_action_valid(): + action = {"type": "pause_for_human", "parameters": {"message": "Valider UHCD ?"}} + assert _validate_replay_action(action) is None + + +def test_validate_pause_for_human_no_params_still_valid(): + """Le validateur ne doit pas exiger 'message' (fallback côté handler).""" + action = {"type": "pause_for_human"} + assert _validate_replay_action(action) is None + + +# ---------------------------------------------------------------------- +# Conversion edge → action normalisée +# ---------------------------------------------------------------------- + +class _FakeAction: + def __init__(self, type_, parameters=None): + self.type = type_ + self.target = None + self.parameters = parameters or {} + + +class _FakeEdge: + def __init__(self, action, edge_id="e1", from_node="n1", to_node="n2"): + self.edge_id = edge_id + self.from_node = from_node + self.to_node = to_node + self.action = action + + +def test_edge_to_action_pause_for_human_preserves_message(): + edge = _FakeEdge(_FakeAction( + "pause_for_human", + parameters={"message": "Tu valides UHCD ?"}, + )) + actions = _edge_to_normalized_actions(edge, params={}) + assert len(actions) == 1 + a = actions[0] + assert a["type"] == "pause_for_human" + assert a["parameters"]["message"] == "Tu valides UHCD ?" + assert "x_pct" not in a # action logique, pas de coords + assert "y_pct" not in a + + +def test_edge_to_action_pause_for_human_default_message(): + edge = _FakeEdge(_FakeAction("pause_for_human", parameters={})) + actions = _edge_to_normalized_actions(edge, params={}) + assert actions[0]["parameters"]["message"] == "Validation requise" + + +def test_edge_to_action_pause_for_human_carries_edge_metadata(): + edge = _FakeEdge( + _FakeAction("pause_for_human", parameters={"message": "x"}), + edge_id="edge_42", from_node="n_src", to_node="n_dst", + ) + actions = _edge_to_normalized_actions(edge, params={}) + a = actions[0] + assert a["edge_id"] == "edge_42" + assert a["from_node"] == "n_src" + assert a["to_node"] == "n_dst" + assert "action_id" in a + + +# ---------------------------------------------------------------------- +# Bridge VWB → core +# ---------------------------------------------------------------------- + +def test_vwb_action_to_core_passthrough(): + assert VWB_ACTION_TO_CORE["pause_for_human"] == "pause_for_human" + + +def test_vwb_params_to_core_preserves_message(): + core_params = _vwb_params_to_core("pause_for_human", {"message": "Coucou"}) + assert core_params == {"message": "Coucou"} + + +def test_vwb_params_to_core_default_message(): + core_params = _vwb_params_to_core("pause_for_human", {}) + assert core_params["message"] == "Validation requise" + + +def test_export_vwb_workflow_with_pause_step(): + """Un workflow VWB contenant une step pause_for_human doit produire un edge + avec action.type='pause_for_human' et message dans parameters.""" + workflow_data = {"id": "wf_demo", "name": "Demo Urgences", "description": ""} + steps_data = [ + {"id": "s1", "action_type": "click_anchor", "parameters": {"target_text": "25003284"}, "label": "Clic IPP"}, + {"id": "s2", "action_type": "pause_for_human", "parameters": {"message": "Valider UHCD ?"}, "label": "Pause"}, + {"id": "s3", "action_type": "click_anchor", "parameters": {"target_text": "Enregistrer"}, "label": "Clic Enregistrer"}, + ] + core = convert_vwb_to_core_workflow(workflow_data, steps_data) + assert core["learning_state"] == "COACHING" + assert len(core["nodes"]) == 3 + assert len(core["edges"]) == 2 + + # L'edge sortant du node de pause doit avoir le bon type + message + pause_edges = [ + e for e in core["edges"] + if e["action"]["type"] == "pause_for_human" + ] + assert len(pause_edges) == 1 + assert pause_edges[0]["action"]["parameters"]["message"] == "Valider UHCD ?" diff --git a/visual_workflow_builder/backend/services/learned_workflow_bridge.py b/visual_workflow_builder/backend/services/learned_workflow_bridge.py index 718ea106f..884f3df96 100644 --- a/visual_workflow_builder/backend/services/learned_workflow_bridge.py +++ b/visual_workflow_builder/backend/services/learned_workflow_bridge.py @@ -58,6 +58,7 @@ VWB_ACTION_TO_CORE = { "visual_condition": "evaluate_condition", "screenshot_evidence": "screenshot", "extract_text": "extract_data", + "pause_for_human": "pause_for_human", # passthrough — intercepté par api_stream /replay/next } @@ -660,6 +661,9 @@ def _vwb_params_to_core(action_type: str, params: Dict[str, Any]) -> Dict[str, A elif action_type == "wait_for_anchor": core_params["duration_ms"] = params.get("duration_ms", 2000) + elif action_type == "pause_for_human": + core_params["message"] = params.get("message", "Validation requise") + return core_params diff --git a/visual_workflow_builder/frontend_v4/src/types.ts b/visual_workflow_builder/frontend_v4/src/types.ts index 0aa9ea95f..32f753794 100644 --- a/visual_workflow_builder/frontend_v4/src/types.ts +++ b/visual_workflow_builder/frontend_v4/src/types.ts @@ -43,6 +43,7 @@ export type ActionType = | 'screenshot_evidence' | 'visual_condition' | 'loop_visual' + | 'pause_for_human' | 'download_to_folder' | 'ai_analyze_text' | 'ai_ocr' @@ -129,6 +130,9 @@ export const ACTIONS: ActionDefinition[] = [ { type: 'loop_visual', label: 'Boucle visuelle', icon: '🔁', description: 'Répète les étapes connectées tant que l\'ancre est visible.', category: 'logic', needsAnchor: true, hidden: true, params: [ { name: 'max_iterations', type: 'number', description: 'Nombre maximum d\'itérations' } ] }, + { type: 'pause_for_human', label: 'Pause supervisée', icon: '⏸', description: 'Léa s\'arrête et demande validation humaine via une bulle interactive (boutons Continuer / Annuler).', category: 'logic', needsAnchor: false, params: [ + { name: 'message', type: 'string', description: 'Message affiché dans la bulle (ex: "Je ne suis pas sûre du critère 3, validez-vous UHCD ?")' } + ] }, // === INTELLIGENCE ARTIFICIELLE === { type: 'ai_ocr', label: 'OCR Intelligent', icon: '📝', description: 'Reconnaissance de texte par IA sur la zone de l\'ancre.', category: 'ai', needsAnchor: true, params: [