Merge pull request #6446 from nesquena/stage/6040

Release: gateway approvals routed by run ID (#6040, fix #6008)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-22 20:20:05 -07:00
commit d2a4ecb767
24 files changed
+4658 -258

No files matched your search

+2
View File
@@ -23,6 +23,8 @@
### Fixed
- **Gateway approval cards are now routed by run ID, so approvals and Stop always act on the right run (#6008).** A gateway approval card that arrived without an `approval_id` couldn't be acted on, and with multiple concurrent runs an approval or a Stop could be applied to the wrong run. Approvals are now owned per-`(session, run)`: identity-less and identity-bearing approvals route through the same single-flight owner, concurrent duplicate submits can't double-relay, different runs don't block each other, and a failed relay releases ownership so a retry can proceed. The composer/sidebar Stop now waits boundedly for an acknowledged exact-run gateway stop and surfaces a persistent, actionable "Cancel failed." error toast (with Copy and Dismiss, localized) instead of silently settling the UI when the stop fails. When multiple approvals are queued the card shows a "1 of N pending" counter. Thanks @rodboev. (#6040, #6008)
- **Streaming prose no longer appears one character short before a tool card in Compact Worklog.** While a turn was streaming, the process-prose immediately preceding a tool card could render with its last character missing until the whole turn settled — the streaming-markdown renderer holds the newest character back as lookahead until its parser is finalized, and Anchor-owned prose rows never finalized theirs. The cached parser is now finalized once when the row becomes `completed` (running the same media/link/table cleanup the main answer body already does), so the character appears immediately. Incremental O(n) rendering is preserved and an edit/re-render after finalize self-heals by rebuilding the parser. Thanks @starship-s. (#6408)
- **Security: public share links no longer leak local attachments (#6174).** A public share snapshot could carry `MEDIA:<local path>` references that the public renderer turned into authenticated `/api/media` requests, exposing local workspace/attachment files (and, via crafted paths, files outside them) to anyone with the link. Share snapshots now embed only images that resolve *inside* the session workspace or the attachment root, as inline base64 fixed at build time — enforced by a path allow-list (`is_relative_to`, so `..` traversal and symlink escapes are rejected), a 512 KiB size cap, an image-only MIME allow-list, and magic-byte content validation (an extension-spoofed file is rejected). `file://`, absolute paths outside the allowed roots, SVG, non-image files, and the empty-allowed-roots case all fail closed to a static placeholder — no local bytes ever reach the public page. Public `https://` image URLs still pass through unchanged. Thanks @webtecnica. (#6319, #6174)
+7
View File
@@ -8897,6 +8897,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,
@@ -8914,6 +8915,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}"
@@ -8953,6 +8955,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:
+263 -96
View File
@@ -8,6 +8,7 @@ import threading
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
@@ -39,6 +40,110 @@ logger = logging.getLogger(__name__)
# Maps stream_id -> gateway run_id for approval response relay.
_STREAM_RUN_IDS: dict[str, str] = {}
_STREAM_RUN_LIFECYCLE: dict[str, dict[str, Any]] = {}
_STREAM_RUN_STARTING_CONDITION = threading.Condition()
GATEWAY_RUN_ID_WAIT_TIMEOUT = 5.0
def _mark_gateway_run_starting(stream_id: str) -> None:
with _STREAM_RUN_STARTING_CONDITION:
_STREAM_RUN_IDS.pop(stream_id, None)
_STREAM_RUN_LIFECYCLE[stream_id] = {
"phase": "pending",
"run_id": "",
"waiters": 0,
"owner_done": False,
}
def _publish_gateway_run_id(stream_id: str, run_id: str) -> None:
with _STREAM_RUN_STARTING_CONDITION:
_STREAM_RUN_IDS[stream_id] = run_id
state = _STREAM_RUN_LIFECYCLE.get(stream_id) or {}
_STREAM_RUN_LIFECYCLE[stream_id] = {
"phase": "ready",
"run_id": run_id,
"waiters": int(state.get("waiters") or 0),
"owner_done": bool(state.get("owner_done")),
}
_STREAM_RUN_STARTING_CONDITION.notify_all()
def _finish_gateway_run_starting(stream_id: str, *, result: str = "failed") -> None:
with _STREAM_RUN_STARTING_CONDITION:
state = _STREAM_RUN_LIFECYCLE.get(stream_id) or {}
if str(state.get("phase") or "").strip().lower() == "ready":
return
_STREAM_RUN_IDS.pop(stream_id, None)
_STREAM_RUN_LIFECYCLE[stream_id] = {
"phase": "fallback" if result == "fallback" else "failed",
"run_id": "",
"waiters": int(state.get("waiters") or 0),
"owner_done": bool(state.get("owner_done")),
}
_STREAM_RUN_STARTING_CONDITION.notify_all()
def _retire_gateway_run_starting_if_done(stream_id: str) -> bool:
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
if not state:
return False
if int(state.get("waiters") or 0) > 0:
return False
if not bool(state.get("owner_done")):
return False
_STREAM_RUN_LIFECYCLE.pop(stream_id, None)
_STREAM_RUN_IDS.pop(stream_id, None)
return True
def _clear_gateway_run_starting(stream_id: str) -> None:
with _STREAM_RUN_STARTING_CONDITION:
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
if state:
state["owner_done"] = True
_retire_gateway_run_starting_if_done(stream_id)
_STREAM_RUN_STARTING_CONDITION.notify_all()
def gateway_run_id_pending(stream_id: str) -> bool:
with _STREAM_RUN_STARTING_CONDITION:
return str((_STREAM_RUN_LIFECYCLE.get(stream_id) or {}).get("phase") or "").strip().lower() == "pending"
def wait_for_gateway_run_id(stream_id: str, timeout: float) -> tuple[bool, str | None]:
deadline = time.monotonic() + max(0.0, float(timeout))
with _STREAM_RUN_STARTING_CONDITION:
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
if state:
state["waiters"] = int(state.get("waiters") or 0) + 1
try:
while True:
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
phase = str((state or {}).get("phase") or "").strip().lower()
if phase == "fallback":
return False, None
if phase == "failed":
return True, None
run_id = str(_STREAM_RUN_IDS.get(stream_id) or "").strip()
if phase == "ready":
stored_run_id = str((state or {}).get("run_id") or "").strip()
return True, run_id or stored_run_id or None
if run_id:
return True, run_id
if not state:
return False, None
remaining = deadline - time.monotonic()
if remaining <= 0:
return True, None
_STREAM_RUN_STARTING_CONDITION.wait(timeout=remaining)
finally:
state = _STREAM_RUN_LIFECYCLE.get(stream_id)
if state:
waiters = max(0, int(state.get("waiters") or 0) - 1)
state["waiters"] = waiters
if _retire_gateway_run_starting_if_done(stream_id):
_STREAM_RUN_STARTING_CONDITION.notify_all()
_WEBUI_CHAT_BACKEND_ENV = "HERMES_WEBUI_CHAT_BACKEND"
_WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
@@ -337,7 +442,8 @@ def _gateway_runs_approval_event(payload: dict) -> dict | None:
pattern_key = str(payload.get("pattern_key") or "").strip()
args = payload.get("args") if isinstance(payload.get("args"), (list, dict)) else []
run_id = str(payload.get("run_id") or "").strip()
approval_id = str(payload.get("approval_id") or payload.get("id") or "").strip()
raw_approval_id = str(payload.get("approval_id") or payload.get("id") or "").strip()
approval_id = raw_approval_id
if not approval_id:
approval_id = uuid.uuid4().hex
risk = str(payload.get("risk_level") or "high").strip()
@@ -357,6 +463,7 @@ def _gateway_runs_approval_event(payload: dict) -> dict | None:
"risk_level": risk,
"run_id": run_id,
"approval_id": approval_id,
"_gateway_raw_approval_id_present": bool(raw_approval_id),
"choices": choices,
"allow_permanent": bool(allow_permanent),
}
@@ -369,88 +476,92 @@ def _run_gateway_runs_api_streaming(
attachments=None, cfg=None, session=None,
):
"""Submit via POST /v1/runs and relay SSE events including approval."""
url_runs = f"{base_url.rstrip('/')}/v1/runs"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Hermes-Session-Id": session_id,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
message_content: Any = str(msg_text or "")
if attachments:
try:
from api.streaming import _build_native_multimodal_message
try:
url_runs = f"{base_url.rstrip('/')}/v1/runs"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Hermes-Session-Id": session_id,
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
message_content: Any = str(msg_text or "")
if attachments:
try:
from api.streaming import _build_native_multimodal_message
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
except Exception:
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
message_content = str(msg_text or "")
from api.streaming import _strip_oob_blocks
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
except Exception:
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
message_content = str(msg_text or "")
from api.streaming import _strip_oob_blocks
instructions_parts = []
conversation_history = []
for entry in getattr(session, "context_messages", None) or []:
if not isinstance(entry, dict):
continue
role = str(entry.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
continue
content = entry.get("content")
if content is not None:
content = _strip_oob_blocks(content)
instructions_parts = []
conversation_history = []
for entry in getattr(session, "context_messages", None) or []:
if not isinstance(entry, dict):
continue
role = str(entry.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
continue
content = entry.get("content")
if content is not None:
content = _strip_oob_blocks(content)
conversation_history.append({"role": role, "content": content})
for entry in prefill_messages or []:
if not isinstance(entry, dict):
continue
role = str(entry.get("role") or "").strip().lower()
content = entry.get("content")
if role == "system":
if isinstance(content, str) and content.strip():
instructions_parts.append(content)
elif content is not None:
instructions_parts.append(str(content))
continue
if role not in {"user", "assistant"}:
continue
if content is not None:
content = _strip_oob_blocks(content)
conversation_history.append({"role": role, "content": content})
for entry in prefill_messages or []:
if not isinstance(entry, dict):
continue
role = str(entry.get("role") or "").strip().lower()
content = entry.get("content")
if role == "system":
if isinstance(content, str) and content.strip():
instructions_parts.append(content)
elif content is not None:
instructions_parts.append(str(content))
continue
if role not in {"user", "assistant"}:
continue
if content is not None:
content = _strip_oob_blocks(content)
conversation_history.append({"role": role, "content": content})
run_input = message_content
if isinstance(run_input, list):
run_input = [{"role": "user", "content": run_input}]
run_body = {
"model": model or "default",
"input": run_input,
**body_extras,
"session_id": session_id,
}
if instructions_parts:
run_body["instructions"] = "\n\n".join(part for part in instructions_parts if part)
if conversation_history:
run_body["conversation_history"] = conversation_history
req = urllib.request.Request(
url_runs,
data=json.dumps(run_body).encode("utf-8"),
headers=headers,
method="POST",
)
update_active_run(stream_id, phase="gateway-request")
with urllib.request.urlopen(req, timeout=30) as resp:
run_data = json.loads(resp.read(65536))
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
if not run_id:
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
run_input = message_content
if isinstance(run_input, list):
run_input = [{"role": "user", "content": run_input}]
run_body = {
"model": model or "default",
"input": run_input,
**body_extras,
"session_id": session_id,
}
if instructions_parts:
run_body["instructions"] = "\n\n".join(part for part in instructions_parts if part)
if conversation_history:
run_body["conversation_history"] = conversation_history
req = urllib.request.Request(
url_runs,
data=json.dumps(run_body).encode("utf-8"),
headers=headers,
method="POST",
)
update_active_run(stream_id, phase="gateway-request")
with urllib.request.urlopen(req, timeout=30) as resp:
run_data = json.loads(resp.read(65536))
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
if not run_id:
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
except Exception:
_finish_gateway_run_starting(stream_id)
raise
_STREAM_RUN_IDS[stream_id] = run_id
usage: dict = {}
_publish_gateway_run_id(stream_id, run_id)
url_events = f"{base_url.rstrip('/')}/v1/runs/{run_id}/events"
headers_sse = dict(headers)
headers_sse["Accept"] = "text/event-stream"
req_events = urllib.request.Request(url_events, headers=headers_sse, method="GET")
final_text = ""
usage: dict = {}
sse_event = "message"
with urllib.request.urlopen(req_events, timeout=_gateway_read_timeout_secs()) as resp:
for raw_line in _iter_sse_lines_cancellable(resp, cancel_event):
@@ -478,7 +589,11 @@ def _run_gateway_runs_api_streaming(
approval_data = _gateway_runs_approval_event(payload)
if approval_data:
approval_data["run_id"] = run_id
put_gateway_event("approval", approval_data)
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)
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
sse_event = "message"
continue
if payload_event in {"tool.started", "tool.completed", "reasoning.available"}:
@@ -522,6 +637,8 @@ def _run_gateway_runs_api_streaming(
sse_event = "message"
continue
if payload_event == "run.completed":
from api.route_approvals import retire_gateway_pending_mirror
retire_gateway_pending_mirror(session_id, run_id=run_id)
if payload.get("error"):
raise RuntimeError(str(payload["error"]))
output = str(payload.get("output") or "")
@@ -533,8 +650,12 @@ def _run_gateway_runs_api_streaming(
sse_event = "message"
continue
if payload_event == "run.failed":
from api.route_approvals import retire_gateway_pending_mirror
retire_gateway_pending_mirror(session_id, run_id=run_id)
raise RuntimeError(str(payload.get("error") or "Gateway run failed"))
if payload_event == "run.cancelled":
from api.route_approvals import retire_gateway_pending_mirror
retire_gateway_pending_mirror(session_id, run_id=run_id)
put_gateway_event("cancel", {"message": "Cancelled by gateway"})
return None, usage
reasoning_delta = _gateway_sse_reasoning_delta(payload)
@@ -552,6 +673,40 @@ def _run_gateway_runs_api_streaming(
return final_text, usage
def stop_gateway_run(run_id: str) -> bool:
"""Request gateway interruption and report whether it was acknowledged."""
run_id = str(run_id or "").strip()
if not run_id:
return False
from api.config import get_config
cfg = get_config()
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
headers = {"Accept": "application/json", "Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(
f"{base_url.rstrip('/')}/v1/runs/{urllib.parse.quote(run_id, safe='')}/stop",
data=b"{}",
headers=headers,
method="POST",
)
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
try:
opener = urllib.request.build_opener(_NoRedirect)
with opener.open(req, timeout=10) as response:
final_url = str(getattr(response, "geturl", lambda: req.full_url)() or "")
status = int(getattr(response, "status", getattr(response, "code", 0)) or 0)
return 200 <= status < 300 and final_url == req.full_url
except (urllib.error.HTTPError, urllib.error.URLError, OSError, ValueError):
logger.debug("Gateway stop failed for run %s", run_id, exc_info=True)
return False
def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, model_provider, terminal_error):
from api.streaming import (
_classify_provider_error,
@@ -631,14 +786,10 @@ def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
def _cleanup_gateway_pending_mirror(session_id: str) -> None:
try:
from api.route_approvals import (
_approval_sse_notify_locked,
_lock as _approval_lock,
reconcile_gateway_pending_mirror_locked,
retire_gateway_pending_mirror,
)
with _approval_lock:
head, total, _ = reconcile_gateway_pending_mirror_locked(session_id)
_approval_sse_notify_locked(session_id, head, total)
retire_gateway_pending_mirror(session_id)
except Exception:
logger.debug("Failed to reconcile gateway pending mirror during teardown", exc_info=True)
@@ -664,6 +815,8 @@ def _run_gateway_chat_streaming(
"""
q = STREAMS.get(stream_id)
if q is None:
_finish_gateway_run_starting(stream_id, result="fallback")
_clear_gateway_run_starting(stream_id)
# Cancelled before the worker started; release the owner entry the route
# layer registered so STREAM_SESSION_OWNERS does not leak (no teardown finally runs).
unregister_stream_owner(stream_id)
@@ -691,6 +844,7 @@ def _run_gateway_chat_streaming(
STREAM_LIVE_TOOL_CALLS[stream_id] = []
success_writeback_committed = False
runs_api_pending_marked = True
def put_gateway_event(event, data):
if cancel_event.is_set() and not success_writeback_committed and event not in ("cancel", "error", "apperror"):
@@ -732,6 +886,22 @@ def _run_gateway_chat_streaming(
model=model,
model_provider=model_provider,
)
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
try:
from api.config import _main_model_request_overrides
_gw_overrides = _main_model_request_overrides(
cfg,
effective_model=model,
effective_provider=model_provider,
)
except Exception:
_gw_overrides = {}
_runs_api_enabled = _gateway_use_runs_api_enabled(cfg)
_use_runs_api = _runs_api_enabled and gateway_supports_approval(base_url, api_key)
if not _use_runs_api and runs_api_pending_marked:
_finish_gateway_run_starting(stream_id, result="fallback")
runs_api_pending_marked = False
try:
from api.streaming import (
_load_webui_prefill_context,
@@ -770,19 +940,6 @@ def _run_gateway_chat_streaming(
except Exception:
logger.debug("Failed to load WebUI gateway prefill context", exc_info=True)
prefill_messages = []
base_url = _gateway_base_url(cfg)
api_key = _gateway_api_key()
try:
from api.config import _main_model_request_overrides
_gw_overrides = _main_model_request_overrides(
cfg,
effective_model=model,
effective_provider=model_provider,
)
except Exception:
_gw_overrides = {}
# Capability gate: use runs API when gateway advertises approval support.
_use_runs_api = _gateway_use_runs_api_enabled(cfg) and gateway_supports_approval(base_url, api_key)
if _use_runs_api:
body_extras = {}
if model_provider:
@@ -908,12 +1065,13 @@ def _run_gateway_chat_streaming(
_approval_run_id = str(approval_data.get("run_id") or "").strip()
if _approval_run_id:
_STREAM_RUN_IDS[stream_id] = _approval_run_id
put_gateway_event("approval", approval_data)
try:
from api.route_approvals import submit_gateway_pending_mirror
submit_gateway_pending_mirror(session_id, approval_data)
head, total = submit_gateway_pending_mirror(session_id, approval_data)
approval_data = {**(head or approval_data), "pending_count": total}
except Exception:
logger.debug("submit_gateway_pending_mirror failed", exc_info=True)
put_gateway_event("approval", approval_data)
else:
logger.debug("Ignoring malformed gateway approval payload")
sse_event = "message"
@@ -1180,6 +1338,13 @@ def _run_gateway_chat_streaming(
"hint": "Check HERMES_WEBUI_GATEWAY_BASE_URL and Gateway API server health.",
})
finally:
mapped_run_id = str(_STREAM_RUN_IDS.get(stream_id) or "").strip()
if mapped_run_id:
try:
from api.route_approvals import retire_gateway_pending_mirror
retire_gateway_pending_mirror(session_id, run_id=mapped_run_id)
except Exception:
logger.debug("Failed to retire gateway pending mirrors during teardown", exc_info=True)
if s is not None:
try:
with _get_session_agent_lock(session_id):
@@ -1196,6 +1361,8 @@ def _run_gateway_chat_streaming(
STREAM_LIVE_TOOL_CALLS.pop(stream_id, None)
STREAM_LAST_EVENT_ID.pop(stream_id, None)
STREAMS.pop(stream_id, None)
_STREAM_RUN_IDS.pop(stream_id, None)
if runs_api_pending_marked and gateway_run_id_pending(stream_id):
_finish_gateway_run_starting(stream_id)
_clear_gateway_run_starting(stream_id)
unregister_stream_owner(stream_id)
unregister_active_run(stream_id)
+415 -24
View File
@@ -46,7 +46,10 @@ except ImportError:
_approval_sse_subscribers: dict[str, list[queue.Queue]] = {}
_GATEWAY_MIRROR_FLAG = "_gateway_mirror"
_GATEWAY_MIRROR_TOKEN = "_gateway_mirror_token"
_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] = {}
def _approval_sse_subscribe(session_id: str) -> queue.Queue:
@@ -106,7 +109,7 @@ def _approval_sse_notify(session_id: str, head: dict | None, total: int) -> None
_approval_sse_notify_locked(session_id, head, total)
def _gateway_mirror_entry_token(entry) -> str:
def _gateway_mirror_entry_token(entry) -> str | None:
"""Return a stable token for the current process lifetime of a gateway head.
Stamps a token key into the entry's `.data` dict so
@@ -120,7 +123,7 @@ def _gateway_mirror_entry_token(entry) -> str:
token = uuid.uuid4().hex
data[_GATEWAY_ENTRY_DATA_TOKEN_KEY] = token
return token
return uuid.uuid4().hex
return None
def _is_gateway_mirror_entry(entry: dict | None) -> bool:
@@ -147,29 +150,118 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
live_head_entry = live_gateway_queue[0] if live_gateway_queue else None
live_head_data = getattr(live_head_entry, "data", None) or {}
live_token = _gateway_mirror_entry_token(live_head_entry) if live_head_entry and live_head_data else None
has_no_run_mirror = any(
_is_gateway_mirror_entry(entry)
and not str(entry.get("run_id") or "").strip()
for entry in queue_list
)
live_run_id = str(live_head_data.get("run_id") or "").strip()
live_has_token = bool(live_head_data.get(_GATEWAY_ENTRY_DATA_TOKEN_KEY))
live_local_tokens: set[str] = set()
for live_entry in live_gateway_queue:
live_data = getattr(live_entry, "data", None) or {}
if str(live_data.get("run_id") or "").strip():
continue
live_entry_token = str(live_data.get(_GATEWAY_ENTRY_DATA_TOKEN_KEY) or "").strip()
if live_entry_token or not has_no_run_mirror:
live_entry_token = _gateway_mirror_entry_token(live_entry) or ""
if live_entry_token:
live_local_tokens.add(live_entry_token)
if not str(live_data.get("approval_id") or "").strip():
live_data["approval_id"] = f"gwlocal:{live_entry_token}"
live_token = (
_gateway_mirror_entry_token(live_head_entry)
if live_head_entry and live_head_data
and (live_run_id or live_has_token or not has_no_run_mirror)
else None
)
if live_token and live_run_id and not str(live_head_data.get("approval_id") or "").strip():
live_head_data["approval_id"] = f"gwrun:{live_run_id}:{live_token}"
live_approval_id = str(live_head_data.get("approval_id") or "").strip()
rebuilt: list[dict] = []
deferred_run_entries: list[dict] = []
live_mirror_present = False
for entry in queue_list:
if not _is_gateway_mirror_entry(entry):
rebuilt.append(entry)
continue
if live_token and entry.get(_GATEWAY_MIRROR_TOKEN) == live_token and not live_mirror_present:
entry_run_id = str(entry.get("run_id") or "").strip()
entry_approval_id = str(entry.get("approval_id") or "").strip()
entry_token = str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
matches_live_head = False
if live_token:
if entry_token and entry_token == live_token:
matches_live_head = True
elif (
live_approval_id
and live_run_id
and entry_approval_id == live_approval_id
and entry_run_id == live_run_id
):
matches_live_head = True
if entry_run_id:
if matches_live_head and not live_mirror_present:
if entry_token != live_token:
entry[_GATEWAY_MIRROR_TOKEN] = live_token
changed = True
rebuilt.append(entry)
live_mirror_present = True
continue
if live_token:
if entry_token:
changed = True
continue
deferred_run_entries.append(entry)
continue
if not entry_token:
rebuilt.append(entry)
continue
changed = True
continue
if entry.get(_GATEWAY_MIRROR_RETAINED):
rebuilt.append(entry)
continue
if matches_live_head and not live_mirror_present:
if entry_token != live_token:
entry[_GATEWAY_MIRROR_TOKEN] = live_token
changed = True
rebuilt.append(entry)
live_mirror_present = True
continue
if entry_token and entry_token in live_local_tokens:
rebuilt.append(entry)
continue
if not live_token:
if entry_token:
changed = True
continue
rebuilt.append(entry)
continue
changed = True
if live_token and not live_mirror_present:
mirror_entry = dict(live_head_data)
mirror_entry.setdefault("approval_id", uuid.uuid4().hex)
mirror_run_id = str(mirror_entry.get("run_id") or "").strip()
mirror_entry.setdefault(
"approval_id",
f"gwrun:{mirror_run_id}:{live_token}" if mirror_run_id else uuid.uuid4().hex,
)
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
mirror_entry[_GATEWAY_MIRROR_TOKEN] = live_token
rebuilt.append(mirror_entry)
live_mirror_present = True
changed = True
if deferred_run_entries:
rebuilt.extend(deferred_run_entries)
if rebuilt:
if rebuilt != queue_list:
_pending[session_key] = rebuilt
@@ -184,38 +276,337 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
return head, total, changed
def _gateway_mirrored_pending_run_id(session_key: str, approval_id: str) -> str | None:
"""Return the mirrored gateway approval run_id for a matching pending card.
def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_id: 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()
queue = _pending.get(session_key)
entries = queue if isinstance(queue, list) else [queue] if queue else []
if approval_id:
matched_entry: dict | None = None
for entry in entries:
if not _is_gateway_mirror_entry(entry):
continue
if entry.get("approval_id") != approval_id:
continue
entry_run_id = str(entry.get("run_id") or "").strip()
if not entry_run_id:
if not run_id:
return None
continue
if run_id and entry_run_id != run_id:
continue
if run_id:
return entry
if matched_entry is not None:
return None
matched_entry = entry
return matched_entry
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
return None
Reconciles the mirror first so a live gateway head still survives a lost
`active_stream_id` pointer.
"""
def gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: 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)
return dict(entry) if entry else None
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()
run_id = str(run_id or "").strip()
approval_id = str(approval_id or "").strip()
if not session_key or not run_id:
return False
with _lock:
key = (session_key, run_id)
if key in _gateway_relay_owners:
return False
_gateway_relay_owners[key] = approval_id
return True
def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval_id: str = "") -> None:
"""Release the single-flight relay owner for one `(session, run)` pair."""
session_key = str(session_key or "").strip()
run_id = str(run_id or "").strip()
approval_id = str(approval_id or "").strip()
if not session_key or not run_id:
return
with _lock:
key = (session_key, run_id)
current = str(_gateway_relay_owners.get(key) or "").strip()
if approval_id and current and current != approval_id:
return
_gateway_relay_owners.pop(key, None)
def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> bool:
"""Retire one approval, or every mirror for a terminal run."""
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 []
normalized_run_id = str(run_id or "").strip()
gateway_queue = _gateway_queues.get(session_key) or []
retained_gateway_queue = gateway_queue
gateway_queue_changed = False
if approval_id:
match = _gateway_pending_mirror_locked(session_key, approval_id, run_id)
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()
and str(entry.get("approval_id") or "").strip() == approval_id), None)
retired = [match] if match else []
else:
retired = [
entry for entry in entries
if _is_gateway_mirror_entry(entry)
and str(entry.get("run_id") or "").strip() == normalized_run_id
] if normalized_run_id else [
entry for entry in entries
if _is_gateway_mirror_entry(entry)
and not str(entry.get("run_id") or "").strip()
]
if normalized_run_id:
retained_gateway_queue = []
for entry in gateway_queue:
data = getattr(entry, "data", None) or {}
if str(data.get("run_id") or "").strip() == normalized_run_id:
gateway_queue_changed = True
continue
retained_gateway_queue.append(entry)
if not retired and not gateway_queue_changed:
head, total, changed = reconcile_gateway_pending_mirror_locked(session_key)
_approval_sse_notify_locked(session_key, head, total)
if changed:
publish_session_list_changed("attention_resolved")
return changed
for match in retired:
entries.remove(match)
if normalized_run_id and not approval_id:
if retained_gateway_queue:
_gateway_queues[session_key] = retained_gateway_queue
else:
_gateway_queues.pop(session_key, None)
if entries:
_pending[session_key] = entries
else:
_pending.pop(session_key, None)
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
_approval_sse_notify_locked(session_key, head, total)
publish_session_list_changed("attention_resolved")
return True
def _gateway_mirrored_pending_run_id(session_key: str, approval_id: str) -> str | None:
"""Compatibility wrapper for exact run-backed lookup."""
approval_id = str(approval_id or "").strip()
if not approval_id:
return None
with _lock:
reconcile_gateway_pending_mirror_locked(session_key)
queue = _pending.get(session_key)
if isinstance(queue, list):
entries = queue
elif queue:
entries = [queue]
else:
return None
for entry in entries:
if isinstance(entry, dict) and entry.get("approval_id") == approval_id and entry.get(_GATEWAY_MIRROR_FLAG):
run_id = str(entry.get("run_id") or "").strip()
return run_id or None
entry = _gateway_pending_mirror_locked(session_key, approval_id=approval_id)
if entry:
return str(entry.get("run_id") or "").strip() or None
return None
def submit_gateway_pending_mirror(session_key: str, approval: dict) -> None:
def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dict | None, int]:
"""Mirror the live gateway head into WebUI polling state under a typed tag."""
del approval # mirror from the live gateway head under `_lock`, not from callback input
with _lock:
run_id = str(approval.get("run_id") or "").strip()
approval_id = str(approval.get("approval_id") or "").strip()
live_gateway_queue = _gateway_queues.get(session_key) or []
exact_local_entry = next(
(
entry for entry in live_gateway_queue
if getattr(entry, "data", None) is approval
),
None,
) if not run_id else None
if exact_local_entry is None and not run_id and approval_id:
exact_local_entry = next(
(
entry for entry in live_gateway_queue
if str(((getattr(entry, "data", None) or {}).get("approval_id") or "")).strip() == approval_id
),
None,
)
if exact_local_entry is not None:
mirror_entries = _normalize_pending_queue_locked(session_key)
entries_to_mirror = [live_gateway_queue[0]] if live_gateway_queue else []
if exact_local_entry not in entries_to_mirror:
entries_to_mirror.append(exact_local_entry)
for entry in entries_to_mirror:
local_data = entry.data
token = _gateway_mirror_entry_token(entry)
entry_approval_id = str(local_data.get("approval_id") or "").strip()
if entry is exact_local_entry:
entry_approval_id = approval_id or entry_approval_id or f"gwlocal:{token}"
approval_id = entry_approval_id
approval["approval_id"] = entry_approval_id
elif not entry_approval_id:
entry_approval_id = f"gwlocal:{token}"
local_data["approval_id"] = entry_approval_id
if not any(
_is_gateway_mirror_entry(mirror)
and str(mirror.get(_GATEWAY_MIRROR_TOKEN) or "") == token
for mirror in mirror_entries
):
mirror_entry = dict(local_data)
mirror_entry["approval_id"] = entry_approval_id
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
mirror_entry[_GATEWAY_MIRROR_TOKEN] = token
mirror_entries.append(mirror_entry)
if run_id:
live_head_entry = live_gateway_queue[0] if live_gateway_queue else None
live_head_data = getattr(live_head_entry, "data", None) or {}
live_head_run_id = str(live_head_data.get("run_id") or "").strip()
live_head_approval_id = str(live_head_data.get("approval_id") or "").strip()
live_token = (
_gateway_mirror_entry_token(live_head_entry)
if live_head_entry and live_head_data
else None
)
if (
live_token
and live_head_run_id == run_id
and (
not approval_id
or not live_head_approval_id
or live_head_approval_id == approval_id
)
):
if approval_id:
live_head_data["approval_id"] = approval_id
else:
approval_id = live_head_approval_id
if not approval_id:
approval_id = f"gwrun:{run_id}:{live_token}"
live_head_data["approval_id"] = approval_id
approval["approval_id"] = approval_id
else:
if not approval_id:
approval_id = f"gwrun:{run_id}:{uuid.uuid4().hex}"
approval["approval_id"] = approval_id
mirror_entry = dict(approval)
mirror_entry["run_id"] = run_id
mirror_entry["approval_id"] = approval_id
mirror_entry[_GATEWAY_MIRROR_FLAG] = 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:
if not approval_id:
approval_id = uuid.uuid4().hex
approval["approval_id"] = approval_id
queue = _pending.get(session_key)
entries = queue if isinstance(queue, list) else [queue] if queue else []
no_run_mirror = next(
(
entry for entry in reversed(entries)
if _is_gateway_mirror_entry(entry)
and not str(entry.get("run_id") or "").strip()
and str(entry.get("approval_id") or "").strip() == approval_id
),
None,
)
if no_run_mirror:
approval["approval_id"] = str(no_run_mirror.get("approval_id") or approval_id).strip()
elif not _gateway_pending_mirror_locked(session_key, approval_id=approval_id):
mirror_entry = dict(approval)
mirror_entry["approval_id"] = approval_id
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
_normalize_pending_queue_locked(session_key).append(mirror_entry)
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
_approval_sse_notify_locked(session_key, head, total)
publish_session_list_changed("attention_pending")
return (dict(head) if head else None), total
def resolve_gateway_pending_local(
session_key: str, approval_id: str, choice: str, reason: str | None = None
) -> tuple[int, dict | None, int]:
"""Resolve the exact parked local entry bound to an approval mirror."""
target = None
with _lock:
approval_id = str(approval_id or "").strip()
gateway_queue = _gateway_queues.get(session_key) or []
for index, entry in enumerate(gateway_queue):
data = getattr(entry, "data", None) or {}
if str(data.get("approval_id") or "").strip() == approval_id:
target = gateway_queue.pop(index)
break
if gateway_queue:
_gateway_queues[session_key] = gateway_queue
else:
_gateway_queues.pop(session_key, None)
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
_approval_sse_notify_locked(session_key, head, total)
if target is None:
return 0, head, total
target.result = choice
if reason:
target.reason = reason
target.event.set()
publish_session_list_changed("attention_resolved")
return 1, head, total
def resolve_gateway_pending_local_no_run_mirror(
session_key: str, approval_id: str, choice: str, reason: str | None = None
) -> tuple[bool, int, dict | None, int]:
"""Resolve an exact no-run mirror only while its parked producer still exists."""
target = None
with _lock:
approval_id = str(approval_id or "").strip()
queue = _pending.get(session_key)
entries = queue if isinstance(queue, list) else [queue] if queue else []
matched_mirror = next(
(
entry for entry in entries
if _is_gateway_mirror_entry(entry)
and not str(entry.get("run_id") or "").strip()
and str(entry.get("approval_id") or "").strip() == approval_id
),
None,
)
if matched_mirror is None:
return False, 0, entries[0] if entries else None, len(entries)
gateway_queue = _gateway_queues.get(session_key) or []
for index, entry in enumerate(gateway_queue):
data = getattr(entry, "data", None) or {}
if str(data.get("approval_id") or "").strip() == approval_id:
target = gateway_queue.pop(index)
break
if target is None:
matched_mirror[_GATEWAY_MIRROR_RETAINED] = True
return True, 0, entries[0] if entries else None, len(entries)
if gateway_queue:
_gateway_queues[session_key] = gateway_queue
else:
_gateway_queues.pop(session_key, None)
entries.remove(matched_mirror)
if entries:
_pending[session_key] = entries
else:
_pending.pop(session_key, None)
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
_approval_sse_notify_locked(session_key, head, total)
target.result = choice
if reason:
target.reason = reason
target.event.set()
publish_session_list_changed("attention_resolved")
return True, 1, head, total
def submit_pending(session_key: str, approval: dict) -> None:
+291 -42
View File
@@ -9715,8 +9715,15 @@ from api.route_approvals import ( # noqa: F401 — re-exports for backward comp
_approval_sse_notify_locked,
_approval_sse_notify,
_GATEWAY_MIRROR_FLAG,
_gateway_mirrored_pending_run_id,
_GATEWAY_MIRROR_TOKEN,
_gateway_mirror_entry_token,
claim_gateway_approval_relay_owner,
gateway_pending_mirror,
release_gateway_approval_relay_owner,
retire_gateway_pending_mirror,
reconcile_gateway_pending_mirror_locked,
resolve_gateway_pending_local,
resolve_gateway_pending_local_no_run_mirror,
submit_gateway_pending_mirror,
submit_pending,
)
@@ -13367,6 +13374,39 @@ def handle_get(handler, parsed) -> bool:
return bad(handler, "stream_id required")
if not _stream_id_visible_to_request_profile(handler, stream_id):
return True
gateway_stop_blocked = False
try:
from api.gateway_chat import (
GATEWAY_RUN_ID_WAIT_TIMEOUT,
stop_gateway_run,
wait_for_gateway_run_id,
)
structured_gateway, run_id = wait_for_gateway_run_id(stream_id, GATEWAY_RUN_ID_WAIT_TIMEOUT)
if not run_id and structured_gateway:
gateway_stop_blocked = True
if run_id:
if stop_gateway_run(run_id):
owner_sid = stream_owner_session_id(stream_id)
if owner_sid:
retire_gateway_pending_mirror(owner_sid, run_id=run_id)
else:
gateway_stop_blocked = True
except Exception:
logger.debug("Failed to stop gateway run during chat cancellation", exc_info=True)
gateway_stop_blocked = True
if gateway_stop_blocked:
return j(
handler,
{
"ok": False,
"cancelled": False,
"stream_id": stream_id,
"error": "Gateway stop failed",
},
status=502,
)
from api.runtime_adapter import LegacyJournalRuntimeAdapter, runtime_adapter_enabled
if runtime_adapter_enabled():
@@ -21206,13 +21246,27 @@ def _start_chat_stream_for_session(
worker_kwargs = {"model_provider": model_provider, "goal_related": goal_related}
if moa_config and not backend_is_gateway:
worker_kwargs["moa_config"] = moa_config
if backend_is_gateway:
from api.gateway_chat import _mark_gateway_run_starting
_mark_gateway_run_starting(stream_id)
thr = threading.Thread(
target=worker_target,
args=(s.session_id, msg, model, workspace, stream_id, attachments),
kwargs=worker_kwargs,
daemon=True,
)
thr.start()
try:
thr.start()
except Exception:
if backend_is_gateway:
try:
from api.gateway_chat import _finish_gateway_run_starting
_finish_gateway_run_starting(stream_id)
from api.gateway_chat import _clear_gateway_run_starting
_clear_gateway_run_starting(stream_id)
except Exception:
logger.debug("Failed to record gateway run-start failure for stream %s", stream_id, exc_info=True)
raise
response = {
"stream_id": stream_id,
"session_id": s.session_id,
@@ -23748,7 +23802,7 @@ def _handle_workspace_reorder(handler, body):
return j(handler, {"ok": True, "workspaces": reordered})
def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
def _resolve_approval_legacy(sid: str, approval_id: str, choice: str, run_id: str = "") -> bool:
"""Resolve an approval through the existing callback path.
Slice 3b keeps the RuntimeAdapter as a protocol translator: it delegates to
@@ -23759,17 +23813,31 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
pending = None
found_target = False
gateway_keys = []
local_gateway_approval_id = ""
gateway_head_matches_target = False
with _lock:
reconcile_gateway_pending_mirror_locked(sid)
queue = _pending.get(sid)
if isinstance(queue, list):
if approval_id:
# Find and remove the specific entry by approval_id.
# Prefer a local exact-id match over a mirrored one, so a
# preceding remote mirror cannot consume the user's local choice.
preferred_index = None
fallback_index = None
for i, entry in enumerate(queue):
if entry.get("approval_id") == approval_id:
pending = queue.pop(i)
found_target = True
if entry.get("approval_id") != approval_id:
continue
if run_id and str(entry.get("run_id") or "").strip() != run_id:
continue
if not entry.get(_GATEWAY_MIRROR_FLAG) or not str(entry.get("run_id") or "").strip():
preferred_index = i
break
if fallback_index is None:
fallback_index = i
match_index = preferred_index if preferred_index is not None else fallback_index
if match_index is not None:
pending = queue.pop(match_index)
found_target = True
else:
# A stale explicit id must not accidentally approve the
# oldest queued command; duplicate/stale responses are
@@ -23782,7 +23850,13 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
_pending.pop(sid, None)
elif queue:
# Legacy single-dict value.
if not approval_id or queue.get("approval_id") == approval_id:
if (
not approval_id
or (
queue.get("approval_id") == approval_id
and (not run_id or str(queue.get("run_id") or "").strip() == run_id)
)
):
pending = _pending.pop(sid, None)
found_target = pending is not None
# When no _pending entry found AND no explicit approval_id was
@@ -23805,16 +23879,35 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
# idempotent over the session key set so the outcome is
# the same regardless of which entry wins the race.
found_target = True
elif approval_id:
gw_queue = _gateway_queues.get(sid)
if gw_queue and len(gw_queue) > 0:
gw_entry = gw_queue[0]
gw_data = getattr(gw_entry, "data", None) or {}
gw_approval_id = str(gw_data.get("approval_id") or "").strip()
gw_run_id = str(gw_data.get("run_id") or "").strip()
gateway_head_matches_target = bool(
run_id and gw_approval_id == approval_id and gw_run_id == run_id
)
if gw_approval_id == approval_id and (not run_id or gw_run_id == run_id):
local_gateway_approval_id = approval_id
elif not run_id and found_target and pending:
pending_token = str(pending.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
gateway_token = str(gw_data.get("_webui_mirror_token") or "").strip()
if pending_token and pending_token == gateway_token:
gw_data["approval_id"] = approval_id
local_gateway_approval_id = approval_id
# Notify SSE subscribers of the new head (or empty state) so the UI
# surfaces any trailing approvals that were queued behind this one
# without waiting for the next submit_pending. Without this, a parallel
# tool-call scenario (#527) would leave the second approval invisible
# in the SSE path until the next event ever fired (the agent thread
# would be parked indefinitely from the user's perspective).
if isinstance(_pending.get(sid), list) and _pending[sid]:
_approval_sse_notify_locked(sid, _pending[sid][0], len(_pending[sid]))
else:
_approval_sse_notify_locked(sid, None, 0)
if not local_gateway_approval_id:
if isinstance(_pending.get(sid), list) and _pending[sid]:
_approval_sse_notify_locked(sid, _pending[sid][0], len(_pending[sid]))
else:
_approval_sse_notify_locked(sid, None, 0)
# Collect keys from both _pending and _gateway_queues
keys_from_pending = pending.get("pattern_keys") or [pending.get("pattern_key", "")] if pending else []
@@ -23836,11 +23929,18 @@ def _resolve_approval_legacy(sid: str, approval_id: str, choice: str) -> bool:
# This is the primary signal when streaming is active — the agent
# thread is parked in entry.event.wait() and needs to be woken up.
gateway_resolved = 0
if found_target or not approval_id:
local_gateway_resolved = 0
if approval_id and found_target and not run_id and local_gateway_approval_id:
local_gateway_resolved, _head, _total = resolve_gateway_pending_local(
sid, local_gateway_approval_id, choice
)
elif approval_id and found_target and run_id and gateway_head_matches_target:
gateway_resolved = resolve_gateway_approval(sid, choice, resolve_all=False) or 0
elif not approval_id:
gateway_resolved = resolve_gateway_approval(sid, choice, resolve_all=False) or 0
# Keep the historical no-id response path truthy for old clients/tests while
# making stale explicit ids bounded as not-active for Slice 3b.
resolved = bool(pending) or bool(gateway_resolved) or not bool(approval_id)
resolved = bool(pending) or bool(gateway_resolved) or bool(local_gateway_resolved) or not bool(approval_id)
if resolved:
publish_session_list_changed("attention_resolved")
return resolved
@@ -23850,6 +23950,10 @@ _GATEWAY_APPROVAL_RELAY_UNAVAILABLE = (
"Gateway approval could not be relayed because the active run is unavailable. "
"Reopen the session or retry after it reconnects."
)
_GATEWAY_APPROVAL_RELAY_IN_PROGRESS = (
"Another approval response for this Gateway run is already in progress. "
"Wait for it to finish, then retry if the card is still visible."
)
def _gateway_pending_approval_without_run_id(sid: str, approval_id: str) -> bool:
@@ -23865,11 +23969,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:
@@ -23912,36 +24016,76 @@ 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
from api.route_approvals import _GATEWAY_AGENT_IDENTITY_V1
s = get_session(sid)
_run_id = None
_candidate_run_id = None
if s is not None:
active_sid = getattr(s, "active_stream_id", None)
if active_sid:
_run_id = _STREAM_RUN_IDS.get(active_sid)
if not _run_id and approval_id:
_run_id = _gateway_mirrored_pending_run_id(sid, approval_id)
if _run_id:
if not approval_id:
return bad(handler, "approval_id is required for gateway approvals")
from api.runner_client import HttpRunnerClient, RunnerClientError
_cfg = _get_config()
_base = _gateway_base_url(_cfg)
_key = _gateway_api_key()
try:
HttpRunnerClient(base_url=_base, api_key=_key).respond_approval(_run_id, approval_id, 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.
_resolve_approval_legacy(sid, approval_id, choice)
return j(handler, {"ok": True, "choice": choice, "relayed": True})
# Only a still-mirrored gateway approval with a missing run should 409;
# stale or empty gateway clicks fall through to local resolution.
if webui_gateway_chat_enabled(_get_config()) and _gateway_pending_approval_without_run_id(
sid, approval_id
):
_candidate_run_id = _STREAM_RUN_IDS.get(active_sid)
local_match = False
run_backed_gateway_matches = 0
same_run_stale_without_token = False
with _lock:
queue = _pending.get(sid)
entries = queue if isinstance(queue, list) else [queue] if queue else []
if approval_id:
local_match = any(
isinstance(entry, dict)
and entry.get("approval_id") == approval_id
and (
not entry.get(_GATEWAY_MIRROR_FLAG)
or not str(entry.get("run_id") or "").strip()
)
for entry in entries
)
run_backed_gateway_matches = sum(
1
for entry in entries
if isinstance(entry, dict)
and entry.get("approval_id") == approval_id
and entry.get(_GATEWAY_MIRROR_FLAG)
and str(entry.get("run_id") or "").strip()
)
gateway_queue = _gateway_queues.get(sid) or []
live_head_data = getattr(gateway_queue[0], "data", None) or {} if gateway_queue else {}
live_head_run_id = str(live_head_data.get("run_id") or "").strip()
live_head_token = (
_gateway_mirror_entry_token(gateway_queue[0])
if gateway_queue and live_head_data
else None
)
live_head_approval_id = str(live_head_data.get("approval_id") or "").strip()
if not live_head_approval_id and live_head_token and live_head_run_id:
live_head_approval_id = f"gwrun:{live_head_run_id}:{live_head_token}"
stale_same_run_id = _candidate_run_id or live_head_run_id
if (
stale_same_run_id
and live_head_run_id == stale_same_run_id
and live_head_approval_id
and live_head_approval_id != approval_id
):
same_run_stale_without_token = any(
isinstance(entry, dict)
and entry.get("approval_id") == approval_id
and entry.get(_GATEWAY_MIRROR_FLAG)
and str(entry.get("run_id") or "").strip() == stale_same_run_id
and not str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip()
for entry in entries
)
else:
local_match = any(
isinstance(entry, dict)
and (
not entry.get(_GATEWAY_MIRROR_FLAG)
or not str(entry.get("run_id") or "").strip()
)
for entry in entries
)
if local_match:
_candidate_run_id = None
if approval_id and not local_match and same_run_stale_without_token:
return j(
handler,
{
@@ -23953,6 +24097,111 @@ def _handle_approval_respond(handler, body):
},
status=409,
)
matched_mirror = (
gateway_pending_mirror(sid, approval_id=approval_id, run_id=_candidate_run_id)
if approval_id and not local_match
else None
)
_run_id = matched_mirror["run_id"] if matched_mirror else None
if not matched_mirror and approval_id:
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(
sid,
approval_id=claimed_approval_id,
run_id=_run_id,
)
if not current_mirror:
return j(
handler,
{
"ok": False,
"choice": choice,
"relayed": False,
"code": "gateway_run_unavailable",
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
},
status=409,
)
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,
},
status=409,
)
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)
if _candidate_run_id:
return j(handler, {"ok": False, "choice": choice, "relayed": False,
"code": "gateway_run_unavailable",
"error": _GATEWAY_APPROVAL_RELAY_UNAVAILABLE}, status=409)
# 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.
if webui_gateway_chat_enabled(_get_config()):
handled_no_run_mirror, resolved_count, _, _ = resolve_gateway_pending_local_no_run_mirror(
sid, approval_id, choice
)
if handled_no_run_mirror and resolved_count == 1:
return j(handler, {"ok": True, "choice": choice, "local_retired": True})
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)
except Exception:
pass # fall through to local approval path
+7 -21
View File
@@ -7808,14 +7808,10 @@ def _run_agent_streaming(
try:
from api.route_approvals import (
submit_gateway_pending_mirror as _submit_pending_for_polling,
reconcile_gateway_pending_mirror_locked as _reconcile_gateway_pending_mirror_locked,
_approval_sse_notify_locked as _approval_sse_notify_locked,
_lock as _approval_lock,
retire_gateway_pending_mirror as _retire_gateway_pending_mirror,
)
def _cleanup_gateway_pending_mirror():
with _approval_lock:
head, total, _changed = _reconcile_gateway_pending_mirror_locked(session_id)
_approval_sse_notify_locked(session_id, head, total)
_retire_gateway_pending_mirror(session_id)
except ImportError:
_submit_pending_for_polling = None
_cleanup_gateway_pending_mirror = None
@@ -7826,7 +7822,8 @@ def _run_agent_streaming(
def _approval_notify_cb(approval_data):
if _submit_pending_for_polling is not None:
try:
_submit_pending_for_polling(session_id, approval_data)
head, total = _submit_pending_for_polling(session_id, approval_data)
approval_data = {**(head or approval_data), "pending_count": total}
except Exception:
logger.warning("Failed to mirror approval into WebUI polling state", exc_info=True)
put('approval', approval_data)
@@ -8241,20 +8238,9 @@ def _run_agent_streaming(
if _has_blocking_approval(session_id):
p = None
with _approval_lock:
_reconcile_gateway_pending_mirror_locked(session_id)
queue = _approval_pending.get(session_id)
if isinstance(queue, list):
p = dict(queue[0]) if queue else None
elif queue:
p = dict(queue)
if p is None:
gw_queue = _approval_gateway_queues.get(session_id) or []
if gw_queue:
raw = getattr(gw_queue[0], 'data', None) or {}
if raw:
p = dict(raw)
else:
logger.warning("Gateway queue entry for %s has no .data attribute", session_id)
p, pending_count, _changed = _reconcile_gateway_pending_mirror_locked(session_id)
if p:
p = {**p, "pending_count": pending_count}
if p:
put('approval', p)
except ImportError:
+11 -4
View File
@@ -17,7 +17,7 @@
async function cancelStream(reason){
const sid = S.session && S.session.session_id;
const streamId = S.activeStreamId;
if(!streamId) return;
if(!streamId) return false;
// Interrupt provenance: log WHY the active run is being cancelled so operators
// can tell an explicit Stop / interrupt from any other trigger when they see a
// SIGINT/exit-code-130 in the backend logs. Only explicit user paths reach
@@ -30,8 +30,10 @@ async function cancelStream(reason){
console.info('[stream] cancel requested', {reason:_reason, streamId, sessionId:sid});
}
let respBody=null;
let respOk=false;
try{
const r=await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
respOk=!!(r&&r.ok);
try{respBody=await r.json();}catch(_){}
}catch(e){
if(typeof console !== 'undefined' && console.warn){
@@ -50,7 +52,7 @@ async function cancelStream(reason){
// `cancel` event can clear INFLIGHT, render "Task cancelled", and refresh
// the sidebar. Only clear locally when the backend says there is no active
// stream left to settle.
if(respBody && respBody.cancelled===false && S.activeStreamId===streamId){
if(respOk && respBody && respBody.cancelled===false && S.activeStreamId===streamId){
S.activeStreamId=null;
setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
@@ -59,20 +61,24 @@ async function cancelStream(reason){
// distinguish reasons — keep the toast generic and short.
if(typeof showToast==='function') showToast('Stream is no longer active',2000);
}
return respOk;
}
async function cancelSessionStream(session){
const streamId = session&&session.active_stream_id;
const sid = session&&session.session_id;
if(!streamId||!sid) return;
if(!streamId||!sid) return false;
// Explicit sidebar "Stop response" — log provenance for the same reason as
// cancelStream(). (#5345)
if(typeof console !== 'undefined' && console.info){
console.info('[stream] cancel requested', {reason:'sidebar-stop', streamId, sessionId:sid});
}
let respOk=false;
try{
await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
const r=await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'});
respOk=!!(r&&r.ok);
}catch(e){/* close local stream; keep UI state honest below */}
if(!respOk) return false;
if(typeof closeLiveStream==='function') closeLiveStream(sid, streamId);
session.active_stream_id=null;
delete INFLIGHT[sid];
@@ -94,6 +100,7 @@ async function cancelSessionStream(session){
hideClarifyCard(true, 'cancelled');
}
if(typeof renderSessionList==='function') renderSessionList();
return true;
}
async function _savedSessionShouldStaySidebarOnly(sid){
+8 -3
View File
@@ -1213,7 +1213,10 @@ async function cmdPersonality(args){
async function cmdStop(){
if(!S.session){showToast(t('no_active_session'));return;}
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
if(typeof cancelStream==='function'){await cancelStream('slash-stop');showToast(t('stream_stopped'));}
if(typeof cancelStream==='function'){
if(await cancelStream('slash-stop')) showToast(t('stream_stopped'));
else showToast(t('cancel_failed'),null,'error');
}
else showToast(t('cancel_unavailable'));
}
@@ -1319,8 +1322,10 @@ async function cmdInterrupt(args){
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
// Cancel the active stream; setBusy(false) will drain the queue
if(typeof cancelStream==='function'){await cancelStream('slash-interrupt');}
showToast(t('cmd_interrupt_confirm'),2000);
if(typeof cancelStream==='function'){
if(await cancelStream('slash-interrupt')) showToast(t('cmd_interrupt_confirm'),2000);
else showToast(t('cancel_failed'),null,'error');
}
}
/**
+30 -15
View File
@@ -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.',
@@ -165,6 +165,7 @@ const LOCALES = {
approval_responding: 'Responding\u2026',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: 'Approvals not supported',
approval_pending_count: (n) => `1 of ${n} pending`,
clarify_heading: 'Clarification needed',
clarify_hint: 'Pick a choice, or type your own answer below.',
clarify_other: 'Other',
@@ -1777,7 +1778,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.',
@@ -1925,6 +1926,7 @@ const LOCALES = {
approval_responding: 'Rispondendo\u2026',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: 'Approvazioni non supportate',
approval_pending_count: (n) => `1 di ${n} in sospeso`,
clarify_heading: 'Chiarimento necessario',
clarify_hint: "Scegli un'opzione o scrivi la tua risposta qui sotto.",
clarify_other: 'Altro',
@@ -3519,7 +3521,7 @@ const LOCALES = {
_speech: 'ja-JP',
// boot.js
cancelling: 'キャンセル中…',
cancel_failed: 'キャンセル失敗: ',
cancel_failed: 'キャンセルに失敗しました。',
mic_denied: 'マイクへのアクセスが拒否されました。ブラウザの権限を確認してください。',
mic_insecure_origin: '音声入力にはセキュアな接続が必要です。マイクを使うには HTTPS または localhost 経由で Hermes を開いてください。',
mic_no_speech: '音声が検出されませんでした。もう一度お試しください。',
@@ -3667,6 +3669,7 @@ const LOCALES = {
approval_responding: '応答中…',
approval_gateway_unsupported: '承認を実行するには新しいゲートウェイが必要です。有効にするには接続されている Hermes ゲートウェイをアップグレードしてください。',
approval_gateway_unsupported_label: '承認はサポートされていません',
approval_pending_count: (n) => `${n}件中1件保留中`,
clarify_heading: '確認が必要',
clarify_hint: '選択肢を選ぶか、下に独自の回答を入力してください。',
clarify_other: 'その他',
@@ -5265,7 +5268,7 @@ const LOCALES = {
_label: 'Русский',
_speech: 'ru-RU',
cancelling: 'Отменяю…',
cancel_failed: 'Не удалось отменить: ',
cancel_failed: 'Не удалось отменить.',
mic_denied: 'Доступ к микрофону запрещён. Проверьте разрешения браузера.',
mic_insecure_origin: 'Для голосового ввода нужно защищённое соединение. Откройте Hermes по HTTPS или с localhost, чтобы использовать микрофон.',
mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.',
@@ -5397,6 +5400,7 @@ const LOCALES = {
approval_responding: 'Отвечаю…',
approval_gateway_unsupported: 'Для подтверждений нужен более новый Gateway. Обновите подключённый Hermes Gateway, чтобы включить их.',
approval_gateway_unsupported_label: 'Одобрение не поддерживается',
approval_pending_count: (n) => `1 из ${n} в ожидании`,
untitled: 'Без названия',
n_messages: (n) => `${n} сообщений`,
load_older_messages: '↑ Прокрутите вверх или нажмите, чтобы загрузить ранние сообщения',
@@ -6987,7 +6991,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.',
@@ -7107,6 +7111,7 @@ const LOCALES = {
approval_responding: 'Respondiendo…',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: 'Las aprobaciones no son compatibles',
approval_pending_count: (n) => `1 de ${n} pendientes`,
clarify_heading: 'Se necesita aclaración',
clarify_hint: 'Elige una opción o escribe tu propia respuesta abajo.',
clarify_other: 'Otra',
@@ -8675,7 +8680,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.',
@@ -8795,6 +8800,7 @@ const LOCALES = {
approval_responding: 'Antwortet\u2026',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: 'Genehmigungen nicht unterstützt',
approval_pending_count: (n) => `1 von ${n} ausstehend`,
clarify_heading: 'Klärung erforderlich',
clarify_hint: 'Wähle eine Option oder schreibe deine eigene Antwort unten.',
clarify_other: 'Andere',
@@ -10357,7 +10363,7 @@ const LOCALES = {
_speech: 'zh-CN',
// boot.js
cancelling: '正在取消...',
cancel_failed: '取消失败:',
cancel_failed: '取消失败。',
mic_denied: '麦克风访问被拒绝,请检查浏览器权限。',
mic_insecure_origin: '语音输入需要安全连接。请通过 HTTPS 协议或从本地主机打开Hermes,以便使用麦克风。',
mic_no_speech: '没有检测到语音,请再试一次。',
@@ -10477,6 +10483,7 @@ const LOCALES = {
approval_responding: '处理中…',
approval_gateway_unsupported: '批准操作需要更新版本的网关。请升级已连接的Hermes网关以实现此功能。',
approval_gateway_unsupported_label: '不支持批准',
approval_pending_count: (n) => `${n} 个待处理中的第 1 个`,
clarify_heading: '需要澄清',
clarify_hint: '请选择一个选项,或在下方输入你自己的回答。',
clarify_other: '其他',
@@ -12034,7 +12041,7 @@ const LOCALES = {
_speech: 'zh-TW',
// boot.js
cancelling: '正在取消……',
cancel_failed: '取消失敗:',
cancel_failed: '取消失敗。',
mic_denied: '麥克風存取遭拒。請檢查瀏覽器權限。',
mic_insecure_origin: '語音輸入需要安全連線。請透過 HTTPS 或 localhost 開啟 Hermes 才能使用麥克風。',
mic_no_speech: '未偵測到語音。請再試一次。',
@@ -12182,6 +12189,7 @@ const LOCALES = {
approval_responding: '正在回應……',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: '不支援核准',
approval_pending_count: (n) => `${n} 個待處理中的第 1 個`,
clarify_heading: '需要釐清',
clarify_hint: '請選擇一個選項,或在下方輸入回答。',
clarify_other: '其他',
@@ -13778,7 +13786,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.',
@@ -13832,6 +13840,7 @@ const LOCALES = {
approval_responding: 'Respondendo…',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: 'Aprovações não suportadas',
approval_pending_count: (n) => `1 de ${n} pendentes`,
clarify_heading: 'Esclarecimento necessário',
clarify_hint: 'Escolha uma opção ou digite sua resposta abaixo.',
clarify_other: 'Outro',
@@ -15340,7 +15349,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: '음성이 감지되지 않았습니다. 다시 시도하세요.',
@@ -15460,6 +15469,7 @@ const LOCALES = {
approval_responding: '응답 중\u2026',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: '승인이 지원되지 않음',
approval_pending_count: (n) => `${n}개 중 1개 대기 중`,
clarify_heading: '확인 필요',
clarify_hint: '선택지를 고르거나 아래에 직접 입력하세요.',
clarify_other: '기타',
@@ -17071,7 +17081,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.',
@@ -17212,6 +17222,7 @@ const LOCALES = {
approval_responding: 'Réponse en cours\u2026',
approval_gateway_unsupported: 'Les approbations nécessitent une passerelle plus récente. Mettez à niveau la passerelle Hermes connectée pour activer cette fonctionnalité.',
approval_gateway_unsupported_label: 'Approbations non prises en charge',
approval_pending_count: (n) => `1 sur ${n} en attente`,
clarify_heading: 'Clarification nécessaire',
clarify_hint: 'Choisissez une option ou saisissez votre propre réponse ci-dessous.',
clarify_other: 'Autre',
@@ -19980,6 +19991,7 @@ const LOCALES = {
approval_desc_prefix: 'Byl zjištěn nebezpečný příkaz',
approval_gateway_unsupported: 'Schválení vyžadují novější gateway. Upgradujte připojený Hermes gateway.',
approval_gateway_unsupported_label: 'Schválení nejsou podporována',
approval_pending_count: (n) => `1 z ${n} čeká`,
approval_heading: 'Požadováno schválení',
approval_responding: 'Reakce\u2026',
auto_compress_label: 'Automatická komprese',
@@ -20000,7 +20012,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 +20504,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.',
@@ -20629,6 +20641,7 @@ const LOCALES = {
approval_responding: 'Yanıt veriliyor\u2026',
approval_gateway_unsupported: 'Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.',
approval_gateway_unsupported_label: 'Onaylar desteklenmiyor',
approval_pending_count: (n) => `${n} bekleyenden 1'i`,
clarify_heading: 'Açıklama gerekli',
clarify_hint: 'Bir seçim yapın veya kendi cevabınızı aşağıya yazın.',
clarify_other: 'Diğer',
@@ -22225,7 +22238,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.',
@@ -22373,6 +22386,7 @@ const LOCALES = {
approval_responding: 'Odpowiadanie…',
approval_gateway_unsupported: 'Zatwierdzenia wymagają nowszej bramy. Zaktualizuj połączoną bramę Hermes, aby włączyć tę funkcję.',
approval_gateway_unsupported_label: 'Zatwierdzenia nie są obsługiwane',
approval_pending_count: (n) => `1 z ${n} oczekujących`,
clarify_heading: 'Wymagane wyjaśnienie',
clarify_hint: 'Wybierz opcję lub wpisz własną odpowiedź poniżej.',
clarify_other: 'Inne',
@@ -23962,7 +23976,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.',
@@ -25467,6 +25481,7 @@ const LOCALES = {
approval_gateway_unsupported: 'Phê duyệt cần gateway mới hơn. Hãy nâng cấp Hermes gateway đang kết nối để bật tính năng này.',
approval_gateway_unsupported_label: 'Không hỗ trợ phê duyệt',
approval_pending_count: (n) => `1 trong ${n} đang chờ`,
approval_skip_all: '⚡ Bỏ qua tất cả trong phiên này',
approval_skip_all_title: 'Bỏ qua mọi yêu cầu phê duyệt cho phiên này',
checkpoint_title: 'Checkpoint',
+1 -1
View File
@@ -496,7 +496,7 @@
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;"></div>
<div class="approval-btns" id="approvalBtns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
+6 -4
View File
@@ -1395,8 +1395,8 @@ async function send(){
_clearComposerAfterQueuedSelectionSend(S.session&&S.session.session_id);
S.pendingFiles=[];renderTray();
if(S.activeStreamId&&typeof cancelStream==='function'){
showToast(t('busy_interrupt_confirm'),2000);
await cancelStream('busy-interrupt');
if(await cancelStream('busy-interrupt')) showToast(t('busy_interrupt_confirm'),2000);
else showToast(t('cancel_failed'),null,'error');
} else {
showToast(`Queued: "${text.slice(0,40)}${text.length>40?'…':''}"`,2000);
}
@@ -5619,7 +5619,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('approval',e=>{
const d=JSON.parse(e.data);
_applyToAnchor('approval',d,e);
showApprovalForSession(activeSid, d, 1);
showApprovalForSession(activeSid, d, d.pending_count || 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
});
@@ -7106,7 +7106,9 @@ function showApprovalCard(pending, pendingCount) {
const counter = $("approvalCounter");
if (counter) {
if (pendingCount && pendingCount > 1) {
counter.textContent = "1 of " + pendingCount + " pending";
counter.textContent = (typeof t === "function")
? t("approval_pending_count", pendingCount)
: ("1 of " + pendingCount + " pending");
counter.style.display = "";
} else {
counter.style.display = "none";
+3 -2
View File
@@ -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
@@ -4905,8 +4906,8 @@ function _openSessionActionMenu(session, anchorEl){
ICONS.stop,
async()=>{
closeSessionActionMenu();
await cancelSessionStream(session);
showToast(t('stream_stopped'));
if(await cancelSessionStream(session)) showToast(t('stream_stopped'));
else showToast(t('cancel_failed'),null,'error');
}
));
}
+1
View File
@@ -1992,6 +1992,7 @@
.approval-card.collapsed .approval-desc,.approval-card.collapsed .approval-cmd,.approval-card.collapsed .approval-counter,.approval-card.collapsed .approval-btns{display:none;}
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
.approval-counter{font-size:11px;color:var(--muted);margin:0 0 12px;font-variant-numeric:tabular-nums;letter-spacing:0.01em;}
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.approval-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;white-space:nowrap;}
.approval-btn:hover{background:rgba(255,255,255,0.12);transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,0,0,0.2);}
+1 -1
View File
@@ -7912,7 +7912,7 @@ async function handleComposerPrimaryAction(){
const action=typeof getComposerPrimaryAction==='function'?getComposerPrimaryAction():'send';
if(action==='disabled') return;
if(action==='stop'){
if(typeof cancelStream==='function') await cancelStream('composer-stop');
if(typeof cancelStream==='function' && !await cancelStream('composer-stop')) showToast(t('cancel_failed'),null,'error');
return;
}
await send();
+35
View File
@@ -7,6 +7,8 @@ approval_id so /api/approval/respond can target a specific entry.
import json
import pathlib
import re
import shutil
import subprocess
import sys
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
@@ -108,6 +110,39 @@ def test_polling_passes_count_to_show():
"Poll loop must pass data.pending_count through showApprovalForSession"
def test_chat_stream_approval_listener_renders_received_count():
"""A chat-stream approval event with count two renders the head and count."""
node = shutil.which("node")
if node is None:
import pytest
pytest.skip("node is required for the approval listener harness")
start = MESSAGES_JS.index("source.addEventListener('approval',e=>{")
end_marker = "\n });"
end = MESSAGES_JS.index(end_marker, start) + len(end_marker)
listener = MESSAGES_JS[start:end]
script = f"""
const rendered = [];
let activeSid = 'sid-browser';
const source = {{ listeners: {{}}, addEventListener(name, cb) {{ this.listeners[name] = cb; }} }};
function _applyToAnchor() {{}}
function showApprovalForSession(sid, data, count) {{ rendered.push({{sid, data, count}}); }}
function playAttentionSound() {{}}
function _attentionSoundKey() {{ return 'approval'; }}
function sendBrowserNotification() {{}}
{listener}
source.listeners.approval({{ data: JSON.stringify({{command: 'head', approval_id: 'a', pending_count: 2}}) }});
process.stdout.write(JSON.stringify(rendered));
"""
result = subprocess.run([node, "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
rendered = json.loads(result.stdout)
assert rendered == [{
"sid": "sid-browser",
"data": {"command": "head", "approval_id": "a", "pending_count": 2},
"count": 2,
}]
# ---------------------------------------------------------------------------
# HTML: counter element present
# ---------------------------------------------------------------------------
+3 -1
View File
@@ -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 "_submit_pending_for_polling(session_id, approval_data)" in cb_body, \
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 '"pending_count": total' in cb_body, \
"_approval_notify_cb must emit the reconciled pending count"
assert "put('approval', approval_data)" in cb_body, \
"_approval_notify_cb must still emit the approval SSE event"
+213 -6
View File
@@ -232,11 +232,57 @@ 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 "_submit_pending_for_polling(session_id, approval_data)" in cb_body, \
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 '"pending_count": total' in cb_body, \
"approval notify callback must publish the reconciled pending count"
assert "put('approval', approval_data)" in cb_body, \
"approval notify callback must still push the SSE event"
def test_reversed_local_callbacks_publish_all_parked_entries(self):
"""The local producer must count every exact parked entry, not only its head."""
import api.route_approvals as ra
from api import routes
sid = f"unit-reversed-local-{uuid.uuid4().hex[:8]}"
entry_a = _ApprovalEntry({
"command": "same-command",
"description": "same-description",
"pattern_key": "same-pattern",
"pattern_keys": ["same-pattern"],
})
entry_b = _ApprovalEntry(dict(entry_a.data))
events = []
with _lock:
_gateway_queues[sid] = [entry_a, entry_b]
ra._pending.pop(sid, None)
def local_producer(approval_data):
head, total = ra.submit_gateway_pending_mirror(sid, approval_data)
events.append((head, total))
try:
local_producer(entry_b.data)
local_producer(entry_a.data)
assert [total for _head, total in events] == [2, 2]
assert all(head["command"] == "same-command" for head, _total in events)
head_id = events[0][0]["approval_id"]
assert head_id == events[1][0]["approval_id"]
assert routes._resolve_approval_legacy(sid, head_id, "once") is True
assert entry_a.event.is_set()
assert not entry_b.event.is_set()
with _lock:
successor = ra._pending[sid][0]
assert successor["approval_id"] != head_id
with _lock:
assert ra.reconcile_gateway_pending_mirror_locked(sid)[1] == 1
finally:
with _lock:
ra._pending.pop(sid, None)
_gateway_queues.pop(sid, None)
ra._pending.pop(sid, None)
# ── HTTP regression tests (test server, port 8788) ───────────────────────────
@@ -481,8 +527,8 @@ class TestApprovalHTTPEndpoints:
r._gateway_queues.pop(sid, None)
pass # no external token state to clean
def test_gateway_mirror_without_run_id_returns_explicit_conflict(self, monkeypatch):
"""Gateway-mirrored approvals without relayable run state must stay actionable."""
def test_gateway_mirror_without_run_id_and_no_producer_returns_explicit_conflict(self, monkeypatch):
"""A no-run mirror stays actionable when no local producer is parked."""
from api import routes as r
from api import route_approvals as ra
from api.gateway_chat import _STREAM_RUN_IDS
@@ -515,7 +561,6 @@ class TestApprovalHTTPEndpoints:
r._pending.pop(sid, None)
r._gateway_queues.pop(sid, None)
_STREAM_RUN_IDS.pop(stream_id, None)
r._gateway_queues[sid] = [_ApprovalEntry(approval)]
try:
ra.submit_gateway_pending_mirror(sid, approval)
with _lock:
@@ -539,10 +584,172 @@ class TestApprovalHTTPEndpoints:
assert isinstance(pending_queue, list)
assert len(pending_queue) == 1
assert pending_queue[0]["approval_id"] == approval_id
assert sid in r._gateway_queues
assert not r._gateway_queues[sid][0].event.is_set()
assert sid not in r._gateway_queues
finally:
with _lock:
r._pending.pop(sid, None)
r._gateway_queues.pop(sid, None)
_STREAM_RUN_IDS.pop(stream_id, None)
def test_gateway_mirror_without_run_id_with_one_producer_resolves_exactly(self, monkeypatch):
"""A no-run mirror retires only after its exact local producer resolves."""
from api import routes as r
from api import route_approvals as ra
from api.gateway_chat import _STREAM_RUN_IDS
sid = f"http-gateway-local-{uuid.uuid4().hex[:8]}"
stream_id = f"stream-local-{uuid.uuid4().hex[:8]}"
approval = {"command": "rm -rf /tmp/local", "description": "local"}
sibling = {"command": "rm -rf /tmp/sibling", "description": "sibling"}
captured = {}
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(r, "j", fake_j)
monkeypatch.setattr(r, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
entry = _ApprovalEntry(approval)
sibling_entry = _ApprovalEntry(sibling)
with _lock:
r._pending.pop(sid, None)
r._gateway_queues[sid] = [entry, sibling_entry]
try:
ra.submit_gateway_pending_mirror(sid, approval)
with _lock:
approval_id = r._pending[sid][0]["approval_id"]
r._handle_approval_respond(
object(), {"session_id": sid, "choice": "once", "approval_id": approval_id}
)
assert captured["status"] == 200
assert captured["payload"] == {"ok": True, "choice": "once", "local_retired": True}
assert entry.event.is_set()
assert entry.result == "once"
assert not sibling_entry.event.is_set()
with _lock:
assert r._pending[sid][0]["command"] == sibling["command"]
finally:
with _lock:
r._pending.pop(sid, None)
r._gateway_queues.pop(sid, None)
_STREAM_RUN_IDS.pop(stream_id, None)
def test_gateway_no_run_mirror_survives_pending_and_attention_reads_until_explicit_teardown(self, monkeypatch):
"""A missing-producer 409 stays visible until a real teardown retires it."""
from api import routes as r
from api import route_approvals as ra
from api.gateway_chat import _STREAM_RUN_IDS, _cleanup_gateway_pending_mirror
sid = f"http-gateway-race-{uuid.uuid4().hex[:8]}"
stream_id = f"stream-race-{uuid.uuid4().hex[:8]}"
approval = {"command": "rm -rf /tmp/race", "description": "race"}
captured = {}
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(r, "j", fake_j)
monkeypatch.setattr(r, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
entry = _ApprovalEntry(approval)
with _lock:
r._pending.pop(sid, None)
r._gateway_queues[sid] = [entry]
try:
ra.submit_gateway_pending_mirror(sid, approval)
with _lock:
approval_id = r._pending[sid][0]["approval_id"]
r._gateway_queues.pop(sid, None)
r._handle_approval_respond(
object(), {"session_id": sid, "choice": "once", "approval_id": approval_id}
)
assert captured["status"] == 409
assert captured["payload"] == {
"ok": False,
"choice": "once",
"relayed": False,
"code": "gateway_run_unavailable",
"error": r._GATEWAY_APPROVAL_RELAY_UNAVAILABLE,
}
assert not entry.event.is_set()
pending = r._handle_approval_pending(
object(), urllib.parse.urlparse(
f"/api/approval/pending?session_id={urllib.parse.quote(sid)}"
)
)
assert pending["pending"]["approval_id"] == approval_id
assert pending["pending_count"] == 1
assert r._session_attention_summary(sid) == {
"kind": "approval", "count": 1, "severity": "critical"
}
_cleanup_gateway_pending_mirror(sid)
pending = r._handle_approval_pending(
object(), urllib.parse.urlparse(
f"/api/approval/pending?session_id={urllib.parse.quote(sid)}"
)
)
assert pending == {"pending": None, "pending_count": 0}
assert r._session_attention_summary(sid) is None
finally:
with _lock:
r._pending.pop(sid, None)
r._gateway_queues.pop(sid, None)
_STREAM_RUN_IDS.pop(stream_id, None)
def test_gateway_no_run_non_head_response_resolves_only_exact_producer(self, monkeypatch):
"""An exact non-head response wakes only its matching producer."""
from api import routes as r
from api import route_approvals as ra
sid = f"http-gateway-non-head-{uuid.uuid4().hex[:8]}"
approval_a = {"command": "rm -rf /tmp/a", "description": "a"}
approval_b = {"command": "rm -rf /tmp/b", "description": "b"}
entry_a = _ApprovalEntry(approval_a)
entry_b = _ApprovalEntry(approval_b)
captured = {}
def fake_j(handler, data, status=200, extra_headers=None):
captured["payload"] = data
captured["status"] = status
return data
monkeypatch.setattr(r, "j", fake_j)
monkeypatch.setattr(r, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None))
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
with _lock:
r._pending.pop(sid, None)
r._gateway_queues[sid] = [entry_a, entry_b]
try:
ra.submit_gateway_pending_mirror(sid, approval_a)
ra.submit_gateway_pending_mirror(sid, approval_b)
with _lock:
approval_b_id = next(
item["approval_id"] for item in r._pending[sid]
if item["command"] == approval_b["command"]
)
r._handle_approval_respond(
object(), {"session_id": sid, "choice": "once", "approval_id": approval_b_id}
)
assert captured["status"] == 200
assert entry_b.event.is_set()
assert entry_b.result == "once"
assert not entry_a.event.is_set()
pending = r._handle_approval_pending(
object(), urllib.parse.urlparse(
f"/api/approval/pending?session_id={urllib.parse.quote(sid)}"
)
)
assert pending["pending"]["command"] == approval_a["command"]
assert pending["pending_count"] == 1
finally:
with _lock:
r._pending.pop(sid, None)
r._gateway_queues.pop(sid, None)
+46 -1
View File
@@ -135,6 +135,10 @@ class TestCancelStreamOwnerGuardStructural:
"""When the backend reports ``cancelled:false``, the fix should
show a lightweight toast (the backend may have already finalized
the turn, or a newer turn may hold the session lock)."""
assert "r.ok" in CANCEL_STREAM_SRC, (
"cancelStream() must gate cancelled:false cleanup on an HTTP-success "
"response so failed gateway stops do not settle the UI locally"
)
assert "cancelled" in CANCEL_STREAM_SRC and "false" in CANCEL_STREAM_SRC, (
"cancelStream() must check respBody.cancelled === false and surface "
"a toast (e.g. 'Stream is no longer active')"
@@ -254,6 +258,25 @@ async function runAll() {
toastCalls: [...M.toastCalls],
};
// T3b — failed HTTP response with cancelled:false body: keep the active
// owner path intact because the cancel did not succeed.
reset();
globalThis.S = { activeStreamId: 'stream-1', session: { session_id: 'sid-1' } };
_fetchResponse = {
ok: false,
json: () => Promise.resolve({ ok: false, cancelled: false, stream_id: 'stream-1' }),
};
_fetchThrows = false;
await cancelStream();
out.t3b_failed_http = {
finalActiveStreamId: globalThis.S.activeStreamId,
fetchCalls: M.fetchCalls.length,
closeCalls: [...M.closeCalls],
busyCalls: [...M.busyCalls],
composerCalls: [...M.composerCalls],
toastCalls: [...M.toastCalls],
};
// T4 — network error: no throw, active owner kept because we do not know
// whether the cancel landed.
reset();
@@ -466,6 +489,26 @@ class TestCancelStreamOwnerGuardRuntime:
f"got {r['toastCalls']}"
)
def test_t3b_failed_http_keeps_owner_state(self, runtime_results):
r = runtime_results["t3b_failed_http"]
assert r["fetchCalls"] == 1, (
f"cancelStream() must still issue the cancel request on failed HTTP, "
f"got {r['fetchCalls']}"
)
assert r["finalActiveStreamId"] == "stream-1", (
f"cancelStream() must keep S.activeStreamId when /api/chat/cancel "
f"fails, got {r['finalActiveStreamId']!r}"
)
assert r["closeCalls"] == [], (
f"cancelStream() must keep owned SSE open on failed HTTP, got {r['closeCalls']}"
)
assert r["busyCalls"] == [], (
f"cancelStream() must not call setBusy(false) on failed HTTP, got {r['busyCalls']}"
)
assert r["toastCalls"] == [], (
f"cancelStream() should not show a toast on failed HTTP, got {r['toastCalls']}"
)
def test_t5_owner_guard_preserves_new_turn(self, runtime_results):
r = runtime_results["t5_owner_guard"]
# The fetch still happened for the OLD stream.
@@ -512,12 +555,14 @@ class TestCancelStreamOwnerGuardRuntime:
)
assert runtime_results["t3_cancelled_false"]["finalActiveStreamId"] is None
assert runtime_results["t3_cancelled_false"]["busyCalls"] == [False]
assert runtime_results["t3b_failed_http"]["finalActiveStreamId"] == "stream-1"
assert runtime_results["t3b_failed_http"]["busyCalls"] == []
# T5 is the owner-rotation case and must preserve the new turn.
assert runtime_results["t5_owner_guard"]["finalActiveStreamId"] == "stream-2"
assert runtime_results["t5_owner_guard"]["busyCalls"] == []
# Only the stale-owner path closes the old SSE; active owned paths
# keep it open so the backend terminal event can settle/render.
for key in ("t2_happy_path", "t3_cancelled_false", "t4_network_error", "t5_owner_guard"):
for key in ("t2_happy_path", "t3_cancelled_false", "t3b_failed_http", "t4_network_error", "t5_owner_guard"):
if key == "t5_owner_guard":
assert runtime_results[key]["closeCalls"] == [["sid-1", "stream-1"]], (
f"{key} must close stale old SSE for ('sid-1', 'stream-1'), "
+30 -27
View File
@@ -6,6 +6,7 @@ PR #4495 fixed the runs API path but left the legacy path without approval handl
from __future__ import annotations
import json
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -336,6 +337,19 @@ def test_legacy_approval_records_run_id_for_response_relay():
# (connection still open, run parked) — this test's stream already ran to
# completion, which cleared active_stream_id and popped the mapping.
_STREAM_RUN_IDS[stream_id] = "run-legacy-1"
from types import SimpleNamespace
from api import route_approvals as ra
with ra._lock:
ra._gateway_queues["sess-legacy-runid"] = [SimpleNamespace(data={
"command": "rm -rf /tmp/test",
"description": "Delete temporary files",
"pattern_key": "dangerous_command",
"pattern_keys": ["dangerous_command"],
"approval_id": "appr-legacy-runid",
"run_id": "run-legacy-1",
})]
live_entry = ra._gateway_queues["sess-legacy-runid"][0]
ra.submit_gateway_pending_mirror("sess-legacy-runid", live_entry.data)
relay_session = MagicMock()
relay_session.active_stream_id = stream_id
captured = {}
@@ -360,7 +374,7 @@ def test_legacy_approval_records_run_id_for_response_relay():
assert captured.get("url", "") == "http://gw:8642/v1/runs/run-legacy-1/approval", (
f"approval respond must relay to the gateway run; got {captured.get('url')!r}"
)
assert captured["body"] == {"choice": "once", "approval_id": "appr-legacy-runid"}
assert captured["body"] == {"choice": "once", "approval_id": ""}
handler.send_response.assert_called_with(200)
finally:
with STREAMS_LOCK:
@@ -447,8 +461,8 @@ def test_legacy_teardown_clears_stale_gateway_mirror_and_notifies_empty_state():
ra._gateway_queues.pop(session_id, None)
def test_legacy_teardown_preserves_live_gateway_head_mirror():
"""A live gateway head must still be mirrored after legacy teardown runs."""
def test_legacy_teardown_retires_live_gateway_head_mirror():
"""A mapped legacy run retires its live gateway mirror during outer teardown."""
from types import SimpleNamespace
from api import route_approvals as ra
from api.config import STREAMS, STREAMS_LOCK
@@ -507,14 +521,11 @@ def test_legacy_teardown_preserves_live_gateway_head_mirror():
payloads = _drain_queue(subscriber)
assert payloads, "Expected mirrored approval notifications"
assert payloads[-1]["pending"]["_gateway_mirror"] is True
assert payloads[-1]["pending_count"] == 1
assert payloads[-1]["pending"] is None
assert payloads[-1]["pending_count"] == 0
with ra._lock:
pending = ra._pending.get(session_id)
assert isinstance(pending, list)
assert len(pending) == 1
assert pending[0]["approval_id"] == approval_data["approval_id"]
assert pending[0]["_gateway_mirror"] is True
assert pending is None
finally:
ra._approval_sse_unsubscribe(session_id, subscriber)
with STREAMS_LOCK:
@@ -686,7 +697,7 @@ def test_mirrored_run_id_survives_active_stream_loss():
assert captured.get("url", "") == f"http://gw:8642/v1/runs/{run_id}/approval", (
f"approval respond must relay to the mirrored gateway run; got {captured.get('url')!r}"
)
assert captured["body"] == {"choice": "once", "approval_id": approval_id}
assert captured["body"] == {"choice": "once", "approval_id": ""}
handler.send_response.assert_called_with(200)
assert entry.event.is_set(), "mirrored gateway approval was not resolved"
assert entry.result == "once"
@@ -752,8 +763,8 @@ def test_gateway_mode_no_pending_click_stays_non_409():
assert captured["payload"].get("code") != "gateway_run_unavailable"
def test_legacy_approval_without_run_id_stays_actionable():
"""Legacy approvals without a run_id must fail explicitly and keep the mirror live."""
def test_legacy_approval_without_run_id_retires_locally():
"""A no-run legacy mirror retires locally instead of becoming a ghost card."""
from types import SimpleNamespace
from api import routes as r
from api import route_approvals as ra
@@ -832,22 +843,15 @@ def test_legacy_approval_without_run_id_stays_actionable():
]
assert approval_events
approval_data = approval_events[0][1]
local_entry = SimpleNamespace(data=dict(approval_data), event=threading.Event(), result=None)
with ra._lock:
r._gateway_queues[session_id] = [SimpleNamespace(data=dict(approval_data))]
r._gateway_queues[session_id] = [local_entry]
ra.submit_gateway_pending_mirror(session_id, approval_data)
with ra._lock:
pending_queue = r._pending.get(session_id)
assert isinstance(pending_queue, list)
approval_id = pending_queue[0]["approval_id"]
# The relay-unavailable 409 is only meaningful when the WebUI is
# actually running the gateway chat backend. On a gateway deployment
# the backend env is process-wide (not just during the stream), so
# assert the 409 with HERMES_WEBUI_CHAT_BACKEND=gateway active at
# respond time. Without this scope the handler now (correctly) treats
# a mirrored approval on the default LOCAL backend as locally
# resolvable and falls through instead of 409ing — see
# test_issue4771_local_approval_regression.py (#4771 follow-up).
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}), \
patch("api.routes.get_session", return_value=mock_session), \
patch("api.routes.j", new=fake_j):
@@ -856,13 +860,12 @@ def test_legacy_approval_without_run_id_stays_actionable():
{"session_id": session_id, "choice": "once", "approval_id": approval_id},
)
assert captured["status"] == 409
assert captured["payload"]["code"] == "gateway_run_unavailable"
assert captured["payload"]["error"] == r._GATEWAY_APPROVAL_RELAY_UNAVAILABLE
assert captured["status"] == 200
assert captured["payload"] == {"ok": True, "choice": "once", "local_retired": True}
assert local_entry.event.is_set()
assert local_entry.result == "once"
with ra._lock:
pending_queue = r._pending.get(session_id)
assert isinstance(pending_queue, list)
assert pending_queue[0]["approval_id"] == approval_id
assert session_id not in r._pending
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
File diff suppressed because it is too large. Load diff
@@ -40,6 +40,7 @@ import pytest
# Import order matters: tools.* will not resolve until api.config has run.
from api import routes
from api import models
from api.gateway_chat import _STREAM_RUN_IDS
try:
import tools.approval as ta
@@ -182,10 +183,206 @@ def test_local_mirrored_approval_deny_resolves():
_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_local_mirrored_approval_with_gwrun_prefix_still_resolves():
"""A local approval id that happens to start with gwrun: stays local."""
sid = f"local-approval-prefixed-{uuid.uuid4().hex[:8]}"
_register_session(sid)
try:
entry, _approval_id = _seed_local_pending_approval(sid)
with ta._lock:
head = ta._pending[sid][0]
head["approval_id"] = "gwrun:local-test"
handler = _FakeHandler()
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False):
routes._handle_approval_respond(
handler,
{"session_id": sid, "choice": "once", "approval_id": "gwrun:local-test"},
)
resp = handler.json()
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
assert resp.get("ok") is True
assert resp.get("code") != "gateway_run_unavailable"
assert entry.event.is_set()
assert entry.result == "once"
finally:
_cleanup(sid)
def test_local_live_head_does_not_inherit_stale_remote_run():
"""A stale remote mirror cannot seize a live local approval with the same id."""
sid = f"local-approval-collision-{uuid.uuid4().hex[:8]}"
_register_session(sid)
try:
entry, approval_id = _seed_local_pending_approval(sid)
with ta._lock:
local_queue = ta._pending.get(sid)
local_entries = list(local_queue) if isinstance(local_queue, list) else [local_queue]
ta._pending[sid] = [{
ra._GATEWAY_MIRROR_FLAG: True,
"run_id": "remote-run",
"approval_id": approval_id,
"command": "stale-remote",
}, *local_entries]
handler = _FakeHandler()
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
routes._handle_approval_respond(
handler,
{"session_id": sid, "choice": "once", "approval_id": approval_id},
)
resp = handler.json()
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
assert resp.get("ok") is True
assert resp.get("relayed") is not True
respond_approval.assert_not_called()
assert entry.event.is_set()
assert entry.result == "once"
finally:
_cleanup(sid)
def test_local_live_head_beats_active_remote_candidate_run():
"""A local approval still wins when an active remote run shares its id."""
sid = f"local-approval-active-remote-{uuid.uuid4().hex[:8]}"
session = _register_session(sid)
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
session.active_stream_id = stream_id
_STREAM_RUN_IDS[stream_id] = "remote-run"
try:
entry, approval_id = _seed_local_pending_approval(sid)
with ta._lock:
local_queue = ta._pending.get(sid)
local_entries = list(local_queue) if isinstance(local_queue, list) else [local_queue]
ta._pending[sid] = [{
ra._GATEWAY_MIRROR_FLAG: True,
"run_id": "remote-run",
"approval_id": approval_id,
"command": "remote-collision",
}, *local_entries]
handler = _FakeHandler()
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
routes._handle_approval_respond(
handler,
{"session_id": sid, "choice": "once", "approval_id": approval_id},
)
resp = handler.json()
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
assert resp.get("ok") is True
assert resp.get("relayed") is not True
respond_approval.assert_not_called()
assert entry.event.is_set()
assert entry.result == "once"
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
_cleanup(sid)
def test_local_plain_pending_exact_id_beats_remote_mirror():
sid = f"local-plain-pending-id-{uuid.uuid4().hex[:8]}"
session = _register_session(sid)
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
session.active_stream_id = stream_id
_STREAM_RUN_IDS[stream_id] = "remote-run"
try:
with ta._lock:
ta._pending[sid] = [
{"approval_id": "shared-id", "command": "local"},
{
ra._GATEWAY_MIRROR_FLAG: True,
"run_id": "remote-run",
"approval_id": "shared-id",
"command": "remote",
},
]
handler = _FakeHandler()
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval, \
patch.object(routes, "_resolve_approval_legacy", return_value=True) as resolve_legacy:
routes._handle_approval_respond(
handler,
{"session_id": sid, "choice": "once", "approval_id": "shared-id"},
)
resp = handler.json()
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
assert resp.get("ok") is True
assert resp.get("relayed") is not True
resolve_legacy.assert_called_once_with(sid, "shared-id", "once")
respond_approval.assert_not_called()
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
_cleanup(sid)
def test_local_plain_pending_exact_id_beats_remote_first_queue_order():
sid = f"local-plain-pending-remote-first-{uuid.uuid4().hex[:8]}"
session = _register_session(sid)
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
session.active_stream_id = stream_id
_STREAM_RUN_IDS[stream_id] = "remote-run"
try:
with ta._lock:
ta._pending[sid] = [
{
ra._GATEWAY_MIRROR_FLAG: True,
"run_id": "remote-run",
"approval_id": "shared-id",
"command": "remote",
},
{"approval_id": "shared-id", "command": "local"},
]
handler = _FakeHandler()
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
routes._handle_approval_respond(
handler,
{"session_id": sid, "choice": "once", "approval_id": "shared-id"},
)
resp = handler.json()
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
assert resp.get("ok") is True
assert resp.get("relayed") is not True
respond_approval.assert_not_called()
with ta._lock:
queue = ta._pending.get(sid) or []
assert len(queue) == 1
assert queue[0].get("command") == "remote"
assert queue[0].get(ra._GATEWAY_MIRROR_FLAG) is True
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
_cleanup(sid)
def test_local_plain_pending_without_id_beats_active_remote_candidate_run():
sid = f"local-plain-pending-no-id-{uuid.uuid4().hex[:8]}"
session = _register_session(sid)
stream_id = f"remote-stream-{uuid.uuid4().hex[:8]}"
session.active_stream_id = stream_id
_STREAM_RUN_IDS[stream_id] = "remote-run"
try:
with ta._lock:
ta._pending[sid] = [{"command": "local"}]
handler = _FakeHandler()
with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=False), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval, \
patch.object(routes, "_resolve_approval_legacy", return_value=True) as resolve_legacy:
routes._handle_approval_respond(
handler,
{"session_id": sid, "choice": "once"},
)
resp = handler.json()
assert handler.status == 200, f"expected 200, got {handler.status}: {resp}"
assert resp.get("ok") is True
assert resp.get("relayed") is not True
resolve_legacy.assert_called_once_with(sid, "", "once")
respond_approval.assert_not_called()
finally:
_STREAM_RUN_IDS.pop(stream_id, None)
_cleanup(sid)
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:
@@ -197,10 +394,13 @@ 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}
assert entry.event.is_set()
assert entry.result == "once"
with ta._lock:
assert sid not in ta._pending
assert sid not in ta._gateway_queues
finally:
_cleanup(sid)
@@ -0,0 +1,238 @@
"""Sidebar stop must not settle the UI when /api/chat/cancel fails."""
from __future__ import annotations
import json
import pathlib
import re
import subprocess
REPO = pathlib.Path(__file__).parent.parent
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
def _extract_function(src: str, name: str) -> str:
m = re.search(rf"(?:async )?function {name}\s*\(", src)
assert m, f"{name} not found in static/boot.js"
brace_pos = src.index("{", m.end())
depth = 1
pos = brace_pos + 1
while pos < len(src) and depth > 0:
ch = src[pos]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
pos += 1
return src[m.start():pos]
CANCEL_SESSION_STREAM_SRC = _extract_function(BOOT_JS, "cancelSessionStream")
COMPOSER_PRIMARY_ACTION_SRC = _extract_function(UI_JS, "handleComposerPrimaryAction")
_TOAST_DEFAULT_NAME = "TOAST_" + "DEFAULT_MS"
_TOAST_ERROR_DEFAULT_NAME = "TOAST_ERROR_" + "DEFAULT_MS"
SHOW_TOAST_SRC = _extract_function(UI_JS, "show" + "Toast").replace(
_TOAST_DEFAULT_NAME, "toast_default_ms"
).replace(_TOAST_ERROR_DEFAULT_NAME, "toast_error_default_ms")
def test_source_gates_sidebar_settle_on_http_success():
assert "return false" in CANCEL_SESSION_STREAM_SRC
assert "return true" in CANCEL_SESSION_STREAM_SRC
assert "r.ok" in CANCEL_SESSION_STREAM_SRC, (
"cancelSessionStream() must check the /api/chat/cancel HTTP status before "
"closing local UI state"
)
assert "if(!respOk)returnfalse;" in "".join(CANCEL_SESSION_STREAM_SRC.split()), (
"cancelSessionStream() must bail out on failed stop responses"
)
def test_stop_callers_gate_success_toasts_on_cancel_result():
compact_commands = "".join(COMMANDS_JS.split())
compact_messages = "".join(MESSAGES_JS.split())
compact_sessions = "".join(SESSIONS_JS.split())
assert (
"if(awaitcancelStream('slash-stop'))showToast(t('stream_stopped'));"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_commands
assert (
"if(awaitcancelStream('slash-interrupt'))showToast(t('cmd_interrupt_confirm'),2000);"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_commands
assert (
"if(awaitcancelStream('busy-interrupt'))showToast(t('busy_interrupt_confirm'),2000);"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_messages
assert (
"if(awaitcancelSessionStream(session))showToast(t('stream_stopped'));"
"elseshowToast(t('cancel_failed'),null,'error');"
) in compact_sessions
assert (
"if(typeofcancelStream==='function'&&!awaitcancelStream('composer-stop'))"
"showToast(t('cancel_failed'),null,'error');"
) in "".join(UI_JS.split())
_NODE_SCRIPT = r'''
const M = {
closeCalls: [],
busyCalls: [],
composerCalls: [],
statusCalls: [],
renderCalls: 0,
clearCalls: [],
approvalStops: 0,
approvalHides: 0,
clarifyStops: 0,
clarifyHides: 0,
fetchCalls: [],
};
globalThis.INFLIGHT = { 'sid-1': { streamId: 'stream-1' } };
globalThis.S = { activeStreamId: 'stream-1', session: { session_id: 'sid-1', active_stream_id: 'stream-1' } };
globalThis.closeLiveStream = (...a) => M.closeCalls.push(a);
globalThis.clearInflightState = (sid) => M.clearCalls.push(['clearInflightState', sid]);
globalThis.clearInflight = () => M.clearCalls.push(['clearInflight']);
globalThis.setBusy = (v) => M.busyCalls.push(v);
globalThis.setComposerStatus = (v) => M.composerCalls.push(v);
globalThis.setStatus = (v) => M.statusCalls.push(v);
globalThis.stopApprovalPolling = () => M.approvalStops += 1;
globalThis.hideApprovalCard = () => M.approvalHides += 1;
globalThis.stopClarifyPolling = () => M.clarifyStops += 1;
globalThis.hideClarifyCard = () => M.clarifyHides += 1;
globalThis.renderSessionList = () => M.renderCalls += 1;
globalThis._approvalSessionId = 'sid-1';
globalThis._clarifySessionId = 'sid-1';
globalThis.document = { baseURI: 'http://localhost:8787/' };
globalThis.location = { href: 'http://localhost:8787/' };
globalThis.fetch = (url, opts) => {
M.fetchCalls.push({ url: String(url), opts });
return Promise.resolve({
ok: false,
json: () => Promise.resolve({ ok: false, cancelled: false, stream_id: 'stream-1' }),
});
};
__CANCEL_SESSION_STREAM_SRC__
const session = { session_id: 'sid-1', active_stream_id: 'stream-1' };
await cancelSessionStream(session);
console.log(JSON.stringify({
sessionActiveStreamId: session.active_stream_id,
activeStreamId: globalThis.S.activeStreamId,
closeCalls: M.closeCalls,
busyCalls: M.busyCalls,
composerCalls: M.composerCalls,
renderCalls: M.renderCalls,
clearCalls: M.clearCalls,
approvalStops: M.approvalStops,
approvalHides: M.approvalHides,
clarifyStops: M.clarifyStops,
clarifyHides: M.clarifyHides,
fetchCalls: M.fetchCalls.length,
}));
'''
def test_failed_sidebar_stop_keeps_local_state():
script = _NODE_SCRIPT.replace("__CANCEL_SESSION_STREAM_SRC__", CANCEL_SESSION_STREAM_SRC)
completed = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=str(REPO),
capture_output=True,
encoding="utf-8",
timeout=30,
)
assert completed.returncode == 0, (
f"node subprocess failed:\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}"
)
result = json.loads(completed.stdout.splitlines()[-1])
assert result["fetchCalls"] == 1
assert result["sessionActiveStreamId"] == "stream-1"
assert result["activeStreamId"] == "stream-1"
assert result["closeCalls"] == []
assert result["busyCalls"] == []
assert result["composerCalls"] == []
assert result["renderCalls"] == 0
assert result["clearCalls"] == []
assert result["approvalStops"] == 0
assert result["approvalHides"] == 0
assert result["clarifyStops"] == 0
assert result["clarifyHides"] == 0
def test_primary_composer_stop_renders_localized_error_and_preserves_success():
cancel_key = "cancel_" + "failed"
english = re.search(rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS)
japanese = re.search(
rf"{cancel_key}:\s*'((?:\\'|[^'])*)'", I18N_JS[I18N_JS.index("ja:"):]
)
assert english and japanese
english_message = english.group(1).replace("\\'", "'")
japanese_message = japanese.group(1).replace("\\'", "'")
script = (r'''
const M = { renders: [], sends: 0, results: [] };
const toast_default_ms = 2800;
const toast_error_default_ms = 20000;
const toast = {
className: '', dataset: {}, _innerHTML: '', _textContent: '',
classList: { remove() {} },
};
Object.defineProperty(toast, 'innerHTML', {
get() { return this._innerHTML; },
set(value) { this._innerHTML = String(value); this._textContent = ''; },
});
Object.defineProperty(toast, 'textContent', {
get() { return this._textContent; },
set(value) { this._textContent = String(value); this._innerHTML = ''; },
});
globalThis.$ = (id) => id === 'toast' ? toast : null;
globalThis.esc = (value) => String(value);
globalThis.clearToastDismissTimer = () => {};
globalThis.setToastDismissTimer = (el, duration) => {
M.renders.push({ message: el.dataset.toastMessage, className: el.className,
duration, copy: el.innerHTML.includes('data-toast-' + 'copy="1"') });
};
globalThis.setTimeout = () => 0;
globalThis.window = {};
globalThis.S = {};
globalThis.getComposerPrimaryAction = () => 'stop';
__SHOW_TOAST_SRC__
globalThis.send = () => { M.sends += 1; };
globalThis.cancelStream = async () => M.cancelResult;
__COMPOSER_PRIMARY_ACTION_SRC__
for (const [message, result] of [[__ENGLISH__, false], [__JAPANESE__, false], ['unused', true]]) {
M.cancelResult = result;
globalThis.t = () => message;
const before = M.renders.length;
await handleComposerPrimaryAction();
M.results.push({ result, rendered: M.renders.length - before, sends: M.sends });
}
console.log(JSON.stringify(M));
'''.replace("__SHOW_TOAST_SRC__", SHOW_TOAST_SRC)
.replace("__COMPOSER_PRIMARY_ACTION_SRC__", COMPOSER_PRIMARY_ACTION_SRC)
.replace("__ENGLISH__", json.dumps(english_message))
.replace("__JAPANESE__", json.dumps(japanese_message)))
completed = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=str(REPO), capture_output=True, encoding="utf-8", timeout=30,
)
assert completed.returncode == 0, (
f"node subprocess failed:\n--- stdout ---\n{completed.stdout}\n--- stderr ---\n{completed.stderr}"
)
result = json.loads(completed.stdout.splitlines()[-1])
assert result["renders"] == [
{"message": english_message, "className": "toast show error", "duration": 20000, "copy": True},
{"message": japanese_message, "className": "toast show error", "duration": 20000, "copy": True},
]
assert result["results"] == [
{"result": False, "rendered": 1, "sends": 0},
{"result": False, "rendered": 1, "sends": 0},
{"result": True, "rendered": 0, "sends": 0},
]
+1 -1
View File
@@ -325,7 +325,7 @@ def test_approval_respond_does_not_fallback_to_oldest_when_explicit_id_is_stale(
helper_body = src[helper_idx:src.index("def _handle_approval_respond", helper_idx)]
assert "A stale explicit id must not accidentally approve" in helper_body
assert "if found_target or not approval_id:" in helper_body
assert "if not pending and not approval_id:" in helper_body
stale_branch = helper_body[helper_body.index("else:", helper_body.index("for i, entry")):helper_body.index("else:\n pending = queue.pop(0)")]
assert "pending = None" in stale_branch
assert "queue.pop(0)" not in stale_branch
+62
View File
@@ -115,6 +115,68 @@ def test_attention_summary_prefers_pending_approvals_over_clarify_questions():
_clear_attention_state(sid)
def test_attention_summary_keeps_direct_runs_mirror_after_stream_pointer_clears():
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import api.route_approvals as approvals
sid = "attention-retained-gateway-session"
_clear_attention_state(sid)
try:
approvals.submit_gateway_pending_mirror(sid, {
"_gateway_mirror": True,
"approval_id": "approval-retained",
"run_id": "run-retained",
"command": "rm -rf /tmp/retained",
"description": "Retained approval",
})
handler = MagicMock()
handler.wfile = io.BytesIO()
with patch("api.routes.get_session", return_value=SimpleNamespace(active_stream_id=None)), \
patch("api.runner_client.HttpRunnerClient.respond_approval") as respond_approval:
assert routes._session_attention_summary(sid) == {
"kind": "approval",
"count": 1,
"severity": "critical",
}
routes._handle_approval_respond(handler, {
"session_id": sid,
"choice": "deny",
"approval_id": "approval-retained",
})
handler.send_response.assert_called_with(200)
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)
_clear_attention_state(sid)
def test_attention_summary_hides_retired_direct_runs_mirror():
import api.route_approvals as approvals
sid = "attention-retired-gateway-session"
_clear_attention_state(sid)
try:
approvals.submit_gateway_pending_mirror(sid, {
"_gateway_mirror": True,
"approval_id": "approval-retired",
"run_id": "run-retired",
"command": "rm -rf /tmp/retired",
"description": "Retired approval",
})
assert approvals.retire_gateway_pending_mirror(sid, run_id="run-retired") is True
assert routes._session_attention_summary(sid) is None
assert approvals.gateway_pending_mirror(sid, run_id="run-retired") is None
finally:
approvals._pending.pop(sid, None)
_clear_attention_state(sid)
def test_attention_summary_reports_clarify_when_no_approval_is_pending():
sid = "attention-clarify-session"
_clear_attention_state(sid)