* fix(approvals): handle gateway yolo in webui * fix(approvals): preserve concurrent yolo enables * fix(approvals): honor settled yolo state * fix(approvals): commit yolo after relay * fix(approvals): preserve gateway yolo relay state * fix(approvals): close remaining yolo race windows * fix(approvals): serialize gateway yolo handoff * fix(approvals): linearize yolo disable * fix(approvals): serialize initial yolo relay * fix(approvals): serialize card yolo handoff * fix(yolo): ignore stale card responses * fix(yolo): reject stale session generations * fix(yolo): bind responses to approval owner * fix(yolo): drain pending approvals with exact ownership * fix(approvals): show skip-all busy state * fix: close approval lifecycle races * test: update approval SSE handoff contract * test: include displayed approval owner helpers * fix: linearize yolo transition registration * Release — approval-card YOLO handoff + stale-response ownership fence (#6945) --------- Co-authored-by: dss539 <dss539@users.noreply.github.com> Co-authored-by: n <a@n>
This commit is contained in:
15 files changed
+2992
-212
No files matched your search
@@ -5,6 +5,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Toggling YOLO from an approval card no longer hangs the gateway run or leaks across a session switch.** The approval card's YOLO branch now takes the same single `gateway_yolo_handoff(sid)` as the Runs dispatcher (revalidating the exact mirror after acquisition and holding it through settlement, with no nested-lock path), and the client captures one immutable owner tuple (card-owner session, approval id, load generation) at click time — failing closed without a POST when the visible card is not the active session, and fencing every post-response mutation (card hide, pending clears, stale-clear requery, controls, toast, status) on that exact tuple so a stale card can't project its result into the successor session. Thanks @dss539. (#6945)
|
||||
- **Git operations no longer flash a console window on Windows.** Every git subprocess boundary (agent runtime, rollback, updates, workspace, worktrees) now routes through a shared `windows_hide_flags()` helper that passes `CREATE_NO_WINDOW` on Win32 and stays a no-op on POSIX, so short-lived git commands no longer pop a visible console window on Windows while keeping captured stdout/stderr intact. Thanks @ya1yi2fa3. (#6534)
|
||||
|
||||
- **A `/goal` launch no longer silently switches providers mid-session after a deliberate model pick.** When a session had a deliberate cross-provider model/provider choice, running `/goal` omitted the `explicit_model_pick` marker that `/api/chat/start` sends, so the model resolver treated the persisted pick as stale and "repaired" it back to the profile default — silently changing providers while the goal ran. Both the frontend `/goal` command and the backend `_handle_goal_command` (initial kickoff and kickoff-prompt fallback paths) now carry the explicit-pick marker and stamp the pick signature, matching `/api/chat/start`; ordinary default sessions are unaffected (they still omit the marker, so a default pick stays repairable). Thanks @webtecnica. (#6705)
|
||||
|
||||
+78
-3
@@ -327,6 +327,73 @@ def _gateway_reasoning_effort_for_request(cfg, *, model=None, model_provider=Non
|
||||
return None
|
||||
|
||||
|
||||
def _gateway_session_yolo_enabled(session_id: str) -> bool:
|
||||
"""Return the WebUI-owned, in-memory YOLO state for a browser session."""
|
||||
try:
|
||||
from tools.approval import is_session_yolo_enabled
|
||||
|
||||
return bool(is_session_yolo_enabled(str(session_id or "")))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _settle_gateway_run_approval(
|
||||
session_id: str,
|
||||
approval_data: dict,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
) -> tuple[bool, dict | None, int]:
|
||||
"""Auto-approve or mirror one run approval at a session-linearized point."""
|
||||
from api.route_approvals import gateway_yolo_handoff, submit_gateway_pending_mirror
|
||||
|
||||
run_id = str(approval_data.get("run_id") or "").strip()
|
||||
identity_v1 = bool(approval_data.get("_gateway_agent_identity_v1"))
|
||||
with gateway_yolo_handoff(session_id):
|
||||
if _gateway_session_yolo_enabled(session_id):
|
||||
try:
|
||||
_auto_approve_gateway_run(
|
||||
base_url,
|
||||
api_key,
|
||||
run_id,
|
||||
approval_data["approval_id"] if identity_v1 else "",
|
||||
)
|
||||
return True, None, 0
|
||||
except Exception:
|
||||
# Fail closed: if remote approval fails, surface the real card
|
||||
# before allowing a same-session toggle to pass the handoff.
|
||||
logger.warning(
|
||||
"WebUI YOLO could not auto-approve run %s; showing approval card",
|
||||
run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
head, total = submit_gateway_pending_mirror(session_id, approval_data)
|
||||
return False, head, total
|
||||
|
||||
|
||||
def _auto_approve_gateway_run(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
run_id: str,
|
||||
approval_id: str,
|
||||
) -> None:
|
||||
"""Resolve one Runs API prompt using only the shipped approval contract.
|
||||
|
||||
This is a WebUI-owned compatibility path: the Runs API does not yet expose
|
||||
session YOLO, so WebUI answers each approval request while its own session
|
||||
flag is enabled. Native Agent-side YOLO would be preferable because it can
|
||||
bypass gates before they pause and also covers Agent-owned computer-use
|
||||
policy; https://github.com/NousResearch/hermes-agent/pull/61946 tracks that
|
||||
API capability. Until then, do not send speculative fields to the Agent.
|
||||
"""
|
||||
from api.runner_client import HttpRunnerClient
|
||||
|
||||
HttpRunnerClient(base_url=base_url, api_key=api_key).respond_approval(
|
||||
run_id,
|
||||
approval_id,
|
||||
"once",
|
||||
)
|
||||
|
||||
|
||||
def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict:
|
||||
"""Return redacted Gateway-backed chat configuration status."""
|
||||
mode = webui_chat_backend_mode(config_data, environ)
|
||||
@@ -615,9 +682,17 @@ def _run_gateway_runs_api_streaming(
|
||||
if approval_data:
|
||||
approval_data["run_id"] = run_id
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
approval_data["_gateway_agent_identity_v1"] = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
from api.route_approvals import submit_gateway_pending_mirror
|
||||
head, total = submit_gateway_pending_mirror(session_id, approval_data)
|
||||
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
approval_data["_gateway_agent_identity_v1"] = identity_v1
|
||||
auto_approved, head, total = _settle_gateway_run_approval(
|
||||
session_id,
|
||||
approval_data,
|
||||
base_url,
|
||||
api_key,
|
||||
)
|
||||
if auto_approved:
|
||||
sse_event = "message"
|
||||
continue
|
||||
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
|
||||
sse_event = "message"
|
||||
continue
|
||||
|
||||
+222
-8
@@ -6,6 +6,7 @@ Extracts approval state, not handlers, by design.
|
||||
import queue
|
||||
import threading
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
|
||||
from api.session_events import publish_session_list_changed
|
||||
|
||||
@@ -50,6 +51,95 @@ _GATEWAY_MIRROR_RETAINED = "_gateway_mirror_retained"
|
||||
_GATEWAY_ENTRY_DATA_TOKEN_KEY = "_webui_mirror_token"
|
||||
_GATEWAY_AGENT_IDENTITY_V1 = "_gateway_agent_identity_v1"
|
||||
_gateway_relay_owners: dict[tuple[str, str], str] = {}
|
||||
_yolo_transition_lock = threading.Lock()
|
||||
_yolo_transitions: dict[str, dict] = {}
|
||||
_gateway_yolo_handoff_guard = threading.Lock()
|
||||
_gateway_yolo_handoffs: dict[str, dict] = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def gateway_yolo_handoff(session_key: str):
|
||||
"""Serialize one session's YOLO toggles with gateway approval dispatch."""
|
||||
session_key = str(session_key or "").strip()
|
||||
with _gateway_yolo_handoff_guard:
|
||||
entry = _gateway_yolo_handoffs.get(session_key)
|
||||
if entry is None:
|
||||
entry = {"lock": threading.Lock(), "users": 0}
|
||||
_gateway_yolo_handoffs[session_key] = entry
|
||||
entry["users"] += 1
|
||||
lock = entry["lock"]
|
||||
lock.acquire()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock.release()
|
||||
with _gateway_yolo_handoff_guard:
|
||||
entry["users"] -= 1
|
||||
if entry["users"] == 0:
|
||||
_gateway_yolo_handoffs.pop(session_key, None)
|
||||
|
||||
|
||||
def begin_session_yolo_transition(session_key: str) -> object | None:
|
||||
"""Register a pending YOLO enable until one approval relay settles.
|
||||
|
||||
Multiple tabs may relay approvals for different runs in the same session.
|
||||
Track every in-flight enable intent so one failed relay cannot undo another
|
||||
successful or explicit enable. Do not publish an unconfirmed enable to the
|
||||
shared session flag: the gateway stream may only auto-approve later prompts
|
||||
after a relay succeeds or an explicit enable wins.
|
||||
"""
|
||||
session_key = str(session_key or "").strip()
|
||||
if not session_key:
|
||||
return None
|
||||
token = object()
|
||||
with _yolo_transition_lock:
|
||||
transition = _yolo_transitions.get(session_key)
|
||||
if transition is None:
|
||||
transition = {
|
||||
"was_enabled": bool(is_session_yolo_enabled(session_key)),
|
||||
"tokens": set(),
|
||||
"committed": False,
|
||||
}
|
||||
_yolo_transitions[session_key] = transition
|
||||
transition["tokens"].add(token)
|
||||
return token
|
||||
|
||||
|
||||
def finish_session_yolo_transition(session_key: str, token: object | None, *, succeeded: bool) -> None:
|
||||
"""Settle one pending YOLO enable without exposing or applying stale state."""
|
||||
session_key = str(session_key or "").strip()
|
||||
if not session_key or token is None:
|
||||
return
|
||||
with _yolo_transition_lock:
|
||||
transition = _yolo_transitions.get(session_key)
|
||||
if transition is None or token not in transition["tokens"]:
|
||||
return
|
||||
transition["tokens"].remove(token)
|
||||
if succeeded:
|
||||
transition["committed"] = True
|
||||
# The first confirmed relay commits YOLO immediately. Any remaining
|
||||
# tokens may fail later but cannot revoke this successful enable.
|
||||
enable_session_yolo(session_key)
|
||||
if transition["tokens"]:
|
||||
return
|
||||
_yolo_transitions.pop(session_key, None)
|
||||
if transition["committed"] or transition["was_enabled"]:
|
||||
enable_session_yolo(session_key)
|
||||
else:
|
||||
disable_session_yolo(session_key)
|
||||
|
||||
|
||||
def set_session_yolo_enabled(session_key: str, enabled: bool) -> None:
|
||||
"""Apply an explicit YOLO choice and supersede in-flight rollbacks."""
|
||||
session_key = str(session_key or "").strip()
|
||||
if not session_key:
|
||||
return
|
||||
with _yolo_transition_lock:
|
||||
_yolo_transitions.pop(session_key, None)
|
||||
if enabled:
|
||||
enable_session_yolo(session_key)
|
||||
else:
|
||||
disable_session_yolo(session_key)
|
||||
|
||||
|
||||
def _approval_sse_subscribe(session_id: str) -> queue.Queue:
|
||||
@@ -210,12 +300,15 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
live_mirror_present = True
|
||||
continue
|
||||
if live_token:
|
||||
if entry.get(_GATEWAY_MIRROR_RETAINED):
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
if entry_token:
|
||||
changed = True
|
||||
continue
|
||||
deferred_run_entries.append(entry)
|
||||
continue
|
||||
if not entry_token:
|
||||
if entry.get(_GATEWAY_MIRROR_RETAINED) or not entry_token:
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
changed = True
|
||||
@@ -276,10 +369,16 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
return head, total, changed
|
||||
|
||||
|
||||
def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None:
|
||||
def _gateway_pending_mirror_locked(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> dict | None:
|
||||
"""Return the exact live run-backed mirror under `_lock`."""
|
||||
approval_id = str(approval_id or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
mirror_token = str(mirror_token or "").strip()
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
if approval_id:
|
||||
@@ -296,6 +395,8 @@ def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_
|
||||
continue
|
||||
if run_id and entry_run_id != run_id:
|
||||
continue
|
||||
if mirror_token and str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip() != mirror_token:
|
||||
continue
|
||||
if run_id:
|
||||
return entry
|
||||
if matched_entry is not None:
|
||||
@@ -305,19 +406,46 @@ def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_
|
||||
for entry in entries:
|
||||
if not _is_gateway_mirror_entry(entry) or not str(entry.get("run_id") or "").strip():
|
||||
continue
|
||||
if run_id and entry.get("run_id") == run_id:
|
||||
return entry
|
||||
if mirror_token and str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip() != mirror_token:
|
||||
continue
|
||||
if run_id:
|
||||
if entry.get("run_id") == run_id:
|
||||
return entry
|
||||
continue
|
||||
# With no caller-supplied identity, the queue order is authoritative:
|
||||
# return the current run-backed projection and let its embedded
|
||||
# `(approval_id, run_id)` identify the exact relay owner.
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None:
|
||||
def gateway_pending_mirror(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> dict | None:
|
||||
"""Return an exact live run-backed mirror for this session."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
entry = _gateway_pending_mirror_locked(session_key, approval_id, run_id)
|
||||
entry = _gateway_pending_mirror_locked(session_key, approval_id, run_id, mirror_token)
|
||||
return dict(entry) if entry else None
|
||||
|
||||
|
||||
def gateway_pending_mirrors(session_key: str) -> list[dict]:
|
||||
"""Return every currently parked run-backed mirror in queue order."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
return [
|
||||
dict(entry)
|
||||
for entry in entries
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and str(entry.get("run_id") or "").strip()
|
||||
]
|
||||
|
||||
|
||||
def claim_gateway_approval_relay_owner(session_key: str, run_id: str, approval_id: str) -> bool:
|
||||
"""Claim the single-flight relay owner for one `(session, run)` pair."""
|
||||
session_key = str(session_key or "").strip()
|
||||
@@ -348,7 +476,12 @@ def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval
|
||||
_gateway_relay_owners.pop(key, None)
|
||||
|
||||
|
||||
def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> bool:
|
||||
def retire_gateway_pending_mirror(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> bool:
|
||||
"""Retire one approval, or every mirror for a terminal run."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
@@ -359,7 +492,12 @@ def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_i
|
||||
retained_gateway_queue = gateway_queue
|
||||
gateway_queue_changed = False
|
||||
if approval_id:
|
||||
match = _gateway_pending_mirror_locked(session_key, approval_id, run_id)
|
||||
match = _gateway_pending_mirror_locked(
|
||||
session_key,
|
||||
approval_id,
|
||||
run_id,
|
||||
mirror_token,
|
||||
)
|
||||
if match is None and not normalized_run_id:
|
||||
match = next((entry for entry in entries if _is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
@@ -523,6 +661,8 @@ def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dic
|
||||
mirror_entry["run_id"] = run_id
|
||||
mirror_entry["approval_id"] = approval_id
|
||||
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
|
||||
mirror_entry[_GATEWAY_MIRROR_TOKEN] = uuid.uuid4().hex
|
||||
mirror_entry[_GATEWAY_MIRROR_RETAINED] = True
|
||||
if not _gateway_pending_mirror_locked(session_key, approval_id=approval_id, run_id=run_id):
|
||||
_normalize_pending_queue_locked(session_key).append(mirror_entry)
|
||||
elif not exact_local_entry:
|
||||
@@ -632,6 +772,80 @@ def resolve_gateway_pending_local_no_run_mirror(
|
||||
return True, 1, head, total
|
||||
|
||||
|
||||
def resolve_gateway_pending_local_all(
|
||||
session_key: str,
|
||||
choice: str,
|
||||
reason: str | None = None,
|
||||
) -> tuple[int, dict | None, int]:
|
||||
"""Resolve every parked local/no-run approval without touching remote runs."""
|
||||
targets = []
|
||||
removed_pending = False
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("run_id") or "").strip():
|
||||
retained_gateway_queue.append(entry)
|
||||
else:
|
||||
targets.append(entry)
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
retained_pending = [
|
||||
entry
|
||||
for entry in entries
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and str(entry.get("run_id") or "").strip()
|
||||
]
|
||||
removed_pending = len(retained_pending) != len(entries)
|
||||
if retained_pending:
|
||||
_pending[session_key] = retained_pending
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
|
||||
for entry in targets:
|
||||
entry.result = choice
|
||||
if reason:
|
||||
entry.reason = reason
|
||||
entry.event.set()
|
||||
if targets or removed_pending:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return len(targets), head, total
|
||||
|
||||
|
||||
def settle_gateway_pending_local_notification(
|
||||
session_key: str,
|
||||
approval: dict,
|
||||
) -> tuple[bool, dict | None, int]:
|
||||
"""Auto-resolve or publish one local approval at the YOLO handoff boundary.
|
||||
|
||||
The Agent adds its blocking entry before invoking WebUI's notify callback.
|
||||
Serialize that callback with session YOLO commit/disable so a waiter arriving
|
||||
after a drain snapshot cannot be parked behind an already-committed enable.
|
||||
Run-backed approvals stay on the Runs API path and are never resolved here.
|
||||
"""
|
||||
with gateway_yolo_handoff(session_key):
|
||||
run_id = str((approval or {}).get("run_id") or "").strip()
|
||||
if not run_id and is_session_yolo_enabled(session_key):
|
||||
_resolved, head, total = resolve_gateway_pending_local_all(
|
||||
session_key,
|
||||
"once",
|
||||
)
|
||||
return True, head, total
|
||||
head, total = submit_gateway_pending_mirror(session_key, approval)
|
||||
return False, head, total
|
||||
|
||||
|
||||
def submit_pending(session_key: str, approval: dict) -> None:
|
||||
"""Append a pending approval to the per-session queue.
|
||||
|
||||
|
||||
+428
-110
@@ -9934,16 +9934,23 @@ from api.route_approvals import ( # noqa: F401 — re-exports for backward comp
|
||||
_approval_sse_unsubscribe,
|
||||
_approval_sse_notify_locked,
|
||||
_approval_sse_notify,
|
||||
_GATEWAY_AGENT_IDENTITY_V1,
|
||||
_GATEWAY_MIRROR_FLAG,
|
||||
_GATEWAY_MIRROR_TOKEN,
|
||||
_gateway_mirror_entry_token,
|
||||
gateway_yolo_handoff,
|
||||
begin_session_yolo_transition,
|
||||
claim_gateway_approval_relay_owner,
|
||||
finish_session_yolo_transition,
|
||||
gateway_pending_mirror,
|
||||
gateway_pending_mirrors,
|
||||
release_gateway_approval_relay_owner,
|
||||
retire_gateway_pending_mirror,
|
||||
reconcile_gateway_pending_mirror_locked,
|
||||
resolve_gateway_pending_local,
|
||||
resolve_gateway_pending_local_all,
|
||||
resolve_gateway_pending_local_no_run_mirror,
|
||||
set_session_yolo_enabled,
|
||||
submit_gateway_pending_mirror,
|
||||
submit_pending,
|
||||
)
|
||||
@@ -15593,22 +15600,15 @@ def handle_post(handler, parsed) -> bool:
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
sid = body["session_id"]
|
||||
sid = str(body["session_id"] or "").strip()
|
||||
enabled = bool(body.get("enabled", True))
|
||||
if enabled:
|
||||
enable_session_yolo(sid)
|
||||
# Also resolve any pending approvals for this session so the
|
||||
# agent doesn't stay stuck waiting on an already-dismissed card.
|
||||
try:
|
||||
from tools.approval import _pending as _p, _lock as _l
|
||||
with _l:
|
||||
_p.pop(sid, None)
|
||||
except Exception:
|
||||
pass
|
||||
resolve_gateway_approval(sid, "once", resolve_all=True)
|
||||
else:
|
||||
disable_session_yolo(sid)
|
||||
return j(handler, {"ok": True, "yolo_enabled": enabled})
|
||||
if not enabled:
|
||||
with gateway_yolo_handoff(sid):
|
||||
set_session_yolo_enabled(sid, False)
|
||||
return j(handler, {"ok": True, "yolo_enabled": bool(is_session_yolo_enabled(sid))})
|
||||
|
||||
payload, status = _enable_session_yolo_and_release_pending(sid, choice="once")
|
||||
return j(handler, payload, status=status)
|
||||
|
||||
if parsed.path == "/api/btw":
|
||||
return _handle_btw(handler, body)
|
||||
@@ -24677,6 +24677,269 @@ _GATEWAY_APPROVAL_RELAY_IN_PROGRESS = (
|
||||
)
|
||||
|
||||
|
||||
def _gateway_approval_failure(
|
||||
sid: str,
|
||||
choice: str,
|
||||
*,
|
||||
code: str,
|
||||
error: str,
|
||||
status: int,
|
||||
enable_yolo: bool,
|
||||
relayed: bool = False,
|
||||
) -> tuple[dict, int]:
|
||||
"""Build a failed relay response with authoritative session-YOLO state."""
|
||||
payload = {
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": relayed,
|
||||
"code": code,
|
||||
"error": error,
|
||||
}
|
||||
if enable_yolo:
|
||||
payload["yolo_enabled"] = bool(is_session_yolo_enabled(sid))
|
||||
return payload, status
|
||||
|
||||
|
||||
def _relay_gateway_run_approval(
|
||||
sid: str,
|
||||
mirror: dict,
|
||||
choice: str,
|
||||
*,
|
||||
enable_yolo: bool,
|
||||
) -> tuple[dict, int]:
|
||||
"""Relay one exact run-backed mirror under the shared `(session, run)` owner.
|
||||
|
||||
The mirror remains actionable unless the remote Runs API confirms success.
|
||||
Both the approval-card endpoint and the ordinary session-YOLO endpoint use
|
||||
this chokepoint so one tab cannot retire another tab's parked remote run.
|
||||
"""
|
||||
from api.config import gateway_supports_approval_identity_v1, get_config as _get_config
|
||||
from api.gateway_chat import _gateway_api_key, _gateway_base_url
|
||||
from api.runner_client import HttpRunnerClient, RunnerClientError
|
||||
|
||||
run_id = str(mirror.get("run_id") or "").strip()
|
||||
approval_id = str(mirror.get("approval_id") or "").strip()
|
||||
mirror_token = str(mirror.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
|
||||
if not run_id or not approval_id:
|
||||
return _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
if not claim_gateway_approval_relay_owner(sid, run_id, approval_id):
|
||||
return _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_approval_in_progress",
|
||||
error=_GATEWAY_APPROVAL_RELAY_IN_PROGRESS,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
|
||||
try:
|
||||
current_mirror = gateway_pending_mirror(
|
||||
sid,
|
||||
approval_id=approval_id,
|
||||
run_id=run_id,
|
||||
mirror_token=mirror_token,
|
||||
)
|
||||
if not current_mirror:
|
||||
return _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
|
||||
base_url = _gateway_base_url(_get_config())
|
||||
api_key = _gateway_api_key()
|
||||
identity_v1 = bool(current_mirror.get(_GATEWAY_AGENT_IDENTITY_V1)) and (
|
||||
gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
)
|
||||
if not identity_v1:
|
||||
run_head = gateway_pending_mirror(sid, run_id=run_id)
|
||||
if not run_head or str(run_head.get("approval_id") or "").strip() != approval_id:
|
||||
return _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
|
||||
yolo_transition = begin_session_yolo_transition(sid) if enable_yolo else None
|
||||
relay_error = None
|
||||
relay_succeeded = False
|
||||
try:
|
||||
HttpRunnerClient(base_url=base_url, api_key=api_key).respond_approval(
|
||||
run_id,
|
||||
approval_id if identity_v1 else "",
|
||||
choice,
|
||||
)
|
||||
relay_succeeded = True
|
||||
except (RunnerClientError, ValueError) as exc:
|
||||
relay_error = str(exc)
|
||||
finally:
|
||||
finish_session_yolo_transition(sid, yolo_transition, succeeded=relay_succeeded)
|
||||
|
||||
if relay_error is not None:
|
||||
return _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_approval_relay_failed",
|
||||
error=relay_error,
|
||||
status=502,
|
||||
enable_yolo=enable_yolo,
|
||||
relayed=True,
|
||||
)
|
||||
|
||||
# The outbound relay resumes the remote run. Retire the local projection
|
||||
# only after that succeeds, then settle any matching in-process mirror.
|
||||
_resolve_approval_legacy(sid, approval_id, choice, run_id=run_id)
|
||||
retire_gateway_pending_mirror(
|
||||
sid,
|
||||
approval_id=approval_id,
|
||||
run_id=run_id,
|
||||
mirror_token=mirror_token,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"choice": choice,
|
||||
"relayed": True,
|
||||
**(
|
||||
{"yolo_enabled": bool(is_session_yolo_enabled(sid))}
|
||||
if enable_yolo
|
||||
else {}
|
||||
),
|
||||
}, 200
|
||||
finally:
|
||||
release_gateway_approval_relay_owner(sid, run_id, approval_id)
|
||||
|
||||
|
||||
def _pending_approval_owner_state(
|
||||
sid: str,
|
||||
approval_id: str,
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> tuple[bool, bool]:
|
||||
"""Return `(exact_owner_exists, any_pending_exists)` under queue authority."""
|
||||
approval_id = str(approval_id or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
mirror_token = str(mirror_token or "").strip()
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(sid)
|
||||
queue = _pending.get(sid)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
exact = False
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or str(entry.get("approval_id") or "") != approval_id:
|
||||
continue
|
||||
entry_run_id = str(entry.get("run_id") or "").strip()
|
||||
entry_mirror_token = str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
|
||||
if run_id and entry_run_id != run_id:
|
||||
continue
|
||||
if mirror_token and entry_mirror_token != mirror_token:
|
||||
continue
|
||||
if (run_id or mirror_token) and not entry.get(_GATEWAY_MIRROR_FLAG):
|
||||
continue
|
||||
exact = True
|
||||
break
|
||||
return exact, bool(entries or _gateway_queues.get(sid))
|
||||
|
||||
|
||||
def _enable_session_yolo_and_release_pending(
|
||||
sid: str,
|
||||
*,
|
||||
choice: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
include_choice: bool = False,
|
||||
) -> tuple[dict, int]:
|
||||
"""Relay every parked remote approval, drain local waiters, then commit YOLO."""
|
||||
approval_id = str(approval_id or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
mirror_token = str(mirror_token or "").strip()
|
||||
has_exact_remote_owner = bool(run_id or mirror_token)
|
||||
if has_exact_remote_owner and (not approval_id or not run_id or not mirror_token):
|
||||
return _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=True,
|
||||
)
|
||||
|
||||
yolo_transition = None
|
||||
try:
|
||||
with gateway_yolo_handoff(sid):
|
||||
yolo_transition = begin_session_yolo_transition(sid)
|
||||
stale_cleared = False
|
||||
if approval_id:
|
||||
exact_owner, any_pending = _pending_approval_owner_state(
|
||||
sid,
|
||||
approval_id,
|
||||
run_id,
|
||||
mirror_token,
|
||||
)
|
||||
if not exact_owner and (has_exact_remote_owner or any_pending):
|
||||
return _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=True,
|
||||
)
|
||||
stale_cleared = not exact_owner
|
||||
|
||||
run_mirrors = gateway_pending_mirrors(sid)
|
||||
relayed = 0
|
||||
for mirror in run_mirrors:
|
||||
relay_payload, relay_status = _relay_gateway_run_approval(
|
||||
sid,
|
||||
mirror,
|
||||
choice,
|
||||
enable_yolo=False,
|
||||
)
|
||||
if relay_status != 200 or not relay_payload.get("ok"):
|
||||
finish_session_yolo_transition(
|
||||
sid,
|
||||
yolo_transition,
|
||||
succeeded=False,
|
||||
)
|
||||
yolo_transition = None
|
||||
return {
|
||||
**relay_payload,
|
||||
"yolo_enabled": bool(is_session_yolo_enabled(sid)),
|
||||
}, relay_status
|
||||
relayed += 1
|
||||
|
||||
resolve_gateway_pending_local_all(
|
||||
sid,
|
||||
choice,
|
||||
)
|
||||
finish_session_yolo_transition(sid, yolo_transition, succeeded=True)
|
||||
yolo_transition = None
|
||||
return {
|
||||
"ok": True,
|
||||
"yolo_enabled": bool(is_session_yolo_enabled(sid)),
|
||||
**({"choice": choice} if include_choice or relayed else {}),
|
||||
**({"relayed": True} if relayed else {}),
|
||||
**({"stale_cleared": True} if stale_cleared else {}),
|
||||
}, 200
|
||||
finally:
|
||||
if yolo_transition is not None:
|
||||
finish_session_yolo_transition(sid, yolo_transition, succeeded=False)
|
||||
|
||||
|
||||
def _gateway_pending_approval_without_run_id(sid: str, approval_id: str) -> bool:
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(sid)
|
||||
@@ -24726,6 +24989,55 @@ def _handle_approval_respond(handler, body):
|
||||
if choice not in ("once", "session", "always", "deny"):
|
||||
return bad(handler, f"Invalid choice: {choice}")
|
||||
approval_id = body.get("approval_id", "")
|
||||
enable_yolo = body.get("yolo") is True
|
||||
requested_run_id = str(body.get("run_id") or "").strip()
|
||||
requested_mirror_token = str(body.get("mirror_token") or "").strip()
|
||||
|
||||
if enable_yolo:
|
||||
payload, status = _enable_session_yolo_and_release_pending(
|
||||
sid,
|
||||
choice=choice,
|
||||
approval_id=approval_id,
|
||||
run_id=requested_run_id,
|
||||
mirror_token=requested_mirror_token,
|
||||
include_choice=True,
|
||||
)
|
||||
return j(handler, payload, status=status)
|
||||
|
||||
if requested_run_id or requested_mirror_token:
|
||||
if not approval_id or not requested_run_id or not requested_mirror_token:
|
||||
relay_payload, relay_status = _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=False,
|
||||
)
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
exact_mirror = gateway_pending_mirror(
|
||||
sid,
|
||||
approval_id=approval_id,
|
||||
run_id=requested_run_id,
|
||||
mirror_token=requested_mirror_token,
|
||||
)
|
||||
if exact_mirror is None:
|
||||
relay_payload, relay_status = _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=False,
|
||||
)
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
relay_payload, relay_status = _relay_gateway_run_approval(
|
||||
sid,
|
||||
exact_mirror,
|
||||
choice,
|
||||
enable_yolo=False,
|
||||
)
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
|
||||
# Gateway relay: forward choice to the runs API when session has an active run,
|
||||
# or recover the run_id from the mirrored gateway approval entry if the
|
||||
@@ -24733,12 +25045,9 @@ def _handle_approval_respond(handler, body):
|
||||
try:
|
||||
from api.gateway_chat import (
|
||||
_STREAM_RUN_IDS,
|
||||
_gateway_base_url,
|
||||
_gateway_api_key,
|
||||
webui_gateway_chat_enabled,
|
||||
)
|
||||
from api.config import get_config as _get_config, gateway_supports_approval_identity_v1
|
||||
from api.route_approvals import _GATEWAY_AGENT_IDENTITY_V1
|
||||
from api.config import get_config as _get_config
|
||||
s = get_session(sid)
|
||||
_candidate_run_id = None
|
||||
if s is not None:
|
||||
@@ -24807,17 +25116,15 @@ def _handle_approval_respond(handler, body):
|
||||
if local_match:
|
||||
_candidate_run_id = None
|
||||
if approval_id and not local_match and same_run_stale_without_token:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
},
|
||||
relay_payload, relay_status = _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
matched_mirror = (
|
||||
gateway_pending_mirror(sid, approval_id=approval_id, run_id=_candidate_run_id)
|
||||
if approval_id and not local_match
|
||||
@@ -24828,88 +25135,61 @@ def _handle_approval_respond(handler, body):
|
||||
if local_match:
|
||||
_candidate_run_id = None
|
||||
elif run_backed_gateway_matches > 1 and not _candidate_run_id:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
if _run_id:
|
||||
from api.runner_client import HttpRunnerClient, RunnerClientError
|
||||
_cfg = _get_config()
|
||||
_base = _gateway_base_url(_cfg)
|
||||
_key = _gateway_api_key()
|
||||
claimed_approval_id = str(matched_mirror.get("approval_id") or "").strip()
|
||||
if not claim_gateway_approval_relay_owner(sid, _run_id, claimed_approval_id):
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_approval_in_progress",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_IN_PROGRESS,
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
try:
|
||||
current_mirror = gateway_pending_mirror(
|
||||
relay_payload, relay_status = _gateway_approval_failure(
|
||||
sid,
|
||||
approval_id=claimed_approval_id,
|
||||
run_id=_run_id,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
if not current_mirror:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
},
|
||||
status=409,
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
if _run_id:
|
||||
if enable_yolo:
|
||||
# The visible card path must serialize the same session-wide
|
||||
# handoff as the ordinary /api/session/yolo route and the Runs
|
||||
# stream. Revalidate the exact mirror after acquiring it so a
|
||||
# later approval cannot be parked while this relay commits YOLO.
|
||||
with gateway_yolo_handoff(sid):
|
||||
current_mirror = gateway_pending_mirror(
|
||||
sid,
|
||||
approval_id=approval_id,
|
||||
run_id=_run_id,
|
||||
)
|
||||
identity_v1 = bool(current_mirror.get(_GATEWAY_AGENT_IDENTITY_V1)) and gateway_supports_approval_identity_v1(_base, _key)
|
||||
if not identity_v1:
|
||||
run_head = gateway_pending_mirror(sid, run_id=_run_id)
|
||||
if not run_head or str(run_head.get("approval_id") or "").strip() != claimed_approval_id:
|
||||
return j(
|
||||
handler,
|
||||
{
|
||||
"ok": False,
|
||||
"choice": choice,
|
||||
"relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
},
|
||||
if current_mirror is None:
|
||||
relay_payload, relay_status = _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=True,
|
||||
)
|
||||
matched_mirror = current_mirror
|
||||
try:
|
||||
HttpRunnerClient(base_url=_base, api_key=_key).respond_approval(
|
||||
_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)
|
||||
# The outbound relay only resumes the remote run; the local mirror
|
||||
# still needs the same cleanup path so the parked entry, mirrored
|
||||
# card, and agent signal all settle here too.
|
||||
cleanup_approval_id = matched_mirror["approval_id"]
|
||||
_resolve_approval_legacy(sid, cleanup_approval_id, choice, run_id=_run_id)
|
||||
retire_gateway_pending_mirror(sid, approval_id=cleanup_approval_id, run_id=_run_id)
|
||||
return j(handler, {"ok": True, "choice": choice, "relayed": True})
|
||||
finally:
|
||||
release_gateway_approval_relay_owner(sid, _run_id, claimed_approval_id)
|
||||
else:
|
||||
relay_payload, relay_status = _relay_gateway_run_approval(
|
||||
sid,
|
||||
current_mirror,
|
||||
choice,
|
||||
enable_yolo=True,
|
||||
)
|
||||
else:
|
||||
relay_payload, relay_status = _relay_gateway_run_approval(
|
||||
sid,
|
||||
matched_mirror or {},
|
||||
choice,
|
||||
enable_yolo=False,
|
||||
)
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
if _candidate_run_id:
|
||||
return j(handler, {"ok": False, "choice": choice, "relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409)
|
||||
relay_payload, relay_status = _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
# A no-run mirror is local visibility state only. Resolve it only while
|
||||
# the exact parked producer still exists; otherwise keep the card live
|
||||
# and fail closed instead of claiming success.
|
||||
@@ -24918,11 +25198,28 @@ def _handle_approval_respond(handler, body):
|
||||
sid, approval_id, choice
|
||||
)
|
||||
if handled_no_run_mirror and resolved_count == 1:
|
||||
return j(handler, {"ok": True, "choice": choice, "local_retired": True})
|
||||
if enable_yolo:
|
||||
set_session_yolo_enabled(sid, True)
|
||||
return j(handler, {
|
||||
"ok": True,
|
||||
"choice": choice,
|
||||
"local_retired": True,
|
||||
**(
|
||||
{"yolo_enabled": bool(is_session_yolo_enabled(sid))}
|
||||
if enable_yolo
|
||||
else {}
|
||||
),
|
||||
})
|
||||
if handled_no_run_mirror:
|
||||
return j(handler, {"ok": False, "choice": choice, "relayed": False,
|
||||
"code": "gateway_run_unavailable",
|
||||
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409)
|
||||
relay_payload, relay_status = _gateway_approval_failure(
|
||||
sid,
|
||||
choice,
|
||||
code="gateway_run_unavailable",
|
||||
error=_GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
|
||||
status=409,
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
return j(handler, relay_payload, status=relay_status)
|
||||
except Exception:
|
||||
pass # fall through to local approval path
|
||||
|
||||
@@ -24954,8 +25251,29 @@ def _handle_approval_respond(handler, body):
|
||||
# card instead of dead-ending. When something IS still pending, keep
|
||||
# the protective ok:false. `stale_cleared` lets the frontend log/branch
|
||||
# without showing an error toast.
|
||||
return j(handler, {"ok": True, "choice": choice, "stale_cleared": True})
|
||||
return j(handler, {"ok": ok, "choice": choice})
|
||||
if enable_yolo:
|
||||
set_session_yolo_enabled(sid, True)
|
||||
return j(handler, {
|
||||
"ok": True,
|
||||
"choice": choice,
|
||||
"stale_cleared": True,
|
||||
**(
|
||||
{"yolo_enabled": bool(is_session_yolo_enabled(sid))}
|
||||
if enable_yolo
|
||||
else {}
|
||||
),
|
||||
})
|
||||
if ok and enable_yolo:
|
||||
set_session_yolo_enabled(sid, True)
|
||||
return j(handler, {
|
||||
"ok": ok,
|
||||
"choice": choice,
|
||||
**(
|
||||
{"yolo_enabled": bool(is_session_yolo_enabled(sid))}
|
||||
if ok and enable_yolo
|
||||
else {}
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
def _resolve_clarify_legacy(sid: str, clarify_id: str, response: str) -> bool:
|
||||
|
||||
+9
-4
@@ -8732,22 +8732,27 @@ def _run_agent_streaming(
|
||||
try:
|
||||
try:
|
||||
from api.route_approvals import (
|
||||
submit_gateway_pending_mirror as _submit_pending_for_polling,
|
||||
settle_gateway_pending_local_notification as _settle_pending_for_polling,
|
||||
retire_gateway_pending_mirror as _retire_gateway_pending_mirror,
|
||||
)
|
||||
def _cleanup_gateway_pending_mirror():
|
||||
_retire_gateway_pending_mirror(session_id)
|
||||
except ImportError:
|
||||
_submit_pending_for_polling = None
|
||||
_settle_pending_for_polling = None
|
||||
_cleanup_gateway_pending_mirror = None
|
||||
from tools.approval import (
|
||||
register_gateway_notify as _reg_notify,
|
||||
unregister_gateway_notify as _unreg_notify,
|
||||
)
|
||||
def _approval_notify_cb(approval_data):
|
||||
if _submit_pending_for_polling is not None:
|
||||
if _settle_pending_for_polling is not None:
|
||||
try:
|
||||
head, total = _submit_pending_for_polling(session_id, approval_data)
|
||||
auto_resolved, head, total = _settle_pending_for_polling(
|
||||
session_id,
|
||||
approval_data,
|
||||
)
|
||||
if auto_resolved and head is None:
|
||||
return
|
||||
approval_data = {**(head or approval_data), "pending_count": total}
|
||||
except Exception:
|
||||
logger.warning("Failed to mirror approval into WebUI polling state", exc_info=True)
|
||||
|
||||
@@ -102,6 +102,21 @@ HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
|
||||
|
||||
Use this when the connected gateway advertises approval support and you want tool approval cards to appear in WebUI. Without `HERMES_WEBUI_GATEWAY_USE_RUNS_API=true`, gateway chat stays on the legacy chat-completions transport and approval-capable commands can remain pending in the agent without a WebUI approval card.
|
||||
|
||||
When YOLO is enabled for a gateway-backed browser session, WebUI approves every
|
||||
approval already parked for that session: Runs API prompts are relayed by their
|
||||
exact `run_id` and mirror token, and local/no-run waiters are all released. It
|
||||
then automatically answers later Runs API approval requests while the WebUI
|
||||
session flag remains active. The flag is committed only after every currently
|
||||
parked remote relay succeeds; a later prompt that races that unconfirmed drain
|
||||
remains visible instead of being speculatively auto-approved. The handoff is
|
||||
also shared with local approval admission: a local waiter arriving after
|
||||
the current drain snapshot waits for the same session handoff and is released
|
||||
immediately if YOLO has committed, rather than being parked behind an enabled
|
||||
session. This is client-managed compatibility behavior: the current Runs API has
|
||||
no session-YOLO toggle, so a request briefly reaches the approval boundary before WebUI answers
|
||||
it, and Agent-owned policy such as unrestricted computer-use mode is unchanged.
|
||||
Native API session YOLO is tracked in [Hermes Agent PR #61946](https://github.com/NousResearch/hermes-agent/pull/61946).
|
||||
|
||||
`HERMES_WEBUI_CHAT_BACKEND` is intentionally strict: only `gateway`,
|
||||
`api_server`, or `api-server` enable the bridge. Generic truthy values such as
|
||||
`1` or `true` are ignored so existing deployments do not change execution
|
||||
|
||||
+36
-8
@@ -1941,22 +1941,50 @@ function cmdVoice(){
|
||||
async function cmdYolo(){
|
||||
const sid=S.session&&S.session.session_id;
|
||||
if(!sid){showToast(t('yolo_no_session'));return;}
|
||||
const generation=_loadSessionGeneration;
|
||||
const viewIsCurrent=()=>!!(
|
||||
S.session&&S.session.session_id===sid&&_loadSessionGeneration===generation
|
||||
);
|
||||
let approvalOwner=null;
|
||||
try{
|
||||
// Check current state first to toggle
|
||||
// Check current state first to toggle.
|
||||
const status=await api('/api/session/yolo?session_id='+encodeURIComponent(sid));
|
||||
if(!viewIsCurrent())return;
|
||||
const enable=!status.yolo_enabled;
|
||||
await api('/api/session/yolo',{
|
||||
// A visible approval must belong to this exact session load before any
|
||||
// command handler may POST through it. Otherwise fail closed.
|
||||
const card=$('approvalCard');
|
||||
if(card&&card.classList.contains('visible')){
|
||||
approvalOwner=typeof _captureApprovalResponseOwner==='function'
|
||||
?_captureApprovalResponseOwner()
|
||||
:null;
|
||||
if(!approvalOwner)return;
|
||||
if(enable&&typeof toggleYoloFromApproval==='function'){
|
||||
await toggleYoloFromApproval();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result=await api('/api/session/yolo',{
|
||||
method:'POST',
|
||||
body:JSON.stringify({session_id:sid,enabled:enable}),
|
||||
});
|
||||
_yoloEnabled=enable;
|
||||
if(!viewIsCurrent()||(approvalOwner&&!_approvalResponseOwnerIsCurrent(approvalOwner)))return;
|
||||
const settled=(result&&typeof result.yolo_enabled==='boolean')?result.yolo_enabled:enable;
|
||||
_yoloEnabled=settled;
|
||||
_updateYoloPill();
|
||||
showToast(enable?t('yolo_enabled'):t('yolo_disabled'));
|
||||
if(enable){
|
||||
// Dismiss any visible approval card
|
||||
hideApprovalCard(true);
|
||||
showToast(settled?t('yolo_enabled'):t('yolo_disabled'));
|
||||
}catch(e){
|
||||
if(!viewIsCurrent()||(approvalOwner&&!_approvalResponseOwnerIsCurrent(approvalOwner)))return;
|
||||
let errorPayload=null;
|
||||
if(e&&typeof e.body==='string'){
|
||||
try{errorPayload=JSON.parse(e.body);}catch(_){}
|
||||
}
|
||||
}catch(e){showToast('YOLO: '+e.message);}
|
||||
if(errorPayload&&typeof errorPayload.yolo_enabled==='boolean'){
|
||||
_yoloEnabled=errorPayload.yolo_enabled;
|
||||
_updateYoloPill();
|
||||
}
|
||||
showToast('YOLO: '+((errorPayload&&(errorPayload.error||errorPayload.message))||e.message));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Branch / fork command ──
|
||||
|
||||
+195
-62
@@ -7264,18 +7264,9 @@ function _updateYoloPill() {
|
||||
}
|
||||
|
||||
async function toggleYoloFromApproval() {
|
||||
const sid = S.session && S.session.session_id;
|
||||
if (!sid) return;
|
||||
try {
|
||||
await api('/api/session/yolo', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sid, enabled: true }),
|
||||
});
|
||||
_yoloEnabled = true;
|
||||
_updateYoloPill();
|
||||
hideApprovalCard(true);
|
||||
showToast(t('yolo_enabled'));
|
||||
} catch (e) { showToast('YOLO: ' + e.message); }
|
||||
const owner = _captureApprovalResponseOwner();
|
||||
if (!owner) return false;
|
||||
return !!(await respondApproval('once', {yolo: true, owner}));
|
||||
}
|
||||
|
||||
// ── Approval polling ──
|
||||
@@ -7337,8 +7328,13 @@ function hideApprovalCard(force=false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const preserveDisplayedOwner = _approvalOwnerIdentityMatches(
|
||||
_approvalDisplayedOwner,
|
||||
_approvalResponding,
|
||||
);
|
||||
_approvalSessionId = null;
|
||||
_resetApprovalCardState();
|
||||
if (!preserveDisplayedOwner) _approvalDisplayedOwner = null;
|
||||
card.classList.remove("visible");
|
||||
card.classList.remove("collapsed");
|
||||
_setPromptFlyoutHidden(card, true);
|
||||
@@ -7352,6 +7348,8 @@ let _approvalSessionId = null;
|
||||
let _approvalCurrentId = null; // approval_id of the card currently shown
|
||||
let _approvalPendingBySession = new Map();
|
||||
let _approvalResponding = null;
|
||||
let _approvalClearedOwner = null;
|
||||
let _approvalDisplayedOwner = null;
|
||||
|
||||
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
||||
|
||||
@@ -7440,20 +7438,113 @@ function _renderPendingApprovalForActiveSession() {
|
||||
if (entry) showApprovalCard(entry.pending, entry.pendingCount);
|
||||
}
|
||||
|
||||
function _approvalResponseMatches(sid, approvalId) {
|
||||
function _approvalMirrorOwnerFor(sid, approvalId) {
|
||||
const entry = _approvalPendingBySession.get(sid);
|
||||
const pending = entry && entry.pending;
|
||||
if (!pending || pending.approval_id !== approvalId) return {runId: '', mirrorToken: ''};
|
||||
const runId = String(pending.run_id || '').trim();
|
||||
const mirrorToken = String(pending._gateway_mirror_token || '').trim();
|
||||
return runId && mirrorToken ? {runId, mirrorToken} : {runId: '', mirrorToken: ''};
|
||||
}
|
||||
|
||||
function _approvalOwnerForPending(sid, pending) {
|
||||
if (!pending) return null;
|
||||
const approvalId = pending.approval_id || null;
|
||||
if (!sid || !approvalId) return null;
|
||||
const runId = String(pending.run_id || '').trim();
|
||||
const mirrorToken = String(pending._gateway_mirror_token || '').trim();
|
||||
return {
|
||||
sid,
|
||||
approvalId,
|
||||
runId: runId && mirrorToken ? runId : '',
|
||||
mirrorToken: runId && mirrorToken ? mirrorToken : '',
|
||||
};
|
||||
}
|
||||
|
||||
function _approvalOwnerIdentityMatches(left, right) {
|
||||
return !!(
|
||||
left &&
|
||||
right &&
|
||||
left.sid === right.sid &&
|
||||
left.approvalId === right.approvalId &&
|
||||
left.runId === right.runId &&
|
||||
left.mirrorToken === right.mirrorToken
|
||||
);
|
||||
}
|
||||
|
||||
function _captureApprovalResponseOwner() {
|
||||
const card = $("approvalCard");
|
||||
const sid = _approvalSessionId;
|
||||
const approvalId = _approvalCurrentId;
|
||||
if (!card || !card.classList.contains("visible") || !sid || !approvalId) return null;
|
||||
if (!S.session || S.session.session_id !== sid) return null;
|
||||
if (
|
||||
!_approvalDisplayedOwner ||
|
||||
_approvalDisplayedOwner.sid !== sid ||
|
||||
_approvalDisplayedOwner.approvalId !== approvalId
|
||||
) return null;
|
||||
return {..._approvalDisplayedOwner, generation: _loadSessionGeneration};
|
||||
}
|
||||
|
||||
function _approvalResponseOwnerIsCurrent(owner) {
|
||||
return !!(
|
||||
owner &&
|
||||
S.session &&
|
||||
S.session.session_id === owner.sid &&
|
||||
_loadSessionGeneration === owner.generation &&
|
||||
_approvalOwnerIdentityMatches(_approvalDisplayedOwner, owner)
|
||||
);
|
||||
}
|
||||
|
||||
function _approvalResponseMatches(
|
||||
sid,
|
||||
approvalId,
|
||||
generation = _loadSessionGeneration,
|
||||
mirrorOwner = _approvalMirrorOwnerFor(sid, approvalId),
|
||||
) {
|
||||
return !!(
|
||||
_approvalResponding &&
|
||||
_approvalResponding.sid === sid &&
|
||||
(_approvalResponding.approvalId || null) === (approvalId || null)
|
||||
_approvalResponding.generation === generation &&
|
||||
_approvalResponding.approvalId === approvalId &&
|
||||
_approvalResponding.runId === mirrorOwner.runId &&
|
||||
_approvalResponding.mirrorToken === mirrorOwner.mirrorToken
|
||||
);
|
||||
}
|
||||
|
||||
function _releaseApprovalResponseOwner(owner) {
|
||||
if (_approvalResponseMatches(owner.sid, owner.approvalId, owner.generation, owner)) {
|
||||
_approvalResponding = null;
|
||||
}
|
||||
const card = $("approvalCard");
|
||||
if (
|
||||
_approvalOwnerIdentityMatches(_approvalDisplayedOwner, owner) &&
|
||||
(!card || !card.classList.contains("visible"))
|
||||
) {
|
||||
_approvalDisplayedOwner = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _approvalClearedOwnerMayRefresh(owner) {
|
||||
return !!(
|
||||
_approvalClearedOwner === owner &&
|
||||
S.session &&
|
||||
S.session.session_id === owner.sid &&
|
||||
_loadSessionGeneration === owner.generation &&
|
||||
_approvalSessionId === null &&
|
||||
_approvalCurrentId === null
|
||||
);
|
||||
}
|
||||
|
||||
function _setApprovalControlsDisabled(choice, disabled) {
|
||||
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny"].forEach(id => {
|
||||
const loadingId = choice === "skipAll"
|
||||
? "approvalSkipAll"
|
||||
: (choice ? "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1) : null);
|
||||
["approvalBtnOnce","approvalBtnSession","approvalBtnAlways","approvalBtnDeny","approvalSkipAll"].forEach(id => {
|
||||
const b = $(id);
|
||||
if (!b) return;
|
||||
b.disabled = !!disabled;
|
||||
if (disabled && choice && b.id === "approvalBtn" + choice.charAt(0).toUpperCase() + choice.slice(1)) {
|
||||
if (disabled && b.id === loadingId) {
|
||||
b.classList.add("loading");
|
||||
} else {
|
||||
b.classList.remove("loading");
|
||||
@@ -7471,16 +7562,25 @@ function showApprovalCard(pending, pendingCount) {
|
||||
const sid = _rememberApprovalPending(pending, pendingCount);
|
||||
if (!_approvalPromptBelongsToActiveSession(sid)) return;
|
||||
if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return;
|
||||
_approvalClearedOwner = null;
|
||||
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
||||
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
||||
const cmd = pending.command || "";
|
||||
const sig = JSON.stringify({desc, cmd, sid: pending._session_id || (S.session && S.session.session_id) || null, approval_id: pending.approval_id || null});
|
||||
const sig = JSON.stringify({
|
||||
desc,
|
||||
cmd,
|
||||
sid: pending._session_id || (S.session && S.session.session_id) || null,
|
||||
approval_id: pending.approval_id || null,
|
||||
run_id: pending.run_id || null,
|
||||
mirror_token: pending._gateway_mirror_token || null,
|
||||
});
|
||||
const card = $("approvalCard");
|
||||
const sameApproval = card.classList.contains("visible") && _approvalSignature === sig;
|
||||
$("approvalDesc").textContent = desc;
|
||||
$("approvalCmd").textContent = cmd;
|
||||
_approvalSessionId = sid;
|
||||
_approvalCurrentId = pending.approval_id || null;
|
||||
_approvalDisplayedOwner = _approvalOwnerForPending(sid, pending);
|
||||
_approvalSignature = sig;
|
||||
// Show "1 of N" counter when multiple approvals are queued
|
||||
const counter = $("approvalCounter");
|
||||
@@ -7503,7 +7603,7 @@ function showApprovalCard(pending, pendingCount) {
|
||||
}
|
||||
const responding = _approvalResponseMatches(sid, _approvalCurrentId);
|
||||
_setApprovalControlsDisabled(
|
||||
responding ? _approvalResponding.choice : null,
|
||||
responding ? (_approvalResponding.controlChoice || _approvalResponding.choice) : null,
|
||||
responding,
|
||||
);
|
||||
_setPromptFlyoutHidden(card, false);
|
||||
@@ -7575,14 +7675,22 @@ function _syncApprovalTranscriptSpace(card, opts) {
|
||||
setTimeout(measure, 420);
|
||||
}
|
||||
|
||||
function _restoreFailedApprovalResponse(sid, errMsg) {
|
||||
_approvalResponding = null;
|
||||
function _restoreFailedApprovalResponse(owner, errMsg) {
|
||||
const isCurrent = _approvalResponseOwnerIsCurrent(owner);
|
||||
_releaseApprovalResponseOwner(owner);
|
||||
if (!isCurrent) return;
|
||||
_setApprovalControlsDisabled(null, false);
|
||||
if (_approvalPromptBelongsToActiveSession(sid)) _renderPendingApprovalForActiveSession();
|
||||
_renderPendingApprovalForActiveSession();
|
||||
if (typeof showToast === "function") showToast(errMsg, 5000);
|
||||
if (typeof setStatus === "function") setStatus(errMsg);
|
||||
}
|
||||
|
||||
function _applyApprovalYoloProjection(result) {
|
||||
if (!result || typeof result.yolo_enabled !== "boolean") return;
|
||||
_yoloEnabled = result.yolo_enabled;
|
||||
_updateYoloPill();
|
||||
}
|
||||
|
||||
function toggleApprovalCardCollapsed(forceCollapsed) {
|
||||
const card = $("approvalCard");
|
||||
if (!card) return;
|
||||
@@ -7592,59 +7700,84 @@ function toggleApprovalCardCollapsed(forceCollapsed) {
|
||||
_syncApprovalTranscriptSpace(card, {immediate: true});
|
||||
}
|
||||
|
||||
async function respondApproval(choice) {
|
||||
const sid = _approvalSessionId || (S.session && S.session.session_id);
|
||||
if (!sid) return;
|
||||
const approvalId = _approvalCurrentId;
|
||||
if (_approvalResponseMatches(sid, approvalId)) return;
|
||||
async function respondApproval(choice, options = {}) {
|
||||
const owner = options.owner || _captureApprovalResponseOwner();
|
||||
if (!_approvalResponseOwnerIsCurrent(owner)) return false;
|
||||
const {sid, approvalId} = owner;
|
||||
if (_approvalResponseMatches(sid, approvalId, owner.generation, owner)) return false;
|
||||
_approvalClearedOwner = null;
|
||||
_unmarkApprovalDismissed(sid, approvalId);
|
||||
_approvalResponding = {sid, approvalId: approvalId || null, choice};
|
||||
_setApprovalControlsDisabled(choice, true);
|
||||
const controlChoice = options.yolo ? "skipAll" : choice;
|
||||
_approvalResponding = {...owner, choice};
|
||||
_approvalResponding.controlChoice = controlChoice;
|
||||
_setApprovalControlsDisabled(controlChoice, true);
|
||||
try {
|
||||
const result = await api("/api/approval/respond", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id: sid, choice, approval_id: approvalId })
|
||||
body: JSON.stringify({
|
||||
session_id: sid,
|
||||
choice,
|
||||
approval_id: approvalId,
|
||||
...(owner.runId ? {run_id: owner.runId} : {}),
|
||||
...(owner.mirrorToken ? {mirror_token: owner.mirrorToken} : {}),
|
||||
...(options.yolo ? {yolo: true} : {}),
|
||||
})
|
||||
});
|
||||
if (!_approvalResponseOwnerIsCurrent(owner)) {
|
||||
_releaseApprovalResponseOwner(owner);
|
||||
return false;
|
||||
}
|
||||
if (result && result.ok) {
|
||||
_approvalResponding = null;
|
||||
_releaseApprovalResponseOwner(owner);
|
||||
if (options.yolo) _applyApprovalYoloProjection(result);
|
||||
const pendingEntry = _approvalPendingBySession.get(sid);
|
||||
const samePending = !!(pendingEntry && pendingEntry.pending && (pendingEntry.pending.approval_id || null) === (approvalId || null));
|
||||
// `stale_cleared` means the server found nothing pending for this session
|
||||
// (the approval already resolved or its stream ended while the card was
|
||||
// up). The orphan card must be cleared unconditionally so it can never
|
||||
// get stuck — even if the displayed id has since drifted. (#4948 local
|
||||
// variant: previously surfaced as a stuck "Approval response not
|
||||
// accepted." toast.)
|
||||
if (result.stale_cleared || (_approvalSessionId === sid && _approvalCurrentId === approvalId)) {
|
||||
_approvalSessionId = null;
|
||||
_approvalCurrentId = null;
|
||||
hideApprovalCard(true);
|
||||
}
|
||||
if (samePending || result.stale_cleared) _clearApprovalPendingForSession(sid);
|
||||
// Hardening for the narrow stale-clear race: a brand-new approval could
|
||||
// have been parked server-side after the server's empty-check but before
|
||||
// we processed this stale response. The unconditional clear above would
|
||||
// hide that fresh card. Re-query the authoritative server pending state
|
||||
// (same endpoint the fallback poll uses) so any approval that arrived in
|
||||
// the window re-surfaces immediately instead of waiting for the next
|
||||
// SSE/poll tick. Best-effort; poll/SSE remain the backstop. (Opus review
|
||||
// nit on the #4948 fix.)
|
||||
const pendingOwner = _approvalMirrorOwnerFor(sid, approvalId);
|
||||
const samePending = !!(
|
||||
pendingEntry &&
|
||||
pendingEntry.pending &&
|
||||
pendingEntry.pending.approval_id === approvalId &&
|
||||
pendingOwner.runId === owner.runId &&
|
||||
pendingOwner.mirrorToken === owner.mirrorToken
|
||||
);
|
||||
if (samePending) _clearApprovalPendingForSession(sid);
|
||||
_approvalSessionId = null;
|
||||
_approvalCurrentId = null;
|
||||
_approvalClearedOwner = owner;
|
||||
hideApprovalCard(true);
|
||||
if (result.stale_cleared) {
|
||||
api("/api/approval/pending?session_id=" + encodeURIComponent(sid), {timeoutToast: false})
|
||||
.then(data => {
|
||||
if (data && data.pending && _approvalPromptBelongsToActiveSession(sid)) {
|
||||
showApprovalForSession(sid, data.pending, data.pending_count || 1);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
void (async () => {
|
||||
if (!_approvalClearedOwnerMayRefresh(owner)) return;
|
||||
try {
|
||||
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid), {timeoutToast: false});
|
||||
if (!_approvalClearedOwnerMayRefresh(owner)) return;
|
||||
_approvalClearedOwner = null;
|
||||
if (data && data.pending) showApprovalForSession(sid, data.pending, data.pending_count || 1);
|
||||
} catch (_) {
|
||||
if (_approvalClearedOwner === owner) _approvalClearedOwner = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return;
|
||||
if (options.yolo) showToast(t(_yoloEnabled ? 'yolo_enabled' : 'yolo_disabled'));
|
||||
return options.yolo ? result : true;
|
||||
}
|
||||
const errMsg = (result && result.error) || "Approval response not accepted.";
|
||||
_restoreFailedApprovalResponse(sid, errMsg);
|
||||
_restoreFailedApprovalResponse(owner, errMsg);
|
||||
return false;
|
||||
} catch(e) {
|
||||
const errMsg = (e && e.message) || (t("approval_responding") + " failed");
|
||||
_restoreFailedApprovalResponse(sid, errMsg);
|
||||
let errorPayload = null;
|
||||
if (e && typeof e.body === 'string') {
|
||||
try { errorPayload = JSON.parse(e.body); } catch (_) { /* non-JSON HTTP error */ }
|
||||
}
|
||||
const errMsg = (errorPayload && (errorPayload.error || errorPayload.message))
|
||||
|| (e && e.message)
|
||||
|| (t("approval_responding") + " failed");
|
||||
if (!_approvalResponseOwnerIsCurrent(owner)) {
|
||||
_releaseApprovalResponseOwner(owner);
|
||||
return false;
|
||||
}
|
||||
if (options.yolo) _applyApprovalYoloProjection(errorPayload);
|
||||
_restoreFailedApprovalResponse(owner, errMsg);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,8 +113,10 @@ class TestSSEStaticAnalysis:
|
||||
cb_start = streaming_src.find("def _approval_notify_cb(approval_data):")
|
||||
cb_end = streaming_src.find("_reg_notify(session_id, _approval_notify_cb)", cb_start)
|
||||
cb_body = streaming_src[cb_start:cb_end]
|
||||
assert "head, total = _submit_pending_for_polling(session_id, approval_data)" in cb_body, \
|
||||
"_approval_notify_cb must mirror approval data into polling state before SSE"
|
||||
assert "auto_resolved, head, total = _settle_pending_for_polling(" in cb_body, \
|
||||
"_approval_notify_cb must settle local admission before SSE"
|
||||
assert "if auto_resolved and head is None:" in cb_body, \
|
||||
"_approval_notify_cb must suppress SSE for an auto-resolved local approval"
|
||||
assert '"pending_count": total' in cb_body, \
|
||||
"_approval_notify_cb must emit the reconciled pending count"
|
||||
assert "put('approval', approval_data)" in cb_body, \
|
||||
|
||||
@@ -232,8 +232,10 @@ class TestApprovalModuleExports:
|
||||
assert cb_start != -1, "_approval_notify_cb must exist"
|
||||
cb_end = STREAMING_SRC.find("_reg_notify(session_id, _approval_notify_cb)", cb_start)
|
||||
cb_body = STREAMING_SRC[cb_start:cb_end]
|
||||
assert "head, total = _submit_pending_for_polling(session_id, approval_data)" in cb_body, \
|
||||
"approval notify callback must mirror approval data into polling state"
|
||||
assert "auto_resolved, head, total = _settle_pending_for_polling(" in cb_body, \
|
||||
"approval notify callback must settle admission at the YOLO handoff boundary"
|
||||
assert "if auto_resolved and head is None:" in cb_body, \
|
||||
"an auto-resolved local approval must not publish a stale card"
|
||||
assert '"pending_count": total' in cb_body, \
|
||||
"approval notify callback must publish the reconciled pending count"
|
||||
assert "put('approval', approval_data)" in cb_body, \
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,8 @@ import os
|
||||
import re
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_agent_modules
|
||||
@@ -265,3 +267,44 @@ class TestYoloI18n:
|
||||
|
||||
for key in self.REQUIRED_KEYS:
|
||||
assert key in block, f"Key '{key}' missing in locale '{locale}'"
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
||||
def test_yolo_command_ignores_stale_same_session_generation():
|
||||
commands_js = _read_static_file("commands.js")
|
||||
start = commands_js.index("async function cmdYolo(")
|
||||
cmd_yolo = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)]
|
||||
|
||||
def run(api_source, expected_calls):
|
||||
script = "\n".join([
|
||||
"const calls=[]; const toasts=[]; let pillUpdates=0; let cardHides=0;",
|
||||
"const S={session:{session_id:'old-session'}};",
|
||||
"let _loadSessionGeneration=1;",
|
||||
"let _yoloEnabled=false; let _approvalSessionId=null; let _approvalCurrentId=null;",
|
||||
"const t=k=>k; const showToast=msg=>toasts.push(msg);",
|
||||
"const $=()=>({classList:{contains:()=>false}});",
|
||||
"const _updateYoloPill=()=>{pillUpdates+=1;};",
|
||||
"const hideApprovalCard=()=>{cardHides+=1;};",
|
||||
"const toggleYoloFromApproval=async()=>{throw new Error('unexpected card path');};",
|
||||
f"const api={api_source};",
|
||||
cmd_yolo,
|
||||
"(async()=>{",
|
||||
" await cmdYolo();",
|
||||
f" if(JSON.stringify(calls)!==JSON.stringify({expected_calls!r})) throw new Error('wrong calls '+JSON.stringify(calls));",
|
||||
" if(_yoloEnabled!==false) throw new Error('stale response changed new session state');",
|
||||
" if(pillUpdates!==0) throw new Error('stale response updated the new session pill');",
|
||||
" if(cardHides!==0) throw new Error('stale response hid the new session card');",
|
||||
" if(toasts.length!==0) throw new Error('stale response emitted a toast');",
|
||||
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
||||
])
|
||||
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
run(
|
||||
"async path=>{calls.push(path);_loadSessionGeneration=2;return {yolo_enabled:false};}",
|
||||
["/api/session/yolo?session_id=old-session"],
|
||||
)
|
||||
run(
|
||||
"async (path,opts)=>{calls.push(path);if(path.includes('?'))return {yolo_enabled:false};_loadSessionGeneration=2;const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}",
|
||||
["/api/session/yolo?session_id=old-session", "/api/session/yolo"],
|
||||
)
|
||||
@@ -19,7 +19,18 @@ pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
def _extract_fn(src: str, name: str, prefix: str = "function ") -> str:
|
||||
start = src.index(f"{prefix}{name}(")
|
||||
brace = src.index("{", start)
|
||||
paren = src.index("(", start)
|
||||
paren_depth = 0
|
||||
for i in range(paren, len(src)):
|
||||
if src[i] == "(":
|
||||
paren_depth += 1
|
||||
elif src[i] == ")":
|
||||
paren_depth -= 1
|
||||
if paren_depth == 0:
|
||||
brace = src.index("{", i + 1)
|
||||
break
|
||||
else:
|
||||
raise AssertionError(f"{name} parameter list not closed")
|
||||
depth = 0
|
||||
for i in range(brace, len(src)):
|
||||
if src[i] == "{":
|
||||
@@ -38,11 +49,12 @@ def _run_node(script: str) -> dict:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[NODE, script_path],
|
||||
check=True,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
finally:
|
||||
Path(script_path).unlink(missing_ok=True)
|
||||
@@ -60,7 +72,13 @@ def _run_failure_case(api_js: str) -> dict:
|
||||
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
||||
_extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
|
||||
_extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
|
||||
_extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
|
||||
_extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
|
||||
_extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
|
||||
_extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
|
||||
_extract_fn(MESSAGES_JS, "showApprovalCard"),
|
||||
@@ -85,7 +103,10 @@ let renderCalls = 0;
|
||||
let _approvalSessionId = 'sess-1';
|
||||
let _approvalCurrentId = 'appr-1';
|
||||
let _approvalPendingBySession = new Map();
|
||||
let _loadSessionGeneration = 1;
|
||||
let _approvalResponding = null;
|
||||
let _approvalClearedOwner = null;
|
||||
let _approvalDisplayedOwner = null;
|
||||
let _approvalSignature = '';
|
||||
let _approvalVisibleSince = 0;
|
||||
let _approvalHideTimer = null;
|
||||
@@ -242,7 +263,13 @@ def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates():
|
||||
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
||||
_extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
|
||||
_extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
|
||||
_extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
|
||||
_extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
|
||||
_extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
|
||||
_extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
|
||||
_extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
|
||||
_extract_fn(MESSAGES_JS, "showApprovalCard"),
|
||||
@@ -262,7 +289,10 @@ const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
||||
let _approvalSessionId = 'sess-1';
|
||||
let _approvalCurrentId = 'appr-1';
|
||||
let _approvalPendingBySession = new Map();
|
||||
let _loadSessionGeneration = 1;
|
||||
let _approvalResponding = null;
|
||||
let _approvalClearedOwner = null;
|
||||
let _approvalDisplayedOwner = null;
|
||||
let _approvalSignature = '';
|
||||
let _approvalVisibleSince = 0;
|
||||
let _approvalHideTimer = null;
|
||||
|
||||
@@ -418,13 +418,18 @@ def test_respond_approval_uses_approval_session_id(cleanup_test_sessions):
|
||||
if the user switched while approval was pending).
|
||||
"""
|
||||
src = (REPO_ROOT / "static/messages.js").read_text()
|
||||
# The fix introduces _approvalSessionId to track the correct session
|
||||
assert "_approvalSessionId" in src, "messages.js must use _approvalSessionId in respondApproval"
|
||||
# respondApproval must use _approvalSessionId, not S.session.session_id directly
|
||||
# Click ownership is captured from the visible approval card, then passed
|
||||
# immutably into respondApproval rather than re-read after an await.
|
||||
capture_idx = src.find("function _captureApprovalResponseOwner(")
|
||||
assert capture_idx >= 0, "approval response owner capture helper not found"
|
||||
capture_body = src[capture_idx:capture_idx+500]
|
||||
assert "const sid = _approvalSessionId" in capture_body
|
||||
assert "const approvalId = _approvalCurrentId" in capture_body
|
||||
idx = src.find("async function respondApproval(")
|
||||
assert idx >= 0, "respondApproval not found"
|
||||
fn_body = src[idx:idx+300]
|
||||
assert "_approvalSessionId" in fn_body, "respondApproval must read _approvalSessionId, not S.session.session_id"
|
||||
fn_body = src[idx:idx+500]
|
||||
assert "options.owner || _captureApprovalResponseOwner()" in fn_body
|
||||
assert "const {sid, approvalId} = owner" in fn_body
|
||||
|
||||
|
||||
# ── R11: Tool progress must not use shared status chrome ──────────────────
|
||||
|
||||
@@ -274,15 +274,15 @@ class TestApprovalMessagesJS:
|
||||
src = read(REPO / "static/messages.js")
|
||||
assert "_approvalResponseMatches" in src and "_setApprovalControlsDisabled(" in src, \
|
||||
"showApprovalCard should route button state through the shared approval helper"
|
||||
assert "responding ? _approvalResponding.choice : null" in src, \
|
||||
"showApprovalCard should preserve the active loading choice during rerenders"
|
||||
assert "responding ? (_approvalResponding.controlChoice || _approvalResponding.choice) : null" in src, \
|
||||
"showApprovalCard should preserve the active control's loading state during rerenders"
|
||||
|
||||
def test_respond_disables_buttons_immediately(self):
|
||||
src = read(REPO / "static/messages.js")
|
||||
assert "_approvalResponding = {sid, approvalId: approvalId || null, choice};" in src, \
|
||||
"respondApproval should record the in-flight approval before the API call"
|
||||
assert "_setApprovalControlsDisabled(choice, true);" in src, \
|
||||
"respondApproval should disable buttons immediately to prevent double-submit"
|
||||
assert "_approvalResponding = {...owner, choice};" in src, \
|
||||
"respondApproval should record the immutable owner before the API call"
|
||||
assert "_setApprovalControlsDisabled(controlChoice, true);" in src, \
|
||||
"respondApproval should disable buttons immediately using the clicked control target"
|
||||
|
||||
def test_respond_uses_i18n_for_error(self):
|
||||
src = read(REPO / "static/messages.js")
|
||||
|
||||
Reference in new issue
Block a user