fix(gateway): fall back safely without approval identities
This commit is contained in:
1 parent
b481dfea13
commit
f30178e18a
8 files changed
+65
-77
No files matched your search
@@ -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:
|
||||
|
||||
+2
-3
@@ -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})
|
||||
|
||||
+15
-27
@@ -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
|
||||
|
||||
|
||||
+15
-15
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user