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.8 KiB
Python
165 lines
5.8 KiB
Python
"""Tests du bus feedback Léa (events lea:* via Flask-SocketIO).
|
|
|
|
Couvre J2.5 et J2.6 :
|
|
- Flag LEA_FEEDBACK_BUS=0 → _emit_lea no-op, _emit_dual ne propage que l'event legacy
|
|
- Flag LEA_FEEDBACK_BUS=1 → _emit_lea propage 'lea:{event}', _emit_dual propage les deux
|
|
|
|
Approche : on intercepte socketio.emit avec monkeypatch (plus fiable que test_client
|
|
de Flask-SocketIO qui ne capte pas toujours les broadcasts hors contexte requête).
|
|
"""
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
def _reload_app(monkeypatch, flag_value: str):
|
|
monkeypatch.setenv("LEA_FEEDBACK_BUS", flag_value)
|
|
import agent_chat.app as app_mod
|
|
importlib.reload(app_mod)
|
|
return app_mod
|
|
|
|
|
|
def _capture_emits(monkeypatch, app_mod):
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
app_mod.socketio, "emit",
|
|
lambda event, payload=None, **kwargs: calls.append((event, payload, kwargs)),
|
|
)
|
|
return calls
|
|
|
|
|
|
@pytest.fixture
|
|
def app_off(monkeypatch):
|
|
return _reload_app(monkeypatch, "0")
|
|
|
|
|
|
@pytest.fixture
|
|
def app_on(monkeypatch):
|
|
return _reload_app(monkeypatch, "1")
|
|
|
|
|
|
def test_flag_off_by_default(monkeypatch):
|
|
monkeypatch.delenv("LEA_FEEDBACK_BUS", raising=False)
|
|
import agent_chat.app as app_mod
|
|
importlib.reload(app_mod)
|
|
assert app_mod.LEA_FEEDBACK_BUS is False
|
|
|
|
|
|
def test_flag_accepts_truthy_values(monkeypatch):
|
|
for truthy in ["1", "true", "True", "yes", "on", "TRUE"]:
|
|
monkeypatch.setenv("LEA_FEEDBACK_BUS", truthy)
|
|
import agent_chat.app as app_mod
|
|
importlib.reload(app_mod)
|
|
assert app_mod.LEA_FEEDBACK_BUS is True, f"{truthy!r} devrait activer le flag"
|
|
|
|
|
|
def test_emit_lea_noop_when_flag_off(app_off, monkeypatch):
|
|
calls = _capture_emits(monkeypatch, app_off)
|
|
app_off._emit_lea("paused", {"workflow": "demo", "reason": "test"})
|
|
assert calls == []
|
|
|
|
|
|
def test_emit_lea_emits_when_flag_on(app_on, monkeypatch):
|
|
calls = _capture_emits(monkeypatch, app_on)
|
|
app_on._emit_lea("paused", {"workflow": "demo", "reason": "test"})
|
|
assert len(calls) == 1
|
|
event, payload, _ = calls[0]
|
|
assert event == "lea:paused"
|
|
assert payload == {"workflow": "demo", "reason": "test"}
|
|
|
|
|
|
def test_emit_dual_emits_only_legacy_when_flag_off(app_off, monkeypatch):
|
|
calls = _capture_emits(monkeypatch, app_off)
|
|
app_off._emit_dual("execution_started", "action_started", {"workflow": "demo"})
|
|
assert len(calls) == 1
|
|
assert calls[0][0] == "execution_started"
|
|
|
|
|
|
def test_emit_dual_emits_both_when_flag_on(app_on, monkeypatch):
|
|
calls = _capture_emits(monkeypatch, app_on)
|
|
payload = {"workflow": "demo", "params": {"k": "v"}}
|
|
app_on._emit_dual("execution_started", "action_started", payload)
|
|
events = [c[0] for c in calls]
|
|
assert "execution_started" in events
|
|
assert "lea:action_started" in events
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_emit_dual_preserves_kwargs(app_on, monkeypatch):
|
|
"""broadcast=True et autres kwargs Flask-SocketIO doivent être propagés au legacy."""
|
|
calls = _capture_emits(monkeypatch, app_on)
|
|
app_on._emit_dual("execution_cancelled", "cancelled", {}, broadcast=True)
|
|
legacy_call = next(c for c in calls if c[0] == "execution_cancelled")
|
|
assert legacy_call[2].get("broadcast") is True
|
|
|
|
|
|
def test_emit_lea_silenced_on_socketio_error(app_on, monkeypatch):
|
|
"""Une exception dans socketio.emit ne doit jamais remonter."""
|
|
def boom(*args, **kwargs):
|
|
raise RuntimeError("socketio fail")
|
|
monkeypatch.setattr(app_on.socketio, "emit", boom)
|
|
app_on._emit_lea("paused", {"x": 1})
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# J3.5 — Handlers SocketIO depuis ChatWindow
|
|
# ----------------------------------------------------------------------
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, ok=True, status_code=200, text=""):
|
|
self.ok = ok
|
|
self.status_code = status_code
|
|
self.text = text
|
|
|
|
|
|
def test_replay_resume_handler_relays_post_to_streaming(app_on, monkeypatch):
|
|
"""Le handler 'lea:replay_resume' doit POSTer sur /replay/{id}/resume du streaming."""
|
|
captured = {}
|
|
|
|
def fake_post(url, headers=None, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = headers
|
|
return _FakeResponse(ok=True, status_code=200)
|
|
|
|
monkeypatch.setattr(app_on.http_requests, "post", fake_post)
|
|
emit_calls = _capture_emits(monkeypatch, app_on)
|
|
|
|
app_on.handle_lea_replay_resume({"replay_id": "rep_abc123"})
|
|
|
|
assert "rep_abc123" in captured["url"]
|
|
assert captured["url"].endswith("/api/v1/traces/stream/replay/rep_abc123/resume")
|
|
# Le bus doit propager un ack
|
|
acked = [c for c in emit_calls if c[0] == "lea:resume_acked"]
|
|
assert len(acked) == 1
|
|
assert acked[0][1]["status"] == "ok"
|
|
|
|
|
|
def test_replay_resume_handler_emits_error_on_http_failure(app_on, monkeypatch):
|
|
monkeypatch.setattr(
|
|
app_on.http_requests, "post",
|
|
lambda *a, **k: _FakeResponse(ok=False, status_code=500, text="boom"),
|
|
)
|
|
emit_calls = _capture_emits(monkeypatch, app_on)
|
|
app_on.handle_lea_replay_resume({"replay_id": "rep_x"})
|
|
acked = [c for c in emit_calls if c[0] == "lea:resume_acked"]
|
|
assert acked[0][1]["status"] == "error"
|
|
assert acked[0][1]["http_status"] == 500
|
|
|
|
|
|
def test_replay_resume_handler_emits_error_on_no_replay_id(app_on, monkeypatch):
|
|
emit_calls = _capture_emits(monkeypatch, app_on)
|
|
app_on.handle_lea_replay_resume({})
|
|
acked = [c for c in emit_calls if c[0] == "lea:resume_acked"]
|
|
assert acked[0][1]["status"] == "error"
|
|
assert "replay_id manquant" in acked[0][1]["detail"]
|
|
|
|
|
|
def test_replay_abort_handler_stops_local_execution(app_on, monkeypatch):
|
|
app_on.execution_status["running"] = True
|
|
emit_calls = _capture_emits(monkeypatch, app_on)
|
|
app_on.handle_lea_replay_abort({"replay_id": "rep_y"})
|
|
assert app_on.execution_status["running"] is False
|
|
acked = [c for c in emit_calls if c[0] == "lea:abort_acked"]
|
|
assert acked[0][1]["status"] == "ok"
|