From f30178e18aa99bb1059ff62983b33eef507cbc12 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 19 Jul 2026 15:30:00 -0400 Subject: [PATCH] fix(gateway): fall back safely without approval identities --- api/config.py | 7 +++ api/gateway_chat.py | 5 +-- api/routes.py | 42 +++++++----------- static/i18n.js | 30 ++++++------- static/sessions.js | 1 + tests/test_gateway_approval_runs_api.py | 43 +++++++++---------- ...est_issue4771_local_approval_regression.py | 12 ++---- tests/test_session_attention_badges.py | 2 +- 8 files changed, 65 insertions(+), 77 deletions(-) diff --git a/api/config.py b/api/config.py index 0647b945..dd3569bc 100644 --- a/api/config.py +++ b/api/config.py @@ -8892,6 +8892,7 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict: caps = { "approval_events": False, "run_approval_response": False, + "approval_identity_v1": False, "capabilities_reachable": False, "probe_error": None, "fetched_at": 0.0, @@ -8909,6 +8910,7 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict: features = {} caps["approval_events"] = bool(features.get("approval_events")) caps["run_approval_response"] = bool(features.get("run_approval_response")) + caps["approval_identity_v1"] = bool(features.get("approval_identity_v1")) except urllib.error.HTTPError as exc: caps["capabilities_reachable"] = True caps["probe_error"] = f"{type(exc).__name__}: {exc}" @@ -8948,6 +8950,11 @@ def gateway_supports_approval(base_url: str, api_key: str = "") -> bool: return bool(caps.get("approval_events") and caps.get("run_approval_response")) +def gateway_supports_approval_identity_v1(base_url: str, api_key: str = "") -> bool: + """True only when Gateway advertises authoritative approval identities.""" + return bool(get_gateway_caps(base_url, api_key).get("approval_identity_v1")) + + def invalidate_gateway_caps(base_url: str | None = None) -> None: """Evict capability cache for base_url, or all entries when base_url is None.""" with _GATEWAY_CAPS_LOCK: diff --git a/api/gateway_chat.py b/api/gateway_chat.py index f4e5158a..c0746074 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -470,7 +470,8 @@ def _run_gateway_runs_api_streaming( if stop_confirmed: put_gateway_event("cancel", {"message": "Cancelled by user"}) return None, usage - cancel_event.clear() + put_gateway_event("apperror", {"message": "Gateway stop failed"}) + return None, usage url_events = f"{base_url.rstrip('/')}/v1/runs/{run_id}/events" headers_sse = dict(headers) @@ -504,8 +505,6 @@ def _run_gateway_runs_api_streaming( approval_data = _gateway_runs_approval_event(payload) if approval_data: approval_data["run_id"] = run_id - if not approval_data.get("approval_id"): - approval_data["approval_id"] = f"gwrun:{run_id}:{uuid.uuid4().hex}" from api.route_approvals import submit_gateway_pending_mirror head, total = submit_gateway_pending_mirror(session_id, approval_data) put_gateway_event("approval", {**(head or approval_data), "pending_count": total}) diff --git a/api/routes.py b/api/routes.py index b893d7e9..8b8854f1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -13378,15 +13378,13 @@ def handle_get(handler, parsed) -> bool: run_id = _STREAM_RUN_IDS.get(stream_id) pending_run_id = False if not run_id: - import time as _time - pending_run_id = gateway_run_id_pending(stream_id) - attempts_remaining = 10 - while not run_id and pending_run_id and attempts_remaining > 0: - _time.sleep(0.05) - run_id = _STREAM_RUN_IDS.get(stream_id) - pending_run_id = gateway_run_id_pending(stream_id) - attempts_remaining -= 1 + if pending_run_id: + cancel_flag = CANCEL_FLAGS.get(stream_id) + if cancel_flag: + cancel_flag.set() + cancelled = cancel_stream(stream_id) + return j(handler, {"ok": True, "cancelled": cancelled, "deferred": True, "stream_id": stream_id}) if run_id: if stop_gateway_run(stream_id): owner_sid = stream_owner_session_id(stream_id) @@ -13394,8 +13392,6 @@ def handle_get(handler, parsed) -> bool: retire_gateway_pending_mirror(owner_sid, run_id=run_id) else: gateway_stop_blocked = True - elif pending_run_id: - gateway_stop_blocked = True except Exception: logger.debug("Failed to stop gateway run during chat cancellation", exc_info=True) gateway_stop_blocked = True @@ -23955,11 +23951,11 @@ def _gateway_pending_approval_without_run_id(sid: str, approval_id: str) -> bool if approval_id: for entry in entries: if isinstance(entry, dict) and entry.get("approval_id") == approval_id: - return bool(entry.get(_GATEWAY_MIRROR_FLAG)) + return bool(entry.get(_GATEWAY_MIRROR_FLAG)) and not str(entry.get("run_id") or "").strip() return False if not entries or not isinstance(entries[0], dict): return False - return bool(entries[0].get(_GATEWAY_MIRROR_FLAG)) + return bool(entries[0].get(_GATEWAY_MIRROR_FLAG)) and not str(entries[0].get("run_id") or "").strip() def _session_has_pending_approval(sid: str) -> bool: @@ -24002,7 +23998,7 @@ def _handle_approval_respond(handler, body): _gateway_api_key, webui_gateway_chat_enabled, ) - from api.config import get_config as _get_config + from api.config import get_config as _get_config, gateway_supports_approval_identity_v1 s = get_session(sid) _candidate_run_id = None if s is not None: @@ -24109,8 +24105,9 @@ def _handle_approval_respond(handler, body): _base = _gateway_base_url(_cfg) _key = _gateway_api_key() try: + identity_v1 = gateway_supports_approval_identity_v1(_base, _key) HttpRunnerClient(base_url=_base, api_key=_key).respond_approval( - _run_id, matched_mirror["approval_id"], choice + _run_id, matched_mirror["approval_id"] if identity_v1 else "", choice ) except (RunnerClientError, ValueError) as exc: return j(handler, {"ok": False, "choice": choice, "relayed": True, "error": str(exc)}, status=502) @@ -24125,22 +24122,13 @@ def _handle_approval_respond(handler, body): return j(handler, {"ok": False, "choice": choice, "relayed": False, "code": "gateway_run_unavailable", "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409) - # Only a still-mirrored gateway approval with a missing run should 409; - # stale or empty gateway clicks fall through to local resolution. + # A no-run mirror is local visibility state only. Retire it instead of + # claiming that a remote approval can still be resolved. if webui_gateway_chat_enabled(_get_config()) and _gateway_pending_approval_without_run_id( sid, approval_id ): - return j( - handler, - { - "ok": False, - "choice": choice, - "relayed": False, - "code": "gateway_run_unavailable", - "error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE, - }, - status=409, - ) + retire_gateway_pending_mirror(sid, approval_id=approval_id) + return j(handler, {"ok": True, "choice": choice, "local_retired": True}) except Exception: pass # fall through to local approval path diff --git a/static/i18n.js b/static/i18n.js index 3c55bf51..48a926c1 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -17,7 +17,7 @@ const LOCALES = { _speech: 'en-US', // boot.js cancelling: 'Cancelling\u2026', - cancel_failed: 'Cancel failed: ', + cancel_failed: 'Cancel failed.', mic_denied: 'Microphone access denied. Check browser permissions.', mic_insecure_origin: 'Voice input needs a secure connection. Open Hermes over HTTPS or from localhost to use the microphone.', mic_no_speech: 'No speech detected. Try again.', @@ -1777,7 +1777,7 @@ const LOCALES = { _speech: 'it-IT', // boot.js cancelling: 'Annullamento\u2026', - cancel_failed: 'Annullamento fallito: ', + cancel_failed: 'Annullamento fallito.', mic_denied: 'Accesso al microfono negato. Controlla i permessi del browser.', mic_insecure_origin: 'L\'input vocale richiede una connessione sicura. Apri Hermes tramite HTTPS o da localhost per usare il microfono.', mic_no_speech: 'Nessuna voce rilevata. Riprova.', @@ -3519,7 +3519,7 @@ const LOCALES = { _speech: 'ja-JP', // boot.js cancelling: 'キャンセル中…', - cancel_failed: 'キャンセル失敗: ', + cancel_failed: 'キャンセルに失敗しました。', mic_denied: 'マイクへのアクセスが拒否されました。ブラウザの権限を確認してください。', mic_insecure_origin: '音声入力にはセキュアな接続が必要です。マイクを使うには HTTPS または localhost 経由で Hermes を開いてください。', mic_no_speech: '音声が検出されませんでした。もう一度お試しください。', @@ -5265,7 +5265,7 @@ const LOCALES = { _label: 'Русский', _speech: 'ru-RU', cancelling: 'Отменяю…', - cancel_failed: 'Не удалось отменить: ', + cancel_failed: 'Не удалось отменить.', mic_denied: 'Доступ к микрофону запрещён. Проверьте разрешения браузера.', mic_insecure_origin: 'Для голосового ввода нужно защищённое соединение. Откройте Hermes по HTTPS или с localhost, чтобы использовать микрофон.', mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.', @@ -6987,7 +6987,7 @@ const LOCALES = { _speech: 'es-ES', // boot.js cancelling: 'Cancelando…', - cancel_failed: 'Error al cancelar: ', + cancel_failed: 'Error al cancelar.', mic_denied: 'Acceso al micrófono denegado. Revisa los permisos del navegador.', mic_insecure_origin: 'La entrada de voz necesita una conexión segura. Abre Hermes con HTTPS o desde localhost para usar el micrófono.', mic_no_speech: 'No se detectó voz. Inténtalo de nuevo.', @@ -8675,7 +8675,7 @@ const LOCALES = { _speech: 'de-DE', // boot.js cancelling: 'Wird abgebrochen\u2026', - cancel_failed: 'Abbrechen fehlgeschlagen: ', + cancel_failed: 'Abbrechen fehlgeschlagen.', mic_denied: 'Mikrofonzugriff verweigert. Überprüfen Sie die Browserberechtigungen.', mic_insecure_origin: 'Für die Spracheingabe ist eine sichere Verbindung erforderlich. Öffne Hermes über HTTPS oder von localhost, um das Mikrofon zu verwenden.', mic_no_speech: 'Keine Sprache erkannt. Versuchen Sie es erneut.', @@ -10357,7 +10357,7 @@ const LOCALES = { _speech: 'zh-CN', // boot.js cancelling: '正在取消...', - cancel_failed: '取消失败:', + cancel_failed: '取消失败。', mic_denied: '麦克风访问被拒绝,请检查浏览器权限。', mic_insecure_origin: '语音输入需要安全连接。请通过 HTTPS 协议或从本地主机打开Hermes,以便使用麦克风。', mic_no_speech: '没有检测到语音,请再试一次。', @@ -12034,7 +12034,7 @@ const LOCALES = { _speech: 'zh-TW', // boot.js cancelling: '正在取消……', - cancel_failed: '取消失敗:', + cancel_failed: '取消失敗。', mic_denied: '麥克風存取遭拒。請檢查瀏覽器權限。', mic_insecure_origin: '語音輸入需要安全連線。請透過 HTTPS 或 localhost 開啟 Hermes 才能使用麥克風。', mic_no_speech: '未偵測到語音。請再試一次。', @@ -13778,7 +13778,7 @@ const LOCALES = { _speech: 'pt-BR', // boot.js cancelling: 'Cancelando…', - cancel_failed: 'Falha ao cancelar: ', + cancel_failed: 'Falha ao cancelar.', mic_denied: 'Acesso ao microfone negado. Verifique as permissões do navegador.', mic_insecure_origin: 'A entrada de voz precisa de uma conexão segura. Abra o Hermes por HTTPS ou a partir do localhost para usar o microfone.', mic_no_speech: 'Nenhuma fala detectada. Tente novamente.', @@ -15340,7 +15340,7 @@ const LOCALES = { _speech: 'ko-KR', // boot.js cancelling: '취소 중\u2026', - cancel_failed: '취소 실패: ', + cancel_failed: '취소에 실패했습니다.', mic_denied: '마이크 접근이 거부되었습니다. 브라우저 권한을 확인하세요.', mic_insecure_origin: '음성 입력에는 보안 연결이 필요합니다. 마이크를 사용하려면 HTTPS 또는 localhost를 통해 Hermes를 여세요.', mic_no_speech: '음성이 감지되지 않았습니다. 다시 시도하세요.', @@ -17071,7 +17071,7 @@ const LOCALES = { _label: 'Français', _speech: 'fr-FR', cancelling: 'Annulation\u2026', - cancel_failed: 'Échec de l\'annulation : ', + cancel_failed: 'Échec de l\'annulation.', mic_denied: 'Accès au microphone refusé. Vérifiez les autorisations du navigateur.', mic_insecure_origin: 'La saisie vocale nécessite une connexion sécurisée. Ouvrez Hermes en HTTPS ou depuis localhost pour utiliser le microphone.', mic_no_speech: 'Aucune parole détectée. Réessayez.', @@ -20000,7 +20000,7 @@ const LOCALES = { btw_no_answer: 'Nebyla přijata žádná odpověď.', busy_interrupt_confirm: 'Přerušeno — odeslání nové zprávy', busy_steer_fallback: 'Řízení není k dispozici — návrh byl obnoven', - cancel_failed: 'Zrušení selhalo: ', + cancel_failed: 'Zrušení selhalo.', cancel_unavailable: 'Zrušení není k dispozici.', cancelling: 'Rušení\u2026', clarify_heading: 'Zde je zapotřebí vyjasnění.', @@ -20492,7 +20492,7 @@ const LOCALES = { _speech: 'tr-TR', // boot.js cancelling: 'İptal ediliyor\u2026', - cancel_failed: 'İptal başarısız oldu:', + cancel_failed: 'İptal başarısız oldu.', mic_denied: 'Mikrofon erişimi reddedildi. Tarayıcı izinlerini kontrol edin.', mic_insecure_origin: 'Sesli giriş güvenli bir bağlantı gerektirir. Mikrofonu kullanmak için Hermes\'i HTTPS üzerinden veya localhost\'tan açın.', mic_no_speech: 'Konuşma algılanmadı. Tekrar deneyin.', @@ -22225,7 +22225,7 @@ const LOCALES = { _speech: 'pl-PL', // boot.js cancelling: 'Anulowanie…', - cancel_failed: 'Anulowanie nie powiodło się: ', + cancel_failed: 'Anulowanie nie powiodło się.', mic_denied: 'Odmowa dostępu do mikrofonu. Sprawdź uprawnienia przeglądarki.', mic_insecure_origin: 'Wprowadzanie głosowe wymaga bezpiecznego połączenia. Otwórz Hermes przez HTTPS lub z localhost, aby użyć mikrofonu.', mic_no_speech: 'Nie wykryto mowy. Spróbuj ponownie.', @@ -23962,7 +23962,7 @@ const LOCALES = { _speech: 'vi-VN', // boot.js cancelling: 'Đang hủy…', - cancel_failed: 'Hủy thất bại: ', + cancel_failed: 'Hủy thất bại.', mic_denied: 'Quyền truy cập microphone bị từ chối. Kiểm tra quyền trình duyệt.', mic_no_speech: 'Không phát hiện giọng nói. Hãy thử lại.', mic_network: 'Nhận diện giọng nói không khả dụng.', diff --git a/static/sessions.js b/static/sessions.js index 1100794a..db7ee1c0 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2315,6 +2315,7 @@ async function loadSession(sid){ setComposerStatus(''); updateQueueBadge(sid); syncTopbar();renderMessages(sameSessionForceReload?{preserveScroll:true}:undefined); + startApprovalPolling(sid); if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid); // Workspace refresh is guarded by session id inside loadDir(); keep it // after the transcript's first paint so chat switching is not competing diff --git a/tests/test_gateway_approval_runs_api.py b/tests/test_gateway_approval_runs_api.py index 3b06d611..7561490e 100644 --- a/tests/test_gateway_approval_runs_api.py +++ b/tests/test_gateway_approval_runs_api.py @@ -489,7 +489,7 @@ def test_gateway_runs_api_streaming_same_run_fifo_emits_head_and_promotes_succes }) handler.send_response.assert_called_with(200) - respond_approval.assert_called_once_with("run-fifo", "approval-first", "deny") + respond_approval.assert_called_once_with("run-fifo", "", "deny") promoted = approvals.gateway_pending_mirror(sid, run_id="run-fifo") assert promoted is not None assert promoted["approval_id"] == "approval-second" @@ -639,7 +639,7 @@ def test_gateway_approval_response_relay_cleans_only_selected_shared_id_run(): }) handler.send_response.assert_called_with(200) - respond_approval.assert_called_once_with("run-live", "same-id", "once") + respond_approval.assert_called_once_with("run-live", "", "once") assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-live") is None assert approvals.gateway_pending_mirror(sid, approval_id="same-id", run_id="run-old") is not None finally: @@ -723,7 +723,7 @@ def test_gateway_approval_relay_does_not_unblock_unrelated_local_head(): }) handler.send_response.assert_called_with(200) - respond_approval.assert_called_once_with("run-remote", "remote-a", "once") + respond_approval.assert_called_once_with("run-remote", "", "once") assert local_entry.event.is_set() is False assert local_entry.result is None assert approvals.gateway_pending_mirror(sid, approval_id="remote-a", run_id="run-remote") is None @@ -1207,7 +1207,7 @@ def test_runs_api_cancel_after_run_creation_stops_remote_run_before_events(): _STREAM_RUN_IDS.pop(stream_id, None) -def test_runs_api_cancel_after_run_creation_keeps_stream_open_when_stop_fails(): +def test_runs_api_cancel_after_run_creation_reports_stop_failure(): from api.gateway_chat import _STREAM_RUN_IDS, _run_gateway_runs_api_streaming stream_id = "sid-run-create-cancel-stop-failed" @@ -1272,14 +1272,11 @@ def test_runs_api_cancel_after_run_creation_keeps_stream_open_when_stop_fails(): cancel_event=cancel_event, cfg={}, ) - assert final_text == "Hello" + assert final_text is None assert usage == {} stop_run.assert_called_once_with(stream_id) - assert requests == [ - "http://gw:8642/v1/runs", - "http://gw:8642/v1/runs/run-pre-events-stop-failed/events", - ] - assert [event for event, _data in events] == ["token"] + assert requests == ["http://gw:8642/v1/runs"] + assert [event for event, _data in events] == ["apperror"] finally: _STREAM_RUN_IDS.pop(stream_id, None) @@ -1457,7 +1454,7 @@ def test_chat_cancel_surfaces_redirected_gateway_stop(monkeypatch): monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args, **_kwargs: True) monkeypatch.setattr(routes, "stream_owner_session_id", lambda _stream_id: sid) - monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True)) + monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True) or True) monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: False) def fake_j(handler, data, status=200, extra_headers=None): @@ -1539,7 +1536,7 @@ def test_chat_cancel_surfaces_gateway_stop_failure(monkeypatch): monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda *_args, **_kwargs: True) monkeypatch.setattr(routes, "stream_owner_session_id", lambda _stream_id: sid) - monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True)) + monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True) or True) monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: False) def fake_j(handler, data, status=200, extra_headers=None): @@ -1565,7 +1562,7 @@ def test_chat_cancel_surfaces_gateway_stop_failure(monkeypatch): approvals._pending.pop(sid, None) -def test_chat_cancel_blocks_while_gateway_run_id_is_pending(monkeypatch): +def test_chat_cancel_defers_while_gateway_run_id_is_pending(monkeypatch): import urllib.parse from api import routes @@ -1575,7 +1572,7 @@ def test_chat_cancel_blocks_while_gateway_run_id_is_pending(monkeypatch): called = {"cancel": False, "stop": False} monkeypatch.setattr(routes, "_stream_id_visible_to_request_profile", lambda *_args: True) - monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True)) + monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: called.__setitem__("cancel", True) or True) monkeypatch.setattr("api.gateway_chat.gateway_run_id_pending", lambda _stream_id: True) monkeypatch.setattr("api.gateway_chat.stop_gateway_run", lambda _stream_id: called.__setitem__("stop", True)) monkeypatch.setattr("time.sleep", lambda _seconds: None) @@ -1588,18 +1585,18 @@ def test_chat_cancel_blocks_while_gateway_run_id_is_pending(monkeypatch): monkeypatch.setattr(routes, "j", fake_j) parsed = urllib.parse.urlparse(f"/api/chat/cancel?stream_id={stream_id}") routes.handle_get(object(), parsed) - assert captured["status"] == 502 + assert captured["status"] == 200 assert captured["payload"] == { - "ok": False, - "cancelled": False, + "ok": True, + "cancelled": True, + "deferred": True, "stream_id": stream_id, - "error": "Gateway stop failed", } - assert called["cancel"] is False + assert called["cancel"] is True assert called["stop"] is False -def test_chat_cancel_waits_for_pending_gateway_run_id_then_stops_remote_run(monkeypatch): +def test_chat_cancel_defers_stop_until_gateway_run_id_exists(monkeypatch): import urllib.parse from api import routes @@ -1629,8 +1626,8 @@ def test_chat_cancel_waits_for_pending_gateway_run_id_then_stops_remote_run(monk try: routes.handle_get(object(), parsed) assert captured["status"] == 200 - assert captured["payload"] == {"ok": True, "cancelled": True, "stream_id": stream_id} - assert called["stop"] is True + assert captured["payload"] == {"ok": True, "cancelled": True, "deferred": True, "stream_id": stream_id} + assert called["stop"] is False assert called["cancel"] is True finally: _STREAM_RUN_IDS.pop(stream_id, None) @@ -2130,7 +2127,7 @@ def test_gateway_approval_response_relay(): _handle_approval_respond(handler, body) assert captured.get("url", "") == "http://gw:8642/v1/runs/run%20abc%2F1/approval" - assert captured["body"] == {"choice": "once", "approval_id": "appr x/y"} + assert captured["body"] == {"choice": "once", "approval_id": ""} handler.send_response.assert_called_with(200) # Cleanup. diff --git a/tests/test_issue4771_local_approval_regression.py b/tests/test_issue4771_local_approval_regression.py index 5a3da490..3c45c978 100644 --- a/tests/test_issue4771_local_approval_regression.py +++ b/tests/test_issue4771_local_approval_regression.py @@ -381,10 +381,8 @@ def test_local_plain_pending_without_id_beats_active_remote_candidate_run(): _cleanup(sid) -def test_gateway_mirrored_approval_without_run_still_409s(): - """#4771 behaviour preserved: on the gateway backend, a mirrored approval - whose run is gone must still surface the actionable 409 so the card stays - visible instead of silently failing.""" +def test_gateway_mirrored_approval_without_run_retires_locally(): + """A gateway mirror without a run retires instead of leaving a ghost card.""" sid = f"gw-approval-{uuid.uuid4().hex[:8]}" _register_session(sid) # no active_stream_id -> no _STREAM_RUN_IDS mapping try: @@ -396,10 +394,8 @@ def test_gateway_mirrored_approval_without_run_still_409s(): {"session_id": sid, "choice": "once", "approval_id": approval_id}, ) resp = handler.json() - assert handler.status == 409, f"expected 409, got {handler.status}: {resp}" - assert resp.get("ok") is False - assert resp.get("relayed") is False - assert resp.get("code") == "gateway_run_unavailable" + assert handler.status == 200, f"expected 200, got {handler.status}: {resp}" + assert resp == {"ok": True, "choice": "once", "local_retired": True} finally: _cleanup(sid) diff --git a/tests/test_session_attention_badges.py b/tests/test_session_attention_badges.py index e67f2328..b9ff2b1e 100644 --- a/tests/test_session_attention_badges.py +++ b/tests/test_session_attention_badges.py @@ -148,7 +148,7 @@ def test_attention_summary_keeps_direct_runs_mirror_after_stream_pointer_clears( }) handler.send_response.assert_called_with(200) - respond_approval.assert_called_once_with("run-retained", "approval-retained", "deny") + respond_approval.assert_called_once_with("run-retained", "", "deny") assert json.loads(handler.wfile.getvalue().decode("utf-8"))["ok"] is True finally: approvals._pending.pop(sid, None)