feat(lea): bulle paused_need_help interactive — asset démo majeur
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>
This commit is contained in:
@@ -100,3 +100,65 @@ def test_emit_lea_silenced_on_socketio_error(app_on, monkeypatch):
|
||||
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"
|
||||
|
||||
@@ -123,3 +123,42 @@ def test_stop_silenced_on_disconnect_error():
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user