Quand Léa bascule en pause supervisée (event 'lea:paused'), affichage d'une bulle dédiée dans ChatWindow avec encadré orangé, raison de la pause, et deux boutons Continuer/Annuler. C'est le moment qui incarne la différence RPA classique vs Léa devant Carvella : Léa SAIT qu'elle ne sait pas et demande de l'aide. Architecture (canal SocketIO bidirectionnel, pas de nouvel endpoint streaming) : ChatWindow ──[lea:replay_resume]──> agent_chat ──POST /resume──> streaming ChatWindow ──[lea:replay_abort ]──> agent_chat (running=False local) Composants ajoutés : - agent_chat/app.py : handlers 'lea:replay_resume' / 'lea:replay_abort' + acks 'lea:resume_acked' / 'lea:abort_acked' pour feedback côté client - network/feedback_bus.py : méthodes resume_replay() / abort_replay() avec helper _safe_emit (silencieux + retourne bool succès) - ui/chat_window.py : palette PAUSED_*, _add_paused_bubble(), _render_paused_bubble(), _close_active_paused_bubble() (auto-fermeture sur lea:resumed/done), _on_paused_resume/abort 8 nouveaux tests pytest (4 handlers serveur + 4 méthodes client). Total branche : 29/29 verts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.5 KiB
Python
165 lines
5.5 KiB
Python
"""Tests FeedbackBusClient (J3.2).
|
|
|
|
On mock python-socketio pour ne pas ouvrir de vraie connexion réseau.
|
|
Le test E2E réel (vraie connexion bus 5004) est différé à J4.3.
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from agent_v0.agent_v1.network.feedback_bus import FeedbackBusClient, LEA_EVENTS
|
|
|
|
|
|
def test_init_creates_socketio_client():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
assert bus._sio is not None
|
|
assert bus.connected is False
|
|
|
|
|
|
def test_init_strips_trailing_slash():
|
|
bus = FeedbackBusClient("http://localhost:5004/")
|
|
assert bus._url == "http://localhost:5004"
|
|
|
|
|
|
def test_lea_events_registered():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
handlers = bus._sio.handlers.get('/', {})
|
|
for ev in LEA_EVENTS:
|
|
assert ev in handlers, f"Handler {ev!r} non enregistré sur le client"
|
|
|
|
|
|
def test_dispatch_calls_callback():
|
|
received = []
|
|
bus = FeedbackBusClient(
|
|
"http://localhost:5004",
|
|
on_event=lambda e, p: received.append((e, p)),
|
|
)
|
|
bus._dispatch('lea:paused', {'workflow': 'demo', 'reason': 'incertain'})
|
|
assert received == [('lea:paused', {'workflow': 'demo', 'reason': 'incertain'})]
|
|
|
|
|
|
def test_dispatch_handles_none_payload():
|
|
received = []
|
|
bus = FeedbackBusClient(
|
|
"http://localhost:5004",
|
|
on_event=lambda e, p: received.append((e, p)),
|
|
)
|
|
bus._dispatch('lea:done', None)
|
|
assert received == [('lea:done', {})]
|
|
|
|
|
|
def test_dispatch_silenced_on_callback_error():
|
|
"""Une exception dans le callback consommateur ne doit jamais remonter."""
|
|
def boom(event, payload):
|
|
raise RuntimeError("callback fail")
|
|
bus = FeedbackBusClient("http://localhost:5004", on_event=boom)
|
|
bus._dispatch('lea:paused', {}) # ne doit pas raise
|
|
|
|
|
|
def test_default_callback_is_silent():
|
|
"""Sans callback fourni, le dispatch ne casse pas."""
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
bus._dispatch('lea:paused', {'x': 1}) # ne doit pas raise
|
|
|
|
|
|
def test_token_in_authorization_header():
|
|
bus = FeedbackBusClient("http://localhost:5004", token="abc123")
|
|
captured = {}
|
|
|
|
def fake_connect(url, headers=None, **kwargs):
|
|
captured['headers'] = headers
|
|
raise RuntimeError("stop here")
|
|
|
|
with patch.object(bus._sio, 'connect', side_effect=fake_connect):
|
|
bus._run()
|
|
|
|
assert captured['headers']['Authorization'] == 'Bearer abc123'
|
|
|
|
|
|
def test_no_token_means_no_auth_header():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
captured = {}
|
|
|
|
def fake_connect(url, headers=None, **kwargs):
|
|
captured['headers'] = headers
|
|
raise RuntimeError("stop here")
|
|
|
|
with patch.object(bus._sio, 'connect', side_effect=fake_connect):
|
|
bus._run()
|
|
|
|
assert 'Authorization' not in captured['headers']
|
|
|
|
|
|
def test_run_silenced_on_connect_error():
|
|
"""connect() qui raise ne doit pas faire crasher le thread."""
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
with patch.object(bus._sio, 'connect', side_effect=ConnectionError("boom")):
|
|
bus._run() # ne doit pas raise
|
|
|
|
|
|
def test_start_is_idempotent():
|
|
"""Un second start() pendant que le thread tourne ne doit pas en créer un autre."""
|
|
import threading
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
block = threading.Event()
|
|
with patch.object(bus, '_run', side_effect=lambda: block.wait(timeout=2)):
|
|
bus.start()
|
|
first_thread = bus._thread
|
|
bus.start()
|
|
second_thread = bus._thread
|
|
block.set()
|
|
assert first_thread is second_thread, "start() doit être idempotent quand un thread tourne"
|
|
|
|
|
|
def test_stop_when_not_connected_is_silent():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
bus.stop() # ne doit pas raise même si jamais connecté
|
|
|
|
|
|
def test_stop_silenced_on_disconnect_error():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
# Forcer connected=True sur l'instance et faire raise disconnect()
|
|
with patch.object(bus._sio, 'disconnect', side_effect=RuntimeError("boom")):
|
|
bus._sio.connected = True
|
|
bus.stop() # ne doit pas raise
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# J3.5 — Actions utilisateur (resume_replay / abort_replay)
|
|
# ----------------------------------------------------------------------
|
|
|
|
def test_resume_replay_emits_when_connected():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
bus._sio.connected = True
|
|
with patch.object(bus._sio, 'emit') as mock_emit:
|
|
ok = bus.resume_replay("rep_abc")
|
|
assert ok is True
|
|
mock_emit.assert_called_once_with("lea:replay_resume", {"replay_id": "rep_abc"})
|
|
|
|
|
|
def test_resume_replay_returns_false_when_disconnected():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
# _sio.connected reste False par défaut
|
|
with patch.object(bus._sio, 'emit') as mock_emit:
|
|
ok = bus.resume_replay("rep_abc")
|
|
assert ok is False
|
|
mock_emit.assert_not_called()
|
|
|
|
|
|
def test_abort_replay_emits_when_connected():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
bus._sio.connected = True
|
|
with patch.object(bus._sio, 'emit') as mock_emit:
|
|
ok = bus.abort_replay("rep_xyz")
|
|
assert ok is True
|
|
mock_emit.assert_called_once_with("lea:replay_abort", {"replay_id": "rep_xyz"})
|
|
|
|
|
|
def test_safe_emit_silenced_on_error():
|
|
bus = FeedbackBusClient("http://localhost:5004")
|
|
bus._sio.connected = True
|
|
with patch.object(bus._sio, 'emit', side_effect=RuntimeError("boom")):
|
|
ok = bus.resume_replay("rep_abc")
|
|
assert ok is False # erreur avalée silencieusement
|