diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000000..d327e5befc6ec --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# CodeRabbit configuration. +# +# Auto-review is DISABLED repo-wide: the app was enabled at the org level on +# 2026-08-14 and immediately began reviewing every opened PR. This repo's +# merge gate is CI ("All required checks pass") plus maintainer review — +# CodeRabbit reviews carry no merge weight (protect-main ruleset requires +# 0 approving reviews), so auto-firing on the full PR firehose adds comment +# noise without gating value. +# +# The bot stays installed and summonable on demand: comment +# `@coderabbitai review` on any PR to request a one-off review, or +# `@coderabbitai ignore` to mute it on a PR it has already joined. +# +# To re-enable auto-review, flip `enabled: true` below (or delete this file — +# the app default is on). +reviews: + auto_review: + enabled: false diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 9119e0c7a9b9f..9631cdd7faf70 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -20,9 +20,33 @@ jobs: - name: grab npm 12 run: | - npm i -g npm@12 + # No-op once the bundled npm is already 12.x — saves ~5-15s/job and + # keeps the installed major aligned with the npm12 cache-key tag. + npm --version | grep -q '^12\.' || npm i -g npm@12 + + # ``setup-node``'s ``cache: npm`` only caches the ~/.npm tarball cache; + # every job still re-extracts the full workspace node_modules and reruns + # postinstalls (including the Electron binary fetch). Cache the installed + # tree itself, keyed on the lockfile, and skip ``npm ci`` on an exact + # hit. No restore-keys: a partial hit would leave a stale tree, so + # anything but an exact lockfile match reinstalls from scratch. + # The discovery job installs with --ignore-scripts, so its tree differs + # from the check jobs' — hence the distinct ``-noscripts`` key. + - name: Restore node_modules + id: node-modules-cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + node_modules + apps/*/node_modules + ui-tui/node_modules + ui-tui/packages/*/node_modules + tests-js/node_modules + web/node_modules + key: node-modules-noscripts-${{ runner.os }}-node26-npm12-${{ hashFiles('package-lock.json') }} - uses: ./.github/actions/retry + if: steps.node-modules-cache.outputs.cache-hit != 'true' with: command: npm ci --ignore-scripts - id: set-matrix @@ -71,9 +95,31 @@ jobs: - name: grab npm 12 run: | - npm i -g npm@12 + # No-op once the bundled npm is already 12.x — saves ~5-15s/job and + # keeps the installed major aligned with the npm12 cache-key tag. + npm --version | grep -q '^12\.' || npm i -g npm@12 + + # Same rationale as the discovery job's cache above, but this ``npm ci`` + # runs WITH install scripts, so the tree includes postinstall artifacts + # (electron's postinstall unpacks its binary into node_modules/electron/ + # dist, which lives inside the cached tree — the ~/.cache/electron + # download cache is deliberately NOT cached: with npm ci skipped on hit + # it would never be read, only inflate the archive). + - name: Restore node_modules + id: node-modules-cache + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + node_modules + apps/*/node_modules + ui-tui/node_modules + ui-tui/packages/*/node_modules + tests-js/node_modules + web/node_modules + key: node-modules-scripts-${{ runner.os }}-node26-npm12-${{ hashFiles('package-lock.json') }} - uses: ./.github/actions/retry + if: steps.node-modules-cache.outputs.cache-hit != 'true' with: command: npm ci - run: npm run --prefix ${{ matrix.package }} ${{ matrix.script }} diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f5e827b7c5e2a..e6b26493dbeaa 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1500,8 +1500,15 @@ def create(self, **kwargs) -> Any: # build_kwargs, so they need the same guard applied independently. _host_for_input = str(getattr(self._client, "base_url", "") or "") _is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com") + # Auxiliary calls never send ``context_management`` (native + # compaction is a main-turn feature), so they must never replay a + # compaction checkpoint from the replayed history nor let one + # restructure this request — the summarizer/aggregator model is + # usually not even the one that minted the blob. input_items = _chat_messages_to_responses_input( - replay_messages, is_github_responses=_is_github_for_input, + replay_messages, + is_github_responses=_is_github_for_input, + native_compaction_eligible=False, ) resp_kwargs: Dict[str, Any] = { diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index e618488e76546..3c5a90370ab66 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -29,7 +29,10 @@ from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH -from agent.error_classifier import FailoverReason +from agent.error_classifier import ( + FailoverReason, + PROVIDER_STREAM_NON_JSON_ERROR_CODE, +) from agent.errors import EmptyStreamError from agent.turn_context import substitute_api_content from agent.gemini_native_adapter import is_native_gemini_base_url @@ -46,6 +49,9 @@ logger = logging.getLogger(__name__) _OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"} +_PROVIDER_STREAM_ERROR_FINISH_REASONS = {"error", "error_finish"} +_PROVIDER_STREAM_SSE_FIELDS = {"event", "data", "id", "retry"} +_PROVIDER_STREAM_ERROR_TEXT_LIMIT = 4096 # When the fallback chain is fully exhausted on a non-rate-limit failure # (e.g. every provider returns a non-retryable client error like HTTP 400), @@ -76,6 +82,353 @@ def _ra(): return run_agent +class ProviderStreamError(Exception): + """Provider encoded an API error as streaming content instead of an SDK error.""" + + def __init__( + self, + *, + status_code: Optional[int], + body: dict, + raw_text: str, + headers: Any = None, + ): + self.status_code = status_code + self.body = body + self.raw_text = raw_text + self.response = SimpleNamespace(headers=headers or {}) + super().__init__(self._format_message()) + + def _format_message(self) -> str: + error_obj = self.body.get("error", {}) if isinstance(self.body, dict) else {} + code = error_obj.get("code") if isinstance(error_obj, dict) else None + message = error_obj.get("message") if isinstance(error_obj, dict) else None + parts = ["Provider stream returned an error event"] + if self.status_code: + parts.append(f"HTTP {self.status_code}") + if code: + parts.append(str(code)) + text = " - ".join(parts) + if message: + text += f": {message}" + return text + + +def _status_code_from_value(value: Any) -> Optional[int]: + if isinstance(value, int) and 100 <= value < 600: + return value + if not isinstance(value, str): + return None + match = re.search(r"(?:HTTP_STATUS/)?\b([1-5]\d\d)\b", value, re.IGNORECASE) + if not match: + return None + try: + return int(match.group(1)) + except ValueError: + return None + + +def _status_code_from_payload(payload: Any) -> Optional[int]: + if not isinstance(payload, dict): + return None + + candidates = [ + payload.get("status_code"), + payload.get("status"), + payload.get("http_status"), + ] + error_obj = payload.get("error") + if isinstance(error_obj, dict): + candidates.extend([ + error_obj.get("status_code"), + error_obj.get("status"), + error_obj.get("http_status"), + error_obj.get("code"), + ]) + candidates.append(payload.get("code")) + + for candidate in candidates: + status_code = _status_code_from_value(candidate) + if status_code is not None: + return status_code + return None + + +def _json_object_from_text(text: str) -> Optional[dict]: + stripped = (text or "").strip() + if not stripped.startswith("{"): + return None + try: + decoded = json.loads(stripped) + except (json.JSONDecodeError, TypeError): + return None + return decoded if isinstance(decoded, dict) else None + + +def _parse_provider_sse_events(text: str) -> list[dict]: + """Parse provider text that looks like Server-Sent Events.""" + events: list[dict] = [] + current = {"event": None, "data": [], "comments": [], "fields": {}} + + def _has_event_data(event: dict) -> bool: + return bool( + event.get("event") + or event.get("data") + or event.get("comments") + or event.get("fields") + ) + + def _flush_current(): + nonlocal current + if _has_event_data(current): + data_text = "\n".join(current["data"]) + status_candidates = list(current["comments"]) + for key in ("status", "status_code", "http_status"): + if key in current["fields"]: + status_candidates.append(current["fields"][key]) + events.append({ + "event": current["event"], + "data": data_text, + "comments": list(current["comments"]), + "fields": dict(current["fields"]), + "status_code": next( + ( + status + for status in ( + _status_code_from_value(value) + for value in status_candidates + ) + if status is not None + ), + None, + ), + }) + current = {"event": None, "data": [], "comments": [], "fields": {}} + + for raw_line in (text or "").splitlines(): + line = raw_line.rstrip("\r") + if line == "": + _flush_current() + continue + if line.startswith(":"): + current["comments"].append(line[1:].strip()) + continue + + field, sep, value = line.partition(":") + if not sep: + current["fields"][field.strip().lower()] = "" + continue + field = field.strip().lower() + if value.startswith(" "): + value = value[1:] + if field == "event": + current["event"] = value.strip() + elif field == "data": + current["data"].append(value) + else: + current["fields"][field] = value + + _flush_current() + return events + + +def _provider_error_body(payload: dict, status_code: Optional[int]) -> dict: + """Normalize common provider error payloads to OpenAI-style body.error.""" + if isinstance(payload, dict): + error_obj = payload.get("error") + if isinstance(error_obj, dict): + return payload + else: + payload = {} + + code = ( + payload.get("code") + or payload.get("error_code") + or payload.get("type") + or (f"HTTP_{status_code}" if status_code else "provider_stream_error") + ) + message = ( + payload.get("message") + or payload.get("error_description") + or payload.get("error") + or "Provider stream returned an error event." + ) + normalized_error = {"message": str(message)} + if code: + normalized_error["code"] = str(code) + for key in ("request_id", "param", "type"): + if payload.get(key): + normalized_error[key] = payload[key] + return {"error": normalized_error} + + +def _provider_stream_error_from_json_decode_error( + error: json.JSONDecodeError, + *, + response: Any = None, +) -> ProviderStreamError: + """Preserve plain-text SSE data rejected inside the OpenAI SDK. + + OpenAI-compatible providers occasionally send ``event: error`` with a + non-JSON ``data:`` field. The SDK raises from ``sse.json()`` before it can + yield a completion chunk, but ``JSONDecodeError.doc`` still contains the + provider's original message. + """ + from agent.redact import redact_sensitive_text + + raw_text = str(getattr(error, "doc", "") or "").strip() + safe_text = redact_sensitive_text( + _sanitize_surrogates(raw_text), + force=True, + ) + safe_text = safe_text[:_PROVIDER_STREAM_ERROR_TEXT_LIMIT] + message = safe_text or "Provider stream returned non-JSON SSE data." + headers = getattr(response, "headers", None) if response is not None else None + + return ProviderStreamError( + status_code=None, + body=_provider_error_body( + { + "code": PROVIDER_STREAM_NON_JSON_ERROR_CODE, + "message": message, + }, + None, + ), + raw_text=safe_text, + headers=headers, + ) + + +def _iter_provider_stream_chunks(stream, *, response: Any = None): + """Yield SDK chunks while translating SDK-level SSE decode failures.""" + try: + yield from stream + except json.JSONDecodeError as error: + stream_response = response() if callable(response) else response + if stream_response is None: + stream_response = getattr(stream, "response", None) + raise _provider_stream_error_from_json_decode_error( + error, + response=stream_response, + ) from error + + +def _payload_has_error_shape(payload: Any) -> bool: + if not isinstance(payload, dict): + return False + if isinstance(payload.get("error"), (dict, str)): + return True + if payload.get("message") and ( + payload.get("code") + or payload.get("error_code") + or _status_code_from_payload(payload) is not None + ): + return True + return False + + +def _provider_stream_text_may_be_sse(text: str) -> bool: + """Return True while pending text still looks like an SSE control block.""" + stripped = (text or "").lstrip() + if not stripped: + return False + + lines = stripped.splitlines() + trailing_newline = stripped.endswith(("\n", "\r")) + saw_sse_field = False + + for index, raw_line in enumerate(lines): + line = raw_line.rstrip("\r") + if line == "": + continue + if line.startswith(":"): + saw_sse_field = True + continue + + field, sep, _value = line.partition(":") + field_name = field.strip().lower() + if sep and field_name in _PROVIDER_STREAM_SSE_FIELDS: + saw_sse_field = True + continue + + is_last_incomplete = index == len(lines) - 1 and not trailing_newline + if is_last_incomplete and any( + sse_field.startswith(field_name) + for sse_field in _PROVIDER_STREAM_SSE_FIELDS + ): + return True + return False + + return saw_sse_field + + +def _provider_stream_error_from_text( + text: str, + finish_reason: Optional[str], + *, + response: Any = None, +) -> Optional[ProviderStreamError]: + """Convert provider-streamed error text into an exception for retry logic.""" + if not text: + return None + + finish_reason_text = str(finish_reason or "").lower() + has_error_finish = finish_reason_text in _PROVIDER_STREAM_ERROR_FINISH_REASONS + if not has_error_finish: + return None + + for event in _parse_provider_sse_events(text): + event_name = str(event.get("event") or "").strip().lower() + payload = _json_object_from_text(event.get("data") or "") or {} + status_code = event.get("status_code") or _status_code_from_payload(payload) + is_error_event = event_name == "error" + is_http_error = status_code is not None and status_code >= 400 + is_error_payload = _payload_has_error_shape(payload) + is_structured_error_event = is_error_event and ( + has_error_finish or is_http_error or is_error_payload + ) + is_bare_error_finish_payload = ( + not is_error_event and has_error_finish and is_error_payload + ) + + if not ( + is_http_error + or is_structured_error_event + or is_bare_error_finish_payload + ): + continue + + headers = getattr(response, "headers", None) if response is not None else None + return ProviderStreamError( + status_code=status_code, + body=_provider_error_body(payload, status_code), + raw_text=text, + headers=headers, + ) + + payload = _json_object_from_text(text) + if payload is not None: + status_code = _status_code_from_payload(payload) + if has_error_finish or (status_code is not None and status_code >= 400): + headers = getattr(response, "headers", None) if response is not None else None + return ProviderStreamError( + status_code=status_code, + body=_provider_error_body(payload, status_code), + raw_text=text, + headers=headers, + ) + + if has_error_finish and text.strip(): + headers = getattr(response, "headers", None) if response is not None else None + return ProviderStreamError( + status_code=None, + body=_provider_error_body({}, None), + raw_text=text, + headers=headers, + ) + return None + + def estimate_request_context_tokens(api_payload: Any) -> int: """Estimate context/load tokens from an API payload, dict or messages list. @@ -3367,6 +3720,7 @@ def _call_chat_completions(stream_attempt_id: int): request_client_holder["diag"] = _diag _writer_token = {"value": None} attempt_request_client = {"value": None} + attempt_stream_response = {"value": None} def _open_stream(next_api_kwargs: dict[str, Any]): stream_kwargs = { @@ -3395,6 +3749,7 @@ def _open_stream(next_api_kwargs: dict[str, Any]): def _stream_created(raw_stream: Any) -> None: response = getattr(raw_stream, "response", None) + attempt_stream_response["value"] = response agent._capture_rate_limits(response) agent._capture_credits(response) agent._stream_diag_capture_response(_diag, response) @@ -3481,7 +3836,31 @@ def _relay_final_response() -> dict[str, Any]: # Hermes interrupts the managed stream; Relay retains sole # ownership of closing the underlying provider stream. _set_request_stream_handle(stream) - for chunk in stream: + pending_text_parts: list[str] = [] + + def _flush_pending_stream_text(): + if not pending_text_parts: + return + pending_parts = list(pending_text_parts) + pending_text_parts.clear() + if not tool_calls_acc: + for text in pending_parts: + _fire_first_delta() + agent._fire_stream_delta(text) + deltas_were_sent["yes"] = True + return + if agent.stream_delta_callback: + for text in pending_parts: + try: + agent.stream_delta_callback(text) + agent._record_streamed_assistant_text(text) + except Exception: + pass + + for chunk in _iter_provider_stream_chunks( + stream, + response=lambda: attempt_stream_response["value"], + ): last_chunk_time["t"] = time.time() agent._touch_activity("receiving stream response") @@ -3562,6 +3941,13 @@ def _relay_final_response() -> dict[str, Any]: if delta and delta.content: content_parts.append(delta.content) if not tool_calls_acc: + if pending_text_parts or _provider_stream_text_may_be_sse(delta.content): + pending_text_parts.append(delta.content) + pending_text = "".join(pending_text_parts) + if _provider_stream_text_may_be_sse(pending_text): + continue + _flush_pending_stream_text() + continue _fire_first_delta() agent._fire_stream_delta(delta.content) deltas_were_sent["yes"] = True @@ -3585,6 +3971,7 @@ def _relay_final_response() -> dict[str, Any]: # Accumulate tool call deltas — notify display on first name if delta and delta.tool_calls: + _flush_pending_stream_text() for tc_delta in delta.tool_calls: raw_idx = tc_delta.index if tc_delta.index is not None else 0 delta_id = tc_delta.id or "" @@ -3836,6 +4223,15 @@ def _relay_final_response() -> dict[str, Any]: if has_truncated_tool_args: effective_finish_reason = "length" + provider_stream_error = _provider_stream_error_from_text( + full_content or "", + effective_finish_reason, + response=getattr(stream, "response", None), + ) + if provider_stream_error is not None: + raise provider_stream_error + _flush_pending_stream_text() + full_reasoning = "".join(reasoning_parts) or None mock_message = SimpleNamespace( role=role, diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 2b431cff720b9..ac1129d4fcbe0 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -414,6 +414,7 @@ def _chat_messages_to_responses_input( is_github_responses: bool = False, replay_encrypted_reasoning: bool = True, current_issuer_kind: Optional[str] = None, + native_compaction_eligible: bool = False, ) -> List[Dict[str, Any]]: """Convert internal chat-style messages to Responses input items. @@ -458,6 +459,24 @@ def _chat_messages_to_responses_input( ``replay_encrypted_reasoning=False`` is the session-wide kill switch (drops ALL replay); ``current_issuer_kind`` is the per-item filter that runs only when replay is still enabled. + + ``native_compaction_eligible`` mirrors, for THIS request, the decision + made by ``native_compaction.native_compaction_context_management`` — it + is True only when that gate returned a payload, i.e. when the request + actually carries ``context_management``. It controls two things that + must never outlive the gate: replaying ``type: "compaction"`` checkpoint + items, and restructuring the wire around them + (``prune_pre_checkpoint_items``). Checkpoints are persisted in the + ``codex_reasoning_items`` sidecar and survive a mid-session model swap, + a ``compression.enabled: false`` flip, the rejection kill switch and a + resumed session; without this flag a single captured checkpoint would + keep deleting every pre-checkpoint item from every later request, on a + model that cannot decrypt the blob (#85914). Default False = pre-feature + wire, which is also correct for every caller that never sends + ``context_management`` (auxiliary/compression client, ad-hoc + ``convert_messages``). Dropping the checkpoint costs nothing: Hermes' + local history is never truncated by native compaction, so the full + conversation is still on the wire. """ items: List[Dict[str, Any]] = [] seen_item_ids: set = set() @@ -499,6 +518,20 @@ def _chat_messages_to_responses_input( item_id = ri.get("id") if item_id and item_id in seen_item_ids: continue + # Native-compaction gate: a checkpoint is only + # meaningful to the endpoint/model that minted it + # AND only while this request still asks for + # server-side compaction. Once the gate closes + # (model swapped out of the gpt-5.6 family, + # compression disabled, rejection kill switch), + # the persisted checkpoint must not be replayed — + # replaying it is what makes the wire restructure + # below erase pre-checkpoint history forever. + if ( + ri.get("type") == "compaction" + and not native_compaction_eligible + ): + continue # Cross-issuer guard: drop reasoning blocks that # were minted by a different Responses endpoint. # The current endpoint cannot decrypt foreign @@ -697,8 +730,13 @@ def _chat_messages_to_responses_input( # from before the boundary silently vanish from the model's view. Keep # the newest checkpoint first, retain pre-checkpoint USER messages # verbatim within a token budget (Codex CLI parity), and leave the - # post-checkpoint tail untouched. Self-gating: histories without a - # checkpoint (every non-native session) return unchanged. + # post-checkpoint tail untouched. Gated on the CURRENT request's native + # eligibility, not merely on the presence of a checkpoint: a persisted + # checkpoint outlives the gate, and pruning for a request that carries no + # ``context_management`` deletes history the server never compacted. + if not native_compaction_eligible: + return items + from agent.native_compaction import prune_pre_checkpoint_items return prune_pre_checkpoint_items(items) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index b62b8c8ad41c6..aeffedb678b2a 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -3404,6 +3404,14 @@ def _release_lock() -> None: migrate_heartbeat_to_session(old_session_id, agent.session_id) except Exception as _hb_err: logger.debug("Could not migrate heartbeat on compression: %s", _hb_err) + # Same boundary hazard for a persistent /loop — carry it + # onto the continuation session so the recurring wakeups + # survive compression. + try: + from hermes_cli.loops import migrate_loop_to_session + migrate_loop_to_session(old_session_id, agent.session_id, reason="compression") + except Exception as _loop_err: + logger.debug("Could not migrate loop on compression: %s", _loop_err) # Carry the title across the compression boundary unchanged. # # This used to renumber ("Fix X" → "Fix X #2") on every diff --git a/agent/deadline.py b/agent/deadline.py new file mode 100644 index 0000000000000..5aa58e6c06e9b --- /dev/null +++ b/agent/deadline.py @@ -0,0 +1,544 @@ +"""Unified deadline layer — one bounded-execution primitive, one timeout resolver. + +Phase 1 of the architectural fix for the timeout/hang backlog +(https://github.com/NousResearch/hermes-agent/issues/85125). + +The tree currently carries at least six site-local deadline mechanisms, each +built for one incident, none shared (tool_executor batch deadline, telegram +``_await_with_thread_deadline``, gateway turn lease, reasoning stale floors, +``human_wait_ceiling``, per-MCP-handler timeouts). Every new stall report +grows that list by one. This module is the shared foundation the call sites +migrate onto in later phases: + +* :func:`resolve_timeout` — one config-first resolution path for timeout + values (``timeouts:`` section in config.yaml > legacy env var > default), + so new surfaces stop inventing ``HERMES_*_TIMEOUT`` env vars (".env is for + secrets only") and hardcoded literals stop ignoring user config + (#63302, #53161, #43272 class). + +* :func:`clamp_timeout` — platform-safe clamping. Large user-supplied + timeouts overflow ``time_t`` inside ``threading.Lock.acquire(timeout=...)`` + / ``Thread.join(timeout=...)`` on macOS and kill whole tool batches + (#83220). Clamping at the shared boundary fixes that class once, for + every consumer. + +* :func:`run_bounded_async` — a wall-clock deadline for awaitables that does + NOT depend on event-loop timers. ``asyncio.wait_for`` schedules its expiry + on the loop; when the loop thread itself is blocked in a synchronous call + (family A of the #84047 stall triage), every asyncio-based timeout in the + process is silently disabled. This helper drives the deadline from a + daemon ``threading.Timer`` (generalizing the proven telegram-adapter + primitive) and abandons cancellation-shielded tasks instead of waiting for + cancellation to complete. The telegram adapter's private copy + (``plugins/platforms/telegram/adapter.py:_await_with_thread_deadline``) + migrates onto this in Phase 2 of #85125 — do not let the two drift in the + meantime; fix bugs here first. + +* :func:`run_bounded_sync` — the same contract for synchronous callables + bounded from a synchronous context (daemon worker thread, abandoned on + expiry). + +* :func:`kill_process_tree` — portable whole-tree termination so + kill-on-timeout stops orphaning descendants (#71148, #59549, #84967, + #68139 class). Existing site-local tree-kills that migrate onto this in + Phase 4 of #85125: ``gateway/status.py`` (taskkill wrapper + psutil + snapshot/reap pair) and ``tools/code_execution_tool.py`` (psutil + recursive children kill). + +Design invariants: + +* Exceptions raised by the bounded operation propagate unchanged — callers + keep their existing error handling. Only the *timeout* outcome is + reified (as :class:`BoundedResult`), because that is the outcome the + call sites keep getting wrong. +* A timeout produced by this layer is OUR deadline, not the provider's. + Callers that feed errors into ``agent/error_classifier.py`` should + classify :class:`DeadlineExpired` distinctly from transport timeouts + (the #59549 / #80323 misattribution class). +* ``None`` timeout means unbounded, and non-positive resolved values are + normalized to ``None`` (matching the existing + ``HERMES_CONCURRENT_TOOL_TIMEOUT_S`` convention). +""" + +from __future__ import annotations + +import asyncio +import faulthandler +import logging +import os +import subprocess +import sys +import threading +import time +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Optional + +logger = logging.getLogger(__name__) + +__all__ = [ + "MAX_SAFE_TIMEOUT_S", + "BoundedResult", + "DeadlineExpired", + "clamp_timeout", + "resolve_timeout", + "run_bounded_async", + "run_bounded_sync", + "kill_process_tree", +] + +# Upper bound for any timeout handed to platform wait primitives. +# +# CPython converts ``threading.Lock.acquire(timeout=...)`` / +# ``Thread.join(timeout=...)`` deadlines to an absolute timestamp; very large +# relative timeouts overflow ``time_t`` on macOS and raise +# ``OverflowError: timestamp out of range for platform time_t`` (#83220). +# One year is semantically "unbounded" for every wait in this codebase while +# staying far below any platform conversion limit. +MAX_SAFE_TIMEOUT_S = 31_536_000.0 # 365 days + +# Grace period after a deadline fires before concluding the event loop thread +# is blocked in a synchronous call and dumping stacks (family A diagnostics). +_LOOP_BLOCKED_DUMP_GRACE_S = 5.0 + + +class DeadlineExpired(TimeoutError): + """A deadline enforced by this layer expired. + + Distinct from transport/provider timeout types on purpose: when this is + raised (or a :class:`BoundedResult` reports ``timed_out``), the timeout + was Hermes's own bound — error classification must not attribute it to + the provider (#59549 / #80323 misattribution class). + """ + + def __init__(self, label: str, timeout_s: float): + super().__init__(f"deadline expired after {timeout_s:.1f}s: {label}") + self.label = label + self.timeout_s = timeout_s + + +@dataclass(frozen=True, kw_only=True) +class BoundedResult: + """Outcome of a bounded operation. + + ``timed_out`` is the reified outcome; on completion ``value`` holds the + operation's return value. Operation exceptions are never captured here — + they propagate to the caller unchanged. + """ + + timed_out: bool + value: Any + elapsed_s: float + timeout_s: Optional[float] + label: str + + def raise_if_timed_out(self) -> Any: + """Return ``value``, raising :class:`DeadlineExpired` on timeout.""" + if self.timed_out: + raise DeadlineExpired(self.label, float(self.timeout_s or 0.0)) + return self.value + + +def clamp_timeout(timeout: Optional[float]) -> Optional[float]: + """Normalize a timeout value for platform wait primitives. + + * ``None`` stays ``None`` (unbounded). + * Non-positive values become ``None`` (unbounded) — matching the existing + ``HERMES_CONCURRENT_TOOL_TIMEOUT_S`` "0 disables the bound" convention. + * Values above :data:`MAX_SAFE_TIMEOUT_S` are capped so they can never + overflow ``time_t`` inside ``Lock.acquire`` / ``Thread.join`` on macOS + (#83220). + * Non-numeric values are treated as unset (``None``) with a warning + rather than crashing the call path they were meant to protect. + """ + if timeout is None: + return None + try: + value = float(timeout) + except (TypeError, ValueError): + logger.warning("clamp_timeout: non-numeric timeout %r; treating as unbounded", timeout) + return None + if value != value: # NaN + logger.warning("clamp_timeout: NaN timeout; treating as unbounded") + return None + if value <= 0: + return None + return min(value, MAX_SAFE_TIMEOUT_S) + + +# --------------------------------------------------------------------------- +# Timeout resolution: config.yaml ``timeouts:`` section > legacy env var > +# registered default. +# --------------------------------------------------------------------------- + +def _timeouts_section() -> dict: + """Read the ``timeouts:`` root section from config.yaml (read-only). + + Isolated for testability and so a broken config read can never take down + the call path the timeout was protecting. + """ + try: + from hermes_cli.config import load_config_readonly + + section = load_config_readonly().get("timeouts") + return section if isinstance(section, dict) else {} + except Exception: + logger.debug("timeouts: config read failed; using defaults", exc_info=True) + return {} + + +def _lookup_dotted(section: dict, key: str) -> Any: + """Walk ``a.b.c`` through nested dicts; return None when absent.""" + node: Any = section + for part in key.split("."): + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node + + +def resolve_timeout( + key: str, + *, + default: Optional[float], + env_var: Optional[str] = None, +) -> Optional[float]: + """Resolve a timeout in seconds for a dotted config key. + + Precedence (established by the ``providers.*.request_timeout_seconds`` + pattern — config wins over the legacy env var): + + 1. ``timeouts.`` in config.yaml (dotted key walks nested maps, e.g. + ``tools.concurrent_batch`` reads ``timeouts: {tools: {concurrent_batch: ...}}``) + 2. ``env_var`` when set and non-empty (legacy bridge — internal mechanism + and back-compat only; new surfaces must not grow new user-facing + ``HERMES_*`` timeout env vars) + 3. ``default`` + + The winning value is passed through :func:`clamp_timeout`, so ``0`` or a + negative value means "unbounded" and oversized values are made + platform-safe. Invalid (non-numeric) config/env values fall through to + the next source with a warning instead of breaking the protected path. + """ + raw = _lookup_dotted(_timeouts_section(), key) + if raw is not None: + # Explicit float() (clamp_timeout would also convert) so that invalid + # config values FALL THROUGH to the env var / default instead of + # resolving as unbounded — do not "simplify" this away. bool is + # rejected because YAML `true` would silently become a 1-second + # deadline; NaN is rejected for the same fall-through reason. + if not isinstance(raw, bool): + try: + value = float(raw) + if value == value: # not NaN + return clamp_timeout(value) + except (TypeError, ValueError): + pass + logger.warning("timeouts.%s: invalid value %r in config.yaml; ignoring", key, raw) + + if env_var: + env_raw = os.getenv(env_var, "").strip() + if env_raw: + try: + return clamp_timeout(float(env_raw)) + except ValueError: + logger.warning("invalid %s=%r; ignoring", env_var, env_raw) + + return clamp_timeout(default) + + +# --------------------------------------------------------------------------- +# Bounded execution — async flavor. +# +# Generalizes plugins/platforms/telegram/adapter.py:_await_with_thread_deadline +# (the #63309 fix): the deadline is driven by a daemon threading.Timer so a +# blocked event loop cannot disable it, and a second timer dumps all thread +# stacks when the loop provably failed to process the expiry — the one piece +# of information loop-blocked hangs otherwise never surface. +# --------------------------------------------------------------------------- + +def _consume_abandoned(task: "asyncio.Future[Any]") -> None: + """Observe an abandoned task's outcome so it never logs 'never retrieved'.""" + try: + if not task.cancelled(): + task.exception() + except Exception: + pass + + +async def _run_abandon_cleanup(on_abandon: Callable[[], Awaitable[Any]]) -> None: + """Run abandonment cleanup fully fire-and-forget (its failures swallowed).""" + try: + await on_abandon() + except Exception: + logger.debug("deadline abandon-cleanup failed", exc_info=True) + + +def _dump_blocked_loop_diagnostics(label: str, timeout_s: float) -> None: + logger.warning( + "[deadline] %r deadline (%.0fs) expired but the event loop has not " + "processed the expiry after a further %.0fs — the loop thread appears " + "BLOCKED in a synchronous call, which is why no asyncio timeout can " + "fire. Dumping all thread stacks to stderr to identify the blocking " + "frame.", + label, + timeout_s, + _LOOP_BLOCKED_DUMP_GRACE_S, + ) + try: + faulthandler.dump_traceback(all_threads=True) + except Exception: + logger.debug("faulthandler traceback dump failed", exc_info=True) + + +async def run_bounded_async( + awaitable: Awaitable[Any], + timeout: Optional[float], + *, + label: str = "operation", + on_abandon: Optional[Callable[[], Awaitable[Any]]] = None, + dump_on_blocked_loop: bool = True, +) -> BoundedResult: + """Await ``awaitable`` under a wall-clock deadline independent of loop timers. + + On completion returns ``BoundedResult(timed_out=False, value=...)``; + exceptions from the operation (including ``asyncio.CancelledError`` from a + caller cancelling *us*) propagate unchanged. + + On timeout the underlying task is cancelled and **abandoned** — we do not + await cancellation completion, because cancellation-shielded scopes (anyio, + httpcore init, MCP SDK teardown) are exactly the paths that wedge forever. + ``on_abandon`` (zero-arg callable returning an awaitable) is scheduled as + detached best-effort cleanup for the half-built state the abandoned task + may leave behind. Returns ``BoundedResult(timed_out=True, value=None)``. + + ``timeout=None`` (or a non-positive resolved value) awaits unbounded. + """ + timeout_s = clamp_timeout(timeout) + start = time.monotonic() + if timeout_s is None: + value = await awaitable + return BoundedResult(timed_out=False, value=value, elapsed_s=time.monotonic() - start, timeout_s=None, label=label) + + task = asyncio.ensure_future(awaitable) + loop = asyncio.get_running_loop() + deadline: "asyncio.Future[None]" = loop.create_future() + loop_processed_expiry = threading.Event() + + def _mark_expired() -> None: + loop_processed_expiry.set() + if not deadline.done(): + deadline.set_result(None) + + def _expire_from_thread() -> None: + loop.call_soon_threadsafe(_mark_expired) + + def _watchdog_check() -> None: + if not loop_processed_expiry.is_set(): + _dump_blocked_loop_diagnostics(label, timeout_s) + + timer = threading.Timer(timeout_s, _expire_from_thread) + timer.daemon = True + timer.start() + watchdog: Optional[threading.Timer] = None + if dump_on_blocked_loop: + watchdog = threading.Timer( + timeout_s + _LOOP_BLOCKED_DUMP_GRACE_S, _watchdog_check + ) + watchdog.daemon = True + watchdog.start() + try: + try: + done, _ = await asyncio.wait( + {task, deadline}, return_when=asyncio.FIRST_COMPLETED + ) + except asyncio.CancelledError: + # The CALLER cancelled us. Without this, `task` would keep running + # unobserved (and later log "exception was never retrieved") — + # a leak the telegram original also had. Cancel + abandon it, then + # let the cancellation propagate. + task.cancel() + task.add_done_callback(_consume_abandoned) + raise + if task in done: + if not deadline.done(): + deadline.cancel() + value = await task + return BoundedResult(timed_out=False, value=value, elapsed_s=time.monotonic() - start, timeout_s=timeout_s, label=label) + + task.cancel() + task.add_done_callback(_consume_abandoned) + if on_abandon is not None: + cleanup = asyncio.ensure_future(_run_abandon_cleanup(on_abandon)) + cleanup.add_done_callback(_consume_abandoned) + logger.warning("[deadline] %r timed out after %.1fs; task abandoned", label, timeout_s) + return BoundedResult(timed_out=True, value=None, elapsed_s=time.monotonic() - start, timeout_s=timeout_s, label=label) + finally: + timer.cancel() + if watchdog is not None: + watchdog.cancel() + # cancel() cannot stop a Timer whose callback is already running; + # setting the event closes that race so a completed await can never + # be misreported as a blocked loop. + loop_processed_expiry.set() + + +# --------------------------------------------------------------------------- +# Bounded execution — sync flavor. +# --------------------------------------------------------------------------- + +def run_bounded_sync( + fn: Callable[[], Any], + timeout: Optional[float], + *, + label: str = "operation", + on_timeout: Optional[Callable[[], None]] = None, +) -> BoundedResult: + """Run ``fn`` in a daemon worker thread under a wall-clock deadline. + + On completion returns its value (exceptions re-raised in the caller). + On expiry the worker thread is **abandoned** (daemon, so it cannot block + interpreter exit), ``on_timeout`` (if given) runs best-effort in the + caller's thread — e.g. to mark a backend suspect or kill a subprocess — + and ``BoundedResult(timed_out=True)`` is returned. + + Intended for infrequent, seconds-scale blocking backend calls. Do NOT + use per-item in hot loops: each call spawns a thread, and every timeout + permanently leaks an abandoned daemon thread — a wedged backend called + in a retry loop would accumulate them. + + ``timeout=None`` (or non-positive) blocks until ``fn`` returns. + """ + timeout_s = clamp_timeout(timeout) + start = time.monotonic() + if timeout_s is None: + return BoundedResult(timed_out=False, value=fn(), elapsed_s=time.monotonic() - start, timeout_s=None, label=label) + + box: dict[str, Any] = {} + done = threading.Event() + + def _worker() -> None: + try: + box["value"] = fn() + except BaseException as exc: # re-raised in caller; must not vanish + box["exc"] = exc + finally: + done.set() + + thread = threading.Thread( + target=_worker, name=f"deadline-{label}", daemon=True + ) + thread.start() + if not done.wait(timeout_s): + logger.warning("[deadline] %r timed out after %.1fs; worker abandoned", label, timeout_s) + if on_timeout is not None: + try: + on_timeout() + except Exception: + logger.debug("deadline on_timeout callback failed", exc_info=True) + return BoundedResult(timed_out=True, value=None, elapsed_s=time.monotonic() - start, timeout_s=timeout_s, label=label) + + if "exc" in box: + raise box["exc"] + return BoundedResult(timed_out=False, value=box.get("value"), elapsed_s=time.monotonic() - start, timeout_s=timeout_s, label=label) + + +# --------------------------------------------------------------------------- +# Whole-tree process termination. +# --------------------------------------------------------------------------- + +def kill_process_tree(pid: int, *, sig: Optional[int] = None) -> bool: + """Terminate ``pid`` and all its descendants, portably. + + Kill-on-timeout that signals only the direct child orphans process trees + (cron scripts, in-container shells, browser daemons — #71148 class). + + * Windows: ``taskkill /F /T`` terminates the tree (``sig`` ignored; + Windows has no equivalent). Console-window flash is suppressed via + ``windows_hide_flags`` and the exit code is checked, so a dead or + inaccessible PID reports ``False`` like the POSIX path. + * POSIX: the descendant set is snapshotted via psutil (a hard + dependency) BEFORE any signal — once the parent dies its children are + reparented and can no longer be found by a parent walk. Then the + process group is signalled when ``pid`` leads one (covers + grandchildren in the same session in one syscall), and every + snapshotted descendant is signalled individually — which also reaches + descendants that created their OWN sessions (a child that called + ``setsid``, exactly what user shell commands do; see + tools/environments/base.py). ``sig`` defaults to ``SIGKILL``. + psutil's identity-aware ``Process`` (PID + create time) means a + recycled PID is never signalled. + + Returns True when the target (or any of its tree) was signalled, False + when the process was already gone or every termination call failed. + """ + if sys.platform == "win32": + try: + from hermes_cli._subprocess_compat import windows_hide_flags + + creationflags = windows_hide_flags() + except Exception: + creationflags = 0 + try: + proc = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, + timeout=15, + check=False, + creationflags=creationflags, + ) + # taskkill exits non-zero for not-found / access-denied; keep the + # cross-platform contract (False = nothing was terminated). + return proc.returncode == 0 + except Exception: + logger.debug("kill_process_tree: taskkill failed for pid %s", pid, exc_info=True) + return False + + import signal as _signal + + if sig is None: + sig = _signal.SIGKILL + + # Snapshot descendants while the parent is still alive — after it dies + # they reparent to init/subreaper and a parent walk finds nothing. + descendants: list = [] + try: + import psutil + + descendants = psutil.Process(int(pid)).children(recursive=True) + except Exception: + # Already gone, or psutil unavailable in a stripped env — the + # group-signal below still covers same-session descendants. + descendants = [] + + signalled = False + try: + # NOTE: getpgid→killpg has an inherent TOCTOU (pid could be reaped and + # recycled between the calls). All existing killpg sites share it; the + # psutil sweep below is identity-aware and does not. + pgid = os.getpgid(pid) + except (ProcessLookupError, PermissionError, OSError): + pgid = None + try: + if pgid is not None and pgid == pid: + # pid leads its own group: one syscall covers the whole group. + # (The == check guards against signalling the caller's own group + # when pid is not a leader.) + os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only branch (win32 returns above) + else: + os.kill(pid, sig) + signalled = True + except ProcessLookupError: + pass + except (PermissionError, OSError): + logger.debug("kill_process_tree: signal failed for pid %s", pid, exc_info=True) + + # Sweep the snapshot: reaches descendants outside the parent's group + # (their own setsid sessions) and the non-group-leader case. + for child in descendants: + try: + if child.is_running(): # identity-aware: recycled PIDs skipped + child.send_signal(sig) + signalled = True + except Exception: + continue + return signalled diff --git a/agent/error_classifier.py b/agent/error_classifier.py index d28ddb06023f8..e8df941361ec5 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -18,6 +18,12 @@ logger = logging.getLogger(__name__) +# Synthetic error code used when the OpenAI SDK rejects a provider's SSE +# ``data:`` field before Hermes receives a completion chunk. Keeping this +# distinct from generic JSON parse failures lets the classifier make narrow, +# provider-stream-specific recovery decisions without inventing an HTTP status. +PROVIDER_STREAM_NON_JSON_ERROR_CODE = "provider_stream_non_json_data" + # ── Error taxonomy ────────────────────────────────────────────────────── @@ -108,6 +114,8 @@ def is_auth(self) -> bool: "credit balance", "credits exhausted", "credits have been exhausted", + "requires available credits", + "account balance is too low", "no usable credits", "top up your credits", "payment required", @@ -1558,6 +1566,20 @@ def _classify_by_error_code( """Classify by structured error codes from the response body.""" code_lower = error_code.lower() + if ( + code_lower == PROVIDER_STREAM_NON_JSON_ERROR_CODE + and "request validation failed:" in error_msg + ): + # Some OpenAI-compatible endpoints encode deterministic request + # validation failures as plain-text ``event: error`` SSE data behind + # HTTP 200. Retrying the unchanged request cannot succeed, but a + # configured provider fallback still may. + return result_fn( + FailoverReason.format_error, + retryable=False, + should_fallback=True, + ) + if code_lower in {"resource_exhausted", "throttled", "rate_limit_exceeded"}: return result_fn( FailoverReason.rate_limit, diff --git a/agent/model_metadata.py b/agent/model_metadata.py index a638798a970ed..9a44b2c9bc68e 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -48,18 +48,37 @@ def __getattr__(name: str): raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -def _resolve_requests_verify() -> bool | str: - """Resolve SSL verify setting for `requests` calls from env vars. - - The `requests` library only honours REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE - by default. Hermes also honours HERMES_CA_BUNDLE (its own convention) - and SSL_CERT_FILE (used by the stdlib `ssl` module and by httpx), so - that a single env var can cover both `requests` and `httpx` callsites - inside the same process. - - Returns either a filesystem path to a CA bundle, or True to defer to - the requests default (certifi). +def _resolve_requests_verify(base_url: str = "") -> bool | str: + """Resolve SSL verify setting for `requests` calls. + + Priority (mirrors ``agent.ssl_verify.resolve_httpx_verify`` so the + ``requests``-based ``/models`` probes agree with the httpx chat client): + + 1. Per-provider ``ssl_verify: false`` for ``base_url`` — disable verification. + 2. Per-provider ``ssl_ca_cert`` for ``base_url`` — an explicit CA bundle. + Without this, a custom endpoint whose chain only verifies against the + provider's configured bundle (not the process ``SSL_CERT_FILE``) logs a + spurious CERTIFICATE_VERIFY_FAILED on every probe even though the chat + path succeeds (per-provider ``ssl_ca_cert`` was reaching only httpx). + 3. Env vars ``HERMES_CA_BUNDLE`` / ``REQUESTS_CA_BUNDLE`` / ``SSL_CERT_FILE`` + (a single var covers both ``requests`` and ``httpx`` in-process). + 4. ``True`` — defer to the requests default (certifi). + + ``base_url`` is optional so existing callers (OpenRouter, etc.) keep the + env-only behavior unchanged; only probes that pass a base_url pick up the + per-provider override. """ + if base_url: + try: + from hermes_cli.config import get_custom_provider_tls_settings + tls = get_custom_provider_tls_settings(base_url) + if tls.get("ssl_verify") is False: + return False + ca = tls.get("ssl_ca_cert") + if isinstance(ca, str) and ca and os.path.isfile(ca): + return ca + except Exception: + pass # fall through to env vars — never break a probe on config lookup for env_var in ("HERMES_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"): val = os.getenv(env_var) if val and os.path.isfile(val): @@ -1261,7 +1280,7 @@ def fetch_endpoint_model_metadata( server_url.rstrip("/") + "/api/v1/models", headers=headers, timeout=(5, 10), - verify=_resolve_requests_verify(), + verify=_resolve_requests_verify(normalized), ) response.raise_for_status() payload = response.json() @@ -1324,7 +1343,7 @@ def fetch_endpoint_model_metadata( url, headers=headers, timeout=(5, 10), - verify=_resolve_requests_verify(), + verify=_resolve_requests_verify(normalized), stream=True, ) if response.status_code in (401, 403): @@ -1364,7 +1383,7 @@ def fetch_endpoint_model_metadata( try: # Try /v1/props first (current llama.cpp); fall back to /props for older builds base = request_candidate.rstrip("/").replace("/v1", "") - _verify = _resolve_requests_verify() + _verify = _resolve_requests_verify(normalized) props_resp = requests.get(base + "/v1/props", headers=headers, timeout=5, verify=_verify) if not props_resp.ok: props_resp = requests.get(base + "/props", headers=headers, timeout=5, verify=_verify) @@ -2284,7 +2303,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> "anthropic-version": "2023-06-01", } _ensure_requests() - resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify()) + resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify(base_url)) if resp.status_code != 200: return None data = resp.json() diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 96f3eb4786813..a9e11c7abb71f 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -13,7 +13,13 @@ from collections import OrderedDict from pathlib import Path -from hermes_constants import get_hermes_home, get_skills_dir, is_wsl +from hermes_constants import ( + get_hermes_home, + get_skills_dir, + is_wsl, + reset_hermes_home_override, + set_hermes_home_override, +) from typing import List, Optional from agent.runtime_cwd import resolve_agent_cwd @@ -1477,7 +1483,12 @@ def drain_truncation_warnings() -> list: # Skills prompt cache # ========================================================================= -_SKILLS_PROMPT_CACHE_MAX = 8 +# Sized for multi-profile processes: since #86313 the cache key carries a +# per-profile skills_dir (one entry per profile × platform), so the old cap +# of 8 could thrash on a gateway multiplexing default + several bots (each +# miss = full os.walk manifest rebuild). ~32 costs low single-digit MB worst +# case. +_SKILLS_PROMPT_CACHE_MAX = 32 _SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict() _SKILLS_PROMPT_CACHE_LOCK = threading.Lock() # v2: entries gained org provenance fields (org_id/org_author/rel_dir) for M2 @@ -1718,6 +1729,7 @@ def build_skills_system_prompt( available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None, compact_categories: "frozenset[str] | None" = None, + skills_dir_override: "Path | None" = None, ) -> str: """Build a compact skill index for the system prompt. @@ -1739,13 +1751,44 @@ def build_skills_system_prompt( visible and loadable via ``skill_view`` / ``skills_list``; only the descriptions are dropped, and a footer note explains the demotion. """ - skills_dir = get_skills_dir() - external_dirs = get_all_skills_dirs()[1:] # skip local (index 0) + # Home resolution is EXPLICIT when a caller passes skills_dir_override + # (the agent knows its own profile home from its session_db path). This + # avoids the ContextVar-on-a-thread trap: build threads that didn't bind + # HERMES_HOME would otherwise fall back to the launch (default) home and + # leak the default profile's skills into a bot's prompt (confirmed: a + # no-override thread builds default's full index). Snapshot + external + # dirs are scoped to the same home so nothing reads ambient state. + if skills_dir_override is not None: + skills_dir = Path(skills_dir_override) + _home_token = set_hermes_home_override(str(skills_dir.parent)) + else: + skills_dir = get_skills_dir() + _home_token = None + try: + external_dirs = get_all_skills_dirs()[1:] # skip local (index 0) + + if not skills_dir.exists() and not external_dirs: + return "" + + return _build_skills_system_prompt_inner( + skills_dir, + external_dirs, + available_tools, + available_toolsets, + compact_categories, + ) + finally: + if _home_token is not None: + reset_hermes_home_override(_home_token) - if not skills_dir.exists() and not external_dirs: - return "" - # ── Layer 1: in-process LRU cache ───────────────────────────────── +def _build_skills_system_prompt_inner( + skills_dir: "Path", + external_dirs: "list[Path]", + available_tools: "set[str] | None", + available_toolsets: "set[str] | None", + compact_categories: "frozenset[str] | None", +) -> str: # Include the resolved platform so per-platform disabled-skill lists # produce distinct cache entries (gateway serves multiple platforms). _platform_hint = _current_session_platform_hint() @@ -2116,12 +2159,21 @@ def _truncate_content( return head + marker + tail -def load_soul_md(context_length: Optional[int] = None) -> Optional[str]: +def load_soul_md( + context_length: Optional[int] = None, + home_override: "Path | None" = None, +) -> Optional[str]: """Load SOUL.md from HERMES_HOME and return its content, or None. Used as the agent identity (slot #1 in the system prompt). When this returns content, ``build_context_files_prompt`` should be called with ``skip_soul=True`` so SOUL.md isn't injected twice. + + ``home_override`` scopes the read to an explicit profile home (the agent + knows its own home from its session_db path). Without it, resolution is + ambient — which on a thread that lost the HERMES_HOME ContextVar falls + back to the launch home and reads the wrong profile's SOUL.md (#50233, + same class as the skills-index leak fixed in #86313). """ try: from hermes_cli.config import ensure_hermes_home @@ -2129,7 +2181,8 @@ def load_soul_md(context_length: Optional[int] = None) -> Optional[str]: except Exception as e: logger.debug("Could not ensure HERMES_HOME before loading SOUL.md: %s", e) - soul_path = get_hermes_home() / "SOUL.md" + _home = Path(home_override) if home_override is not None else get_hermes_home() + soul_path = _home / "SOUL.md" if not soul_path.exists(): return None try: @@ -2312,6 +2365,7 @@ def build_context_files_prompt( skip_soul: bool = False, context_length: Optional[int] = None, allow_install_tree_fallback: bool = False, + home_override: "Path | None" = None, ) -> str: """Discover and load context files for the system prompt. @@ -2375,7 +2429,7 @@ def build_context_files_prompt( # SOUL.md from HERMES_HOME only — skip when already loaded as identity if not skip_soul: - soul_content = load_soul_md(context_length) + soul_content = load_soul_md(context_length, home_override=home_override) if soul_content: sections.append(soul_content) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index c15989e16e736..a61ff7668cc78 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -50,7 +50,8 @@ drain_truncation_warnings, ) from agent.runtime_cwd import resolve_context_cwd -from hermes_constants import get_hermes_home +from hermes_constants import get_default_hermes_root, get_hermes_home +from pathlib import Path from utils import is_truthy_value logger = logging.getLogger(__name__) @@ -162,9 +163,16 @@ def _plugin_session_info(agent: Any) -> Dict[str, str]: except Exception: cwd = "" try: - from hermes_cli.profiles import get_active_profile_name + # Prefer the agent's own home (override-aware, session_db fallback) — + # ambient get_active_profile_name() misreports on threads that lost + # the HERMES_HOME ContextVar (#86313 class; plugin half per @helix4u). + _home = _agent_home(agent) + if _home is not None: + profile_name = _profile_name_for_home(_home) + else: + from hermes_cli.profiles import get_active_profile_name - profile_name = str(get_active_profile_name() or "default") + profile_name = str(get_active_profile_name() or "default") except Exception: profile_name = "default" return { @@ -262,6 +270,71 @@ def _plugin_section_blocks(sections: tuple, position: str) -> List[str]: return [block] if block else [] +def _agent_home(agent: Any) -> Optional[Path]: + """The agent's OWN profile home. + + Resolution order: + + 1. A bound HERMES_HOME ContextVar override wins. Surfaces that multiplex + several profiles over ONE shared session DB (the messaging gateway: + ``gateway/run.py`` hands every agent the launch-home ``state.db`` and + binds the profile home per turn via ``_profile_runtime_scope`` + + ``copy_context``) would otherwise have the db-derived launch home + STOMP the correctly-bound profile — inverting the leak this helper + exists to fix (found by @kshitijk4poor's post-merge probe on #86313). + 2. Fallback: the home containing the agent's ``_session_db.db_path`` + (``/state.db``) — ground truth on threads that lost the + ContextVar (ContextVars don't propagate into ``threading.Thread``), + where the unbound build previously fell back to the launch home and + leaked the default profile's skills/identity into a bot prompt. + + Returns None when neither resolves so callers fall back to ambient. + """ + try: + from hermes_constants import get_hermes_home_override + + override = get_hermes_home_override() + if override: + return Path(override) + except Exception: + pass + try: + db = getattr(agent, "_session_db", None) + db_path = getattr(db, "db_path", None) + if db_path: + return Path(db_path).parent + except Exception: + pass + return None + + +def _agent_skills_dir(agent: Any) -> Optional[Path]: + """The agent's own ``/skills`` dir, or None to use ambient home.""" + home = _agent_home(agent) + return (home / "skills") if home is not None else None + + +def _profile_name_for_home(home: Path) -> str: + """Derive the profile name for an explicit agent home. + + ``/profiles/X`` -> ``"X"``; anything else -> ``"default"``. + + Uses :func:`get_default_hermes_root` (NOT ``get_hermes_home()``): on a + correctly bound profile session the ambient home IS the profile dir, so + ``get_hermes_home()/profiles`` would never contain ``home`` and every + profile would misreport as "default". + """ + try: + from hermes_constants import get_default_hermes_root + + root = get_default_hermes_root() + rel = home.resolve().relative_to((root / "profiles").resolve()) + return rel.parts[0] if rel.parts else "default" + except (ValueError, OSError): + # Home IS the root (default profile) or unrelatable -> default. + return "default" + + def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]: """Assemble the system prompt as three ordered cache tiers. @@ -304,7 +377,10 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # cwd project instructions disabled. _soul_loaded = False if agent.load_soul_identity or not agent.skip_context_files: - _soul_content = _r.load_soul_md(_ctx_len) + # Scope the SOUL.md read to the agent's OWN home (see _agent_home) — + # ambient resolution on a thread that lost the HERMES_HOME ContextVar + # reads the launch profile's SOUL.md instead (#50233). + _soul_content = _r.load_soul_md(_ctx_len, home_override=_agent_home(agent)) if _soul_content: stable_parts.append(_soul_content) _soul_loaded = True @@ -435,6 +511,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) available_tools=agent.valid_tool_names, available_toolsets=avail_toolsets, compact_categories=_compact_cats or None, + skills_dir_override=_agent_skills_dir(agent), ) else: skills_prompt = "" @@ -514,26 +591,57 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # mid-session, so this doesn't break the prompt cache. # See file_safety._resolve_active_profile_name + classify_cross_profile_target # for the matching tool-side guard. + # + # Resolve from the agent's OWN home first (its session_db path), not the + # ambient HERMES_HOME: on a build thread that lost the ContextVar this + # line would otherwise print "default" for a bot profile — the same + # thread-fallback bug that leaked default's skills index. + _agent_home_path = _agent_home(agent) + active_profile = "default" try: - from agent.file_safety import _resolve_active_profile_name - active_profile = _resolve_active_profile_name() + if _agent_home_path is not None: + active_profile = _profile_name_for_home(_agent_home_path) + else: + from agent.file_safety import _resolve_active_profile_name + active_profile = _resolve_active_profile_name() except Exception: active_profile = "default" + # Home string for the message text: prefer the agent's own home so the + # paths named match the profile just resolved. When we have an explicit + # agent home, the root (where the default profile's data lives) comes + # from get_default_hermes_root(): get_hermes_home() on a bound profile + # session is the PROFILE dir, which would misname the default profile's + # paths. Without an agent home, keep the ambient resolution byte-identical + # to the legacy behavior (and patchable via this module's get_hermes_home). + if _agent_home_path is not None: + _home_str = str(_agent_home_path) + _root_str = str(get_default_hermes_root()) + else: + _home_str = _root_str = str(get_hermes_home()) if active_profile == "default": post_workspace_parts.append( "Active Hermes profile: default. Other profiles (if any) live " - "under " + str(get_hermes_home()) + "/profiles//. Each profile has its own " + "under " + _root_str + "/profiles//. Each profile has its own " "skills/, plugins/, cron/, and memories/ that affect a different " "session than this one. Do not modify another profile's " "skills/plugins/cron/memories unless the user explicitly directs " "you to." ) else: + # A non-default name is only ever returned when the resolved home is + # ALREADY /profiles/ — that is exactly how both + # _profile_name_for_home() and _resolve_active_profile_name() derive + # it. So the profile home is the session home itself; appending + # /profiles/ again doubled it (#72894). The default profile's + # data sits at the ROOT (get_default_hermes_root()), which in ambient + # profile mode is NOT get_hermes_home(). + profile_home = _home_str + default_root = get_default_hermes_root() post_workspace_parts.append( f"Active Hermes profile: {active_profile}. This session reads " - f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default " - f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, " - f"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a " + f"and writes {profile_home}/. The default " + f"profile's data lives at {default_root}/skills/, {default_root}/plugins/, " + f"{default_root}/cron/, {default_root}/memories/ — those belong to a " f"different session run from a different shell. Do NOT modify " f"another profile's skills/plugins/cron/memories unless the user " f"explicitly directs you to. The cross-profile write guard will " @@ -612,7 +720,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) context_files_prompt = _r.build_context_files_prompt( cwd=resolve_context_cwd(), skip_soul=_soul_loaded, context_length=_ctx_len, - allow_install_tree_fallback=agent.platform in ("cli", "tui")) + allow_install_tree_fallback=agent.platform in ("cli", "tui"), + home_override=_agent_home(agent)) if context_files_prompt: context_parts.append(context_files_prompt) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index c1697a6c31992..130acd5c8a91b 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -33,6 +33,7 @@ _detect_tool_failure, ) from agent.tool_dispatch_helpers import ( + _NEVER_PARALLEL_TOOLS, _is_destructive_command, _is_multimodal_tool_result, _multimodal_text_summary, @@ -158,21 +159,19 @@ def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]: def _resolve_concurrent_tool_timeout() -> float | None: - raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip() - if not raw: - return _DEFAULT_CONCURRENT_TOOL_TIMEOUT_S - try: - value = float(raw) - except ValueError: - logger.warning( - "invalid HERMES_CONCURRENT_TOOL_TIMEOUT_S=%r; using %.0fs", - raw, - _DEFAULT_CONCURRENT_TOOL_TIMEOUT_S, - ) - return _DEFAULT_CONCURRENT_TOOL_TIMEOUT_S - if value <= 0: - return None - return value + """Resolve the per-batch concurrent tool deadline. + + Delegates to the unified resolver (#85125): ``timeouts.tools.concurrent_batch`` + in config.yaml wins, the legacy ``HERMES_CONCURRENT_TOOL_TIMEOUT_S`` env var + remains the back-compat bridge, and ``0``/negative still disables the bound. + """ + from agent.deadline import resolve_timeout + + return resolve_timeout( + "tools.concurrent_batch", + default=_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S, + env_var="HERMES_CONCURRENT_TOOL_TIMEOUT_S", + ) def _flush_session_db_after_tool_progress( @@ -389,6 +388,10 @@ class _ManagedToolResult: dispatched: bool +class _ToolTimeoutResult(str): + """Marker for a synthesized sequential-tool timeout result.""" + + class _ConcurrentToolAuthorizationGate: """Serialize policy prompts and exclude human approval waits from batch deadlines. @@ -663,6 +666,146 @@ def _hermes_pipeline(relay_args: dict[str, Any]) -> Any: ) +def _resolve_sequential_tool_timeout() -> float | None: + """Deadline for one sequential tool call (#85125 Phase 2a). + + ``timeouts.tools.sequential_call`` in config.yaml wins; when unset, the + sequential path inherits the concurrent batch deadline (same value, same + ``HERMES_CONCURRENT_TOOL_TIMEOUT_S`` legacy bridge) so the two executor + paths cannot drift apart by default. ``0``/negative disables the bound. + + NOTE: this path deliberately does NOT use ``agent.deadline.run_bounded_sync``. + The sequential/concurrent executors extend their deadline dynamically while + a human approval prompt is open (``_ConcurrentToolAuthorizationGate`` + excluded seconds — a MUST-preserve invariant) and touch agent activity + mid-wait; the shared primitive is fixed-deadline by design. Simpler call + sites migrate onto the primitive; these two stay symmetric with each other. + """ + from agent.deadline import resolve_timeout + + return resolve_timeout( + "tools.sequential_call", + default=_resolve_concurrent_tool_timeout(), + ) + + +def _run_sequential_tool_execution_middleware( + agent, + *, + function_name: str, + function_args: dict, + effective_task_id: str, + tool_call_id: str, + execute, + scope_block: str | None = None, + display_index: int | None = None, + middleware_trace: list[dict[str, Any]] | None = None, +) -> _ManagedToolResult: + """Run one sequential call with the concurrent executor's deadline. + + Interactive input tools such as ``clarify`` wait on a human. Their own + timeout (``agent.clarify_timeout``: default 3600s, or unlimited when + ``<= 0``) owns that wait. Applying the generic tool deadline here would + return ``tool_timeout`` while the prompt and worker stay active. + """ + timeout_s = _resolve_sequential_tool_timeout() + kwargs = { + "function_name": function_name, + "function_args": function_args, + "effective_task_id": effective_task_id, + "tool_call_id": tool_call_id, + "execute": execute, + "scope_block": scope_block, + "display_index": display_index, + "middleware_trace": middleware_trace, + } + if timeout_s is None or function_name in _NEVER_PARALLEL_TOOLS: + return _run_agent_tool_execution_middleware(agent, **kwargs) + + from tools.daemon_pool import DaemonThreadPoolExecutor + + authorization_gate = _ConcurrentToolAuthorizationGate() + worker_tid: list[int] = [] + + def _run() -> _ManagedToolResult: + tid = threading.current_thread().ident + worker_tid.append(tid) + with agent._tool_worker_threads_lock: + agent._tool_worker_threads.add(tid) + try: + return _run_agent_tool_execution_middleware( + agent, authorization_gate=authorization_gate, **kwargs + ) + finally: + with agent._tool_worker_threads_lock: + agent._tool_worker_threads.discard(tid) + try: + _ra()._set_interrupt(False, tid) + except Exception: + pass + + executor = DaemonThreadPoolExecutor(max_workers=1) + future = executor.submit(propagate_context_to_thread(_run)) + deadline = time.monotonic() + timeout_s + started = time.monotonic() + timed_out = False + try: + while True: + remaining = ( + deadline + authorization_gate.excluded_seconds() - time.monotonic() + ) + if remaining <= 0: + timed_out = True + break + try: + return future.result(timeout=min(5.0, remaining)) + except concurrent.futures.TimeoutError: + elapsed = int(time.monotonic() - started) + if elapsed > 0 and elapsed % 30 < 5: + agent._touch_activity( + f"sequential tool running ({elapsed}s): {function_name}" + ) + + message = ( + f"Error executing tool '{function_name}': " + f"timed out after {timeout_s:.1f}s" + ) + logger.warning( + "sequential tool %s timed out after %.1fs", function_name, timeout_s + ) + future.cancel() + for tid in worker_tid: + try: + _ra()._set_interrupt(True, tid) + except Exception: + pass + trace = middleware_trace if middleware_trace is not None else [] + _emit_terminal_post_tool_call( + agent, + function_name=function_name, + function_args=function_args, + result=message, + effective_task_id=effective_task_id, + tool_call_id=tool_call_id, + duration_ms=int(timeout_s * 1000), + status="timeout", + error_type="tool_timeout", + error_message=message, + middleware_trace=list(trace), + ) + return _ManagedToolResult( + result=_ToolTimeoutResult(message), + args=function_args, + middleware_trace=trace, + blocked=False, + dispatched=True, + ) + finally: + # Never join a wedged worker. DaemonThreadPoolExecutor also keeps it out + # of the stdlib atexit join, matching the concurrent timeout path. + executor.shutdown(wait=not timed_out, cancel_futures=timed_out) + + def _begin_tool_execution( agent, *, @@ -1610,6 +1753,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe """ # Resolve the context-scaled tool-output budget once per turn. _tool_budget = _budget_for_agent(agent) + + # Keep every runtime-tool branch on one bounded execution funnel without + # duplicating timeout policy across the branch-specific callbacks below. + def _run_agent_tool_execution_middleware(agent, **kwargs): + return _run_sequential_tool_execution_middleware(agent, **kwargs) + for i, tool_call in enumerate(assistant_message.tool_calls, 1): if getattr(agent, "_incremental_persistence_failed", False): return @@ -2048,27 +2197,30 @@ def _execute(next_args: dict) -> Any: _spinner_result = None try: def _execute(next_args: dict) -> Any: - return _ra().handle_function_call( - function_name, - next_args, - effective_task_id, - tool_call_id=tool_call.id, - session_id=agent.session_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") - or "", - enabled_tools=( - list(agent.valid_tool_names) - if agent.valid_tool_names - else None - ), - skip_pre_tool_call_hook=True, - skip_tool_request_middleware=True, - skip_tool_execution_middleware=True, - tool_request_middleware_trace=list(middleware_trace), - enabled_toolsets=getattr(agent, "enabled_toolsets", None), - disabled_toolsets=getattr(agent, "disabled_toolsets", None), - ) + from model_tools import suppress_post_tool_call_hook + + with suppress_post_tool_call_hook(): + return _ra().handle_function_call( + function_name, + next_args, + effective_task_id, + tool_call_id=tool_call.id, + session_id=agent.session_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") + or "", + enabled_tools=( + list(agent.valid_tool_names) + if agent.valid_tool_names + else None + ), + skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, + skip_tool_execution_middleware=True, + tool_request_middleware_trace=list(middleware_trace), + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), + ) ( function_result, @@ -2127,27 +2279,30 @@ def _execute(next_args: dict) -> Any: else: try: def _execute(next_args: dict) -> Any: - return _ra().handle_function_call( - function_name, - next_args, - effective_task_id, - tool_call_id=tool_call.id, - session_id=agent.session_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") - or "", - enabled_tools=( - list(agent.valid_tool_names) - if agent.valid_tool_names - else None - ), - skip_pre_tool_call_hook=True, - skip_tool_request_middleware=True, - skip_tool_execution_middleware=True, - tool_request_middleware_trace=list(middleware_trace), - enabled_toolsets=getattr(agent, "enabled_toolsets", None), - disabled_toolsets=getattr(agent, "disabled_toolsets", None), - ) + from model_tools import suppress_post_tool_call_hook + + with suppress_post_tool_call_hook(): + return _ra().handle_function_call( + function_name, + next_args, + effective_task_id, + tool_call_id=tool_call.id, + session_id=agent.session_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") + or "", + enabled_tools=( + list(agent.valid_tool_names) + if agent.valid_tool_names + else None + ), + skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, + skip_tool_execution_middleware=True, + tool_request_middleware_trace=list(middleware_trace), + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), + ) ( function_result, @@ -2195,6 +2350,7 @@ def _execute(next_args: dict) -> Any: logger.error("handle_function_call raised for %s: %s", function_name, tool_error, exc_info=True) tool_duration = time.time() - tool_start_time + _execution_timed_out = isinstance(function_result, _ToolTimeoutResult) if isinstance(function_result, str): result_preview = function_result if agent.verbose_logging else ( function_result[:200] if len(function_result) > 200 else function_result @@ -2212,15 +2368,12 @@ def _execute(next_args: dict) -> Any: # context-engine, memory-manager, clarify, delegate_task) are # dispatched inline — they never reach handle_function_call, so the # executor is the one that has to fire post_tool_call. For - # registry-dispatched tools the else-branch above invoked - # handle_function_call, which already fires the hook. - from agent.agent_runtime_helpers import agent_runtime_owns_post_tool_hook + # Every dispatch suppresses the inner handle_function_call observer so + # the executor owns one terminal event for this tool_call_id. This also + # prevents an abandoned timeout worker from reporting late success. _executor_must_emit_post_hook = ( not _execution_blocked - and ( - not _execution_dispatched - or agent_runtime_owns_post_tool_hook(agent, function_name) - ) + and not _execution_timed_out ) if _executor_must_emit_post_hook: _emit_terminal_post_tool_call( @@ -2289,7 +2442,12 @@ def _execute(next_args: dict) -> Any: # Unwrap _multimodal dicts to an OpenAI-style content list # (see parallel path for rationale). String results pass through. _tool_content = agent._tool_result_content_for_active_model(function_name, function_result) - tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id) + tool_message = make_tool_result_message( + function_name, + _tool_content, + tool_call.id, + effect_disposition="unknown" if _execution_timed_out else None, + ) messages.append(tool_message) risk_metadata = tool_message.get("_tool_output_risk") if not _flush_session_db_after_tool_progress( diff --git a/agent/transports/codex.py b/agent/transports/codex.py index fac925fdba05d..919e6e6fddf78 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -263,6 +263,21 @@ def _pair_ids(raw: Any, explicit: Any = None) -> set: return False +def _native_compaction_active(context_management: Any) -> bool: + """Is THIS request natively compacted? + + True only when the caller's eligibility gate + (``native_compaction.native_compaction_context_management``) produced a + non-empty payload. Every native-compaction side effect on the wire — + sending ``context_management``, replaying a ``type: "compaction"`` + checkpoint, restructuring the input around it — hangs off this one + predicate, so a checkpoint that outlives the gate (model swapped out of + the gpt-5.6 family, compression disabled, rejection kill switch, resumed + session) cannot keep reshaping requests on its own. + """ + return isinstance(context_management, list) and bool(context_management) + + class ResponsesApiTransport(ProviderTransport): """Transport for api_mode='codex_responses'. @@ -303,6 +318,9 @@ def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: kwargs.get("replay_encrypted_reasoning", True) ), current_issuer_kind=issuer, + native_compaction_eligible=_native_compaction_active( + kwargs.get("context_management") + ), ) def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: @@ -377,6 +395,12 @@ def build_kwargs( # agent.native_compaction.native_compaction_context_management(); # None means the field is never added to the request. context_management = params.get("context_management") + # Single source of truth for "this request is natively compacted": + # the same value decides whether the field goes out AND whether the + # converter may replay/prune around a compaction checkpoint. Keeping + # them derived from one expression is what stops a persisted + # checkpoint from restructuring the wire after the gate closes. + native_compaction_active = _native_compaction_active(context_management) # Resolve the issuing endpoint for this call. Stashed on the # transport so normalize_response can stamp it onto reasoning @@ -471,6 +495,7 @@ def build_kwargs( is_github_responses=is_github_responses, replay_encrypted_reasoning=replay_encrypted_reasoning, current_issuer_kind=issuer_kind, + native_compaction_eligible=native_compaction_active, ), "store": False, } @@ -478,7 +503,7 @@ def build_kwargs( kwargs["tools"] = response_tools kwargs["tool_choice"] = "auto" kwargs["parallel_tool_calls"] = True - if isinstance(context_management, list) and context_management: + if native_compaction_active: kwargs["context_management"] = context_management session_id = params.get("session_id") diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index de31bbabd48a5..7297c7edca897 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -699,8 +699,8 @@ def finalize_turn( "health (`hermes doctor`), then send your message again" ) # Machine-readable cause for the gateway/desktop: exactly - # 'session_persistence_failed:'. Never clobber a - # failure_reason another path already stamped on this result. + # 'session_persistence_failed:'. + # Never clobber a failure_reason another path already stamped. if "failure_reason" not in result: _cause = getattr(agent, "_last_persistence_error_cause", None) result["failure_reason"] = ( diff --git a/apps/desktop/electron/external-terminal.test.ts b/apps/desktop/electron/external-terminal.test.ts new file mode 100644 index 0000000000000..c5bac2c989dad --- /dev/null +++ b/apps/desktop/electron/external-terminal.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + buildTerminalScript, + posixQuote, + resolveTerminalLaunch, + terminalScriptEnv, + terminalScriptExtension, + tuiResumeArgs, + windowsQuote +} from './external-terminal' + +const never = () => null +const always = (command: string) => `/usr/bin/${command}` + +test('tuiResumeArgs resumes the session in the TUI', () => { + assert.deepEqual(tuiResumeArgs('20260814_101010_abc123'), ['--tui', '--resume', '20260814_101010_abc123']) +}) + +test('tuiResumeArgs pins the profile ahead of the mode flag', () => { + assert.deepEqual(tuiResumeArgs('sess', 'work'), ['--profile', 'work', '--tui', '--resume', 'sess']) +}) + +test('posixQuote survives embedded single quotes', () => { + assert.equal(posixQuote("/tmp/o'brien"), `'/tmp/o'\\''brien'`) +}) + +test('windowsQuote doubles embedded quotes', () => { + assert.equal(windowsQuote('C:\\a "b"'), '"C:\\a ""b"""') +}) + +test('terminalScriptEnv drops PATH in any casing and keeps the rest', () => { + const env = terminalScriptEnv( + { Path: 'C:\\junk', PATH: '/junk', PYTHONPATH: '/repo', PYTHONUTF8: '1' }, + '/home/b/.hermes' + ) + + assert.deepEqual(env, { PYTHONPATH: '/repo', PYTHONUTF8: '1', HERMES_HOME: '/home/b/.hermes' }) +}) + +test('terminalScriptEnv skips empty values and an absent home', () => { + assert.deepEqual(terminalScriptEnv({ PYTHONPATH: '' }), {}) +}) + +test('buildTerminalScript execs the resolved runtime with its env', () => { + const script = buildTerminalScript({ + args: ['-m', 'hermes_cli.main', '--tui', '--resume', 'sess'], + command: '/home/b/.hermes/hermes-agent/venv/bin/python', + cwd: "/home/b/o'brien", + env: { PYTHONPATH: '/home/b/.hermes/hermes-agent' }, + platform: 'darwin' + }) + + assert.equal( + script, + [ + '#!/bin/sh', + `cd '/home/b/o'\\''brien' || exit 1`, + `export PYTHONPATH='/home/b/.hermes/hermes-agent'`, + `exec '/home/b/.hermes/hermes-agent/venv/bin/python' '-m' 'hermes_cli.main' '--tui' '--resume' 'sess'`, + '' + ].join('\n') + ) +}) + +test('buildTerminalScript emits a cmd script on Windows', () => { + const script = buildTerminalScript({ + args: ['--tui', '--resume', 'sess'], + command: 'C:\\hermes\\venv\\Scripts\\hermes.exe', + cwd: 'C:\\Users\\b', + env: { PYTHONUTF8: '1' }, + platform: 'win32' + }) + + assert.deepEqual(script.split('\r\n'), [ + '@echo off', + 'cd /d "C:\\Users\\b"', + 'set "PYTHONUTF8=1"', + '"C:\\hermes\\venv\\Scripts\\hermes.exe" "--tui" "--resume" "sess"', + '' + ]) +}) + +test('terminalScriptExtension matches what the platform binds to a terminal', () => { + assert.equal(terminalScriptExtension('darwin'), '.command') + assert.equal(terminalScriptExtension('win32'), '.cmd') + assert.equal(terminalScriptExtension('linux'), '.sh') +}) + +test('macOS opens the script with no -a so LaunchServices picks the user handler', () => { + assert.deepEqual(resolveTerminalLaunch({ findOnPath: never, platform: 'darwin', scriptPath: '/tmp/x.command' }), { + command: 'open', + args: ['/tmp/x.command'] + }) +}) + +test('Windows prefers Windows Terminal and falls back to a cmd console', () => { + assert.deepEqual( + resolveTerminalLaunch({ + findOnPath: command => (command === 'wt.exe' ? 'C:\\wt.exe' : null), + platform: 'win32', + scriptPath: 'C:\\x.cmd' + }), + { command: 'C:\\wt.exe', args: ['cmd.exe', '/k', 'C:\\x.cmd'] } + ) + + assert.deepEqual(resolveTerminalLaunch({ findOnPath: never, platform: 'win32', scriptPath: 'C:\\x.cmd' }), { + command: 'cmd.exe', + args: ['/c', 'start', '', 'cmd.exe', '/k', 'C:\\x.cmd'] + }) +}) + +test("Linux leads with the user's x-terminal-emulator alternative", () => { + assert.deepEqual(resolveTerminalLaunch({ findOnPath: always, platform: 'linux', scriptPath: '/tmp/x.sh' }), { + command: '/usr/bin/x-terminal-emulator', + args: ['-e', '/bin/sh', '/tmp/x.sh'] + }) +}) + +test('Linux falls down the emulator ladder and omits a flagless terminal', () => { + const onlyKitty = (command: string) => (command === 'kitty' ? '/usr/bin/kitty' : null) + + assert.deepEqual(resolveTerminalLaunch({ findOnPath: onlyKitty, platform: 'linux', scriptPath: '/tmp/x.sh' }), { + command: '/usr/bin/kitty', + args: ['/bin/sh', '/tmp/x.sh'] + }) +}) + +test('Linux with no emulator installed reports no launch', () => { + assert.equal(resolveTerminalLaunch({ findOnPath: never, platform: 'linux', scriptPath: '/tmp/x.sh' }), null) +}) diff --git a/apps/desktop/electron/external-terminal.ts b/apps/desktop/electron/external-terminal.ts new file mode 100644 index 0000000000000..07cfe53a170e7 --- /dev/null +++ b/apps/desktop/electron/external-terminal.ts @@ -0,0 +1,173 @@ +// Launching the Hermes TUI in the user's OWN terminal emulator. +// +// This is deliberately NOT the in-app terminal pane: the point of the verb is +// to hand a session to the terminal the user already lives in, running +// `hermes --tui --resume ` there. Two problems have to be solved for that +// to work anywhere: +// +// 1. WHAT to run. The desktop's Hermes runtime is often a venv Python invoked +// as `python -m hermes_cli.main`, not a `hermes` on PATH — so the command +// and its PYTHONPATH have to be carried over verbatim. We write them into a +// small launcher script instead of trying to quote a nested command through +// a terminal emulator's `-e` argument, which every emulator parses +// differently. +// 2. WHERE to run it. There is no portable "default terminal" API, so each +// platform gets its own resolution: +// - macOS: `open` the `.command` script with NO `-a`, letting +// LaunchServices route it to whichever app the user has bound to shell +// scripts (Terminal.app by default, iTerm2/Ghostty/WezTerm when they've +// claimed it). That is the closest thing macOS has to "their terminal". +// - Linux: an ordered ladder of emulators, led by Debian's +// `x-terminal-emulator` alternative — which IS the user's configured +// choice — before falling back to the common concrete emulators. +// - Windows: Windows Terminal when installed, else a `cmd.exe` console. +// +// Everything here is pure so it can be unit-tested without Electron; the side +// effects (writing the script, spawning) live in main.ts. + +/** Argv for resuming a session in the TUI, profile-pinned when we know it. */ +export function tuiResumeArgs(sessionId: string, profile?: string): string[] { + const head = profile ? ['--profile', profile] : [] + + return [...head, '--tui', '--resume', sessionId] +} + +/** Single-quote a value for /bin/sh (the POSIX launcher script). */ +export function posixQuote(value: string): string { + return `'${String(value ?? '').replaceAll("'", `'\\''`)}'` +} + +/** Quote a value for a cmd.exe script line. */ +export function windowsQuote(value: string): string { + return `"${String(value ?? '').replaceAll('"', '""')}"` +} + +/** + * The environment the launcher script exports. + * + * PATH is deliberately dropped: the script runs inside a login shell that + * already has the user's own PATH, and the desktop's PATH (assembled for a + * headless child) is the wrong answer for an interactive terminal. The Hermes + * command is invoked by absolute path, so nothing here depends on PATH. + */ +export function terminalScriptEnv( + backendEnv: Record = {}, + hermesHome?: string +): Record { + const out: Record = {} + + for (const [key, value] of Object.entries(backendEnv)) { + if (key.toUpperCase() === 'PATH' || value === undefined || value === '') { + continue + } + + out[key] = value + } + + if (hermesHome) { + out.HERMES_HOME = hermesHome + } + + return out +} + +export interface TerminalScriptSpec { + command: string + args: string[] + cwd: string + env?: Record + platform?: NodeJS.Platform +} + +/** + * The launcher script contents. `exec` on POSIX so the terminal window belongs + * to the TUI itself rather than an idle shell wrapping it. + */ +export function buildTerminalScript({ command, args, cwd, env = {}, platform = process.platform }: TerminalScriptSpec) { + const entries = Object.entries(env) + + if (platform === 'win32') { + return [ + '@echo off', + `cd /d ${windowsQuote(cwd)}`, + ...entries.map(([key, value]) => `set ${windowsQuote(`${key}=${value}`)}`), + [command, ...args].map(windowsQuote).join(' '), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + `cd ${posixQuote(cwd)} || exit 1`, + ...entries.map(([key, value]) => `export ${key}=${posixQuote(value)}`), + `exec ${[command, ...args].map(posixQuote).join(' ')}`, + '' + ].join('\n') +} + +export function terminalScriptExtension(platform: NodeJS.Platform = process.platform): string { + if (platform === 'win32') { + return '.cmd' + } + + // `.command` is the UTI macOS binds to a terminal app; on Linux the + // extension is cosmetic (we always name the interpreter explicitly). + return platform === 'darwin' ? '.command' : '.sh' +} + +// Linux emulators in resolution order, with the flag that precedes a program +// to run. `x-terminal-emulator` is Debian/Ubuntu's alternatives symlink to the +// user's chosen terminal, so it leads; the rest are the common concretes. +const LINUX_TERMINALS: Array<{ command: string; flag: string }> = [ + { command: 'x-terminal-emulator', flag: '-e' }, + { command: 'gnome-terminal', flag: '--' }, + { command: 'konsole', flag: '-e' }, + { command: 'xfce4-terminal', flag: '-x' }, + { command: 'tilix', flag: '-e' }, + { command: 'kitty', flag: '' }, + { command: 'alacritty', flag: '-e' }, + { command: 'wezterm', flag: '-e' }, + { command: 'foot', flag: '' }, + { command: 'xterm', flag: '-e' } +] + +export interface TerminalLaunchOptions { + scriptPath: string + findOnPath: (command: string) => null | string + platform?: NodeJS.Platform +} + +/** + * Resolve the argv that opens `scriptPath` in a terminal window, or null when + * no terminal emulator could be found (Linux boxes with none installed). + */ +export function resolveTerminalLaunch({ + scriptPath, + findOnPath, + platform = process.platform +}: TerminalLaunchOptions): { command: string; args: string[] } | null { + if (platform === 'darwin') { + // No `-a`: LaunchServices picks the user's handler for shell scripts. + return { command: 'open', args: [scriptPath] } + } + + if (platform === 'win32') { + const windowsTerminal = findOnPath('wt.exe') + + if (windowsTerminal) { + return { command: windowsTerminal, args: ['cmd.exe', '/k', scriptPath] } + } + + return { command: 'cmd.exe', args: ['/c', 'start', '', 'cmd.exe', '/k', scriptPath] } + } + + for (const { command, flag } of LINUX_TERMINALS) { + const resolved = findOnPath(command) + + if (resolved) { + return { command: resolved, args: [...(flag ? [flag] : []), '/bin/sh', scriptPath] } + } + } + + return null +} diff --git a/apps/desktop/electron/get-windows.d.ts b/apps/desktop/electron/get-windows.d.ts new file mode 100644 index 0000000000000..444e7f9dd2a4d --- /dev/null +++ b/apps/desktop/electron/get-windows.d.ts @@ -0,0 +1,22 @@ +// Type declarations for the get-windows optionalDependency. +// +// get-windows ships no bundled types and is an optionalDependency. `npm ci` +// can skip it when its native install fails, including Linux and Windows ARM64 +// where 9.3.0 has no prebuilt, so it can legitimately be absent from +// node_modules. Declaring the module here keeps typecheck independent of +// whether the package installed. The runtime import in window-below.ts +// degrades to null when it is absent. + +declare module 'get-windows' { + export interface GetWindowsWindow { + bounds?: { height?: number; width?: number; x?: number; y?: number } + id?: number + owner?: { name?: string; processId?: number } + title?: string + } + + export function openWindows(options?: { + accessibilityPermission?: boolean + screenRecordingPermission?: boolean + }): Promise +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9ae71552845c3..5d7a05e87600e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -96,6 +96,13 @@ import { import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' +import { + buildTerminalScript, + resolveTerminalLaunch, + terminalScriptEnv, + terminalScriptExtension, + tuiResumeArgs +} from './external-terminal' import { findGitBash as _findGitBash } from './find-git-bash' import { installFoundInPageForwarder, performFind, stopFind } from './find-in-page' import { createFirstRunSetupGate } from './first-run-setup-gate' @@ -206,7 +213,13 @@ import { } from './ssh-connection' import { createStreamThrottle } from './stream-throttle' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' -import { resolveBehindCount, shouldCountCommits } from './update-count' +import { + compareApiUrl, + parseCompareBehindCount, + resolveBehindCount, + resolveCommitLogSelection, + shouldCountCommits +} from './update-count' import { waitForUpdateClearance } from './update-gate' import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' @@ -2574,11 +2587,27 @@ async function checkUpdates() { } } + // Passive SSH-official checks only know tip SHAs (ls-remote) — never + // fabricate a "1 commit behind". Recover the exact count via the GitHub + // compare API when possible; otherwise behind stays null ("update + // available, count unknown") and updateAvailable carries the signal. + // ahead_by === 0 with differing tips means the remote tip is reachable + // from our HEAD — a local carried commit sitting AHEAD, not behind: + // flagging that as an update nudges the user into wiping their work. + const tipsEqual = Boolean(currentSha && currentSha === targetSha) + + const sshBehind = tipsEqual + ? 0 + : await fetchCompareBehindCount({ currentSha, originUrl: OFFICIAL_REPO_HTTPS_URL, targetSha }) + + const upToDate = tipsEqual || sshBehind === 0 + return { supported: true, branch, currentBranch, - behind: currentSha && currentSha === targetSha ? 0 : 1, + behind: upToDate ? 0 : sshBehind, + updateAvailable: !upToDate, currentSha, targetSha, commits: [], @@ -2603,43 +2632,55 @@ async function checkUpdates() { const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) - const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr, mergeBaseStr] = await Promise.all([ + const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']), - git(['rev-parse', '--is-shallow-repository']), - // merge-base exits non-zero with empty stdout when HEAD shares no common - // ancestor with the freshly fetched tip — exactly the shallow-clone case. - git(['merge-base', 'HEAD', `origin/${branch}`]) + git(['rev-parse', '--is-shallow-repository']) ]) const isShallow = shallowStr === 'true' - const hasMergeBase = Boolean(mergeBaseStr) - // Only enumerate the commit count when it is meaningful. On a shallow checkout - // with no merge-base, `rev-list --count` walks the entire remote ancestry - // (thousands of commits, see #51922) and resolveBehindCount discards the - // result anyway in favour of a SHA compare — so skip the expensive query. - const countStr = shouldCountCommits({ isShallow, hasMergeBase }) - ? await git(['rev-list', `HEAD..origin/${branch}`, '--count']) - : '' + // A shallow graph cannot provide a trustworthy exact count, even when it has + // a visible merge-base. Skip the ancestry walk and use the SHA fallback. + const countStr = shouldCountCommits({ isShallow }) ? await git(['rev-list', `HEAD..origin/${branch}`, '--count']) : '' + + // A positive directional ancestry result remains trustworthy in a shallow + // graph and prevents a local commit on top of origin from looking outdated. + const targetIsAncestorOfHead = + isShallow && + currentSha !== targetSha && + (await runGit(['merge-base', '--is-ancestor', `origin/${branch}`, 'HEAD'], { cwd: updateRoot })).code === 0 - const behind = resolveBehindCount({ + let behind = resolveBehindCount({ countStr, currentSha, targetSha, isShallow, - hasMergeBase + targetIsAncestorOfHead }) - const commits = behind > 0 ? await readCommitLog(updateRoot, branch) : [] + // Recover the exact count a shallow clone can't compute: the GitHub compare + // API knows the full graph regardless of local clone depth. Best-effort — + // offline, rate-limited, or non-GitHub origins keep the honest null + // ("update available", no fabricated number). + if (behind === null) { + behind = await fetchCompareBehindCount({ currentSha, originUrl, targetSha }) + } + + // behind === null means "update available, exact count unknown" (shallow + // clone): still list what origin offers — resolveCommitLogSelection keeps + // the shallow log to the fetched tip so the range walk can't enumerate the + // contaminated ancestry — so "See what's new" stays useful and honest. + const commits = behind !== 0 ? await readCommitLog(updateRoot, branch, isShallow) : [] return { supported: true, branch, currentBranch, behind, + updateAvailable: behind === null || behind > 0, currentSha, targetSha, commits, @@ -2649,12 +2690,67 @@ async function checkUpdates() { } } -async function readCommitLog(cwd, branch) { +// Best-effort exact behind-count for graphs the local clone can't measure. +// Delegates URL building + response parsing to update-count.ts (pure, unit +// tested); this wrapper only does the bounded network call. Any failure — +// offline, 4xx/5xx, rate limit, shape surprise — returns null so callers keep +// the honest "update available, count unknown" state. +async function fetchCompareBehindCount({ currentSha, originUrl, targetSha }) { + const url = compareApiUrl({ currentSha, originUrl, targetSha }) + + if (!url) { + return null + } + + try { + const payload = await new Promise((resolve, reject) => { + const req = https.get( + url, + { + headers: { + Accept: 'application/vnd.github+json', + // GitHub requires a UA on api.github.com; requests without one 403. + 'User-Agent': 'hermes-desktop-update-check' + }, + timeout: 10_000 + }, + res => { + const chunks = [] + res.on('error', reject) + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + if ((res.statusCode || 500) >= 400) { + reject(new Error(`compare API ${res.statusCode}`)) + + return + } + + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + } catch (error) { + reject(error) + } + }) + } + ) + + req.on('timeout', () => req.destroy(new Error('compare API timeout'))) + req.on('error', reject) + }) + + return parseCompareBehindCount(payload) + } catch { + return null + } +} + +async function readCommitLog(cwd, branch, isShallow) { const SEP = '\x1f' const REC = '\x1e' + const { limit, revision } = resolveCommitLogSelection({ branch, isShallow }) const { stdout } = await runGit( - ['log', `HEAD..origin/${branch}`, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', '40'], + ['log', revision, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', String(limit)], { cwd } ) @@ -8020,7 +8116,11 @@ async function ensureBackend(profile) { // A shared backend still owes the caller its profile scope, so renderer-side // WebSocket, filesystem, and cache routing target the selected profile. - return route.descriptorProfile ? { ...connection, profile: route.descriptorProfile } : connection + // `sharedPrimary` marks this as the shared-primary route: pooled backends + // also carry `profile`, so only this descriptor gets the flag. + return route.descriptorProfile + ? { ...connection, profile: route.descriptorProfile, sharedPrimary: true } + : connection } const existing = backendPool.get(key) @@ -10015,6 +10115,70 @@ ipcMain.handle('hermes:window:openInstance', async () => { return { ok: true } }) + +// Hand a session to the user's OWN terminal emulator, running the TUI against +// it (`hermes --tui --resume `). Not the in-app terminal pane: the point is +// to continue the chat in the terminal they already live in. +// +// The desktop's runtime is usually a venv Python invoked as +// `python -m hermes_cli.main`, so we resolve the SAME backend the app itself +// launches and carry its argv + PYTHONPATH into a launcher script rather than +// hoping a `hermes` exists on the user's interactive PATH. Resolution only — +// never ensureRuntime(), which would kick off a first-run install from a menu +// click; an unresolved runtime is reported instead. +ipcMain.handle('hermes:window:openInTerminal', async (_event, sessionId, opts) => { + if (typeof sessionId !== 'string' || !sessionId.trim()) { + return { ok: false, error: 'invalid-session-id' } + } + + try { + const profile = typeof opts?.profile === 'string' ? opts.profile.trim() : '' + const backend = resolveHermesBackend(tuiResumeArgs(sessionId.trim(), profile || undefined)) + + if (!backend.command) { + return { ok: false, error: 'Hermes is not installed yet' } + } + + const { cwd } = sanitizeWorkspaceCwd(opts?.cwd) + const scriptDir = path.join(app.getPath('userData'), 'open-in-terminal') + fs.mkdirSync(scriptDir, { recursive: true }) + + const scriptPath = path.join( + scriptDir, + `hermes-${crypto.randomBytes(6).toString('hex')}${terminalScriptExtension()}` + ) + + fs.writeFileSync( + scriptPath, + buildTerminalScript({ + args: backend.args, + command: backend.command, + cwd, + env: terminalScriptEnv(backend.env, HERMES_HOME) + }), + { mode: 0o700 } + ) + + const launch = resolveTerminalLaunch({ findOnPath, scriptPath }) + + if (!launch) { + return { ok: false, error: 'No terminal emulator found' } + } + + rememberLog(`[terminal] opening session ${sessionId} via ${launch.command}`) + + // Detached + unref'd: the terminal window outlives the desktop app, and + // never inherits our stdio (a closed pipe would kill the TUI). + const child = spawn(launch.command, launch.args, { detached: true, stdio: 'ignore' }) + child.unref() + + return { ok: true } + } catch (error) { + rememberLog(`[terminal] open in terminal failed: ${error.message}`) + + return { ok: false, error: error.message } + } +}) ipcMain.handle('hermes:wake-indicator:get', () => wakeIndicatorController.getState()) ipcMain.on('hermes:wake-indicator:set', (_event, state) => { wakeIndicatorController.setState(state) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index ffa8ffa7e0058..0c0ec40c8c391 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), + openSessionInTerminal: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openInTerminal', sessionId, opts), openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key), wakeIndicator: { diff --git a/apps/desktop/electron/renderer-bundle.test.ts b/apps/desktop/electron/renderer-bundle.test.ts new file mode 100644 index 0000000000000..1e12389f162a6 --- /dev/null +++ b/apps/desktop/electron/renderer-bundle.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import path from 'node:path' + +import { test } from 'vitest' + +import { missingRendererAssets, parseModuleAssetRefs, type RendererBundleDeps } from './renderer-bundle' + +// A production-shaped index.html: the module entry Vite emits plus a +// modulepreload for a lazy chunk. These are the refs the browser fetches +// before any app code runs, and the ones a torn update leaves dangling. +const INDEX_HTML = [ + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + '
', + '' +].join('\n') + +test('parseModuleAssetRefs collects module scripts and modulepreload hrefs', () => { + assert.deepEqual(parseModuleAssetRefs(INDEX_HTML), ['/assets/index-a1b2c3.js', '/assets/shiki-block-COiz1pEN.js']) +}) + +test('parseModuleAssetRefs ignores non-module tags (plain stylesheet, non-module script)', () => { + const html = [ + '', + '', + '' + ].join('\n') + + // Only the type="module" script is a boot-critical module ref; the stylesheet + // and the classic (non-module) script are not part of the module graph. + assert.deepEqual(parseModuleAssetRefs(html), ['/assets/entry.js']) +}) + +test('parseModuleAssetRefs drops absolute/CDN URLs — they are not this generation', () => { + const html = [ + '', + '', + '' + ].join('\n') + + assert.deepEqual(parseModuleAssetRefs(html), ['/assets/local.js']) +}) + +test('parseModuleAssetRefs strips a leading ./ and any query/hash suffix', () => { + const html = [ + '', + '' + ].join('\n') + + assert.deepEqual(parseModuleAssetRefs(html), ['assets/entry.js', 'assets/lazy.js']) +}) + +test('parseModuleAssetRefs returns [] for empty/nullish/module-free html', () => { + assert.deepEqual(parseModuleAssetRefs(''), []) + assert.deepEqual(parseModuleAssetRefs(undefined as unknown as string), []) + assert.deepEqual(parseModuleAssetRefs('no modules here'), []) +}) + +// Build a deps object whose fs is backed by an in-memory set of files that +// "exist beside index.html", so the intact/torn matrix is testable without a +// real bundle on disk. +function depsFor(indexDir: string, html: string, presentFiles: string[]): RendererBundleDeps { + const present = new Set(presentFiles.map(f => path.join(indexDir, f))) + + return { + readFileSync: () => html, + existsSync: (file: string) => present.has(file) + } +} + +const INDEX_PATH = path.join('/app', 'dist', 'index.html') +const INDEX_DIR = path.dirname(INDEX_PATH) + +test('missingRendererAssets: intact generation reports nothing missing', () => { + const deps = depsFor(INDEX_DIR, INDEX_HTML, ['assets/index-a1b2c3.js', 'assets/shiki-block-COiz1pEN.js']) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) + +test('missingRendererAssets: torn generation names the dangling chunk', () => { + // The exact real-world crash: index.html names shiki-block-COiz1pEN.js but the + // update never wrote it beside index.html. + const deps = depsFor(INDEX_DIR, INDEX_HTML, ['assets/index-a1b2c3.js']) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), ['/assets/shiki-block-COiz1pEN.js']) +}) + +test('missingRendererAssets: a fully torn copy lists every referenced module', () => { + const deps = depsFor(INDEX_DIR, INDEX_HTML, []) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), [ + '/assets/index-a1b2c3.js', + '/assets/shiki-block-COiz1pEN.js' + ]) +}) + +test('missingRendererAssets: existence is checked relative to the index dir, per copy', () => { + // The same module name present next to one index but not the other is how a + // split app.asar vs app.asar.unpacked package presents: one copy is intact, + // the other is torn. Resolution must be dir-relative so the loader can prefer + // the intact copy. + const unpackedIndex = path.join('/app', 'app.asar.unpacked', 'dist', 'index.html') + const unpackedDir = path.dirname(unpackedIndex) + + const intact = depsFor(unpackedDir, INDEX_HTML, ['assets/index-a1b2c3.js', 'assets/shiki-block-COiz1pEN.js']) + + const torn = depsFor(INDEX_DIR, INDEX_HTML, ['assets/index-a1b2c3.js']) + + assert.deepEqual(missingRendererAssets(unpackedIndex, intact), []) + assert.deepEqual(missingRendererAssets(INDEX_PATH, torn), ['/assets/shiki-block-COiz1pEN.js']) +}) + +test('missingRendererAssets: an unreadable index is not treated as torn', () => { + // A read failure is the existence gate's concern, not this check's — returning + // [] here keeps the caller from skipping a copy it never actually inspected. + const deps: RendererBundleDeps = { + readFileSync: () => { + throw new Error('EACCES: permission denied') + }, + existsSync: () => false + } + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) + +test('missingRendererAssets: an index naming nothing checkable is not torn', () => { + const deps = depsFor(INDEX_DIR, 'static shell, no modules', []) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) diff --git a/apps/desktop/electron/update-count.test.ts b/apps/desktop/electron/update-count.test.ts index b7e1e89a65832..d3281f126c5ec 100644 --- a/apps/desktop/electron/update-count.test.ts +++ b/apps/desktop/electron/update-count.test.ts @@ -1,22 +1,53 @@ import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { test } from 'vitest' -import { resolveBehindCount, shouldCountCommits } from './update-count' +import { + compareApiUrl, + parseCompareBehindCount, + resolveBehindCount, + resolveCommitLogSelection, + shouldCountCommits +} from './update-count' + +function createTempGitRepo() { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-update-count-')) + const git = (...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }).trim() + + try { + git('init', '--quiet') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + git('config', 'user.name', 'Hermes Test') + git('config', 'user.email', 'hermes@example.invalid') + + return { cwd, git } + } catch (error) { + fs.rmSync(cwd, { recursive: true, force: true }) + throw error + } +} // FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0` // unconditionally, so a shallow checkout with no merge-base surfaced the bogus -// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch. -test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => { +// rev-list count (e.g. 12104) — #51922. Later the branch returned the sentinel +// `1`, which the UI rendered as a literal "1 change included" even when the +// true count was far higher (e.g. 90, or the real-world 61 in #84591). An +// update IS available here, but its exact size is unknown — the only honest +// value is `null`. +test('shallow checkout with no merge-base reports null (unknown count), not a fake 1', () => { assert.equal( resolveBehindCount({ countStr: '12104', currentSha: 'aaa', targetSha: 'bbb', - isShallow: true, - hasMergeBase: false + isShallow: true }), - 1 + null ) }) @@ -26,23 +57,114 @@ test('shallow checkout with no merge-base but identical SHA reports up-to-date', countStr: '12104', currentSha: 'abc', targetSha: 'abc', + isShallow: true + }), + 0 + ) +}) + +test('shallow local-ahead checkout reports up-to-date when origin is a known ancestor', () => { + assert.equal( + resolveBehindCount({ + countStr: '', + currentSha: 'local-child', + targetSha: 'origin-parent', isShallow: true, - hasMergeBase: false + targetIsAncestorOfHead: true }), 0 ) }) -test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => { +test('shallow Git graph proves the remote tip is an ancestor of a local commit', () => { + const { cwd, git } = createTempGitRepo() + + try { + git('commit', '--allow-empty', '-m', 'origin tip') + + const targetSha = git('rev-parse', 'HEAD') + + git('update-ref', 'refs/remotes/origin/main', targetSha) + fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${targetSha}\n`) + git('commit', '--allow-empty', '-m', 'local child') + + const currentSha = git('rev-parse', 'HEAD') + + git('merge-base', '--is-ancestor', 'origin/main', 'HEAD') + assert.notEqual(currentSha, targetSha) + assert.equal( + resolveBehindCount({ + countStr: '', + currentSha, + targetSha, + isShallow: true, + targetIsAncestorOfHead: true + }), + 0 + ) + } finally { + fs.rmSync(cwd, { recursive: true, force: true }) + } +}, 30_000) + +test('shallow checkout with a merge-base does not trust an inflated rev-list count', () => { + const { cwd, git } = createTempGitRepo() + + try { + git('commit', '--allow-empty', '-m', 'root') + git('commit', '--allow-empty', '-m', 'ancestor') + + const redundantParent = git('rev-parse', 'HEAD') + + git('commit', '--allow-empty', '-m', 'installed head') + + const currentSha = git('rev-parse', 'HEAD') + const tree = git('rev-parse', 'HEAD^{tree}') + + const targetSha = execFileSync('git', ['commit-tree', tree, '-p', currentSha, '-p', redundantParent], { + cwd, + encoding: 'utf8', + input: 'remote merge\n', + timeout: 10_000 + }).trim() + + git('update-ref', 'refs/remotes/origin/main', targetSha) + + const completeCount = git('rev-list', 'HEAD..origin/main', '--count') + + assert.equal(completeCount, '1') + + fs.writeFileSync(path.join(cwd, '.git', 'shallow'), `${currentSha}\n`) + + assert.equal(git('rev-parse', '--is-shallow-repository'), 'true') + assert.equal(git('merge-base', 'HEAD', 'origin/main'), currentSha) + + const shallowCount = git('rev-list', 'HEAD..origin/main', '--count') + + assert.ok(Number.parseInt(shallowCount, 10) > Number.parseInt(completeCount, 10)) + assert.equal( + resolveBehindCount({ + countStr: shallowCount, + currentSha, + targetSha, + isShallow: true + }), + null + ) + } finally { + fs.rmSync(cwd, { recursive: true, force: true }) + } +}, 30_000) + +test('shallow checkout with a merge-base still uses presence-only status', () => { assert.equal( resolveBehindCount({ countStr: '3', currentSha: 'aaa', targetSha: 'bbb', - isShallow: true, - hasMergeBase: true + isShallow: true }), - 3 + null ) }) @@ -52,8 +174,7 @@ test('full (non-shallow) clone keeps the exact count path unchanged', () => { countStr: '7', currentSha: 'aaa', targetSha: 'bbb', - isShallow: false, - hasMergeBase: true + isShallow: false }), 7 ) @@ -65,8 +186,7 @@ test('up-to-date full clone reports 0', () => { countStr: '0', currentSha: 'x', targetSha: 'x', - isShallow: false, - hasMergeBase: true + isShallow: false }), 0 ) @@ -78,28 +198,35 @@ test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => countStr: '', currentSha: 'aaa', targetSha: 'bbb', - isShallow: false, - hasMergeBase: true + isShallow: false }), 0 ) }) // shouldCountCommits gates the expensive `rev-list --count` in checkUpdates(). -// FAIL-BEFORE: in the shallow + no-merge-base case the caller ran rev-list -// unconditionally and discarded the bogus result; this predicate lets the -// caller SKIP the whole-ancestry enumeration in exactly that case (#51922). -test('shallow checkout with no merge-base SKIPS the rev-list count', () => { - assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: false }), false) +// Every shallow graph is incomplete, so a visible merge-base is not enough to +// prove that the count is exact. +test('shallow checkouts skip the rev-list count', () => { + assert.equal(shouldCountCommits({ isShallow: true }), false) }) -test('shallow checkout WITH a merge-base still runs the count', () => { - assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: true }), true) +test('full (non-shallow) clones run the rev-list count', () => { + assert.equal(shouldCountCommits({ isShallow: false }), true) }) -test('full (non-shallow) clone always runs the count', () => { - assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: true }), true) - assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: false }), true) +test('shallow commit logs select only the fetched remote tip', () => { + assert.deepEqual(resolveCommitLogSelection({ branch: 'main', isShallow: true }), { + limit: 1, + revision: 'origin/main' + }) +}) + +test('full-clone commit logs keep the complete behind range', () => { + assert.deepEqual(resolveCommitLogSelection({ branch: 'release', isShallow: false }), { + limit: 40, + revision: 'HEAD..origin/release' + }) }) // The skip path produces an empty countStr; resolveBehindCount must NOT trust @@ -110,19 +237,66 @@ test('skipped-count path resolves via SHA compare, never via empty countStr', () countStr: '', currentSha: 'aaa', targetSha: 'bbb', - isShallow: true, - hasMergeBase: false + isShallow: true }), - 1 + null ) assert.equal( resolveBehindCount({ countStr: '', currentSha: 'same', targetSha: 'same', - isShallow: true, - hasMergeBase: false + isShallow: true }), 0 ) }) + +// --- compare-API recovery: the accuracy half of the class fix (#84591) --- + +const SHA_A = 'a'.repeat(40) +const SHA_B = 'b'.repeat(40) + +test('compareApiUrl builds the GitHub compare URL for HTTPS origins', () => { + assert.equal( + compareApiUrl({ + currentSha: SHA_A, + originUrl: 'https://github.com/NousResearch/hermes-agent.git', + targetSha: SHA_B + }), + `https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}` + ) +}) + +test('compareApiUrl handles SSH origin forms', () => { + for (const originUrl of [ + 'git@github.com:NousResearch/hermes-agent.git', + 'ssh://git@github.com/NousResearch/hermes-agent.git', + 'git@github.com:NousResearch/hermes-agent' + ]) { + assert.equal( + compareApiUrl({ currentSha: SHA_A, originUrl, targetSha: SHA_B }), + `https://api.github.com/repos/NousResearch/hermes-agent/compare/${SHA_A}...${SHA_B}` + ) + } +}) + +test('compareApiUrl refuses non-GitHub remotes and partial SHAs', () => { + assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: 'https://gitlab.com/x/y.git', targetSha: SHA_B }), null) + assert.equal(compareApiUrl({ currentSha: 'abc123', originUrl: 'https://github.com/x/y.git', targetSha: SHA_B }), null) + assert.equal(compareApiUrl({ currentSha: SHA_A, originUrl: '', targetSha: SHA_B }), null) +}) + +test('parseCompareBehindCount returns ahead_by (the behind count)', () => { + assert.equal(parseCompareBehindCount({ ahead_by: 61, status: 'ahead' }), 61) + assert.equal(parseCompareBehindCount({ ahead_by: 0, status: 'behind' }), 0) +}) + +test('parseCompareBehindCount rejects malformed payloads', () => { + assert.equal(parseCompareBehindCount(null), null) + assert.equal(parseCompareBehindCount({}), null) + assert.equal(parseCompareBehindCount({ ahead_by: -2 }), null) + assert.equal(parseCompareBehindCount({ ahead_by: '61' }), null) + assert.equal(parseCompareBehindCount({ ahead_by: 1.5 }), null) + assert.equal(parseCompareBehindCount([]), null) +}) diff --git a/apps/desktop/electron/update-count.ts b/apps/desktop/electron/update-count.ts index 23fb6cac13427..adf19f9d14760 100644 --- a/apps/desktop/electron/update-count.ts +++ b/apps/desktop/electron/update-count.ts @@ -1,30 +1,92 @@ // Whether `git rev-list HEAD..origin/ --count` produces a meaningful -// number worth computing. On a SHALLOW checkout (installer clones with -// --depth 1) the local history often shares no merge-base with the freshly -// fetched origin tip, so the count enumerates the entire remote ancestry and -// returns a bogus huge number (e.g. 12104) — see #51922. resolveBehindCount -// discards that bogus count in favour of a SHA compare, so the caller should -// SKIP the expensive rev-list entirely in that case rather than run it and -// throw the result away. -function shouldCountCommits({ isShallow, hasMergeBase }) { - return !(isShallow && !hasMergeBase) +// number worth computing. Installer checkouts are shallow (`--depth 1`), so +// their visible graph is incomplete even when `merge-base` happens to find a +// common commit. A merge can expose ancestry that the local shallow boundary +// hides from HEAD, inflating the count with old commits. Exact counts are only +// trustworthy in full clones; shallow checkouts use presence-only status plus +// any positively proven local-ahead ancestry. +function shouldCountCommits({ isShallow }) { + return !isShallow } // Resolve how many commits the local checkout is behind origin for the desktop -// update indicator. When the count isn't meaningful (shallow + no merge-base) -// fall back to a binary up-to-date check by SHA, exactly like the official-SSH -// path in checkUpdates() and the CLI guard in hermes_cli/banner.py. Full clones -// (developers / Docker dev images) keep the exact count path unchanged. -function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) { - if (!shouldCountCommits({ isShallow, hasMergeBase })) { - if (currentSha && targetSha && currentSha === targetSha) { +// update indicator. Shallow checkouts use SHA equality plus any positively +// proven local-ahead ancestry; exact counts remain exclusive to full clones. +function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, targetIsAncestorOfHead = false }) { + if (!shouldCountCommits({ isShallow })) { + if (currentSha && targetSha && (currentSha === targetSha || targetIsAncestorOfHead)) { return 0 } - return 1 // behind by an unknown amount — show a generic "update available" + // An update IS available, but its size is unknowable without a merge-base. + // Return null — never a numeric sentinel: the UI used to render the old + // `1` as a literal "1 change included" even when the true distance was + // far larger. null lets every surface say "update available" honestly. + return null } return Number.parseInt(countStr, 10) || 0 } -export { resolveBehindCount, shouldCountCommits } +// Shallow history can also contaminate the changelog range. Trust the fetched +// remote tip itself, but do not walk its ancestry. Full clones retain the +// detailed range used by the existing update overlay. +function resolveCommitLogSelection({ branch, isShallow }) { + const remote = `origin/${branch}` + + return isShallow ? { limit: 1, revision: remote } : { limit: 40, revision: `HEAD..${remote}` } +} + +// When the local graph can't count (behind === null), the GitHub compare API +// still can: `GET /repos///compare/...` returns +// `ahead_by` — how many commits the remote tip is ahead of the local HEAD, +// i.e. exactly the behind count the shallow clone lost. Unauthenticated, no +// clone depth required. Pure URL builder + response parser here; the network +// call lives with the caller. +function compareApiUrl({ currentSha, originUrl, targetSha }) { + const sha = /^[0-9a-f]{40}$/i + + if (!sha.test(currentSha || '') || !sha.test(targetSha || '')) { + return null + } + + // Only GitHub remotes have a compare API. Reuse the canonical form the + // official-remote check produces: `github.com//`. + const canonical = canonicalRemoteForCompare(originUrl) + + if (!canonical) { + return null + } + + return `https://api.github.com/repos/${canonical}/compare/${currentSha}...${targetSha}` +} + +function canonicalRemoteForCompare(originUrl) { + const value = String(originUrl || '').trim() + + const match = + /^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value) || + /^(?:ssh:\/\/git@|https:\/\/|http:\/\/)github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(value) + + return match ? match[1] : null +} + +// `ahead_by` counts target commits not reachable from current — the behind +// count. `status` is "ahead" / "behind" / "diverged" / "identical" relative to +// current...target; any shape surprise returns null so the caller keeps the +// honest "update available" fallback instead of trusting a partial answer. +function parseCompareBehindCount(payload) { + if (!payload || typeof payload !== 'object') { + return null + } + + const ahead = payload.ahead_by + + if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) { + return null + } + + return ahead +} + +export { compareApiUrl, parseCompareBehindCount, resolveBehindCount, resolveCommitLogSelection, shouldCountCommits } diff --git a/apps/desktop/electron/window-below.ts b/apps/desktop/electron/window-below.ts index 82581addeddb0..d5fa9e23841db 100644 --- a/apps/desktop/electron/window-below.ts +++ b/apps/desktop/electron/window-below.ts @@ -122,10 +122,15 @@ type GetWindowsModule = { > } -let getWindowsModule: Promise | null = null +let getWindowsModule: Promise | null = null -const loadGetWindows = (): Promise => { - getWindowsModule ??= import('get-windows') +const loadGetWindows = (): Promise => { + // get-windows is an optionalDependency: `npm ci` can skip it when its native + // install fails, including Linux and Windows ARM64 where 9.3.0 has no + // prebuilt. A missing module is therefore a normal state on those targets, + // so the lazy import resolves to null instead of rejecting; enumeration then + // degrades to the failure note instead of an uncaught error. + getWindowsModule ??= import('get-windows').catch(() => null) return getWindowsModule } @@ -143,7 +148,13 @@ async function enumerateViaGetWindows(titlesAvailable: boolean): Promise { assert.equal(percentToZoomLevel(1_000_000), 9) }) -test('installZoomReassertOnWindowEvents wires show, restore, resize, and cross-display moves on macOS and Windows', () => { +test('installZoomReassertOnWindowEvents wires show, restore, focus, resize, and cross-display moves on macOS and Windows', () => { const handlers = new Map() const win = { @@ -95,9 +95,34 @@ test('installZoomReassertOnWindowEvents wires show, restore, resize, and cross-d assert.deepEqual([...handlers.keys()], zoomReassertWindowEvents('win32')) handlers.get('show')() handlers.get('restore')() + handlers.get('focus')() handlers.get('resized')() handlers.get('moved')() - assert.equal(calls, 4) + assert.equal(calls, 5) +}) + +test('focus event reasserts zoom immediately without debounce (Windows high-DPI alt-tab, #50837)', () => { + const handlers = new Map() + + const win = { + isDestroyed: () => false, + on(event, listener) { + handlers.set(event, listener) + } + } + + let calls = 0 + installZoomReassertOnWindowEvents( + win, + () => { + calls += 1 + }, + 'win32' + ) + + // focus should trigger immediate reassert — no timer involved + handlers.get('focus')() + assert.equal(calls, 1) }) test('installZoomReassertOnWindowEvents debounces Linux resize and move events at the trailing edge', () => { diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 9dfdbbc527753..4ba948c362409 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -58,14 +58,17 @@ export function applyZoomLevel(webContents, level) { } // Chromium can drop webContents zoom when a BrowserWindow is resized, minimized -// and restored, or crosses onto a monitor with different display scaling. macOS -// and Windows provide trailing `resized`/`moved` events; Linux only provides the -// noisy `resize`/`move` pair, so debounce those fallbacks before re-applying the -// persisted level. +// and restored, crosses onto a monitor with different display scaling, or loses +// and regains focus (alt-tab on Windows high-DPI displays triggers a DPI +// re-evaluation). macOS and Windows provide trailing `resized`/`moved` events; +// Linux only provides the noisy `resize`/`move` pair, so debounce those +// fallbacks before re-applying the persisted level. export const ZOOM_RESIZE_REASSERT_DELAY_MS = 100 export function zoomReassertWindowEvents(platform = process.platform) { - return platform === 'linux' ? ['show', 'restore', 'resize', 'move'] : ['show', 'restore', 'resized', 'moved'] + return platform === 'linux' + ? ['show', 'restore', 'focus', 'resize', 'move'] + : ['show', 'restore', 'focus', 'resized', 'moved'] } export function installZoomReassertOnWindowEvents(win, reassert, platform = process.platform) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 448c9115fa800..3d2499bc20649 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -62,10 +62,12 @@ "test": "vitest run", "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174", "check:test:desktop:platforms": "npm run test:desktop:platforms", - "check:test:ui": "npm run test:ui", + "check:test:ui:shard-1of3": "node scripts/run-ui-shard.mjs", + "check:test:ui:shard-2of3": "node scripts/run-ui-shard.mjs", + "check:test:ui:shard-3of3": "node scripts/run-ui-shard.mjs", "check:test:desktop:all": "npm run test:desktop:all", "check:lint": "npm run typecheck && npm run lint", - "check": "npm run check:lint && npm run check:test:ui && npm run test:desktop:platforms && npm run test:desktop:all", + "check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all", "test:e2e": "npm run build && playwright test e2e/", "test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list", "test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots" @@ -112,7 +114,6 @@ "emojibase-data": "16.0.3", "fflate": "0.8.3", "frimousse": "0.3.0", - "get-windows": "9.3.0", "hast-util-from-html-isomorphic": "2.0.0", "hast-util-to-text": "4.0.2", "ignore": "7.0.6", @@ -143,6 +144,9 @@ "vfile": "6.0.3", "web-haptics": "0.0.6" }, + "optionalDependencies": { + "get-windows": "9.3.0" + }, "devDependencies": { "@electron/rebuild": "4.2.0", "@playwright/test": "1.58.2", diff --git a/apps/desktop/scripts/before-pack.mjs b/apps/desktop/scripts/before-pack.mjs index e49b049ba3a5f..20045cf728abc 100644 --- a/apps/desktop/scripts/before-pack.mjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -144,11 +144,10 @@ export default async function beforePack(context) { await stageNodePty({ platform, arch: archName }) console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`) } - // get-windows' native payload is per-platform, not per-arch (the macOS - // helper is universal, Windows stages every prebuilt binding dir), so - // it re-stages for the universal target too. - stageGetWindows({ platform }) - console.log(`[before-pack] re-staged get-windows for target ${platform}`) + // The macOS helper is universal, while Windows bindings are arch-specific. + // Pass the target arch so an ARM64 package never stages an x64 binding. + stageGetWindows({ platform, arch: archName }) + console.log(`[before-pack] re-staged get-windows for target ${platform}-${archName}`) } } catch (err) { // This one SHOULD fail the build — a missing/wrong native binary for the diff --git a/apps/desktop/scripts/perf/gateway_attach_bench.py b/apps/desktop/scripts/perf/gateway_attach_bench.py new file mode 100644 index 0000000000000..3e3c0e24e9149 --- /dev/null +++ b/apps/desktop/scripts/perf/gateway_attach_bench.py @@ -0,0 +1,261 @@ +"""Measure the gateway's attach-RPC dispatch, against the real dispatcher. + +Every attach handler (image.attach, image.attach_bytes, file.attach, +clipboard.paste, pdf.attach) resolves its session through ``_sess()``, which +blocks on the deferred agent build. None of them is in ``_LONG_HANDLERS``, so +that block happens INLINE on the socket reader thread. + +This drives the real ``tui_gateway.server.dispatch`` with a session whose +agent build has not completed, and times it. ``prompt.submit`` (which uses +``_sess_nowait``) is timed alongside as the control — it is the path that +stays instant today. + + python3 scripts/perf/gateway_attach_bench.py [--build-seconds 8] [--rounds 3] +""" + +from __future__ import annotations + +import argparse +import base64 +import os +import statistics +import sys +import tempfile +import threading +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(REPO)) + +os.environ.setdefault("HERMES_HOME", tempfile.mkdtemp(prefix="hermes-bench-home-")) + + +class CollectTransport: + """Stand-in for the WS transport: records frames, never touches a socket.""" + + def __init__(self) -> None: + self.frames: list[dict] = [] + self.lock = threading.Lock() + + def write(self, obj: dict) -> bool: + with self.lock: + self.frames.append(obj) + return True + + def close(self) -> None: + return None + + +def make_session(server, sid: str, *, build_seconds: float, home: Path) -> dict: + """A session whose deferred agent build is still running. + + Mirrors the shape ``_deferred_build`` leaves behind: an unset ``agent_ready`` + event plus a live build thread. That is exactly the state a session is in + for the first seconds after ``session.create`` — which is when a user + pastes their first image. + """ + ready = threading.Event() + session: dict = { + "agent": None, + "agent_ready": ready, + "agent_error": None, + "attached_images": [], + "cwd": str(home), + "history": [], + "history_lock": threading.RLock(), + "history_version": 0, + "image_counter": 0, + "profile_home": str(home), + "running": False, + "session_key": sid, + "transport": None, + } + + def build() -> None: + time.sleep(build_seconds) + ready.set() + + thread = threading.Thread(target=build, daemon=True) + session["_agent_build_thread"] = thread + thread.start() + + server._sessions[sid] = session + return session + + +def png_bytes(kb: int) -> bytes: + body = bytearray(b"\x89PNG\r\n\x1a\n") + body.extend(bytes((i * 37) & 0xFF for i in range(kb * 1024))) + return bytes(body) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--build-seconds", type=float, default=8.0) + ap.add_argument("--rounds", type=int, default=3) + ap.add_argument("--kb", type=int, default=900) + args = ap.parse_args() + + from tui_gateway import server + + # The build is already in flight for these sessions (that is the state the + # bench recreates), so the "start one if none is running" call is a no-op. + # Without this stub the real builder races the bench's controlled one and + # completes instantly, hiding the very wait being measured. + server._start_agent_build = lambda sid, session: None + + # Keep the run readable: session.info frames go to the transport, not stdout. + server._emit = lambda *a, **k: None + + home = Path(os.environ["HERMES_HOME"]) + home.mkdir(parents=True, exist_ok=True) + + content_b64 = base64.b64encode(png_bytes(args.kb)).decode("ascii") + + scratch = home / "scratch.txt" + scratch.write_text("hello from the bench\n") + + image_on_disk = home / "on-disk.png" + image_on_disk.write_bytes(png_bytes(args.kb)) + + pdf_on_disk = home / "doc.pdf" + pdf_on_disk.write_bytes(b"%PDF-1.4\n" + b"0" * 2048 + b"\n%%EOF\n") + + calls = [ + ( + "image.attach_bytes", + lambda sid: { + "session_id": sid, + "content_base64": content_b64, + "filename": "bench.png", + }, + ), + ( + "image.attach", + lambda sid: {"session_id": sid, "path": str(image_on_disk)}, + ), + ( + "file.attach", + lambda sid: { + "session_id": sid, + "name": "scratch.txt", + "path": str(scratch), + }, + ), + ( + "pdf.attach", + lambda sid: {"session_id": sid, "path": str(pdf_on_disk)}, + ), + ( + "clipboard.paste", + lambda sid: {"session_id": sid}, + ), + ( + "image.detach", + lambda sid: {"session_id": sid, "path": "/tmp/nothing.png"}, + ), + ( + "prompt.submit", + lambda sid: {"session_id": sid, "text": "control: plain text"}, + ), + ] + + print( + f"agent build takes {args.build_seconds:.1f}s; " + f"image is {args.kb} KB; {args.rounds} rounds\n" + ) + print(f"{'rpc':<22} {'in _LONG_HANDLERS':<19} {'mean':>8} {'max':>8} blocks reader?") + + for method, build_params in calls: + samples: list[float] = [] + + for round_index in range(args.rounds): + sid = f"bench-{method}-{round_index}" + make_session(server, sid, build_seconds=args.build_seconds, home=home) + transport = CollectTransport() + req = { + "jsonrpc": "2.0", + "id": round_index, + "method": method, + "params": build_params(sid), + } + + start = time.perf_counter() + try: + server.dispatch(req, transport) + except Exception as exc: # noqa: BLE001 - report, don't mask + print(f" ! {method} raised {type(exc).__name__}: {exc}") + samples.append(time.perf_counter() - start) + + server._sessions.pop(sid, None) + + pooled = method in server._LONG_HANDLERS + mean = statistics.mean(samples) + worst = max(samples) + verdict = "no (pooled)" if pooled else ("YES" if mean > 1.0 else "no") + + print( + f"{method:<22} {str(pooled):<19} {mean:>7.2f}s {worst:>7.2f}s {verdict}" + ) + + print( + "\ndispatch() returns immediately for pooled handlers, so a pooled timing\n" + "is the enqueue cost — the work still happens, just off the reader thread." + ) + + _report_surfaces() + return 0 + + +def _report_surfaces() -> None: + """Which surfaces can even reach this code path. + + The stall lives in the gateway's session resolver, so a surface is exposed + only if it attaches over the gateway. That is a fact about the call graph + rather than a timing, so it is read out of the source — and it moves if + the call graph moves. + """ + print("\n\n=== which surfaces reach the gateway attach RPCs ===\n") + + root = Path(__file__).resolve().parents[4] + attach_rpcs = ("image.attach", "image.attach_bytes", "file.attach", "clipboard.paste") + + surfaces = { + "CLI (cli.py)": [root / "cli.py"], + "TUI (ui-tui)": sorted((root / "ui-tui" / "src").rglob("*.ts")), + "Desktop (apps/desktop)": sorted((root / "apps" / "desktop" / "src").rglob("*.ts")), + } + + for label, paths in surfaces.items(): + hits: set[str] = set() + + for path in paths: + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for rpc in attach_rpcs: + if f"'{rpc}'" in text or f'"{rpc}"' in text: + hits.add(rpc) + + if hits: + print(f" {label:<24} EXPOSED — calls {', '.join(sorted(hits))}") + else: + print(f" {label:<24} not exposed — no gateway attach RPC") + + print( + "\n CLI attaches inline in its own turn path (cli.py → image_routing) with\n" + " the agent already constructed. There is no gateway session to resolve,\n" + " so the stall is structurally unreachable — matching the ~4s report.\n" + "\n The TUI calls the SAME RPCs and was equally exposed. What differed was\n" + " hit rate, not code path: Desktop mints sessions constantly (new chat,\n" + " tabs, tiles), so a paste routinely lands inside the seconds-long window\n" + " while a fresh session's agent is still building. A TUI user launches\n" + " once and the build finishes while they type." + ) + return None + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/desktop/scripts/perf/image-attach-bench.mjs b/apps/desktop/scripts/perf/image-attach-bench.mjs new file mode 100644 index 0000000000000..ac723d1980f96 --- /dev/null +++ b/apps/desktop/scripts/perf/image-attach-bench.mjs @@ -0,0 +1,307 @@ +// Measures the desktop image-attach pipeline stage by stage on a real image, +// against the real renderer helpers. No Electron, no LLM — just the transforms +// an attached image goes through between the paperclip and prompt.submit. +// +// node scripts/perf/image-attach-bench.mjs [--kb 900] [--rounds 7] + +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' + +const args = process.argv.slice(2) +const flag = (name, fallback) => { + const i = args.indexOf(`--${name}`) + + return i >= 0 ? Number(args[i + 1]) : fallback +} + +const ROUNDS = flag('rounds', 7) +const SIZES_KB = args.includes('--kb') ? [flag('kb', 900)] : [120, 900, 3200] + +const dir = mkdtempSync(join(tmpdir(), 'hermes-img-bench-')) + +/** A PNG-shaped byte blob of a given size. The pipeline treats it as opaque + * bytes everywhere we measure, so the pixels don't matter — the length does. */ +function makeImage(kb) { + const bytes = Buffer.alloc(kb * 1024) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes) + + for (let i = 8; i < bytes.length; i += 1) { + bytes[i] = (i * 2654435761) & 0xff + } + + const path = join(dir, `img-${kb}kb.png`) + writeFileSync(path, bytes) + + return path +} + +const stat = samples => { + const s = [...samples].sort((a, b) => a - b) + + return { + mean: s.reduce((a, b) => a + b, 0) / s.length, + p50: s[Math.floor(s.length * 0.5)], + p95: s[Math.min(s.length - 1, Math.floor(s.length * 0.95))], + max: s[s.length - 1] + } +} + +const time = fn => { + const t0 = performance.now() + const out = fn() + + return { ms: performance.now() - t0, out } +} + +// --- the stages, transcribed from the shipped code paths ------------------- + +// electron/hardening.ts :: readFileDataUrlForIpc — main-process side of +// window.hermesDesktop.readFileDataUrl. +const readFileDataUrl = path => { + const data = readFileSync(path) + + return `data:image/png;base64,${data.toString('base64')}` +} + +// use-prompt-actions/utils.ts :: base64FromDataUrl +const base64FromDataUrl = dataUrl => { + const comma = dataUrl.indexOf(',') + + return comma >= 0 ? dataUrl.slice(comma + 1) : '' +} + +// The JSON-RPC frame the renderer sends for image.attach_bytes. +const encodeRpcFrame = (base64, filename) => + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'image.attach_bytes', + params: { session_id: 'bench', content_base64: base64, filename } + }) + +// lib/embedded-images.ts :: extractEmbeddedImages — runs on the optimistic +// bubble text on EVERY DirectiveContent render, and the composer's base64 +// preview is what it scans. +const DATA_IMAGE_PREFIX = 'data:image/' +const BASE64_MARKER = ';base64,' +const MIN_EMBEDDED_IMAGE_BASE64_LENGTH = 64 + +const isImageMimeCode = c => + (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 45 || c === 46 || c === 95 + +const isBase64Code = c => + (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 47 || c === 61 + +function readDataImageUrl(text, start) { + if (!text.startsWith(DATA_IMAGE_PREFIX, start)) { + return null + } + + let cursor = start + DATA_IMAGE_PREFIX.length + + while (cursor < text.length && isImageMimeCode(text.charCodeAt(cursor))) { + cursor += 1 + } + + if (cursor === start + DATA_IMAGE_PREFIX.length || !text.startsWith(BASE64_MARKER, cursor)) { + return null + } + + cursor += BASE64_MARKER.length + const base64Start = cursor + + while (cursor < text.length && isBase64Code(text.charCodeAt(cursor))) { + cursor += 1 + } + + if (cursor - base64Start < MIN_EMBEDDED_IMAGE_BASE64_LENGTH) { + return null + } + + return { end: cursor, url: text.slice(start, cursor) } +} + +function extractEmbeddedImages(text) { + if (!text || !text.includes(DATA_IMAGE_PREFIX)) { + return { cleanedText: text, images: [] } + } + + const images = [] + const pieces = [] + let appendCursor = 0 + let searchCursor = 0 + + while (searchCursor < text.length) { + const dataStart = text.indexOf(DATA_IMAGE_PREFIX, searchCursor) + + if (dataStart === -1) { + break + } + + const dataUrl = readDataImageUrl(text, dataStart) + + if (!dataUrl) { + searchCursor = dataStart + DATA_IMAGE_PREFIX.length + + continue + } + + pieces.push(text.slice(appendCursor, dataStart)) + images.push(dataUrl.url) + appendCursor = dataUrl.end + searchCursor = dataUrl.end + } + + if (!images.length) { + return { cleanedText: text, images: [] } + } + + pieces.push(text.slice(appendCursor)) + + return { + cleanedText: pieces + .join('') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(), + images + } +} + +// lib/render-weight.ts :: payloadCharacters — walks every string in a message's +// content, including the data URL riding in attachmentRefs. +const RENDER_WEIGHT_CHARS = 512 +const MAX_MEASURED_MESSAGE_CHARS = 300 * RENDER_WEIGHT_CHARS +const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type']) + +function payloadCharacters(roots, budget) { + const seen = new WeakSet() + const pending = [...roots] + let characters = 0 + + while (pending.length > 0 && characters < budget) { + const value = pending.pop() + + if (typeof value === 'string') { + characters += Math.min(value.length, budget - characters) + + continue + } + + if (!value || typeof value !== 'object' || seen.has(value)) { + continue + } + + seen.add(value) + + if (Array.isArray(value)) { + for (const nested of value) { + pending.push(nested) + } + + continue + } + + for (const [key, nested] of Object.entries(value)) { + if (!NON_RENDERED_CONTENT_FIELDS.has(key)) { + pending.push(nested) + } + } + } + + return characters +} + +// store/composer.ts :: cloneDraft — every composer draft stash copies each +// attachment object; previewUrl (the data URL) rides along by reference, but +// the surrounding string ops on the draft still run. +const cloneDraft = draft => ({ + attachments: draft.attachments.map(a => ({ ...a })), + text: draft.text +}) + +// --- run ------------------------------------------------------------------- + +const results = [] + +for (const kb of SIZES_KB) { + const path = makeImage(kb) + const rows = {} + const record = (stage, ms) => { + ;(rows[stage] ??= []).push(ms) + } + + let dataUrl = '' + let base64 = '' + let frame = '' + let bubbleText = '' + + for (let round = 0; round < ROUNDS; round += 1) { + // 1. preview read (attachImagePath → attachmentPreviewDataUrl) + const preview = time(() => readFileDataUrl(path)) + record('preview_read_dataurl', preview.ms) + dataUrl = preview.out + + // 2. submit-time SECOND read of the same file (readImageForRemoteAttach) + const attachRead = time(() => readFileDataUrl(path)) + record('attach_read_dataurl', attachRead.ms) + + // 3. strip the data: prefix + const strip = time(() => base64FromDataUrl(attachRead.out)) + record('base64_from_dataurl', strip.ms) + base64 = strip.out + + // 4. JSON-RPC frame for image.attach_bytes + const encode = time(() => encodeRpcFrame(base64, 'img.png')) + record('rpc_frame_encode', encode.ms) + frame = encode.out + + // 5. the optimistic bubble carries the data URL as its attachmentRef + bubbleText = dataUrl + const extract = time(() => extractEmbeddedImages(bubbleText)) + record('extract_embedded_images', extract.ms) + + // 6. render-weight walk over the message holding that ref + const content = [{ type: 'text', text: 'what is this' }, { attachmentRefs: [dataUrl] }] + const weigh = time(() => payloadCharacters(content, MAX_MEASURED_MESSAGE_CHARS)) + record('render_weight_walk', weigh.ms) + + // 7. draft stash clone with the attachment held + const draft = { attachments: [{ id: 'a', kind: 'image', label: 'i', previewUrl: dataUrl, path }], text: 'hi' } + const clone = time(() => cloneDraft(draft)) + record('draft_clone', clone.ms) + } + + results.push({ + kb, + fileBytes: readFileSync(path).length, + dataUrlChars: dataUrl.length, + rpcFrameChars: frame.length, + rows + }) +} + +for (const r of results) { + console.log(`\n=== ${r.kb} KB image (${r.fileBytes} bytes on disk) ===`) + console.log( + `data URL: ${r.dataUrlChars.toLocaleString()} chars RPC frame: ${r.rpcFrameChars.toLocaleString()} chars ` + + `(${(r.rpcFrameChars / r.fileBytes).toFixed(2)}x the file)` + ) + console.log('') + console.log('stage mean p50 p95 max') + + let total = 0 + + for (const [stage, samples] of Object.entries(r.rows)) { + const s = stat(samples) + total += s.mean + console.log( + `${stage.padEnd(26)} ${s.mean.toFixed(2).padStart(7)} ${s.p50.toFixed(2).padStart(7)} ` + + `${s.p95.toFixed(2).padStart(7)} ${s.max.toFixed(2).padStart(7)} ms` + ) + } + + console.log(`${'TOTAL (mean)'.padEnd(26)} ${total.toFixed(2).padStart(7)} ms`) +} diff --git a/apps/desktop/scripts/run-ui-shard.mjs b/apps/desktop/scripts/run-ui-shard.mjs new file mode 100644 index 0000000000000..8f29f16f62bfd --- /dev/null +++ b/apps/desktop/scripts/run-ui-shard.mjs @@ -0,0 +1,61 @@ +// Runs one shard of the UI vitest suite, deriving the shard index/count from +// the npm script NAME (npm_lifecycle_event), so the name and the flag can +// never disagree. A copy-paste slip like "check:test:ui:shard-2of3" running +// --shard=1/3 would silently skip a third of the suite while CI stays green; +// deriving from the name makes that impossible. +// +// It also validates that this package.json declares exactly the shard family +// 1..M for a single M, so a partial 3→4 migration (adding shard-4of4 without +// updating the siblings) fails loudly instead of dropping coverage. +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SHARD_RE = /^check:test:ui:shard-(\d+)of(\d+)$/ + +const scriptName = process.env.npm_lifecycle_event ?? '' +const match = scriptName.match(SHARD_RE) +if (!match) { + console.error( + `run-ui-shard: must be invoked via an npm script named check:test:ui:shard-of (got ${JSON.stringify(scriptName)})`, + ) + process.exit(1) +} +const [, indexRaw, countRaw] = match +const index = Number(indexRaw) +const count = Number(countRaw) +if (!(index >= 1 && index <= count)) { + console.error(`run-ui-shard: shard index ${index} out of range 1..${count}`) + process.exit(1) +} + +// The whole family must be exactly 1..M of one M — otherwise a rename or a +// partial count bump leaves a silently untested slice of the suite. +const pkgDir = dirname(dirname(fileURLToPath(import.meta.url))) +const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) +const family = Object.keys(pkg.scripts ?? {}) + .map((name) => name.match(SHARD_RE)) + .filter(Boolean) +const counts = new Set(family.map((m) => Number(m[2]))) +const indices = family.map((m) => Number(m[1])).sort((a, b) => a - b) +const expected = Array.from({ length: count }, (_, i) => i + 1) +if (counts.size !== 1 || indices.length !== count || indices.some((v, i) => v !== expected[i])) { + console.error( + `run-ui-shard: shard scripts must form exactly 1..M for a single M; found indices [${indices}] with counts {${[...counts]}}`, + ) + process.exit(1) +} + +// Delegate through test:ui so the vitest command stays single-sourced. +// npm resolves to npm.cmd on Windows, which needs a shell (same handling as +// test-desktop.mjs and stage-native-deps.mjs). +const result = spawnSync( + 'npm', + ['run', 'test:ui', '--', `--shard=${index}/${count}`, ...process.argv.slice(2)], + { stdio: 'inherit', cwd: pkgDir, shell: process.platform === 'win32' }, +) +if (result.error) { + console.error(`run-ui-shard: ${result.error.message}`) +} +process.exit(result.status ?? 1) diff --git a/apps/desktop/scripts/stage-native-deps.mjs b/apps/desktop/scripts/stage-native-deps.mjs index 95fea7d13b4e4..c55a8ed3ab869 100644 --- a/apps/desktop/scripts/stage-native-deps.mjs +++ b/apps/desktop/scripts/stage-native-deps.mjs @@ -407,12 +407,20 @@ export function openWindowsSync() { ` function resolveGetWindowsRoot() { - // get-windows' exports map doesn't expose ./package.json; resolve the entry - // (index.js sits at the package root) and take its directory. - const entryPath = require.resolve('get-windows', { - paths: [projectRoot] - }) - return dirname(entryPath) + // get-windows is an optionalDependency (its node-pre-gyp install script has + // no Linux or Windows ARM64 prebuilt and its node-gyp fallback may fail, so + // `npm ci` can skip it entirely on those targets). Return null when it is + // absent; the caller decides whether that is fatal per platform and arch. + try { + // get-windows' exports map doesn't expose ./package.json; resolve the entry + // (index.js sits at the package root) and take its directory. + const entryPath = require.resolve('get-windows', { + paths: [projectRoot] + }) + return dirname(entryPath) + } catch { + return null + } } /** @@ -429,7 +437,7 @@ const GET_WINDOWS_VERSION = '9.3.0' export function stageGetWindowsInto( srcRoot, destRoot, - { platform = process.platform, rebuild } = {} + { platform = process.platform, arch = process.arch, rebuild } = {} ) { // The STAGED_WINDOWS_JS rewrite mirrors this exact version's export surface. // A version bump must fail the build here until the rewrite is re-verified — @@ -482,11 +490,20 @@ export function stageGetWindowsInto( ? readdirSync(bindingRoot).filter( (dir) => dir.includes(`-${platform}-`) && + dir.endsWith(`-${arch}`) && existsSync(join(bindingRoot, dir, 'node-get-windows.node')) ) : [] let bindingDirs = scanBindingDirs() - if (bindingDirs.length === 0 && typeof rebuild === 'function') { + if (bindingDirs.length === 0 && arch === 'arm64') { + // get-windows 9.3.0 publishes win32 prebuilds for ia32/x64 only. + // The staged windows.js deliberately fails soft when binding/ is absent, + // so preserve the desktop build and disable only window enumeration. + console.warn( + '[stage-native-deps] get-windows has no win32-arm64 prebuilt binding; ' + + 'staging the fail-soft JS surface without native window enumeration.' + ) + } else if (bindingDirs.length === 0 && typeof rebuild === 'function') { // A plain `npm install` won't re-run an install script for a package // that is already on disk, so every checkout that installed while // get-windows was missing from allowScripts stays bricked even after @@ -497,9 +514,9 @@ export function stageGetWindowsInto( rebuild() bindingDirs = scanBindingDirs() } - if (bindingDirs.length === 0) { + if (bindingDirs.length === 0 && arch !== 'arm64') { throw new Error( - '[stage-native-deps] get-windows has no win32 prebuilt binding under lib/binding. ' + + `[stage-native-deps] get-windows has no win32-${arch} prebuilt binding under lib/binding. ` + 'Recover from the checkout root with:\n' + ' npm install-scripts approve get-windows\n' + ' npm rebuild get-windows' @@ -537,19 +554,46 @@ function rebuildGetWindowsViaNpm() { } } -export function stageGetWindows({ platform = process.platform } = {}) { - const srcRoot = resolveGetWindowsRoot() +export function stageGetWindows( + { + platform = process.platform, + arch = process.arch, + resolveRoot = resolveGetWindowsRoot + } = {} +) { + const srcRoot = resolveRoot() const destRoot = resolve(projectRoot, 'dist/node_modules/get-windows') + + if (!srcRoot) { + // npm may omit an optional dependency whose install script fails. That is + // expected on Linux and win32-arm64 because get-windows 9.3.0 publishes no + // native prebuilt for either target. The runtime import already fails soft, + // so disable only window enumeration instead of failing the Desktop build. + // Other Windows architectures and macOS have supported native payloads and + // remain fail-closed so a broken package cannot ship silently. + const canDegrade = platform === 'linux' || (platform === 'win32' && arch === 'arm64') + if (canDegrade) { + console.warn( + `[stage-native-deps] get-windows not installed (optional dep skipped for ${platform}-${arch}); ` + + 'read_window_below will be unavailable in this build' + ) + return undefined + } + throw new Error( + `[stage-native-deps] get-windows is not installed; cannot stage its ${platform}-${arch} native payload` + ) + } + // Only a win32 host can produce the win32 binding, so a cross-platform pack // has nothing to gain from the rebuild. const rebuild = platform === 'win32' && process.platform === 'win32' ? rebuildGetWindowsViaNpm : undefined - return stageGetWindowsInto(srcRoot, destRoot, { platform, rebuild }) + return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, rebuild }) } // Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch] if (isMain(import.meta.url)) { const [platform, arch] = process.argv.slice(2) stageNodePty({ platform, arch }) - stageGetWindows({ platform }) + stageGetWindows({ platform, arch }) } diff --git a/apps/desktop/scripts/stage-native-deps.test.mjs b/apps/desktop/scripts/stage-native-deps.test.mjs index 48a4101175d17..6392b52a80716 100644 --- a/apps/desktop/scripts/stage-native-deps.test.mjs +++ b/apps/desktop/scripts/stage-native-deps.test.mjs @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url' import { test } from 'vitest' import { + stageGetWindows, stageGetWindowsInto, stageNodePtyInto, classifyNativeBinary @@ -390,7 +391,7 @@ test('win32 staging skips the darwin binding the tarball bundles on every platfo ] }) - stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32' }) + stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' }) assert.ok(existsSync(join(destRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node'))) assert.ok(!existsSync(join(destRoot, 'lib', 'binding', 'napi-9-darwin-unknown-arm64'))) @@ -410,7 +411,7 @@ test('win32 staging rejects a binding dir that claims win32 but holds a foreign }) assert.throws( - () => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32' }), + () => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' }), /expected win32, got darwin/ ) } finally { @@ -418,7 +419,7 @@ test('win32 staging rejects a binding dir that claims win32 but holds a foreign } }) -test('win32 staging fails when only foreign bindings exist', () => { +test('win32-x64 staging fails when only foreign bindings exist', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try { const srcRoot = join(tmp, 'get-windows') @@ -429,14 +430,36 @@ test('win32 staging fails when only foreign bindings exist', () => { }) assert.throws( - () => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32' }), - /no win32 prebuilt binding/ + () => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' }), + /no win32-x64 prebuilt binding/ ) } finally { fs.rmSync(tmp, { recursive: true, force: true }) } }) +test('win32-arm64 staging omits incompatible bindings and keeps the fail-soft JS surface', () => { + const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) + try { + const srcRoot = join(tmp, 'get-windows') + const destRoot = join(tmp, 'dest') + + makeFakeGetWindows(srcRoot, { + bindings: [ + { dir: 'napi-9-darwin-unknown-arm64', platform: 'darwin' }, + { dir: 'napi-9-win32-unknown-x64', platform: 'win32' } + ] + }) + + stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'arm64' }) + + assert.ok(existsSync(join(destRoot, 'lib', 'windows.js'))) + assert.ok(!existsSync(join(destRoot, 'lib', 'binding'))) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + test('win32 staging self-heals through the rebuild hook when the binding is missing', () => { const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-')) try { @@ -456,7 +479,7 @@ test('win32 staging self-heals through the rebuild hook when the binding is miss ) } - stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', rebuild }) + stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', rebuild }) assert.equal(calls, 1) assert.ok( @@ -476,7 +499,12 @@ test('win32 staging reports the recovery steps when the rebuild hook produces no makeFakeGetWindows(srcRoot, { bindings: [] }) assert.throws( - () => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', rebuild: () => {} }), + () => + stageGetWindowsInto(srcRoot, destRoot, { + platform: 'win32', + arch: 'x64', + rebuild: () => {} + }), /npm rebuild get-windows/ ) } finally { @@ -519,3 +547,36 @@ test('darwin staging ships the Swift helper executable and the rewritten windows fs.rmSync(tmp, { recursive: true, force: true }) } }) + +// ─── stageGetWindows (optionalDependency gate) ────────────────────── +// +// get-windows is an optionalDependency: on Linux its node-pre-gyp install +// script fails because no prebuilt exists. Windows ARM64 has the same package +// state: its prebuilt URL returns 404 and npm may omit the optional dependency. +// Staging skips those unsupported targets, but supported native targets remain +// a hard failure when the package is missing. + +test('linux staging skips when get-windows is absent (optional dep skipped by npm)', () => { + assert.equal(stageGetWindows({ platform: 'linux', resolveRoot: () => null }), undefined) +}) + +test('darwin staging fails when get-windows is absent', () => { + assert.throws( + () => stageGetWindows({ platform: 'darwin', arch: 'arm64', resolveRoot: () => null }), + /get-windows is not installed/ + ) +}) + +test('win32-arm64 staging skips when get-windows is absent after its optional install fails', () => { + assert.equal( + stageGetWindows({ platform: 'win32', arch: 'arm64', resolveRoot: () => null }), + undefined + ) +}) + +test('win32-x64 staging fails when get-windows is absent', () => { + assert.throws( + () => stageGetWindows({ platform: 'win32', arch: 'x64', resolveRoot: () => null }), + /get-windows is not installed/ + ) +}) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx index 896643636d801..5467b7be86a5d 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.test.tsx @@ -86,4 +86,10 @@ describe('ProjectOverviewRow', () => { expect(onNewSession).toHaveBeenCalledWith(null) }) + + it('tags the row with data-sessions-project so a skin can target one project', () => { + const { container } = render() + + expect(container.querySelector('[data-sessions-project="p1"]')).toBeTruthy() + }) }) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index 155d477e1ee63..4ef812a2d7e8b 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -158,7 +158,11 @@ export function ProjectOverviewRow({ ) return ( -
+ // Tag each project sibling with its id so a custom skin can target one + // project in the overview — the parallel to the entered-project wrapper's + // `data-sessions-project` (index.tsx), which only fires once you've drilled + // in. Here it's present on every row of the list. +
{/* Home has no per-project actions, so it gets no right-click menu. */} {project.isNoProject ? ( shell diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx index 559d648407bce..82faefb85bf77 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { atom } from 'nanostores' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -65,6 +65,7 @@ vi.mock('@/store/projects', () => ({ })) vi.mock('@/store/session', () => ({ $activeSessionId: atom(null), + $connection: atom(null), $selectedStoredSessionId: atom(null), $sessions: atom([]), sessionMatchesStoredId: vi.fn(() => false), @@ -80,8 +81,10 @@ vi.mock('@/store/session-states', () => ({ openSessionTile: vi.fn() })) vi.mock('@/store/windows', () => ({ + canOpenSessionInTerminal: () => false, canOpenSessionWindow: () => false, - openSessionInNewWindow: vi.fn() + openSessionInNewWindow: vi.fn(), + openSessionInTerminal: vi.fn() })) function renderMenu() { @@ -113,4 +116,28 @@ describe('SessionActionsMenu', () => { expect(screen.getByRole('menuitem', { name: /rename/i })).toBeTruthy() expect(screen.getByRole('menuitem', { name: /archive/i })).toBeTruthy() }) + + it('opens the rename dialog focused on its input, not the row trigger', async () => { + renderMenu() + + const trigger = screen.getByRole('button', { name: 'Session actions' }) + + fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' }) + fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' }) + fireEvent.click(trigger) + + const rename = await screen.findByRole('menuitem', { name: /rename/i }) + fireEvent.click(rename) + + // The dialog opens and its textbox takes focus. If the menu's close restored + // focus to the row trigger instead, Space would activate the row and the + // arrow keys would move the list rather than the caret (the reported bug). + const dialog = await screen.findByRole('dialog') + const input = within(dialog).getByRole('textbox') + + // eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document + await waitFor(() => expect(document.activeElement).toBe(input)) + // eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document + expect(document.activeElement).not.toBe(trigger) + }) }) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 0a32e1881f85b..9a0e406f4159a 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -33,6 +33,7 @@ import { notify, notifyError } from '@/store/notifications' import { $projectTree, moveSessionToProject, projectIdForCwd, projectRootCwd } from '@/store/projects' import { $activeSessionId, + $connection, $selectedStoredSessionId, $sessions, sessionMatchesStoredId, @@ -41,7 +42,7 @@ import { } from '@/store/session' import { $sessionColorOverrides, setSessionColorOverride } from '@/store/session-color' import { $sessionTiles } from '@/store/session-states' -import { canOpenSessionWindow } from '@/store/windows' +import { canOpenSessionInTerminal, canOpenSessionWindow, openSessionInTerminal } from '@/store/windows' import type { SessionTitleResponse } from '../../types' @@ -189,8 +190,17 @@ function useSessionActions({ const { t } = useI18n() const r = t.sidebar.row const [renameOpen, setRenameOpen] = useState(false) + // The rename item opens a Dialog. When a menu closes, Radix restores focus to + // its trigger — for a sidebar row that trigger is the row's own - {behind > 0 && supported && !applying && ( + {updateAvailable && supported && !applying && ( <>
diff --git a/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts b/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts new file mode 100644 index 0000000000000..5170d047b6e1c --- /dev/null +++ b/apps/desktop/src/app/shell/hooks/use-context-breakdown.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react' + +import type { ContextBreakdown } from '@/types/hermes' + +interface ContextBreakdownOptions { + busy: boolean + enabled: boolean + requestGateway: (method: string, params?: Record) => Promise + sessionId: null | string +} + +/** The focused session's context breakdown, fetched as soon as the statusbar + * gauge is on screen rather than when its popover opens. + * + * The backend only reports measured context occupancy (`last_prompt_tokens`) + * once a turn has run in THIS process, so a resumed session reports none — + * which is why turning the gauge on used to do nothing at all until you sent + * a message. `session.context_breakdown` estimates the same figure from the + * live system prompt + tools + transcript, so it answers for a session that + * hasn't spoken yet. It is a read-only chars/4 pass: no provider call, no + * prompt-cache impact. + * + * Refetches when the focused session changes and when a turn ends (the + * transcript just grew). Held keyed by the session it describes so switching + * sessions drops the previous numbers instead of painting them under the new + * session's name. */ +export function useContextBreakdown({ busy, enabled, requestGateway, sessionId }: ContextBreakdownOptions) { + const [fetched, setFetched] = useState<{ breakdown: ContextBreakdown; sessionId: string } | null>(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + // Mid-turn the transcript changes on every delta and the gateway already + // streams measured usage, so an estimate would be both stale and wasteful. + if (!enabled || !sessionId || busy) { + return + } + + let cancelled = false + setLoading(true) + + void requestGateway('session.context_breakdown', { session_id: sessionId }) + .then(breakdown => { + if (!cancelled && breakdown) { + setFetched({ breakdown, sessionId }) + } + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) { + setLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [busy, enabled, requestGateway, sessionId]) + + return { + breakdown: fetched && fetched.sessionId === sessionId ? fetched.breakdown : null, + loading + } +} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index a4081e9ac77da..98fd2dce5e8a4 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,10 +1,11 @@ import { useStore } from '@nanostores/react' -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' +import { useContextBreakdown } from '@/app/shell/hooks/use-context-breakdown' import { $paneVisible, togglePaneVisible } from '@/components/pane-shell/tree/store' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' @@ -31,10 +32,10 @@ import { $sessionStartedAt, $turnStartedAt, idsShareLineage, - sessionMatchesStoredId, - setCurrentUsage + sessionMatchesStoredId } from '@/store/session' import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states' +import { $statusbarHiddenIds } from '@/store/statusbar-prefs' import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents' import { $gatewayRestarting } from '@/store/system-actions' import { @@ -50,7 +51,7 @@ import type { StatusResponse, UsageStats } from '@/types/hermes' import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes' import type { StatusbarItem } from '../statusbar-controls' -const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const +const EMPTY_USAGE: UsageStats = { calls: 0, input: 0, output: 0, total: 0 } interface StatusbarItemsOptions { agentsOpen: boolean @@ -223,16 +224,45 @@ export function useStatusbarItems({ ? focusedRowStartedAt * 1000 : null - const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) - const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) + // The backend only knows a session's MEASURED occupancy once a turn has run + // in this process, so a resumed conversation reports none and the gauge had + // nothing to paint — turning it on looked like it did nothing until you sent + // a message. Estimate from the live prompt + transcript instead, on the same + // read-only RPC the popover uses, so the readout is right the moment it's on + // screen. Gated on the gauge being shown — the bar itself is unmounted while + // toggled off, so this covers the rest. + const contextItemHidden = useStore($statusbarHiddenIds).includes('context-usage') + + const { breakdown: contextBreakdown, loading: contextBreakdownLoading } = useContextBreakdown({ + busy, + enabled: !contextItemHidden, + requestGateway, + sessionId: activeSessionId + }) - const publishContextUsage = useCallback( - (snapshot: Pick) => { - setCurrentUsage(current => ({ ...current, ...snapshot })) - }, - [] + // The breakdown wins whenever we have one, for two reasons: it reports the + // MEASURED occupancy once the backend has it (falling back to the estimate + // only before that), and it is keyed to the session it describes. The global + // `$currentUsage` is neither — a resumed session reports no context fields, + // and the store merges rather than replaces, so the PREVIOUS session's gauge + // numbers survive the switch. Mid-turn there's no breakdown by design and + // the streamed usage carries the gauge. + const gaugeUsage = useMemo( + () => + contextBreakdown + ? { + ...currentUsage, + context_max: contextBreakdown.context_max, + context_percent: contextBreakdown.context_percent, + context_used: contextBreakdown.context_used + } + : currentUsage, + [contextBreakdown, currentUsage] ) + const contextUsage = useMemo(() => usageContextLabel(gaugeUsage), [gaugeUsage]) + const contextBar = useMemo(() => contextBarLabel(gaugeUsage), [gaugeUsage]) + const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway) const gatewayMenuContent = useMemo( @@ -282,6 +312,7 @@ export function useStatusbarItems({ restarting: updateApply.stage === 'restart', sha: updateStatus?.currentSha?.slice(0, 7) ?? null, target: 'client', + updateAvailable: updateStatus?.updateAvailable, version: desktopVersion?.appVersion }) @@ -309,7 +340,8 @@ export function useStatusbarItems({ updateApply.stage, updateStatus?.behind, updateStatus?.branch, - updateStatus?.currentSha + updateStatus?.currentSha, + updateStatus?.updateAvailable ]) const backendVersionItem = useMemo(() => { @@ -532,12 +564,7 @@ export function useStatusbarItems({ menuAlign: 'end', menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0', menuContent: ( - + ), toggleLabel: copy.toggleContextUsage, variant: 'menu' @@ -570,18 +597,17 @@ export function useStatusbarItems({ ...(backendVersionItem ? [backendVersionItem] : []) ], [ - activeSessionId, approvalModeItem, backendVersionItem, busy, chatOpen, clientVersionItem, contextBar, + contextBreakdown, + contextBreakdownLoading, contextUsage, copy, - currentUsage, - publishContextUsage, - requestGateway, + gaugeUsage, sessionStartedAt, gatewayState, terminalShowing, diff --git a/apps/desktop/src/components/ui/actions-menu.tsx b/apps/desktop/src/components/ui/actions-menu.tsx index 998174621ca0d..f46cc04f999a2 100644 --- a/apps/desktop/src/components/ui/actions-menu.tsx +++ b/apps/desktop/src/components/ui/actions-menu.tsx @@ -99,7 +99,7 @@ export function renderActionItem( interface ActionsMenuProps extends Pick< React.ComponentProps, - 'align' | 'side' | 'sideOffset' + 'align' | 'side' | 'sideOffset' | 'onCloseAutoFocus' > { /** The trigger (a kebab button). Wrapped in `DropdownMenuTrigger asChild`. */ children: React.ReactNode @@ -122,6 +122,7 @@ export function ActionsMenu({ children, contentClassName, items, + onCloseAutoFocus, onOpenChange, open, side, @@ -134,6 +135,7 @@ export function ActionsMenu({ align={align} aria-label={ariaLabel} className={contentClassName} + onCloseAutoFocus={onCloseAutoFocus} side={side} sideOffset={sideOffset} > @@ -152,6 +154,7 @@ interface ActionsContextMenuProps { contentClassName?: string /** Skip the wrapper (render children bare) — e.g. nothing is actionable yet. */ disabled?: boolean + onCloseAutoFocus?: (event: Event) => void } /** @@ -163,7 +166,8 @@ export function ActionsContextMenu({ children, contentClassName, disabled, - items + items, + onCloseAutoFocus }: ActionsContextMenuProps) { if (disabled) { return <>{children} @@ -172,7 +176,7 @@ export function ActionsContextMenu({ return ( {children} - + {items(CONTEXT_KIT)} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 13e2b8dba5a8b..6d383b98ca677 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -35,6 +35,12 @@ declare global { // a spectator window (lazy resume — no agent build) for live-streaming // a running subagent's session. openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> + // Resume this session in the user's own terminal emulator (`hermes --tui + // --resume `) — the external terminal, not the in-app pane. + openSessionInTerminal: ( + sessionId: string, + opts?: { cwd?: string; profile?: string } + ) => Promise<{ ok: boolean; error?: string }> // Open a new full-chrome app window — a peer instance of the primary that // renders the complete app against the shared backend, so the user can run // multiple GUI windows at once. @@ -439,7 +445,10 @@ export interface DesktopUpdateStatus { reason?: string message?: string error?: string - behind?: number + /** Exact commits behind. null = update available, but the count is + * unknowable (shallow clone without a merge-base) — never render it as a + * literal number. */ + behind?: number | null currentSha?: string /** Backend only: the version string the backend reports for itself. */ currentVersion?: string @@ -534,6 +543,10 @@ export interface HermesConnection { // Set for pool (non-primary) backends so the renderer knows which profile a // connection belongs to. profile?: string + // True only when `profile` is a request scope on the shared primary backend. + // A pooled backend also carries `profile`, so presence alone cannot identify + // the shared-primary routing case. + sharedPrimary?: boolean windowButtonPosition: { x: number; y: number } | null } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 6e0fdf7c6d878..1ac57fb55ac48 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -113,6 +113,7 @@ export const ar = defineLocale({ updateHermes: 'تحديث Hermes', updateReadyTitle: 'التحديث جاهز', updateReadyMessage: count => `${count} تغيير جديد متاح.`, + updateReadyMessageUnknown: 'يتوفر تحديث جديد.', seeWhatsNew: 'عرض الجديد', errors: { elevenLabsNeedsKey: 'يتطلب ElevenLabs STT المفتاح ELEVENLABS_API_KEY.', @@ -602,6 +603,7 @@ export const ar = defineLocale({ cantReach: 'تعذر الوصول لخدمة التحديث', tapCheck: 'اضغط للتحقق من التحديثات.', updateReady: count => `${count} تحديث متاح`, + updateReadyUnknown: 'تحديث جديد جاهز.', lastChecked: age => `آخر تحقق ${age}`, justNowSuffix: 'الآن', automaticUpdates: 'التحديثات التلقائية', @@ -1645,6 +1647,7 @@ export const ar = defineLocale({ rename: 'إعادة تسمية', archive: 'أرشفة', newWindow: 'فتح في نافذة جديدة', + openInTerminal: 'فتح في الطرفية', copyIdFailed: 'فشل نسخ المعرف', sessionActions: 'إجراءات الجلسة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 167d7ac6b4a1c..976b1ab501251 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -128,6 +128,7 @@ export const en: Translations = { updateHermes: 'Update Hermes', updateReadyTitle: 'Update ready', updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`, + updateReadyMessageUnknown: 'A new update is available.', seeWhatsNew: "See what's new", errors: { elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.', @@ -553,6 +554,7 @@ export const en: Translations = { cantReach: "We couldn't reach the update server.", tapCheck: 'Tap "Check now" to look for updates.', updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`, + updateReadyUnknown: 'A new update is ready.', lastChecked: age => `Last checked ${age}`, justNowSuffix: ' · just now', automaticUpdates: 'Automatic updates', @@ -1975,6 +1977,7 @@ export const en: Translations = { rename: 'Rename', archive: 'Archive', newWindow: 'New window', + openInTerminal: 'Open in terminal', hideTabBar: 'Hide tab bar', openInNewTab: 'Open in new tab', openInSplit: 'Open in split', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 24e650a9bd7cc..0a327f33573a2 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -129,6 +129,7 @@ export const ja = defineLocale({ updateHermes: 'Hermes を更新', updateReadyTitle: '更新の準備ができました', updateReadyMessage: count => `${count} 件の新しい変更が利用可能です。`, + updateReadyMessageUnknown: '新しい更新が利用可能です。', seeWhatsNew: '新機能を見る', errors: { elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。', @@ -637,6 +638,7 @@ export const ja = defineLocale({ cantReach: '更新サーバーに接続できませんでした。', tapCheck: '更新を探すには「今すぐ確認」を押してください。', updateReady: count => `新しい更新の準備ができました (${count} 件の変更を含みます)。`, + updateReadyUnknown: '新しい更新の準備ができました。', lastChecked: age => `前回確認: ${age}`, justNowSuffix: ' · たった今', automaticUpdates: '自動更新', @@ -1792,6 +1794,7 @@ export const ja = defineLocale({ rename: '名前を変更', archive: 'アーカイブ', newWindow: '新しいウィンドウ', + openInTerminal: 'ターミナルで開く', copyIdFailed: 'セッション ID をコピーできませんでした', sessionActions: 'セッションアクション', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4772ef6561bbb..c314d85a9a0cb 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -170,6 +170,7 @@ export interface Translations { updateHermes: string updateReadyTitle: string updateReadyMessage: (count: number) => string + updateReadyMessageUnknown: string seeWhatsNew: string errors: { elevenLabsNeedsKey: string @@ -450,6 +451,7 @@ export interface Translations { cantReach: string tapCheck: string updateReady: (count: number) => string + updateReadyUnknown: string lastChecked: (age: string) => string justNowSuffix: string automaticUpdates: string @@ -1662,6 +1664,7 @@ export interface Translations { rename: string archive: string newWindow: string + openInTerminal: string hideTabBar: string openInNewTab: string openInSplit: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 81c30cba79f64..f13033538e4aa 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -125,6 +125,7 @@ export const zhHant = defineLocale({ updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 項新變更可用。`, + updateReadyMessageUnknown: '有新更新可用。', seeWhatsNew: '查看新增內容', errors: { elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。', @@ -624,6 +625,7 @@ export const zhHant = defineLocale({ cantReach: '無法連線到更新伺服器。', tapCheck: '點選「立即檢查」以尋找更新。', updateReady: count => `新更新已就緒(包含 ${count} 項變更)。`, + updateReadyUnknown: '新更新已就緒。', lastChecked: age => `上次檢查:${age}`, justNowSuffix: ' · 剛剛', automaticUpdates: '自動更新', @@ -1734,6 +1736,7 @@ export const zhHant = defineLocale({ rename: '重新命名', archive: '封存', newWindow: '新視窗', + openInTerminal: '在終端機中開啟', copyIdFailed: '無法複製工作階段 ID', sessionActions: '工作階段動作', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index ea1a8c4dc31f0..3a3a2baf74da3 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -125,6 +125,7 @@ export const zh: Translations = { updateHermes: '更新 Hermes', updateReadyTitle: '有可用更新', updateReadyMessage: count => `有 ${count} 项新更改可用。`, + updateReadyMessageUnknown: '有新更新可用。', seeWhatsNew: '查看更新内容', errors: { elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。', @@ -762,6 +763,7 @@ export const zh: Translations = { cantReach: '无法连接更新服务器。', tapCheck: '点击"立即检查"以查找更新。', updateReady: count => `已准备好新更新 (包含 ${count} 项更改)。`, + updateReadyUnknown: '新更新已就绪。', lastChecked: age => `上次检查:${age}`, justNowSuffix: ' · 刚刚', automaticUpdates: '自动更新', @@ -2164,6 +2166,7 @@ export const zh: Translations = { rename: '重命名', archive: '归档', newWindow: '新窗口', + openInTerminal: '在终端中打开', hideTabBar: '隐藏标签栏', openInNewTab: '在新标签页中打开', openInSplit: '在分屏中打开', diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 0f026720144e6..495479899d6d9 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -266,6 +266,13 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ surface: exec(), argumentMode: 'mixed' }, + { + name: '/loop', + description: 'Re-run a prompt on a recurring interval in this session', + aliases: ['/proactive'], + surface: exec(), + argumentMode: 'mixed' + }, { name: '/personality', description: 'Switch personality for this session', diff --git a/apps/desktop/src/lib/version-status.test.ts b/apps/desktop/src/lib/version-status.test.ts index eaa0f7d773497..4596b44f86da6 100644 --- a/apps/desktop/src/lib/version-status.test.ts +++ b/apps/desktop/src/lib/version-status.test.ts @@ -30,6 +30,18 @@ describe('resolveVersionStatus', () => { expect(status.tooltip).toContain('12 commits behind main') }) + // FAIL-BEFORE (#84591 class): a shallow install reports behind:null + + // updateAvailable. The client target ignored updateAvailable entirely, so + // the statusbar showed no update at all — and further back, the fabricated + // behind:1 rendered a frozen "(+1)" while the real distance grew to 61. + it('shows a count-free update hint when the client count is unknown', () => { + const status = client({ behind: 0, updateAvailable: true, version: '0.4.2' }) + + expect(status.label).toBe(`v0.4.2 (${copy.update})`) + expect(status.label).not.toContain('+1') + expect(status.hasUpdate).toBe(true) + }) + it('names the client as one of two versions in remote mode', () => { expect(client({ remote: true, version: '0.4.2' }).label).toBe('client v0.4.2') }) diff --git a/apps/desktop/src/lib/version-status.ts b/apps/desktop/src/lib/version-status.ts index 030464e8ecac5..0b292c9f31d94 100644 --- a/apps/desktop/src/lib/version-status.ts +++ b/apps/desktop/src/lib/version-status.ts @@ -39,7 +39,7 @@ export interface VersionStatusInput { /** Client only: short commit sha of the running build. */ sha?: null | string target: UpdateTarget - /** Backend only: an update the commit count can't express (pip installs). */ + /** An update the commit count can't express (shallow clones, pip installs). */ updateAvailable?: boolean version?: null | string } @@ -70,7 +70,12 @@ export function resolveVersionStatus({ }: VersionStatusInput): VersionStatusResult { const client = target === 'client' const busy = applying || restarting - const available = behind > 0 || (!client && !!updateAvailable) + // updateAvailable covers every "behind but uncountable" shape: shallow + // installer clones (behind === null upstream, coalesced to 0 by callers), + // SSH-official presence-only checks, and pip installs. It applies to BOTH + // targets — the client statusbar item is how a shallow desktop install + // learns it's stale at all. + const available = behind > 0 || !!updateAvailable // A client with no version still identifies itself by sha; a backend can't. const named = version ?? (client ? sha : null) ?? copy.unknown diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index d3d9ae923ef8b..7d13ada9a3762 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -24,7 +24,7 @@ import { openSession, type OpenSessionIntent } from '@/app/open-session' import { $narrowViewport } from '@/components/pane-shell/tree/store' import { onGatewayEvent } from '@/contrib/events' import { getLogs, getStatus } from '@/hermes' -import { $gateway } from '@/store/gateway' +import { $gateway, openGatewayForProfile } from '@/store/gateway' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, ensureGatewayProfile, newSessionInProfile, setShowAllProfiles } from '@/store/profile' import { $activeSessionId, $currentCwd, $currentModel, $gatewayState } from '@/store/session' @@ -96,6 +96,23 @@ export const host = { * unified all-profiles view instead of narrowing it to the target * profile's sessions — a cross-profile open from a plugin surface is a * navigation, not a scope choice; pass false to also scope the sidebar. */ + /** Pre-dial a profile's gateway socket in the background — pool-only, no + * activation, no navigation, no scope change (openGatewayForProfile; it + * already no-ops for shared-remote routes and the primary). Roster UIs + * call this after mount so the FIRST click on an agent doesn't pay the + * whole backend spawn + socket dial latency. Fire-and-forget: failures + * are swallowed — the click path re-runs its own ensure and surfaces + * errors properly. */ + warmProfile: (profile: string): void => { + const name = (profile ?? '').trim() + + if (!name || name === $activeGatewayProfile.get()) { + return + } + + void openGatewayForProfile(name).catch(() => undefined) + }, + openSession: async ( storedSessionId: string, options: { intent?: OpenSessionIntent; keepAllProfilesScope?: boolean; profile?: null | string } = {} diff --git a/apps/desktop/src/store/gateway-shared-remote.test.ts b/apps/desktop/src/store/gateway-shared-remote.test.ts index a16fe376cd082..9d1481ae07c6e 100644 --- a/apps/desktop/src/store/gateway-shared-remote.test.ts +++ b/apps/desktop/src/store/gateway-shared-remote.test.ts @@ -1,19 +1,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // The global-remote share (backend routing case 3): every profile is served -// by the PRIMARY backend over one host, and getConnection() tags the shared -// descriptor with `profile`. Dialing a second WebSocket at that descriptor +// by the PRIMARY backend over one host, and getConnection() explicitly tags +// the shared descriptor with `sharedPrimary`. Dialing a second WebSocket at it // used to fail over SSH (per-backend tunnel/ticket) and poison the active // gateway with a closed socket — "Hermes gateway is not connected" for every -// profile except the primary. These tests pin the fix: a profile routed to -// the shared primary activates the primary socket instead of dialing. +// profile except the primary. Pooled backends (own-remote override, local +// named profile) also carry `profile` for WS URL minting, so `profile` alone +// cannot identify the shared-primary route. These tests pin the fix: only a +// `sharedPrimary` descriptor activates the primary socket; a pooled descriptor +// that also carries `profile` must still dial its own socket. + +const gatewayMocks = vi.hoisted(() => ({ + connect: vi.fn(async (_wsUrl: string): Promise => { + throw new Error('dialed a socket for a shared-primary profile') + }) +})) vi.mock('@/hermes', () => ({ HermesGateway: class { connectionState = 'closed' - connect = vi.fn(async () => { - throw new Error('dialed a socket for a shared-primary profile') - }) + connect = gatewayMocks.connect onEvent = vi.fn(() => () => {}) onState = vi.fn(() => () => {}) } @@ -47,32 +54,45 @@ afterEach(() => { }) describe('ensureGatewayForProfile under a shared global remote', () => { - it('activates the primary socket for a profile tagged onto the shared descriptor', async () => { + it('activates the primary socket for an explicitly shared-primary descriptor', async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') installDesktop({ - // Shared descriptor: primary connection tagged with the profile. - getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', token: 't' })) + // Shared descriptor: primary connection tagged with the profile scope + // AND the explicit sharedPrimary marker. + getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', sharedPrimary: true, token: 't' })) }) await ensureGatewayForProfile('venture') + expect(gatewayMocks.connect).not.toHaveBeenCalled() expect($gateway.get()).toBe(primary) }) - it('still pools a socket for profiles with their own descriptor (untagged)', async () => { + it('dials the exact WebSocket URL for a pooled profile descriptor that carries profile', async () => { const primary = makePrimary() + const remoteWsUrl = 'wss://remote.invalid/api/ws?token=fake-test-token' + setPrimaryGateway(primary as never, 'default') installDesktop({ - // Own descriptor: no profile tag → normal pooled path (dial attempted). - getConnection: vi.fn(async () => ({ port: 5151, token: 't2' })) + // Pooled descriptor: carries `profile` for WS URL minting but is NOT + // shared-primary (no marker) — it must dial its own socket, not reuse + // the primary. This is the local named / own-remote profile case. + getConnection: vi.fn(async () => ({ + authMode: 'token', + baseUrl: 'https://remote.invalid', + mode: 'remote', + profile: 'worker', + token: 'fake-test-token', + wsUrl: remoteWsUrl + })) }) + gatewayMocks.connect.mockResolvedValueOnce(undefined) await ensureGatewayForProfile('worker') - // The pooled path dialed (our stub throws, so the socket stays closed and - // reconnect is scheduled) — the important part is it did NOT silently - // reuse the primary. + expect(gatewayMocks.connect).toHaveBeenCalledOnce() + expect(gatewayMocks.connect).toHaveBeenCalledWith(remoteWsUrl) expect($gateway.get()).not.toBe(primary) }) }) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 0a64b3da1f239..6c815a78834fa 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -248,12 +248,14 @@ function createSecondary(profile: string): Secondary { } // True when `profile`'s backend route resolves to the SHARED primary backend -// (global-remote case 3 in resolveProfileBackendRoute): the descriptor comes -// back as the primary connection tagged with `profile`. Own-remote-override -// and local pooled descriptors are never tagged. Dialing a second socket at -// that descriptor is wrong — over SSH the second dial fails (tunnel/token are -// per-backend) and the closed socket poisons the active gateway with -// "not connected" even though the primary is open right next to it. +// (global-remote case 3 in resolveProfileBackendRoute). Both shared-primary and +// pooled descriptors carry `profile` so WebSocket URL minting targets the right +// profile. `sharedPrimary` is the explicit discriminator; treating every tagged +// descriptor as shared strands local/own-remote pooled profiles on the default +// socket. Dialing a second socket at the shared descriptor is wrong — over SSH +// the second dial fails (tunnel/token are per-backend) and the closed socket +// poisons the active gateway with "not connected" even though the primary is +// open right next to it. async function sharedPrimaryRoute(profile: string): Promise { const desktop = window.hermesDesktop @@ -264,7 +266,7 @@ async function sharedPrimaryRoute(profile: string): Promise { try { const conn = await desktop.getConnection(profile) - return Boolean(conn && typeof conn === 'object' && (conn as { profile?: string }).profile) + return Boolean(conn && typeof conn === 'object' && (conn as { sharedPrimary?: boolean }).sharedPrimary === true) } catch { return false } diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 7507a2f53c5e1..55a9391a1a696 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -123,6 +123,15 @@ describe('maybeNotifyUpdateAvailable', () => { maybeNotifyUpdateAvailable(status({ behind: 0 })) expect(notifySpy).not.toHaveBeenCalled() }) + + // FAIL-BEFORE: a shallow installer clone reports behind:null + updateAvailable + // (exact count unknowable without a merge-base). The guard treated null as 0 + // and silently swallowed the notification entirely. + it('still notifies with generic copy when the exact behind count is unknown', () => { + maybeNotifyUpdateAvailable(status({ behind: null, updateAvailable: true })) + expect(notifySpy).toHaveBeenCalledTimes(1) + expect(notifySpy.mock.calls[0]?.[0]).toMatchObject({ message: 'A new update is available.' }) + }) }) describe('reportBackendContract', () => { diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 19739a977ecd6..3d234e0d4fb46 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -206,7 +206,11 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { return } - if ((status.behind ?? 0) <= 0) { + const behind = typeof status.behind === 'number' ? status.behind : null + + // behind === null means "update available, exact count unknown" (shallow + // clone). That still deserves the toast — just with count-free copy. + if ((behind ?? 0) <= 0 && !status.updateAvailable) { return } @@ -218,8 +222,6 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { return } - const behind = status.behind ?? 0 - notify({ action: { label: translateNow('notifications.seeWhatsNew'), @@ -232,7 +234,10 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { icon: 'gift', id: UPDATE_TOAST_ID, kind: 'info', - message: translateNow('notifications.updateReadyMessage', behind), + message: + behind !== null && behind > 0 + ? translateNow('notifications.updateReadyMessage', behind) + : translateNow('notifications.updateReadyMessageUnknown'), onDismiss: () => snoozeUpdateToast(), title: translateNow('notifications.updateReadyTitle') }) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 7f23c1cdea7cb..3549ce4186d97 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -110,6 +110,13 @@ export function canOpenNewWindow(): boolean { return typeof window !== 'undefined' && typeof window.hermesDesktop?.openWindow === 'function' } +// True when the shell can hand a session to the user's own terminal emulator. +// Desktop-only, and a REMOTE connection is excluded by the caller: the terminal +// we'd open is on this machine, but the session lives on the remote host. +export function canOpenSessionInTerminal(): boolean { + return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionInTerminal === 'function' +} + type WindowOpenResult = { ok: boolean; error?: string } | undefined // Run a window-open bridge call, surfacing any failure as a toast. Shared by the @@ -149,3 +156,20 @@ export async function openNewWindow(): Promise { await runWindowOpen(() => window.hermesDesktop.openWindow(), 'Could not open a new window') } + +// Resume a session in the user's own terminal emulator, running the TUI there. +// `cwd` starts the shell in the session's workspace; `profile` pins the runtime +// to the profile that owns the session. No-ops gracefully outside Electron. +export async function openSessionInTerminal( + sessionId: string, + opts?: { cwd?: string; profile?: string } +): Promise { + if (!sessionId || !canOpenSessionInTerminal()) { + return + } + + await runWindowOpen( + () => window.hermesDesktop.openSessionInTerminal(sessionId, opts), + 'Could not open chat in a terminal' + ) +} diff --git a/cli-config.yaml.example b/cli-config.yaml.example index ade82e57b9f33..8e6d007cfeab8 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -166,6 +166,26 @@ model: # gpt-5.4: # stale_timeout_seconds: 1800 # Longer non-stream stale timeout for slow large-context turns +# ============================================================================= +# Unified Timeouts (operation deadlines) +# ============================================================================= +# One place to override Hermes's internal operation deadlines (seconds). +# Keys are dotted paths resolved by agent/deadline.py:resolve_timeout(). +# Precedence: this section > legacy HERMES_* env var (back-compat) > built-in +# default. 0 or a negative value disables the bound (unbounded); very large +# values are clamped to a platform-safe maximum automatically. +# +# Currently resolved keys (more paths migrate here over time — see issue #85125): +# +# timeouts: +# tools: +# concurrent_batch: 420 # Deadline for a parallel tool-call batch +# # (legacy env: HERMES_CONCURRENT_TOOL_TIMEOUT_S) +# sequential_call: 420 # Deadline for one sequentially-executed tool call. +# # Defaults to concurrent_batch's value so the two +# # executor paths stay in sync; human waits +# # (approval prompts, clarify) never count against it. + # ============================================================================= # OpenRouter Provider Routing (only applies when using OpenRouter) # ============================================================================= @@ -1362,7 +1382,7 @@ code_execution: # The delegate_task tool spawns child agents with isolated context. # Supports single tasks and batch mode (default 3 parallel, configurable). delegation: - max_iterations: 50 # Max tool-calling turns per child (default: 50) + max_iterations: 250 # Max tool-calling turns per child (default: 250) # max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling). # WARNING: values above 10 multiply API cost linearly. # max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat). diff --git a/cli.py b/cli.py index a64c54af31a1a..8fe7720d2b4c3 100644 --- a/cli.py +++ b/cli.py @@ -501,6 +501,12 @@ def load_cli_config() -> Dict[str, Any]: "busy_input_mode": "interrupt", "persistent_output": True, "persistent_output_max_lines": 200, + # Clear terminal scrollback as well as the visible viewport when the + # classic CLI performs a full redraw/resize recovery. Disabled by + # default because some users prefer preserving terminal history; + # enable when a terminal/tmux stack stamps stale prompt chrome into + # scrollback during fullscreen/restore resizes. + "cli_rebuild_scrollback_on_redraw": False, # Print a one-line summary of resolved modal prompts (approval / # clarify) into scrollback so the decision survives the repaint. "persist_prompts": True, @@ -3608,6 +3614,58 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str: return strip_leaked_bracketed_paste_wrappers(text) +def _hermes_call_output_screen_diff( + orig_osd, + app, + output, + screen, + current_pos, + color_depth, + previous_screen, + last_style, + is_done, + full_screen, + attrs_for_style_string, + style_string_has_style, + size, + previous_width, +): + """Call prompt_toolkit ``_output_screen_diff`` with Hermes resize guards. + + 1. Inflate ``previous_screen.height`` when the new screen is taller so pt + skips the reserve-vertical-space cursor move that stamps chrome into + scrollback (pt #29 / Hermes #26137). + 2. On AttributeError/TypeError from a corrupt previous paint buffer + (classic after tmux attach with same width), retry once with + ``previous_screen=None`` so pt first-paints cleanly instead of crashing + the event loop with ``'cell' object has no attribute 'char'``. + """ + try: + if previous_screen is not None and hasattr(previous_screen, "height"): + if previous_screen.height < screen.height: + previous_screen.height = screen.height + except Exception: + pass + + try: + return orig_osd( + app, output, screen, current_pos, color_depth, + previous_screen, last_style, is_done, full_screen, + attrs_for_style_string, style_string_has_style, + size, previous_width, + ) + except (AttributeError, TypeError): + # Corrupt previous_screen / row cells after client reattach. + return orig_osd( + app, output, screen, current_pos, color_depth, + None, # previous_screen → first-paint erase path + None, # last_style + is_done, full_screen, + attrs_for_style_string, style_string_has_style, + size, 0, # previous_width → treat as changed + ) + + def _apply_bracketed_paste_timeout_patch() -> None: """Patch prompt_toolkit to recover from torn bracketed-paste sequences. @@ -3954,6 +4012,26 @@ def _estimate_tui_input_height( return min(max(visual_lines, 1), max(1, int(max_height or 1))) +def _status_bar_visible_from_display_config(display_config: object) -> bool: + """Return the initial classic-CLI status-bar visibility from display config. + + ``display.tui_statusbar`` is the persisted user-facing setting toggled by + the TUI/statusbar controls. YAML parses bare ``off`` as ``False``, while + older config snapshots or hand edits may use strings such as ``"off"`` or + ``"hidden"``. Treat those values consistently so a new CLI process does not + re-enable a status bar that the user deliberately disabled. + """ + if not isinstance(display_config, dict): + display_config = {} + statusbar_config = display_config.get( + "statusbar", + display_config.get("tui_statusbar", "top"), + ) + if isinstance(statusbar_config, str): + return statusbar_config.strip().lower() not in {"0", "false", "hidden", "no", "off"} + return statusbar_config is not False + + def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]: """Collect local image attachments for single-query CLI flows.""" message = query or "" @@ -4865,7 +4943,9 @@ def __init__( self._voice_barge_phase = None # "generation" or "playback" phase of the last barge trip # Status bar visibility (toggled via /statusbar) - self._status_bar_visible = True + self._status_bar_visible = _status_bar_visible_from_display_config( + CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else None + ) # Battery read-out in the status bar (toggled via /battery, off by # default). Persisted to display.battery so it survives restarts. self._battery_visible = bool(CLI_CONFIG["display"].get("battery", False)) @@ -4991,13 +5071,60 @@ def _force_full_redraw(self) -> None: app = getattr(self, "_app", None) if not app: return - self._clear_prompt_toolkit_screen(app) + self._clear_prompt_toolkit_screen( + app, + rebuild_scrollback=self._redraw_rebuilds_scrollback(), + ) _replay_output_history() try: app.invalidate() except Exception: pass + def _schedule_focus_regain_redraw(self, min_interval: float = 1.0) -> None: + """Repaint after a terminal focus-in report (``CSI I``), rate-limited. + + Terminals with focus tracking active (Ghostty, iTerm2, xterm builds, + multiplexers that toggle DECSET 1004 upstream) emit ``\\x1b[I`` when + the Hermes tab/window becomes visible again. Emulators can coalesce + or drop hidden-tab output and repaint the surface while we're + invisible, so on regain prompt_toolkit's incremental diff stacks on + stale content — a second copy of the composer/prompt chrome next to + the ghost of the old one (#60920 focus-regain variant, #25337). + + The stock handling maps ``CSI I``/``CSI O`` to ``Keys.Ignore`` so the + bytes never pollute the input buffer; this hook additionally routes + focus-in through the same recovery as Ctrl+L / ``/redraw``. It is + self-gating: terminals that never enable focus tracking never emit + the sequence, so nothing changes for them. Rate-limited so a burst of + focus reports (rapid Alt+Tab, mux pane hops) repaints at most once + per ``min_interval`` seconds. + """ + now = time.monotonic() + last = getattr(self, "_last_focus_regain_redraw", 0.0) + if now - last < min_interval: + return + self._last_focus_regain_redraw = now + self._force_full_redraw() + + @staticmethod + def _redraw_rebuilds_scrollback() -> bool: + """Return whether CLI redraw/resize recovery should clear scrollback. + + Some terminal/tmux stacks move prompt_toolkit's non-fullscreen bottom + chrome into scrollback when the window is maximized/restored. A normal + CSI 2J viewport clear cannot remove those stale prompt/input-rule rows, + so users who hit that class of bug need CSI 3J as well, followed by the + existing bounded output-history replay. + """ + display_config = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else {} + if not isinstance(display_config, dict): + display_config = {} + raw = display_config.get("cli_rebuild_scrollback_on_redraw", False) + if isinstance(raw, str): + return raw.strip().lower() in {"1", "true", "yes", "on", "always"} + return bool(raw) + def _recover_terminal_after_interrupt(self) -> None: """Recover the terminal after an interrupted agent turn (#33271). @@ -5022,6 +5149,13 @@ def _recover_terminal_after_interrupt(self) -> None: flush_stdin() except Exception: pass + # #60920: The interruption marker is now printed with + # _suspend_output_history in chat(), so _OUTPUT_HISTORY only + # contains the normal response text (no marker text). Do NOT + # clear history here — _force_full_redraw → _replay_output_history + # replays the response correctly without duplicating the marker. + # The /redraw + Ctrl+L paths also preserve replay for scrollback + # recovery as intended. self._force_full_redraw() def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) -> None: @@ -5106,20 +5240,40 @@ def _recover_after_resize(self, app, original_on_resize) -> None: # reach, leaving a duplicated status bar stranded above the live origin. # Ctrl+L / /redraw clears it cleanly, so route the resize path through # the SAME recovery: wipe the visible viewport (banner-safe — CSI 2J - # only, never CSI 3J) and replay the transcript so nothing is lost. - # Row-count-only changes skip this (no reflow → no ghost) to avoid an - # unnecessary full repaint. + # by default; CSI 3J only when display.cli_rebuild_scrollback_on_redraw + # is enabled) and replay the transcript so nothing is lost. + # Same-width SIGWINCH (tmux attach, benign focus/tab signals) is left + # untouched — no clear, no replay — because a 2J without replay erases + # the visible transcript and a replay against preserved scrollback + # duplicates it (#65293). The stale-previous_screen crash tmux attach + # used to trigger is handled by _hermes_call_output_screen_diff's + # retry-with-first-paint instead (#83874). try: new_width = self._get_tui_terminal_width() except Exception: new_width = None prev_width = getattr(self, "_last_resize_width", None) - # First resize of the session has no prior width to compare against; - # treat it as a change so an initial maximize/restore is covered too. - width_changed = new_width is not None and new_width != prev_width + # Replay only on an OBSERVED width change. The first signal of a + # session must not count as one (#65293): GNOME Terminal and friends + # deliver benign SIGWINCHes (tab bar appearing, monitor-scale change, + # focus events), and a 2J+replay against preserved scrollback + # duplicates everything ``_OUTPUT_HISTORY`` holds — after a resume + # that is the entire "Previous Conversation" recap plus the first + # live exchange. ``_install_resize_recovery`` seeds the baseline at + # startup, so an initial maximize/restore still differs from it and + # is still recovered; with no baseline (width probe failed) this + # signal just records one for the next comparison. + width_changed = ( + new_width is not None + and prev_width is not None + and new_width != prev_width + ) if width_changed: try: - self._clear_prompt_toolkit_screen(app, rebuild_scrollback=False) + self._clear_prompt_toolkit_screen( + app, + rebuild_scrollback=self._redraw_rebuilds_scrollback(), + ) _replay_output_history() except Exception: pass @@ -5215,6 +5369,45 @@ def _run_recovery(): self._resize_recovery_pending = False self._recover_after_resize(app, original_on_resize) + def _install_resize_recovery(self, app) -> None: + """Route prompt_toolkit's ``_on_resize`` through the debounced + ghost-clearing recovery (#5474/#49120) and record the current terminal + width as the baseline for width-change detection. + + Seeding the baseline here is what keeps the session's FIRST SIGWINCH + honest (#65293): ``_recover_after_resize`` replays the transcript only + on an observed width change, and without a startup baseline it could + not tell a benign signal (GNOME Terminal tab bar, monitor-scale + change) from a real one. An initial maximize/restore still differs + from the seeded width, so it is still recovered. + + The probe reads ``app.output`` directly — NOT + ``_get_tui_terminal_width`` — because this runs before ``app.run()``, + when ``get_app()`` still returns prompt_toolkit's DummyApplication + whose DummyOutput reports a hardcoded 80 columns; seeding that fake + width would make the first real signal look like a width change and + resurrect the duplicate-replay bug this exists to fix. + ``app.output`` is the same object the running app's resize handler + measures, so install-time and signal-time widths are comparable. + """ + width = None + try: + width = app.output.get_size().columns + except Exception: + width = None + if not width or width <= 0: + try: + width = shutil.get_terminal_size((80, 24)).columns + except Exception: + width = None + self._last_resize_width = width + original_on_resize = app._on_resize + + def _resize_clear_ghosts(): + self._schedule_resize_recovery(app, original_on_resize) + + app._on_resize = _resize_clear_ghosts + def _status_bar_context_style(self, percent_used: Optional[int]) -> str: if percent_used is None: return "class:status-bar-dim" @@ -10973,6 +11166,8 @@ def process_command(self, command: str) -> bool: self._handle_heartbeat_command(cmd_original) elif canonical == "refine": self._handle_refine_command(cmd_original) + elif canonical == "loop": + self._handle_loop_command(cmd_original) elif canonical == "moa": # /moa is one-shot sugar only: run a single prompt through the # default MoA preset, then restore the prior model. To *switch* to a @@ -11317,6 +11512,149 @@ def _loop(): threading.Thread(target=_loop, daemon=True, name="heartbeat-watchdog").start() + # ──────────────────────────────────────────────────────────────── + # /loop — recurring in-session wakeups (Claude Code /loop parity) + # ──────────────────────────────────────────────────────────────── + def _get_loop_manager(self): + """Return the LoopManager bound to the current session_id. + + Cached on ``self._loop_manager`` and rebound lazily when + ``session_id`` changes (mirrors ``_get_goal_manager``). + """ + try: + from hermes_cli.loops import LoopManager + except Exception as exc: + logging.debug("loop manager unavailable: %s", exc) + return None + + sid = getattr(self, "session_id", None) or "" + if not sid: + return None + + existing = getattr(self, "_loop_manager", None) + if existing is not None and getattr(existing, "session_id", None) == sid: + return existing + + mgr = LoopManager(session_id=sid) + self._loop_manager = mgr + return mgr + + def _maybe_fire_loop_tick(self) -> None: + """Idle hook run from process_loop: fire a due /loop wakeup. + + Only runs while the agent is idle and nothing is queued — a real + user message always wins the idle boundary. An active (non-parked) + /goal also wins: its judge-driven continuations own the idle + boundary, so the loop defers to the next poll. + """ + mgr = self._get_loop_manager() + if mgr is None or not mgr.is_due(): + return + # The idle poll runs at ~10 Hz; once a tick is due but deferred + # (queued input / active goal), every poll would otherwise hit the + # DB via goal_blocks_loop_tick. Throttle the deferred re-check. + now = time.time() + if now - getattr(self, "_last_loop_tick_check", 0.0) < 2.0: + return + self._last_loop_tick_check = now + # Real user input (or anything else queued) takes priority; the + # loop stays due and fires at the next idle poll. + try: + if not self._pending_input.empty(): + return + except Exception: + return + try: + from hermes_cli.loops import goal_blocks_loop_tick + + if goal_blocks_loop_tick(mgr.session_id): + return + except Exception: + pass + + wakeup = mgr.fire_tick() + if not wakeup: + return + try: + state = mgr.state + tick_no = state.ticks_fired if state else "?" + _cprint(f" {_DIM}↻ /loop wakeup #{tick_no} firing…{_RST}") + self._pending_input.put(wakeup) + except Exception as exc: + logging.debug("loop tick injection failed: %s", exc) + try: + mgr.abandon_tick() + except Exception: + pass + return + # A slash-command loop (e.g. `/loop 10m /recap`) is dispatched via + # process_command, which never reaches the post-turn chat() finally + # block — so the tick would never complete and the loop would wedge + # on awaiting_response. Slash ticks have no model reply to evaluate; + # complete them immediately (caps and scheduling still apply). + if wakeup.lstrip().startswith("/"): + try: + decision = mgr.complete_tick("") + msg = decision.get("message") or "" + if msg: + _cprint(f" {msg}") + except Exception: + pass + + def _maybe_complete_loop_tick_after_turn(self) -> None: + """Post-turn hook: evaluate a finished /loop wakeup turn. + + No-op unless the turn that just ended was a loop wakeup + (``awaiting_response`` set by ``fire_tick``). Detects the + LOOP_COMPLETE marker, judges --until, applies caps, and schedules + the next tick. Mirrors _maybe_continue_goal_after_turn's shape. + """ + mgr = self._get_loop_manager() + if mgr is None: + return + state = mgr.state + if state is None or not state.awaiting_response: + return + + # A user-interrupted wakeup turn pauses the loop (recoverable via + # /loop resume) — same contract as the goal loop's Ctrl+C handling. + if getattr(self, "_last_turn_interrupted", False): + try: + mgr.pause(reason="user-interrupted (Ctrl+C)") + except Exception: + pass + _cprint( + f" {_DIM}⏸ Loop paused — wakeup turn was interrupted. " + f"Use /loop resume to continue, or /loop stop to end it.{_RST}" + ) + return + + last_response = "" + try: + hist = self.conversation_history or [] + for msg in reversed(hist): + if msg.get("role") == "assistant": + content = msg.get("content", "") + if isinstance(content, list): + parts = [ + p.get("text", "") + for p in content + if isinstance(p, dict) and p.get("type") in {"text", "output_text"} + ] + last_response = "\n".join(t for t in parts if t) + else: + last_response = str(content or "") + break + except Exception: + last_response = "" + + decision = mgr.complete_tick(last_response) + msg = decision.get("message") or "" + if msg: + _cprint(f" {msg}") + elif decision.get("status") == "active" and mgr.state is not None: + _cprint(f" {_DIM}↻ Loop: {mgr.state.remaining_label()}.{_RST}") + def _owns_process_notification(self, event: dict) -> bool: @@ -14980,15 +15318,19 @@ def run_agent(): # Handle interrupt - check if we were interrupted pending_message = None + _show_interrupt_marker = False _interrupted_this_turn = bool(result and result.get("interrupted")) # Expose the flag for post-turn hooks (e.g. goal continuation) # so they can skip themselves when the turn was user-cancelled. self._last_turn_interrupted = _interrupted_this_turn if _interrupted_this_turn: pending_message = result.get("interrupt_message") or interrupt_msg - # Add indicator that we were interrupted - if response and pending_message: - response = response + "\n\n---\n_[Interrupted - processing new message]_" + # #60920: Don't append the interruption marker to response so it + # is never recorded in _OUTPUT_HISTORY by the Panel rendering + # below. The marker is printed separately with _suspend_output_history + # after the response Panel to preserve the visual while avoiding + # duplicates on terminal redraw (_recover_terminal_after_interrupt). + _show_interrupt_marker = bool(response and pending_message) elif interrupt_msg: # We fired agent.interrupt(interrupt_msg) but the turn result # doesn't acknowledge it. Two ways this happens, both racy: @@ -15119,6 +15461,16 @@ def run_agent(): except Exception: pass + # #60920: Print interruption marker with history suppressed so it + # is never recorded in _OUTPUT_HISTORY. The marker was previously + # appended to `response` which caused a duplicate on terminal redraw + # when _replay_output_history replayed it. Printing it here with + # _suspend_output_history preserves the user-visible indicator while + # keeping _OUTPUT_HISTORY clean for replay. + if _show_interrupt_marker: + with _suspend_output_history(): + _cprint(f"\n{_DIM}── [Interrupted — processing new message] ──{_RST}") + # Focus view: dim recovery line reporting what was hidden this turn # (and how to reveal it). Printed after the response so the turn @@ -15919,7 +16271,20 @@ def handle_ignored_terminal_sequence(event): VT100 parser level. Without this no-op binding the default self-insert path would still fire and the bytes would land in the buffer. + + Focus-in (CSI I) additionally schedules a rate-limited full + repaint: while the tab/window was hidden the emulator may have + coalesced output or repainted the surface, so prompt_toolkit's + incremental diff would stack a fresh copy of the prompt chrome + on top of the stale one (#60920 focus-regain variant, #25337). """ + try: + for press in getattr(event, "key_sequence", None) or (): + if getattr(press, "data", None) == "\x1b[I": + self._schedule_focus_regain_redraw() + break + except Exception: + pass return None def handle_enter(event): @@ -17986,20 +18351,20 @@ def _patched_output_screen_diff( other code path's behavior. Critical: do NOT replace a None previous_screen with - a fresh Screen() — that would skip the proper - reset_attributes()+erase_down() at L178-185 which - fires when previous_screen is None (first-paint / + a fresh Screen() on the happy path — that would skip + the proper reset_attributes()+erase_down() at L178-185 + which fires when previous_screen is None (first-paint / width-change). Without that reset, ANSI styles leak between renders. - """ - try: - if previous_screen is not None and hasattr(previous_screen, "height"): - if previous_screen.height < screen.height: - previous_screen.height = screen.height - except Exception: - pass - return _orig_osd( + Safety net: if the diff crashes with AttributeError / + TypeError (corrupt previous_screen after tmux attach — + "'cell' object has no attribute 'char'"), retry once + with previous_screen=None so pt takes the first-paint + erase path instead of wedging the event loop. + """ + return _hermes_call_output_screen_diff( + _orig_osd, app, output, screen, current_pos, color_depth, previous_screen, last_style, is_done, full_screen, attrs_for_style_string, style_string_has_style, @@ -18015,12 +18380,7 @@ def _patched_output_screen_diff( # don't permanently freeze the input (issue #16263). Idempotent. _apply_bracketed_paste_timeout_patch() - _original_on_resize = app._on_resize - - def _resize_clear_ghosts(): - self._schedule_resize_recovery(app, _original_on_resize) - - app._on_resize = _resize_clear_ghosts + self._install_resize_recovery(app) def spinner_loop(): while not self._should_exit: @@ -18066,6 +18426,12 @@ def process_loop(): self._drain_process_notifications("cli-idle") except Exception: pass + # Fire a due /loop wakeup while idle (defers to + # queued user input and active /goal loops). + try: + self._maybe_fire_loop_tick() + except Exception: + pass continue # Voice-transcribed messages arrive wrapped in a sentinel @@ -18236,6 +18602,14 @@ def process_loop(): except Exception as _goal_exc: logging.debug("goal continuation hook failed: %s", _goal_exc) + # /loop tick completion: if the turn that just ended + # was a loop wakeup, evaluate it (LOOP_COMPLETE marker, + # --until judge, caps) and schedule the next tick. + try: + self._maybe_complete_loop_tick_after_turn() + except Exception as _loop_exc: + logging.debug("loop completion hook failed: %s", _loop_exc) + # Continuous voice: auto-restart recording after agent responds. # Dispatch to a daemon thread so play_beep (sd.wait) and # AudioRecorder.start (lock acquire) never block process_loop — diff --git a/contributors/emails/akulayash1996@gmail.com b/contributors/emails/akulayash1996@gmail.com new file mode 100644 index 0000000000000..0f43a8fe19838 --- /dev/null +++ b/contributors/emails/akulayash1996@gmail.com @@ -0,0 +1,2 @@ +buffpesos +# PR #75808 diff --git a/contributors/emails/andrew.lg.ford@gmail.com b/contributors/emails/andrew.lg.ford@gmail.com new file mode 100644 index 0000000000000..4b2ceb5bd5a1f --- /dev/null +++ b/contributors/emails/andrew.lg.ford@gmail.com @@ -0,0 +1 @@ +algf diff --git a/contributors/emails/angeon922@gmail.com b/contributors/emails/angeon922@gmail.com new file mode 100644 index 0000000000000..cfbdc44ae82c3 --- /dev/null +++ b/contributors/emails/angeon922@gmail.com @@ -0,0 +1 @@ +angeon922-collab diff --git a/contributors/emails/benjamin-liang@outlook.com b/contributors/emails/benjamin-liang@outlook.com new file mode 100644 index 0000000000000..873934a19a8d3 --- /dev/null +++ b/contributors/emails/benjamin-liang@outlook.com @@ -0,0 +1 @@ +n1majne3 diff --git a/contributors/emails/brian@bsweatt.com b/contributors/emails/brian@bsweatt.com new file mode 100644 index 0000000000000..8a34b4cb3d816 --- /dev/null +++ b/contributors/emails/brian@bsweatt.com @@ -0,0 +1 @@ +brian717 diff --git a/contributors/emails/devops@sycamore.group b/contributors/emails/devops@sycamore.group new file mode 100644 index 0000000000000..71435945d933b --- /dev/null +++ b/contributors/emails/devops@sycamore.group @@ -0,0 +1 @@ +sycamoregroupltd \ No newline at end of file diff --git a/contributors/emails/dustin.persek@protonmail.com b/contributors/emails/dustin.persek@protonmail.com new file mode 100644 index 0000000000000..cac1c523a36ec --- /dev/null +++ b/contributors/emails/dustin.persek@protonmail.com @@ -0,0 +1 @@ +dpersek diff --git a/contributors/emails/gkd2323c@users.noreply.github.com b/contributors/emails/gkd2323c@users.noreply.github.com new file mode 100644 index 0000000000000..27b127b35b7db --- /dev/null +++ b/contributors/emails/gkd2323c@users.noreply.github.com @@ -0,0 +1 @@ +gkd2323c diff --git a/contributors/emails/halaprix@users.noreply.github.com b/contributors/emails/halaprix@users.noreply.github.com new file mode 100644 index 0000000000000..56ca8f388b767 --- /dev/null +++ b/contributors/emails/halaprix@users.noreply.github.com @@ -0,0 +1 @@ +halaprix diff --git a/contributors/emails/hunter@mail.com b/contributors/emails/hunter@mail.com new file mode 100644 index 0000000000000..3d15751a8ccf9 --- /dev/null +++ b/contributors/emails/hunter@mail.com @@ -0,0 +1 @@ +zoidypuh diff --git a/contributors/emails/justin@actual.inc b/contributors/emails/justin@actual.inc new file mode 100644 index 0000000000000..d09d2f84ccc27 --- /dev/null +++ b/contributors/emails/justin@actual.inc @@ -0,0 +1,2 @@ +somewheresy +# PR #83554 (Actual authenticated model discovery) diff --git a/contributors/emails/laura@localhost b/contributors/emails/laura@localhost new file mode 100644 index 0000000000000..bd1da23c79cf7 --- /dev/null +++ b/contributors/emails/laura@localhost @@ -0,0 +1 @@ +infocentr diff --git a/contributors/emails/metamind@kakao.com b/contributors/emails/metamind@kakao.com new file mode 100644 index 0000000000000..2992c9be00c53 --- /dev/null +++ b/contributors/emails/metamind@kakao.com @@ -0,0 +1 @@ +metamindedu diff --git a/contributors/emails/qlyf@QLYFdeMacBook-Air.local b/contributors/emails/qlyf@QLYFdeMacBook-Air.local new file mode 100644 index 0000000000000..2cc2603d51251 --- /dev/null +++ b/contributors/emails/qlyf@QLYFdeMacBook-Air.local @@ -0,0 +1 @@ +allin2 diff --git a/contributors/emails/sascha.haase@textiletsg.com b/contributors/emails/sascha.haase@textiletsg.com new file mode 100644 index 0000000000000..7a8297408a5f4 --- /dev/null +++ b/contributors/emails/sascha.haase@textiletsg.com @@ -0,0 +1,2 @@ +shaase-ctrl +# PR #85679 salvage (Windows desktop update shim self-lock) diff --git a/contributors/emails/seashore.shi@gmail.com b/contributors/emails/seashore.shi@gmail.com new file mode 100644 index 0000000000000..c88390cb31c2e --- /dev/null +++ b/contributors/emails/seashore.shi@gmail.com @@ -0,0 +1 @@ +SeashoreShi \ No newline at end of file diff --git a/contributors/emails/shiqiming.sqm@taobao.com b/contributors/emails/shiqiming.sqm@taobao.com new file mode 100644 index 0000000000000..b6b1a4461cca7 --- /dev/null +++ b/contributors/emails/shiqiming.sqm@taobao.com @@ -0,0 +1 @@ +KhanCold diff --git a/contributors/emails/venkatbalaji2004@gmail.com b/contributors/emails/venkatbalaji2004@gmail.com new file mode 100644 index 0000000000000..694287245e7b8 --- /dev/null +++ b/contributors/emails/venkatbalaji2004@gmail.com @@ -0,0 +1 @@ +VBS2004 diff --git a/contributors/emails/wangs.coder@gmail.com b/contributors/emails/wangs.coder@gmail.com new file mode 100644 index 0000000000000..4e1784e5ad45c --- /dev/null +++ b/contributors/emails/wangs.coder@gmail.com @@ -0,0 +1 @@ +wangs1203 diff --git a/cron/scheduler.py b/cron/scheduler.py index 4eae63a36394d..79b5210cc1aa8 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -473,6 +473,40 @@ def _is_cron_silence_response(text: str) -> bool: _running_job_ids: set = set() _running_lock = threading.Lock() +# Wall-clock (time.time()) instant each in-flight job id was claimed by +# ``_submit_with_guard``, plus the future that owns its release (a pending +# sentinel until ``pool.submit`` returns). Together these bound the +# in-flight set: an id whose claim is older than its allowance AND has no +# live future can only be a leak — the release path never ran — so the +# stale-sweep force-releases it instead of letting every later tick +# short-circuit on "already running" until the whole gateway process +# restarts (incident: jarvis board-pm-triage-* jobs, 2026-08-02; recurring +# router/watchdog no_agent jobs, 2026-08-14 t_20e23f84). +_running_since: dict = {} +_running_futures: dict = {} + +# Sentinel installed in ``_running_futures`` at claim time, before +# ``pool.submit`` has returned a real future. This closes the race the +# stale sweep previously had: a sweep landing between the claim critical +# section and the future-record section saw ``missing`` and could (in +# principle) release a claim that was about to get its future. With the +# sentinel there is never a window where a claim has neither an age nor a +# future marker — it is ``_FUTURE_PENDING`` until the real future lands. +_FUTURE_PENDING = object() + +# Countable signal for unified-health: how many stale claims this process has +# force-released, and the most recent ones. Exposed via +# ``get_inflight_guard_stats()`` and mirrored to a JSONL under the cron dir so +# an out-of-process probe can catch a wedge in-cycle. +_forced_release_count: int = 0 +_forced_releases: list = [] +_FORCED_RELEASE_HISTORY = 20 + +# Floor for the stale allowance, in minutes. Effective allowance per job is +# max(2 * interval, this) so a slow-but-healthy hourly job is never clipped. +_INFLIGHT_MIN_ALLOWANCE_MINUTES = 30.0 + + # Job IDs the gateway shutdown path force-killed the tool subprocess of # while still in ``_running_job_ids`` (see ``mark_running_jobs_interrupted`` # below). ``run_one_job``'s own completion path checks this set before @@ -522,6 +556,13 @@ def try_register_running_job(job_id: str) -> bool: if job_id in _running_job_ids: return False _running_job_ids.add(job_id) + # Claim timestamp + pending-future sentinel are recorded in the SAME + # critical section as the add, so there is never a window where an + # id is in-flight without an age the stale sweep can bound it by + # (t_3778a491). The sentinel is replaced by the real owning future + # once ``pool.submit`` returns. + _running_since[job_id] = time.time() + _running_futures[job_id] = _FUTURE_PENDING return True @@ -529,6 +570,263 @@ def release_running_job(job_id: str) -> None: """Remove ``job_id`` from the in-flight running set (idempotent).""" with _running_lock: _running_job_ids.discard(job_id) + _running_since.pop(job_id, None) + _running_futures.pop(job_id, None) + + +def _inflight_min_allowance_minutes() -> float: + """Floor for the stale in-flight allowance, in minutes. + + Effective allowance per job is ``max(2 * interval, this)``, so a + slow-but-healthy long-interval job is never clipped by the sweep. + Reads ``cron.inflight_max_minutes`` from config.yaml; the + ``HERMES_CRON_INFLIGHT_MAX_MINUTES`` env var is kept as an internal + escape hatch only. + """ + try: + _ucfg = load_config() or {} + _cfg_val = ( + _ucfg.get("cron", {}) if isinstance(_ucfg, dict) else {} + ).get("inflight_max_minutes") + if _cfg_val is not None: + val = float(_cfg_val) + if val > 0: + return val + except Exception: + pass + raw = os.getenv("HERMES_CRON_INFLIGHT_MAX_MINUTES", "").strip() + if raw: + try: + val = float(raw) + if val > 0: + return val + except (ValueError, TypeError): + logger.warning( + "Invalid HERMES_CRON_INFLIGHT_MAX_MINUTES=%r; using default %s", + raw, + _INFLIGHT_MIN_ALLOWANCE_MINUTES, + ) + return _INFLIGHT_MIN_ALLOWANCE_MINUTES + + +# Cache for cron expression interval computation (expression → minutes). +# A cron expression's cadence never changes, so computing it once per expr +# avoids repeated croniter evaluation on every 60s tick. +_cron_interval_cache: dict = {} + + +def _cron_interval_minutes(expr: str) -> Optional[float]: + """Approximate the natural interval of a cron expression, in minutes. + + The persisted job store keeps ``schedule`` as an already-parsed dict + (``{"kind": "cron", "expr": "0 9 * * 1"}``), so the stale allowance for + a cron job cannot be derived from a schedule *string* — it must come + from the expression itself. We measure the gap between the next two + fire times with croniter; that is the job's cadence, and the sweep's + allowance becomes ``max(2 * cadence, floor)`` exactly like interval + jobs. Falls back to ``None`` (→ floor allowance) if croniter is + missing or the expression cannot be evaluated. + """ + if expr in _cron_interval_cache: + return _cron_interval_cache[expr] + result = None + try: + from cron.jobs import _ensure_croniter + + if _ensure_croniter(): + from cron.jobs import croniter as _croniter + from datetime import datetime + + base = datetime.now() + it = _croniter(expr, base) + first = it.get_next(datetime) + second = it.get_next(datetime) + gap = (second - first).total_seconds() / 60.0 + result = gap if gap > 0 else None + except Exception: + pass + _cron_interval_cache[expr] = result + return result + + +def _job_interval_minutes(job: dict) -> Optional[float]: + """Best-effort interval length for a job, in minutes (None if unknown). + + Reads the PERSISTED schedule shape first: the job store keeps + ``schedule`` as an already-parsed dict (``{"kind": "interval", + "minutes": N}`` or ``{"kind": "cron", "expr": "..."}``), NOT the string + form that ``parse_schedule`` consumes. The string path is kept only as + a defensive fallback for programmatic callers that still build string + schedules (and for tests that exercise that shape). + + ``kind == "once"`` (one-shot) has no recurring interval — returns None, + so the sweep uses the documented floor allowance. + """ + try: + schedule = job.get("schedule") + if isinstance(schedule, str) and schedule.strip(): + from cron.jobs import parse_schedule + + schedule = parse_schedule(schedule) or {} + if isinstance(schedule, dict): + kind = schedule.get("kind") + if kind == "interval": + minutes = schedule.get("minutes") + return float(minutes) if minutes else None + if kind == "cron": + return _cron_interval_minutes(str(schedule.get("expr") or "")) + except Exception: + pass + return None + + +def get_inflight_guard_stats() -> dict: + """Probe-visible snapshot of the in-flight guard. + + ``forced_releases`` is a monotonic counter of stale claims this process + has force-released; any non-zero value means a cron job wedged and was + recovered without a gateway restart. + """ + now = time.time() + with _running_lock: + return { + "running": sorted(_running_job_ids), + "running_ages_seconds": { + jid: round(now - started, 1) + for jid, started in _running_since.items() + }, + "forced_releases": _forced_release_count, + "recent_forced_releases": list(_forced_releases), + } + + +def _record_forced_release(job_id: str, name: str, age_seconds: float, allowance_seconds: float) -> None: + """Persist a countable signal for one forced release (best-effort).""" + entry = { + "job_id": job_id, + "name": name, + "age_seconds": round(age_seconds, 1), + "allowance_seconds": round(allowance_seconds, 1), + "at": _hermes_now().isoformat(), + } + with _running_lock: + _forced_releases.append(entry) + del _forced_releases[:-_FORCED_RELEASE_HISTORY] + try: + path = _get_hermes_home() / "cron" / "inflight_forced_releases.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry) + "\n") + except Exception as e: # never let telemetry break a tick + logger.debug("Could not append forced-release record: %s", e) + + +def sweep_stale_inflight(due_jobs: Optional[list] = None) -> list: + """Force-release in-flight claims that can no longer be making progress. + + A claim is stale when it is older than ``max(2 * interval, floor)`` AND + either has no live future at all (the wedge class: the claim was taken + but the release path was never installed — e.g. a hang in the submit + path before ``pool.submit`` returned) or has a future that already + finished without discarding the id. + + Every release logs a WARNING with the countable ``event=forced_release`` + signal, bumps a probe-visible counter (``get_inflight_guard_stats()``), + mirrors a JSONL row under the cron dir, and writes ``last_error`` on the + job so the wedge surfaces on the job row instead of being invisible + until a downstream liveness key goes dead hours later. A forced release + never consumes a finite-repeat job's budget (see below). + + Returns the list of released job ids. + """ + global _forced_release_count + + by_id = {j.get("id"): j for j in (due_jobs or []) if isinstance(j, dict)} + floor_seconds = _inflight_min_allowance_minutes() * 60.0 + now = time.time() + stale: list = [] + + # Precompute job intervals OUTSIDE _running_lock so croniter evaluation + # does not block try_register/release_running_job for other jobs. + _intervals = {jid: _job_interval_minutes(j) for jid, j in by_id.items()} + + with _running_lock: + for job_id in list(_running_job_ids): + started = _running_since.get(job_id) + if started is None: + # Claim predates this guard (or was injected directly) — adopt + # it now so it becomes sweepable one allowance from here. + _running_since[job_id] = now + continue + age = now - started + interval_minutes = _intervals.get(job_id) + allowance = floor_seconds + if interval_minutes: + allowance = max(allowance, 2.0 * interval_minutes * 60.0) + if age < allowance: + continue + fut = _running_futures.get(job_id) + if fut is _FUTURE_PENDING: + # The claim is past its allowance and the owning future still + # has not been installed — the submit path itself (SessionDB + # init, agent import, config load) hung before ``pool.submit`` + # returned. That is exactly the wedge class; release it. + pass + elif fut is not None and not fut.done(): + continue # genuinely still executing + _running_job_ids.discard(job_id) + _running_since.pop(job_id, None) + _running_futures.pop(job_id, None) + _forced_release_count += 1 + stale.append((job_id, age, allowance, fut)) + + for job_id, age, allowance, fut in stale: + job = by_id.get(job_id) or {} + name = job.get("name") or job_id + if fut is _FUTURE_PENDING: + future_state = "pending" + elif fut is None: + future_state = "missing" + else: + future_state = "finished" + logger.warning( + "cron.inflight.forced_release event=forced_release job='%s' id=%s " + "age=%.0fs allowance=%.0fs future=%s — stale in-flight claim " + "released; the job was skipping every fire with 'already running'", + name, + job_id, + age, + allowance, + future_state, + ) + _record_forced_release(job_id, name, age, allowance) + # Finite-repeat guard: a forced release is NOT a real run, so it must + # not consume a finite one-shot's repeat budget or let mark_job_run + # auto-delete the row (completed >= times). The claim is released and + # the row is left untouched, so the job re-fires normally on its next + # due tick (self-heal) instead of being deleted. + repeat = job.get("repeat") or {} + if isinstance(repeat, dict) and repeat.get("times") is not None: + logger.warning( + "cron.inflight.forced_release.job_untouched job='%s' id=%s — " + "finite-repeat job released without mark_job_run (repeat budget " + "preserved); row left in place so it re-fires normally", + name, + job_id, + ) + continue + try: + mark_job_run( + job_id, + False, + f"Stale in-flight claim force-released after {age / 60:.1f}m " + f"(allowance {allowance / 60:.1f}m); previous run never released " + f"the scheduler in-flight guard", + ) + except Exception as e: + logger.warning("Could not record forced release for job %s: %s", job_id, e) + + return [s[0] for s in stale] def mark_running_jobs_interrupted(reason: str) -> list: @@ -5204,6 +5502,29 @@ def tick( due_jobs = get_due_jobs() + # Bound the in-flight set BEFORE the dedup guard is consulted, so a + # leaked claim is force-released in-cycle rather than silently eating + # every subsequent fire until the gateway process restarts. Skips the + # extra load_jobs when there are no in-flight claims (the common idle + # tick) and reuses due_jobs when they already cover the in-flight set + # (get_due_jobs calls load_jobs internally, so this avoids a redundant + # second file read on every active tick). + if _running_job_ids: + _sweep_jobs = due_jobs + try: + _inflight_ids = set(_running_job_ids) + _due_ids = {j.get("id") for j in due_jobs if isinstance(j, dict)} + if not _inflight_ids <= _due_ids: + from cron.jobs import load_jobs as _load_all_jobs + + _sweep_jobs = _load_all_jobs() + except Exception: + pass + try: + sweep_stale_inflight(_sweep_jobs) + except Exception as e: + logger.warning("Stale in-flight sweep failed: %s", e) + if not due_jobs: # Idle tick: skip config load + pool partitioning entirely # (#33612 — the gateway ticker calls tick(verbose=False) every @@ -5301,9 +5622,18 @@ def _submit_with_guard(job: dict, pool: concurrent.futures.ThreadPoolExecutor): return None # Record the attempt before executor dispatch. Recovery classifies # abandoned records as unknown; it never automatically retries them. - execution = create_execution(job_id, source="builtin") - dispatched_job = dict(job, execution_id=execution["id"]) - _ctx = contextvars.copy_context() + try: + execution = create_execution(job_id, source="builtin") + dispatched_job = dict(job, execution_id=execution["id"]) + _ctx = contextvars.copy_context() + except BaseException: + # Init/creation failure between the claim and the submit — + # release the in-flight claim immediately so the next tick can + # retry instead of wedging on 'already running' forever (the + # audit requirement: every add is paired with guaranteed + # cleanup). Re-raise so the caller sees the failure. + release_running_job(job_id) + raise def _run_and_release(j=dispatched_job, ctx=_ctx): try: @@ -5312,7 +5642,7 @@ def _run_and_release(j=dispatched_job, ctx=_ctx): release_running_job(j["id"]) try: - return pool.submit(_run_and_release) + fut = pool.submit(_run_and_release) except Exception as submit_err: release_running_job(job_id) finish_execution( @@ -5335,6 +5665,13 @@ def _run_and_release(j=dispatched_job, ctx=_ctx): ) return None + # Record the owning future so the stale sweep can distinguish + # "still executing" from "claim leaked before/after the future". + with _running_lock: + if job_id in _running_job_ids: + _running_futures[job_id] = fut + return fut + # Sequential pass for env-mutating (workdir) jobs. # Queued to a persistent single-thread pool so they run one at a time # WITHOUT blocking the ticker thread — a long workdir job no diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 6c3050696a5d7..959c9b4147af0 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -393,7 +393,10 @@ def relay_relevance_policy(platform: Optional[str] = None) -> Optional[dict]: cfg = _load_gateway_config() or {} plat_cfg = cfg.get(platform) if not isinstance(plat_cfg, dict): - plat_cfg = ((cfg.get("gateway") or {}).get("platforms") or {}).get(platform) + _gw_platforms = (cfg.get("gateway") or {}).get("platforms") or {} + if not isinstance(_gw_platforms, dict): + _gw_platforms = {} + plat_cfg = _gw_platforms.get(platform) if not isinstance(plat_cfg, dict): plat_cfg = (cfg.get("platforms") or {}).get(platform) plat_cfg = plat_cfg if isinstance(plat_cfg, dict) else {} diff --git a/gateway/run.py b/gateway/run.py index 464637e2460ce..1dd0e9e7c3465 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -40,6 +40,7 @@ import signal import threading import time +import traceback from collections import OrderedDict from contextvars import copy_context from pathlib import Path @@ -2399,8 +2400,10 @@ def _platform_has_bot_credential(platform: "Platform", platform_config: "Platfor # Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs) os.environ["HERMES_QUIET"] = "1" -# Enable interactive exec approval for dangerous commands on messaging platforms -os.environ["HERMES_EXEC_ASK"] = "1" +# HERMES_EXEC_ASK is set in start_gateway(), not at import time. Importing this +# module from CLI tools (e.g. send_message → _gateway_runner_ref) must not flip +# interactive CLI sessions into ask-mode, or Dangerous Command prompts become +# silent pending_approval with no Approve/Deny UI. # Set terminal working directory for messaging platforms. # config.yaml terminal.cwd is the canonical source (bridged to TERMINAL_CWD @@ -3000,6 +3003,69 @@ def _reap_gateway_turn_processes( return killed +_TURN_STACK_DUMP_FRAME_MARKERS = ( + "run_conversation", + "run_sync", + "_run_sync_with_timeout_lifecycle", + "finalize_turn", + "end_turn", + "run_in_session", +) + + +def _dump_wedged_turn_stacks(task_id: str) -> None: + """Log the stack of every thread that looks like turn work, at reap time. + + When the inactivity reaper fires, the model loop is usually long done and + the worker thread is wedged somewhere in post-turn finalization — but the + reaper's hard interrupt frees it, so the blocked frame is gone before + anyone can attach a profiler. A live incident (Aug 2026, WhatsApp session + on a Relay-corrupted scope stack) wedged EVERY turn for exactly the + 1800s timeout between "Turn ended" and run_sync returning, and the wedge + point was unrecoverable post-mortem. Dumping the stacks here, BEFORE the + interrupt, names the frame. + + Best-effort and bounded: pure in-process frame walking (no signals, no + external tools), only threads whose stack mentions a turn-machinery + marker are logged, output capped per thread. Must never raise into the + reaper. + """ + try: + frames = sys._current_frames() + names = {t.ident: t.name for t in threading.enumerate()} + dumped = 0 + for ident, frame in frames.items(): + if ident == threading.get_ident(): + continue # the reaper itself + stack = traceback.format_stack(frame) + joined = "".join(stack) + if not any(marker in joined for marker in _TURN_STACK_DUMP_FRAME_MARKERS): + continue + dumped += 1 + if dumped > 8: + logger.error( + "Wedged-turn stack dump for task %s truncated: more than " + "8 candidate threads", + task_id, + ) + break + logger.error( + "Wedged-turn stack dump (task=%s thread=%s ident=%s):\n%s", + task_id, + names.get(ident, "?"), + ident, + "".join(stack[-25:]), + ) + if dumped == 0: + logger.error( + "Wedged-turn stack dump for task %s: no thread with " + "turn-machinery frames found (worker may have already exited)", + task_id, + ) + except Exception: + logger.debug("Wedged-turn stack dump failed", exc_info=True) + + def _abandon_timed_out_gateway_turn( *, agent_holder, @@ -3016,6 +3082,11 @@ def _abandon_timed_out_gateway_turn( return False timeout_fired.set() + # Capture the wedged worker's stack BEFORE interrupting it — the + # interrupt frees the blocked frame, destroying the only evidence of + # where the turn was stuck (see _dump_wedged_turn_stacks). + _dump_wedged_turn_stacks(task_id) + agent = agent_holder[0] if agent_holder else None if agent is not None: try: @@ -5113,6 +5184,12 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: # slower than Linux. Off by default; soul identity is preserved so # the persona survives even with minimal context. _platforms_gw_cfg = (ctx.user_config.get("gateway") or {}).get("platforms") or {} + # ``hermes gateway setup`` writes ``gateway.platforms`` as a LIST of + # enabled platform names (e.g. ``- telegram``), not a dict. Treat any + # non-dict shape as "no per-platform overrides" instead of crashing + # on ``.get()`` for every incoming turn (#83185). + if not isinstance(_platforms_gw_cfg, dict): + _platforms_gw_cfg = {} _plat_gw_cfg = _platforms_gw_cfg.get(platform_key) or {} _skip_context = _plat_gw_cfg.get("skip_context_files") skip_context_files = bool(_skip_context) if _skip_context is not None else False @@ -10874,6 +10951,50 @@ def _query_pid(scope_flags): except Exception as e: logger.debug("Failed to launch systemd planned-restart helper: %s", e) + def _wedged_agent_count(self) -> int: + """Count running chat agents already past the inactivity timeout. + + A turn whose agent has recorded no activity (no API bytes, no tool + progress) for longer than ``agent.gateway_timeout`` is wedged — the + same threshold at which the turn reaper gives up on it. The restart + after-turn wait must not treat such turns as work worth waiting for: + a wedged agent pinned ``hermes update`` in "draining" for the full + ``restart_after_turn_timeout`` cap because the drain counted it as + active while its own inactivity watchdog had already declared it dead + (Aug 2026, WhatsApp turn idle 30+ min, drain waited on it anyway). + + Returns 0 when the inactivity timeout is disabled (``gateway_timeout`` + 0/unset ⇒ the operator opted into unbounded turns; the after-turn cap + still bounds the wait). Cron/API-server work has no per-turn activity + clock and is never counted as wedged. Pending sentinels are brand-new + turns, never wedged. Fail-open per agent: an unreadable activity + summary means "not wedged". + """ + timeout = _float_env("HERMES_AGENT_TIMEOUT", 1800) + if timeout <= 0: + return 0 + wedged = 0 + for agent in list((getattr(self, "_running_agents", None) or {}).values()): + if agent is None or agent is _AGENT_PENDING_SENTINEL: + continue + summary_fn = getattr(agent, "get_activity_summary", None) + if not callable(summary_fn): + continue + try: + summary = summary_fn() + if not isinstance(summary, dict): + continue + idle = float(summary.get("seconds_since_activity", 0.0)) + except Exception: + continue + if idle >= timeout: + wedged += 1 + return wedged + + def _awaitable_work_count(self) -> int: + """Active work minus wedged turns — what the restart wait waits on.""" + return max(0, self._active_work_count() - self._wedged_agent_count()) + async def _await_active_work_before_restart(self) -> bool: """Wait for in-flight work to finish before entering ``stop()``. @@ -10883,14 +11004,31 @@ async def _await_active_work_before_restart(self) -> bool: wait here for active agents/cron/api work to reach zero, then let ``stop()`` run against an idle gateway (drain is instant). + Turns already past the inactivity timeout are excluded from the wait + (``_wedged_agent_count``): restart is usually the *remedy* for a + wedged turn, so deferring it behind one inverts the point of the + graceful path. ``stop()``'s drain interrupts them under + ``restart_drain_timeout`` instead. + Returns True when work drained to zero, False when the safety cap - elapsed with work still active (caller proceeds to ``stop()``, which - may then interrupt remaining runs under ``restart_drain_timeout``). + elapsed with work still active — or when only wedged work remains — + (caller proceeds to ``stop()``, which may then interrupt remaining + runs under ``restart_drain_timeout``). """ active = self._active_work_count() if active <= 0: return True + awaitable = self._awaitable_work_count() + if awaitable <= 0: + logger.warning( + "Restart requested with %d active work unit(s), all wedged " + "past the inactivity timeout; skipping the after-turn wait " + "and proceeding to stop()/drain which will interrupt them", + active, + ) + return False + timeout = float(getattr(self, "_restart_after_turn_timeout", 0.0) or 0.0) if timeout <= 0: logger.info( @@ -10915,7 +11053,7 @@ async def _await_active_work_before_restart(self) -> bool: loop = asyncio.get_running_loop() deadline = loop.time() + timeout last_status_at = 0.0 - while self._active_work_count() > 0: + while self._awaitable_work_count() > 0: now = loop.time() if now >= deadline: logger.warning( @@ -10929,8 +11067,9 @@ async def _await_active_work_before_restart(self) -> bool: if (now - last_status_at) >= 30.0: logger.info( "Restart deferred: waiting on %d active work unit(s) " - "(%.0fs remaining before force drain)", - self._active_work_count(), + "(%d wedged and excluded; %.0fs remaining before force drain)", + self._awaitable_work_count(), + self._wedged_agent_count(), deadline - now, ) try: @@ -10940,6 +11079,14 @@ async def _await_active_work_before_restart(self) -> bool: last_status_at = now await asyncio.sleep(0.1) + if self._active_work_count() > 0: + logger.warning( + "Restart deferred wait: %d wedged work unit(s) remain; " + "proceeding to stop()/drain which will interrupt them", + self._active_work_count(), + ) + return False + logger.info( "Restart deferred wait complete — active work drained; " "proceeding to stop()" @@ -12384,6 +12531,11 @@ async def start(self) -> bool: # idle case where the subagent finishes with no agent turn running. self._spawn_supervised(self._async_delegation_watcher, "async_delegation_watcher") + # Start background /loop wakeup watcher — scans persisted loops + # (SessionDB loop:* rows) and injects due wakeup prompts into their + # originating chats while the session is idle. + self._spawn_supervised(self._loop_wakeup_watcher, "loop_wakeup_watcher") + # Start the scale-to-zero idle watcher ONLY when this instance is opted # in (the NAS "Labs" HERMES_SCALE_TO_ZERO stamp), messaging is # relay-only/absent, and a wakeUrl is registered (decisions.md D1/D11/ @@ -15175,6 +15327,7 @@ async def _dispatch_busy_slash_command( "steer": self._busy_steer_command, "egress": self._busy_egress_command, "goal": self._busy_goal_command, + "loop": self._busy_loop_command, }.get(handler_key) if special is not None: return await special(event, quick_key, source) @@ -15405,6 +15558,15 @@ async def _busy_goal_command(self, event: MessageEvent, quick_key: str, source): return await self._handle_goal_command(event) return "Agent is running — use /goal status / pause / clear / wait mid-run, or /stop before setting a new goal." + async def _busy_loop_command(self, event: MessageEvent, quick_key: str, source): + # /loop mirrors /goal: control verbs are safe mid-run (state + # only — read at the next idle boundary); setting a new loop + # mid-run is rejected so we don't race the current turn. + _loop_arg = (event.get_command_args() or "").strip().lower() + if not _loop_arg or _loop_arg in {"status", "pause", "resume", "stop", "clear", "cancel", "help", "--help", "-h"}: + return await self._handle_loop_command(event) + return "Agent is running — use /loop status / pause / stop mid-run, or /stop before setting a new loop." + async def _handle_message(self, event: MessageEvent) -> Optional[str]: """ Handle an incoming message from any platform. @@ -16574,6 +16736,9 @@ async def _do_undo(): if canonical == "goal": return await self._handle_goal_command(event) + if canonical == "loop": + return await self._handle_loop_command(event) + if canonical == "heartbeat": return await self._handle_heartbeat_command(event) if canonical == "refine": @@ -16959,6 +17124,14 @@ async def _do_undo(): source=source, final_response=_final_text, ) + # /loop tick completion: if this turn was a loop + # wakeup, evaluate it (LOOP_COMPLETE marker, --until + # judge, caps) and schedule the next tick. + await self._post_turn_loop_completion( + session_entry=session_entry, + source=source, + final_response=_final_text, + ) except Exception as _goal_exc: logger.debug("goal continuation hook failed: %s", _goal_exc) return _agent_result @@ -20517,6 +20690,148 @@ async def _post_turn_goal_continuation( + async def _post_turn_loop_completion( + self, + *, + session_entry: Any, + source: Any, + final_response: str, + ) -> None: + """Complete a /loop wakeup tick after a gateway turn. + + No-op unless the session has a loop whose tick is in flight + (``awaiting_response`` — set when the wakeup was injected). Applies + the LOOP_COMPLETE marker / --until judge / caps and schedules the + next tick; the idle wakeup watcher fires it when due. + """ + try: + from hermes_cli.loops import LoopManager + except Exception as exc: + logger.debug("loop completion: loops module unavailable: %s", exc) + return + + sid = getattr(session_entry, "session_id", None) or "" + if not sid: + return + + mgr = LoopManager(session_id=sid) + state = mgr.state + if state is None or not state.awaiting_response: + return + + # The --until judge is a sync aux-LLM call — keep it off the event loop. + decision = await asyncio.get_running_loop().run_in_executor( + None, mgr.complete_tick, final_response or "" + ) + msg = decision.get("message") or "" + if msg and source is not None: + await self._defer_goal_status_notice_after_delivery(source, msg) + + async def _loop_wakeup_watcher(self, interval: float = 15.0) -> None: + """Fire due /loop wakeups for idle gateway sessions. + + The gateway has no per-session scheduler thread, so a coarse ticker + scans persisted loops (SessionDB ``loop:*`` rows) and injects the + wakeup prompt into each due session's chat via the same synthetic- + message path used by watch notifications. Deferrals: + + - session currently running an agent turn → skip (stays due; the + adapter FIFO would race the live turn otherwise) + - active non-parked /goal on the session → skip (goal owns the + idle boundary) + - no routing metadata on the loop → skip with a one-time warning + (CLI/TUI loops carry no route and are driven by their own surfaces) + """ + await asyncio.sleep(5) # let platforms finish connecting + warned_no_route: set = set() + while self._running: + try: + from hermes_cli.loops import ( + LoopManager, + goal_blocks_loop_tick, + list_active_loops, + ) + + now = time.time() + for sid, state in list_active_loops(): + if state.awaiting_response or now < state.next_due_at: + continue + route = state.route or {} + platform_name = route.get("platform", "") + chat_id = route.get("chat_id", "") + if not platform_name or not chat_id: + # CLI / TUI-owned loop — their own schedulers drive it. + continue + adapter = None + for p, a in self.adapters.items(): + if p.value == platform_name: + adapter = a + break + if adapter is None: + if sid not in warned_no_route: + warned_no_route.add(sid) + logger.debug( + "loop wakeup: no adapter for platform %r (session %s)", + platform_name, sid, + ) + continue + + # Build the source + session key to check business. + evt_stub = { + "session_key": "", + "platform": platform_name, + "chat_id": chat_id, + "chat_type": route.get("chat_type", ""), + "thread_id": route.get("thread_id", ""), + "user_id": route.get("user_id", ""), + "user_name": route.get("user_name", ""), + } + source = self._build_process_event_source(evt_stub) + if source is None: + continue + try: + session_key = self._session_key_for_source(source) + except Exception: + session_key = None + if session_key and session_key in self._running_agents: + continue # busy — stays due, next scan retries + if goal_blocks_loop_tick(sid): + continue + + mgr = LoopManager(session_id=sid) + if not mgr.is_due(now): + continue + wakeup = mgr.fire_tick() + if not wakeup: + continue + try: + synth_event = MessageEvent( + text=wakeup, + message_type=MessageType.TEXT, + source=source, + internal=True, + ) + logger.info( + "loop wakeup #%s — injecting for %s chat=%s thread=%s", + mgr.state.ticks_fired if mgr.state else "?", + platform_name, source.chat_id, source.thread_id, + ) + await adapter.handle_message(synth_event) + # Slash-command loops dispatch through the command + # path and never hit the post-turn completion hook — + # complete the tick immediately (caps + scheduling). + if wakeup.lstrip().startswith("/"): + mgr.complete_tick("") + except Exception as exc: + logger.warning("loop wakeup injection failed for %s: %s", sid, exc) + try: + mgr.abandon_tick() + except Exception: + pass + except Exception as exc: + logger.debug("loop wakeup watcher error: %s", exc) + await asyncio.sleep(interval) + @staticmethod def _get_guild_id(event: MessageEvent) -> Optional[int]: """Extract Discord guild_id from the raw message object.""" @@ -28805,6 +29120,11 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = Useful for systemd services to avoid restart-loop deadlocks when the previous process hasn't fully exited yet. """ + # Enable interactive exec approval for dangerous commands on messaging + # platforms. Set here (not at module import) so incidental imports of + # gateway.run from CLI/tool code do not poison HERMES_EXEC_ASK. + os.environ["HERMES_EXEC_ASK"] = "1" + from hermes_cli.resource_limits import apply_nofile_soft_limit apply_nofile_soft_limit() diff --git a/gateway/session.py b/gateway/session.py index 74f1242bf2f6b..0121518152d1f 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -2895,6 +2895,12 @@ def set_session_metadata( Values must be small and JSON-serializable — they are written into the routing index (state.db gateway_routing table + the legacy sessions.json mirror) so they survive gateway restarts. + + Metadata writes are internal bookkeeping and deliberately do NOT + advance ``updated_at``: it is the user-activity clock that drives + idle/daily reset policy and the restart-resume freshness gate + (#85709), and a background write must not make an idle session look + fresh. """ with self._lock: self._ensure_loaded_locked() @@ -2902,7 +2908,6 @@ def set_session_metadata( if entry is None: return False entry.metadata[key] = value - entry.updated_at = _now() self._save() return True @@ -3349,7 +3354,10 @@ def advance_compression_session( target_session_id, ): return None - entry.updated_at = _now() + # Compression repoint is store bookkeeping, not user activity — + # leave ``updated_at`` alone so a background compression on an + # idle session cannot make it look fresh to reset policy or the + # restart-resume freshness gate (#85709). self._save() return entry diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index eb19f61bd0c52..6b87f59751cb1 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -1937,7 +1937,8 @@ async def _on_model_selected_scoped( event.source ) await _sess_db.update_session_model( - _sess_entry.session_id, result.new_model + _sess_entry.session_id, result.new_model, + provider=result.target_provider, ) except Exception as exc: logger.debug( @@ -2247,7 +2248,8 @@ async def _finish_switch() -> str: if getattr(_sess_entry, "was_auto_reset", False): _sess_entry.was_auto_reset = False await _sess_db.update_session_model( - _sess_entry.session_id, result.new_model + _sess_entry.session_id, result.new_model, + provider=result.target_provider, ) except Exception as exc: logger.debug( @@ -2980,6 +2982,76 @@ async def _handle_subgoal_command(self, event: "MessageEvent") -> str: idx = len(mgr.state.subgoals) if mgr.state else 0 return f"✓ Added subgoal {idx}: {text}" + async def _get_loop_manager_for_event(self, event: "MessageEvent"): + """Return a LoopManager bound to the session for this gateway event. + + Returns ``(manager, session_entry)`` or ``(None, None)`` when the + loops module or session can't be loaded. Mirrors + ``_get_goal_manager_for_event``. + """ + try: + from hermes_cli.loops import LoopManager + except Exception as exc: + logger.debug("loop manager unavailable: %s", exc) + return None, None + try: + session_entry = await self.async_session_store.get_or_create_session(event.source) + except Exception: + return None, None + sid = getattr(session_entry, "session_id", None) or "" + if not sid: + return None, None + return LoopManager(session_id=sid), session_entry + + async def _handle_loop_command(self, event: "MessageEvent") -> str: + """Handle /loop for gateway platforms — recurring in-session wakeups. + + Mirrors the CLI handler via the shared ``dispatch_loop_command``. + New loops capture the event's routing (platform/chat/thread) so the + gateway's idle loop-wakeup watcher can inject ticks back into this + chat even after a restart. + """ + try: + from hermes_cli.loops import dispatch_loop_command, goal_blocks_loop_tick + except Exception as exc: + logger.debug("loops module unavailable: %s", exc) + return "Loops unavailable." + + mgr, _session_entry = await self._get_loop_manager_for_event(event) + if mgr is None: + return "Loops unavailable (no active session)." + + route: dict = {} + try: + src = event.source + if src is not None: + platform = getattr(src, "platform", "") + route = { + "platform": platform.value if hasattr(platform, "value") else str(platform or ""), + "chat_id": str(getattr(src, "chat_id", "") or ""), + "chat_type": str(getattr(src, "chat_type", "") or ""), + "thread_id": str(getattr(src, "thread_id", "") or ""), + "user_id": str(getattr(src, "user_id", "") or ""), + "user_name": str(getattr(src, "user_name", "") or ""), + } + route = {k: v for k, v in route.items() if v} + except Exception: + route = {} + + args = (event.get_command_args() or "").strip() + result = dispatch_loop_command(mgr, args, route=route) + output = result.get("output") or "" + if result.get("created"): + try: + if goal_blocks_loop_tick(mgr.session_id): + output += ( + "\nNote: an active /goal is driving this session — loop " + "wakeups defer until the goal finishes, pauses, or parks." + ) + except Exception: + pass + return output + async def _handle_undo_command(self, event: MessageEvent) -> str: """Handle /undo [N] — back up N user turns (default 1), soft-deleting the truncated rows on disk and echoing the backed-up message text so diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index e6e5551b0abba..0b15493a39462 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -193,12 +193,54 @@ def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5) -> Optional[str return (result.stdout or "").strip() -def _check_via_rev(local_rev: str) -> Optional[int]: - """Compare an embedded git revision to upstream main via ls-remote. - - Returns 0 if up-to-date, ``UPDATE_AVAILABLE_NO_COUNT`` if behind, - or ``None`` on failure. +def _github_compare_behind(current_rev: str, target_rev: str) -> Optional[int]: + """Exact behind-count via the GitHub compare API for uncountable graphs. + + Shallow installer clones and ls-remote-only probes know the two tip SHAs + but have no local history to run ``rev-list --count`` across. GitHub's + ``GET /repos///compare/...`` knows the full + graph regardless of local clone depth and returns ``ahead_by`` — exactly + the behind count the local graph lost. Unauthenticated, bounded, and + best-effort: any failure (offline, rate limit, diverged/unknown SHAs) + returns None so callers keep the honest UPDATE_AVAILABLE_NO_COUNT. """ + if not (_is_full_sha(current_rev) and _is_full_sha(target_rev)): + return None + url = ( + "https://api.github.com/repos/nousresearch/hermes-agent/" + f"compare/{current_rev}...{target_rev}" + ) + try: + import urllib.request + + req = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + # api.github.com 403s requests without a User-Agent. + "User-Agent": "hermes-cli-update-check", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except Exception: + return None + ahead = payload.get("ahead_by") if isinstance(payload, dict) else None + if isinstance(ahead, int) and not isinstance(ahead, bool) and ahead >= 0: + return ahead + return None + + +def _is_full_sha(value: Optional[str]) -> bool: + return ( + isinstance(value, str) + and len(value) == 40 + and all(c in "0123456789abcdefABCDEF" for c in value) + ) + + +def _upstream_main_sha() -> Optional[str]: + """Tip SHA of upstream main via HTTPS ls-remote (no auth, no prompts).""" try: result = subprocess.run( ["git", "ls-remote", _UPSTREAM_REPO_URL, "refs/heads/main"], @@ -210,9 +252,27 @@ def _check_via_rev(local_rev: str) -> Optional[int]: if result.returncode != 0 or not result.stdout: return None upstream_rev = result.stdout.split()[0] + return upstream_rev or None + + +def _check_via_rev(local_rev: str) -> Optional[int]: + """Compare an embedded git revision to upstream main via ls-remote. + + Returns 0 if up-to-date, the exact behind-count when the GitHub compare + API can recover it, ``UPDATE_AVAILABLE_NO_COUNT`` if behind by an unknown + amount, or ``None`` on failure. + """ + upstream_rev = _upstream_main_sha() if not upstream_rev: return None - return 0 if upstream_rev == local_rev else UPDATE_AVAILABLE_NO_COUNT + if upstream_rev == local_rev: + return 0 + # Behind, but ls-remote only knows tip SHAs. Try to recover the exact + # count from the GitHub compare API before falling back to the sentinel. + # ahead_by == 0 with differing tips means the remote tip is reachable from + # our HEAD — a local-ahead checkout, i.e. NOT behind. + counted = _github_compare_behind(local_rev, upstream_rev) + return counted if counted is not None else UPDATE_AVAILABLE_NO_COUNT def _check_via_local_git(repo_dir: Path) -> Optional[int]: @@ -220,10 +280,32 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]: origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir) if _is_official_ssh_remote(origin_url): head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) - checked = _check_via_rev(head_rev) if head_rev else None - if checked == UPDATE_AVAILABLE_NO_COUNT: - return 1 - return checked + if not head_rev: + return None + # Passive probe via HTTPS ls-remote (never SSH — no hardware-key + # prompts). Tip SHAs alone can't distinguish "behind" from a local + # carried commit sitting AHEAD of origin/main, and misreporting an + # ahead checkout as behind nudges the user into `hermes update`, + # which can wipe their carried work. + upstream_rev = _upstream_main_sha() + if upstream_rev is None: + return None + if upstream_rev == head_rev: + return 0 + # Local-ahead: the remote tip is an ancestor of HEAD. Checked against + # the FRESH upstream SHA (not the possibly stale origin/main tracking + # ref) so a stale ref can't fake an up-to-date report. + ancestor = subprocess.run( + ["git", "merge-base", "--is-ancestor", upstream_rev, "HEAD"], + capture_output=True, timeout=5, cwd=str(repo_dir), + ) + if ancestor.returncode == 0: + return 0 + # Genuinely behind (or diverged). Recover the exact count via the + # GitHub compare API; a local-only HEAD 404s there, which safely + # degrades to the honest no-count sentinel — never a fabricated 1. + counted = _github_compare_behind(head_rev, upstream_rev) + return counted if counted is not None else UPDATE_AVAILABLE_NO_COUNT # Installer checkouts are shallow (`git clone --depth 1`). On a shallow # clone the history stops at a single commit, so a plain `git fetch` would @@ -268,7 +350,14 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]: ) if not head_rev or not target_rev: return None - return 0 if head_rev == target_rev else UPDATE_AVAILABLE_NO_COUNT + if head_rev == target_rev: + return 0 + # Tips differ but the shallow boundary hides the history between them. + # Recover the exact count from the GitHub compare API when possible + # (ahead_by == 0 means local-ahead ⇒ up to date); otherwise report the + # honest "update available, count unknown" sentinel. + counted = _github_compare_behind(head_rev, target_rev) + return counted if counted is not None else UPDATE_AVAILABLE_NO_COUNT try: result = subprocess.run( diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 612ebbb645534..4349db8926d15 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2807,6 +2807,39 @@ def _handle_goal_draft(self, objective: str) -> None: except Exception: pass + def _handle_loop_command(self, cmd: str) -> None: + """Dispatch /loop — recurring in-session wakeups (Claude Code parity). + + Forms: + /loop [interval] [--times N] [--until ] start a loop + /loop status | pause | resume | stop controls + """ + from cli import _DIM, _RST, _cprint + parts = (cmd or "").strip().split(None, 1) + arg = parts[1].strip() if len(parts) > 1 else "" + + mgr = self._get_loop_manager() + if mgr is None: + _cprint(f" {_DIM}Loops unavailable (no active session).{_RST}") + return + + from hermes_cli.loops import dispatch_loop_command + + result = dispatch_loop_command(mgr, arg) + for line in (result.get("output") or "").splitlines(): + _cprint(f" {line}") + if result.get("created"): + try: + from hermes_cli.loops import goal_blocks_loop_tick + + if goal_blocks_loop_tick(mgr.session_id): + _cprint( + f" {_DIM}Note: an active /goal is driving this session — " + f"loop wakeups defer until the goal finishes, pauses, or parks.{_RST}" + ) + except Exception: + pass + def _handle_subgoal_command(self, cmd: str) -> None: """Dispatch /subgoal subcommands. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1f9e70e430891..a0c94bbe47ea2 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -168,6 +168,10 @@ class CommandDef: busy_policy="dispatch"), CommandDef("refine", "Review this conversation now and save lessons to memory/skills", "Session", args_hint="[focus instructions]"), + CommandDef("loop", "Re-run a prompt on a recurring interval in this session", "Session", + aliases=("proactive",), + args_hint="[interval] [--times N] [--until ] | status | pause | resume | stop", + busy_policy="dispatch", busy_handler="loop"), CommandDef("moa", "Run one prompt through the default Mixture of Agents preset, then restore your model", "Session", args_hint="", busy_policy="reject", busy_handler="moa"), CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session", @@ -1277,7 +1281,12 @@ def discord_skill_commands_by_category( # native slash. # - pause: global emergency stop; reached via /hermes pause [off] on # Slack. Added at the 50-cap — a native slot would clamp /platform. -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "pause"}) +# - whoami: one-off identity lookup; reached via /hermes whoami on Slack. +# Demoted when /loop claimed a native slot (loop is a recurring +# interactive surface; whoami is a rare debug lookup) — without this +# entry /loop tips the registry past the 50-cap and silently clamps +# /platform, breaking Telegram parity. +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat", "refine", "pause", "whoami"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 29c7a4551e675..1851d1a3fca7e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1283,6 +1283,42 @@ def _warn_once_per_provider( logger.warning(msg, *args) +_API_MODE_ALIASES = { + # Values accepted by earlier releases (and natural spellings) mapped to + # the canonical transport names consumed by agent_init. Before this map + # existed, an unrecognized api_mode was silently ignored and the + # transport fell through to hostname-based guessing, so a config that + # said ``api_mode: openai`` (valid on older releases) could flip to + # ``codex_responses`` after an update and break the provider (#66543 + # discussion; observed live against api.actual.inc). + "openai": "chat_completions", + "openai_chat": "chat_completions", + "openai-chat": "chat_completions", + "chat-completions": "chat_completions", + "chatcompletions": "chat_completions", + "responses": "codex_responses", + "openai_responses": "codex_responses", + "openai-responses": "codex_responses", + "anthropic": "anthropic_messages", + "anthropic-messages": "anthropic_messages", + "messages": "anthropic_messages", + "bedrock": "bedrock_converse", + "bedrock-converse": "bedrock_converse", +} + + +def _canonical_api_mode(api_mode: str) -> str: + """Map legacy/alias ``api_mode`` spellings to canonical transport names. + + Unknown values pass through unchanged (callers keep their existing + fall-through behavior); known aliases are rewritten so downstream + consumers (``agent_init``'s accepted-set check, runtime resolution) + see a canonical name instead of silently discarding the user's intent. + """ + cleaned = api_mode.strip() + return _API_MODE_ALIASES.get(cleaned.lower(), cleaned) + + def _normalize_custom_provider_entry( entry: Any, *, @@ -1403,7 +1439,7 @@ def _normalize_custom_provider_entry( api_mode = entry.get("api_mode") or entry.get("transport") if isinstance(api_mode, str) and api_mode.strip(): - normalized["api_mode"] = api_mode.strip() + normalized["api_mode"] = _canonical_api_mode(api_mode) model_name = entry.get("model") or entry.get("default_model") if isinstance(model_name, str) and model_name.strip(): @@ -1930,6 +1966,7 @@ def check_config_version() -> Tuple[int, int]: "require_mention", # top-level convenience form honored by the gateway (#3979) "unauthorized_dm_behavior", # top-level form read by gateway/config.py "signal", # Signal settings bridged to env vars by gateway/config.py + "timeouts", # unified timeout resolution section (agent/deadline.py, #85125) } _KNOWN_ROOT_KEYS = frozenset(DEFAULT_CONFIG.keys()) | _EXTRA_KNOWN_ROOT_KEYS @@ -4941,6 +4978,7 @@ def _default_value_for_key(dotted_key: str): "server_actions", "secrets", "goals", + "loops", }) # Top-level keys whose sub-keys are partially schema-defined (e.g. on a diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 278e026120fc6..da37db941da83 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -83,8 +83,11 @@ # this cap for in-flight agents/cron/api runs to complete naturally # so the requesting turn is not amputated by restart_drain_timeout. # 0 = legacy behaviour (enter stop()/drain immediately). Default - # 6h is a safety valve for wedged agents, not a target latency. - "restart_after_turn_timeout": 21600, + # 30 min is a safety valve for wedged agents, not a target latency — + # an interactive `hermes gateway restart` must never block for hours + # on a turn that wedged (#79133). Long unattended turns can raise + # this in config.yaml. + "restart_after_turn_timeout": 1800, # Upper bound (seconds) a submitted prompt waits for the deferred # agent build (MCP discovery, model metadata, skills scan) before # failing with a visible error (#63078). The gateway's wait is @@ -1211,6 +1214,12 @@ # behaves badly with replayed scrollback. "persistent_output": True, "persistent_output_max_lines": 200, + # Clear terminal scrollback as well as the visible viewport when the + # classic CLI performs a full redraw/resize recovery. Disabled by + # default because some users prefer preserving terminal history; + # enable when a terminal/tmux stack stamps stale prompt chrome into + # scrollback during fullscreen/restore window transitions. + "cli_rebuild_scrollback_on_redraw": False, # Print a one-line summary of resolved modal prompts (approval / # clarify) into scrollback so the question and decision survive the # panel repaint. Set false to keep scrollback untouched. @@ -1770,7 +1779,7 @@ # extras" without silently stripping MCP tools the parent already has. # Set to false for strict intersection. "inherit_mcp_toolsets": True, - "max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget, + "max_iterations": 250, # per-subagent iteration cap (each subagent gets its own budget, # independent of the parent's max_iterations) # Subagent summaries return to the parent's context verbatim. A batch # fan-out (N children) returns N summaries at once, which can exceed @@ -1837,6 +1846,24 @@ "max_turns": 20, }, + + # Loops — /loop recurring in-session wakeups (Claude Code parity). + # A loop re-runs a prompt (or slash command) on a cadence inside the + # live session. Fixed-interval mode fires on the user's clock; + # self-paced mode (no interval given) starts at the floor and backs + # off exponentially while the agent's replies stop changing. + "loops": { + # Smallest fixed interval accepted (seconds). Tighter cadences are + # raised to this floor — each tick is a full agent turn. + "min_interval_seconds": 30, + # Backstop tick budget: the loop auto-pauses after this many + # wakeups unless the user set --times. 0 = unlimited. + "max_ticks": 100, + # Self-paced cadence bounds (seconds). + "self_paced_floor_seconds": 60, + "self_paced_ceiling_seconds": 900, + }, + # Mixture of Agents — named presets used by /moa. A preset is an execution # mode around the main model, not a provider/model itself: references + # aggregator synthesize private guidance before each main-model iteration. @@ -3366,7 +3393,7 @@ }, # Config schema version - bump this when adding new required fields - "_config_version": 35, + "_config_version": 36, } # Optional environment variables that enhance functionality diff --git a/hermes_cli/config_migrations.py b/hermes_cli/config_migrations.py index beddf550ea77c..54f360c319fd6 100644 --- a/hermes_cli/config_migrations.py +++ b/hermes_cli/config_migrations.py @@ -754,9 +754,38 @@ def _migrate_to_35(results: Dict[str, Any], quiet: bool) -> None: ) +def _migrate_to_36(results: Dict[str, Any], quiet: bool) -> None: + # ── Version 35 → 36: raise the subagent iteration cap default 50 → 250 ── + # delegation.max_iterations is the per-subagent tool-call budget. The old + # default of 50 truncated substantial delegated work (leaf agents spend + # ~15-20 turns on recon before producing output, then ran out mid-task). + # The shipped default is now 250. Configs still pinned at exactly the old + # default 50 — almost always the inherited default rather than a deliberate + # choice — are lifted to 250 so existing installs get the same headroom on + # update. Any OTHER explicit value (a deliberate override, high or low) is + # the user's own and is preserved; unset inherits 250 at read time. + _c = _cfg() + read_raw_config = _c.read_raw_config + _persist_migration = _c._persist_migration + + config = read_raw_config() + raw_deleg = config.get("delegation") + if isinstance(raw_deleg, dict) and raw_deleg.get("max_iterations") == 50: + raw_deleg["max_iterations"] = 250 + config["delegation"] = raw_deleg + _persist_migration(config) + results["config_added"].append("delegation.max_iterations=250 (was: 50)") + if not quiet: + print( + " ✓ Raised delegation.max_iterations from 50 to 250 — subagents " + "now get a larger per-child tool-call budget so delegated work " + "finishes instead of truncating. Set delegation.max_iterations " + "back to 50 to restore the old cap." + ) + + #: Registry of (target_version, migration_fn), strictly ascending. The driver #: applies every entry whose target version is greater than the on-disk -#: version captured before the ladder started. Order matters: later steps may #: observe earlier steps' writes via read_raw_config() (filesystem state). MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = ( # v12 is the support floor: configs already AT v12 (or newer) still get @@ -777,6 +806,7 @@ def _migrate_to_35(results: Dict[str, Any], quiet: bool) -> None: (33, _migrate_to_33), (34, _migrate_to_34), (35, _migrate_to_35), + (36, _migrate_to_36), ) diff --git a/hermes_cli/dashboard_auth/native_flow.py b/hermes_cli/dashboard_auth/native_flow.py index f7c471b9f02a3..5dfe800c27475 100644 --- a/hermes_cli/dashboard_auth/native_flow.py +++ b/hermes_cli/dashboard_auth/native_flow.py @@ -37,6 +37,15 @@ ``Authorization: Bearer `` (via the existing ``token_auth`` seam) and mints ws-tickets the same way — no cookies anywhere. +Password providers ride the same broker with step 2 swapped: there is no +upstream IDP, so ``/auth/native/authorize`` sends the system browser to the +interactive ``/login`` form (broker_state in the PKCE cookie) and a successful +``/auth/password-login`` plays the role of the upstream callback — it calls +:func:`complete_pending` and bounces the browser to the loopback redirect. +Steps 4–5 are identical. The point of brokering a password login at all is +that the system browser can autofill from the OS password manager (macOS +Passwords, etc.), which no embedded desktop webview can. + Security properties this module guarantees: * **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 15af8077fd23c..91bcec3e8473a 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -304,6 +304,15 @@ async def auth_native_authorize( ``/auth/callback``), carrying the broker_state in the same PKCE cookie the cookie flow uses. On the callback we mint a loopback code (see ``auth_callback``); no browser session cookie is ever set for the desktop. + + Password providers have no upstream IDP round trip to broker, but the + native flow is still exactly what they want: it moves sign-in out of the + desktop's embedded webview (where OS password managers cannot autofill) + into the SYSTEM browser (where they can). For a ``supports_password`` + provider we redirect to the interactive ``/login`` form instead of an + IDP, carrying the broker_state in the PKCE cookie; a successful + ``/auth/password-login`` then completes the pending authorization and + bounces the browser to the loopback redirect (see that route). """ # PKCE method must be S256 (RFC 7636 — plain is disallowed for native apps). if code_challenge_method.upper() != "S256": @@ -344,14 +353,11 @@ async def auth_native_authorize( raise HTTPException( status_code=404, detail=f"Unknown provider: {provider!r}" ) - if not getattr(p, "supports_session", True) or getattr( - p, "supports_password", False - ): - # Native PKCE brokering is only meaningful for redirect/OAuth - # providers; a password provider has no IDP round trip to broker. + if not getattr(p, "supports_session", True): + # Token-only credentials (e.g. drain) are not interactive sign-ins. raise HTTPException( status_code=400, - detail=f"Provider does not support native OAuth login: {p.name!r}", + detail=f"Provider does not support native login: {p.name!r}", ) from hermes_cli.dashboard_auth import native_flow @@ -366,6 +372,30 @@ async def auth_native_authorize( except native_flow.NativeFlowError as e: raise HTTPException(status_code=503, detail=str(e)) + if getattr(p, "supports_password", False): + # Password provider: no IDP to redirect through. Land the system + # browser on the interactive /login form with the broker_state in + # the PKCE cookie (the same server-controlled channel the OAuth + # branch uses); /auth/password-login picks it up on success and + # 302s the browser to the desktop's loopback redirect_uri. The + # desktop's challenge/state never touch the cookie — only our + # opaque broker_state does. + audit_log( + AuditEvent.NATIVE_AUTHORIZE_START, + provider=p.name, + ip=_client_ip(request), + ) + resp = RedirectResponse( + url=f"{_prefix(request)}/login", status_code=302 + ) + set_pkce_cookie( + resp, + payload=f"provider={p.name};broker={broker_state}", + use_https=detect_https(request), + prefix=_prefix(request), + ) + return resp + try: ls = p.start_login(redirect_uri=_redirect_uri(request)) except ProviderError as e: @@ -674,6 +704,15 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody): the credential form POSTs via fetch and navigates client-side, so a 302 (which fetch follows opaquely) is the wrong shape here. + RFC 8252 native-app branch: when ``/auth/native/authorize`` sent this + browser to ``/login`` (password provider), the PKCE cookie carries the + opaque ``broker=`` handle. Mirroring the ``/auth/callback`` native + branch, success then mints a one-time loopback code instead of a + browser session: ``next`` is the desktop's loopback redirect_uri + (validated at authorize time) carrying ``code`` + ``state``, and NO + session cookies are set — the desktop redeems the code at + ``/auth/native/token`` for bearer tokens it stores itself. + Failure modes, all deliberately generic so the endpoint can't be used as a username oracle or a provider-enumeration oracle: * unknown provider / provider lacks password support → 404 @@ -706,6 +745,41 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody): ) raise HTTPException(status_code=404, detail="Unknown provider") + # Native-app branch discriminator (see docstring): a broker handle in + # the PKCE cookie means this sign-in was initiated by + # /auth/native/authorize for a desktop app, not a browser session. The + # cookie is server-set (never client-supplied), so it is trustworthy — + # and it also records WHICH provider the native flow was initiated for. + # /login renders a form for every session provider, so without this + # check a flow started for provider A could be completed with provider + # B's credentials, binding B's session into A's pending authorization. + # Enforce equality BEFORE verifying credentials: nothing is minted, the + # pending authorization is preserved, and the user can submit the form + # the flow was actually started for. + broker_state = "" + cookie_provider = "" + pkce_raw = read_pkce_cookie(request) + if pkce_raw: + pkce_parts = dict( + seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg + ) + broker_state = pkce_parts.get("broker", "") + cookie_provider = pkce_parts.get("provider", "") + if broker_state and cookie_provider != body.provider: + audit_log( + AuditEvent.NATIVE_TOKEN_FAILURE, + provider=body.provider, + reason="provider_mismatch", + ip=ip, + ) + raise HTTPException( + status_code=400, + detail=( + "This native sign-in was started for a different provider; " + "use that provider's form or restart sign-in." + ), + ) + try: session = p.complete_password_login( username=body.username, password=body.password @@ -741,6 +815,48 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody): ip=ip, ) + # Native-app branch: the broker handle was parsed (and its provider + # binding enforced) above, before credential verification. + if broker_state: + from hermes_cli.dashboard_auth import native_flow + + try: + pending = native_flow.get_pending(broker_state) + gw_code = native_flow.complete_pending( + broker_state, session=session + ) + except native_flow.NativeFlowError: + audit_log( + AuditEvent.NATIVE_TOKEN_FAILURE, + provider=body.provider, + reason="pending_not_found", + ip=ip, + ) + raise HTTPException( + status_code=400, + detail="Native login expired or unknown; restart sign-in.", + ) + from urllib.parse import urlencode + + sep = "&" if "?" in pending.redirect_uri else "?" + loopback = ( + f"{pending.redirect_uri}{sep}" + f"{urlencode({'code': gw_code, 'state': pending.client_state})}" + ) + audit_log( + AuditEvent.NATIVE_CODE_ISSUED, + provider=body.provider, + user_id=session.user_id, + ip=ip, + ) + # The login page's form script navigates to ``next`` — here the + # loopback listener, which answers with its own "you can close + # this window" page. No session cookies: the desktop is not a + # browser session (mirrors the /auth/callback native branch). + resp = JSONResponse({"ok": True, "next": loopback}) + clear_pkce_cookie(resp, prefix=_prefix(request)) + return resp + expires_in = max(60, session.expires_at - int(time.time())) landing = _validate_post_login_target(body.next) or "/" resp = JSONResponse({"ok": True, "next": landing}) diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py new file mode 100644 index 0000000000000..f496907c2d0c7 --- /dev/null +++ b/hermes_cli/loops.py @@ -0,0 +1,970 @@ +"""Recurring in-session wakeups — the /loop command (Claude Code parity). + +``/loop [interval] `` re-runs a prompt (or a slash command) on a +recurring cadence INSIDE the current session. Each tick is a real agent +turn: the wakeup prompt is injected through the exact same input path as +a typed user message, so the agent always sees current state (latest CI +result, newest queue depth, the file as it is now). + +Two cadence modes, mirroring Claude Code's ``/loop``: + +- **Fixed interval** — ``/loop 5m check the deploy`` fires every 5 minutes. +- **Self-paced** — ``/loop keep refining the failing test until green`` + (no interval token) lets the loop set its own rhythm: it starts fast and + backs off exponentially while the agent's replies stop changing, then + snaps back to the floor as soon as a reply differs. Zero extra LLM cost — + change detection is a local digest comparison. + +Stop conditions (any of): + +- The agent ends a wakeup reply with ``LOOP_COMPLETE`` on its own line + (the wakeup prompt teaches it to do so when the task is done/moot). +- ``--times N`` — stop after N ticks. +- ``--until `` — an evidence-based stop judged by the same + auxiliary judge that powers /goal (fail-open: a broken judge never + wedges the loop; the tick budget is the backstop). +- ``/loop stop`` / ``/loop clear`` — user control. +- ``loops.max_ticks`` config backstop (default 100, 0 = unlimited). + +Design notes / invariants (same contract as ``hermes_cli/goals.py``): + +- A wakeup is just a normal user-role message appended via the surface's + ordinary input path. No system-prompt mutation, no toolset swap — + prompt caching stays intact and role alternation is preserved. +- Wakeups only fire while the session is IDLE. A real user message always + wins; the tick just re-arms and fires at the next idle boundary. +- State is persisted in SessionDB's ``state_meta`` table keyed by + ``loop:`` so ``/resume`` picks the loop back up. +- /goal mixing: an active /goal takes priority. When the goal loop has a + continuation queued (or the goal judge is mid-flight), the /loop tick + defers to the next interval instead of racing a second synthetic turn. + Goal-continuation turns never count as loop ticks and vice versa. +- This module has zero hard dependency on ``cli.HermesCLI``, the gateway + runner, or the TUI gateway — all three drive the same ``LoopManager``. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import re +import time +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────── +# Constants & defaults +# ────────────────────────────────────────────────────────────────────── + +# Floor for fixed intervals. Claude Code allows 30s; anything tighter is +# almost always an accident that burns tokens polling state that hasn't +# changed. Overridable via loops.min_interval_seconds (still clamped ≥ 5). +DEFAULT_MIN_INTERVAL_SECONDS = 30 + +# Backstop tick budget so an unattended loop can't run forever by default. +# 0 = unlimited (Claude Code behavior); config loops.max_ticks. +DEFAULT_MAX_TICKS = 100 + +# Self-paced mode: start at the floor, double while replies are unchanged, +# cap at the ceiling, snap back to the floor on any change. +DEFAULT_SELF_PACED_FLOOR_SECONDS = 60 +DEFAULT_SELF_PACED_CEILING_SECONDS = 15 * 60 + +# The completion sentinel the wakeup prompt teaches the agent to emit when +# the loop's task is finished or no longer applicable. +LOOP_COMPLETE_MARKER = "LOOP_COMPLETE" + +# Matches the marker on its own line (possibly with surrounding whitespace +# or trailing punctuation the model added despite instructions). +_LOOP_COMPLETE_RE = re.compile( + r"(?im)^\s*" + re.escape(LOOP_COMPLETE_MARKER) + r"\s*[.!]?\s*$" +) + +# Interval token: 30s / 5m / 2h / 1h30m (compound units allowed, at least one). +_INTERVAL_TOKEN_RE = re.compile( + r"^(?=\d)(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", re.IGNORECASE +) + + +WAKEUP_PROMPT_TEMPLATE = ( + "[/loop wakeup #{tick}{cadence}]\n" + "Recurring task: {prompt}\n\n" + "This is an automatic wakeup from the /loop the user set. Perform the " + "task now against the CURRENT state (re-check files, processes, or " + "services fresh — do not assume anything from earlier iterations still " + "holds). Report concisely what you found or did this iteration.\n" + "If the task is now complete, no longer applicable, or the thing you " + "were watching has finished, say so and end your reply with " + f"{LOOP_COMPLETE_MARKER} on its own line — that stops the loop." +) + +WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE = ( + "[/loop wakeup #{tick}{cadence}]\n" + "Recurring task: {prompt}\n\n" + "Stop condition: {until}\n\n" + "This is an automatic wakeup from the /loop the user set. Perform the " + "task now against the CURRENT state (re-check files, processes, or " + "services fresh — do not assume anything from earlier iterations still " + "holds). Report concisely what you found or did this iteration, and " + "show concrete evidence of the stop condition's status.\n" + "If the stop condition is met, or the task is no longer applicable, say " + f"so and end your reply with {LOOP_COMPLETE_MARKER} on its own line — " + "that stops the loop." +) + + +# ────────────────────────────────────────────────────────────────────── +# Interval parsing +# ────────────────────────────────────────────────────────────────────── + + +def parse_interval_token(token: str) -> Optional[int]: + """Parse a compact interval token (``30s``/``5m``/``2h``/``1h30m``). + + Returns total seconds, or None when the token is not an interval. + A bare number is NOT an interval (too easy to collide with prompt + text like ``/loop 3 things to check``) — units are required. + """ + if not token: + return None + m = _INTERVAL_TOKEN_RE.match(token.strip()) + if not m: + return None + h, mnt, s = (int(g) if g else 0 for g in m.groups()) + total = h * 3600 + mnt * 60 + s + return total if total > 0 else None + + +def parse_loop_args(text: str) -> Dict[str, Any]: + """Parse the argument string of ``/loop [interval] [flags]``. + + Recognized shapes:: + + /loop 5m check the deploy status + /loop every 10m /babysit-prs + /loop keep fixing the failing test until the suite passes + /loop 2m poll CI --times 30 + /loop 5m watch the queue --until queue depth reaches zero + + Returns ``{"interval_seconds": int|None, "prompt": str, "times": int, + "until": str, "error": str|None}``. ``interval_seconds`` None means + self-paced. ``error`` is set for unusable input (empty prompt, + interval-only, bad --times). + """ + raw = (text or "").strip() + result: Dict[str, Any] = { + "interval_seconds": None, + "prompt": "", + "times": 0, + "until": "", + "error": None, + } + if not raw: + result["error"] = "empty" + return result + + # Pull trailing flags first so an interval-looking token inside the + # --until clause can't confuse the front parse. Flags may appear in + # either order at the end of the line; --until consumes to end-of-line + # (or to a following --times). + times = 0 + until = "" + + m_times = re.search(r"\s--times\s+(\S+)", raw) + if m_times: + try: + times = int(m_times.group(1)) + if times < 1: + raise ValueError + except ValueError: + result["error"] = f"--times expects a positive integer, got {m_times.group(1)!r}" + return result + raw = (raw[: m_times.start()] + raw[m_times.end():]).strip() + + m_until = re.search(r"\s--until\s+(.+)$", raw, re.DOTALL) + if m_until: + until = m_until.group(1).strip() + raw = raw[: m_until.start()].strip() + + # Leading "every" sugar: /loop every 5m + tokens = raw.split(None, 1) + if tokens and tokens[0].lower() == "every" and len(tokens) > 1: + raw = tokens[1] + tokens = raw.split(None, 1) + + interval: Optional[int] = None + if tokens: + maybe = parse_interval_token(tokens[0]) + if maybe is not None: + interval = maybe + raw = tokens[1].strip() if len(tokens) > 1 else "" + + if not raw: + result["error"] = "missing prompt (usage: /loop [interval] )" + return result + + result["interval_seconds"] = interval + result["prompt"] = raw + result["times"] = times + result["until"] = until + return result + + +def format_interval(seconds: float) -> str: + """Render seconds as a compact human interval (``90`` → ``1m30s``).""" + seconds = int(max(0, round(seconds))) + h, rem = divmod(seconds, 3600) + m, s = divmod(rem, 60) + parts = [] + if h: + parts.append(f"{h}h") + if m: + parts.append(f"{m}m") + if s or not parts: + parts.append(f"{s}s") + return "".join(parts) + + +# ────────────────────────────────────────────────────────────────────── +# Config +# ────────────────────────────────────────────────────────────────────── + + +def _loops_config() -> Dict[str, Any]: + """Read the ``loops:`` config section (cached load_config underneath).""" + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + section = cfg.get("loops") or {} + return section if isinstance(section, dict) else {} + except Exception: + return {} + + +def min_interval_seconds() -> int: + try: + value = int(_loops_config().get("min_interval_seconds", DEFAULT_MIN_INTERVAL_SECONDS)) + return max(5, value) + except Exception: + return DEFAULT_MIN_INTERVAL_SECONDS + + +def max_ticks_default() -> int: + try: + value = int(_loops_config().get("max_ticks", DEFAULT_MAX_TICKS)) + return max(0, value) + except Exception: + return DEFAULT_MAX_TICKS + + +def self_paced_floor_seconds() -> int: + try: + value = int(_loops_config().get("self_paced_floor_seconds", DEFAULT_SELF_PACED_FLOOR_SECONDS)) + return max(10, value) + except Exception: + return DEFAULT_SELF_PACED_FLOOR_SECONDS + + +def self_paced_ceiling_seconds() -> int: + floor = self_paced_floor_seconds() + try: + value = int(_loops_config().get("self_paced_ceiling_seconds", DEFAULT_SELF_PACED_CEILING_SECONDS)) + return max(floor, value) + except Exception: + return max(floor, DEFAULT_SELF_PACED_CEILING_SECONDS) + + +# ────────────────────────────────────────────────────────────────────── +# Dataclass +# ────────────────────────────────────────────────────────────────────── + + +@dataclass +class LoopState: + """Serializable /loop state stored per session.""" + + prompt: str + status: str = "active" # active | paused | done | cleared + mode: str = "interval" # interval | self_paced + interval_seconds: float = 0.0 # fixed cadence (mode == "interval") + current_delay: float = 0.0 # live cadence (self-paced backoff) + times: int = 0 # user cap (--times N); 0 = none + until: str = "" # judged stop condition; "" = none + max_ticks: int = DEFAULT_MAX_TICKS # config backstop; 0 = unlimited + ticks_fired: int = 0 + created_at: float = 0.0 + last_fired_at: float = 0.0 + next_due_at: float = 0.0 + # True between "wakeup injected" and "that turn's response evaluated". + # Keeps a tick from double-firing while its turn is still running and + # tells the post-turn hook that the turn that just ended was ours. + awaiting_response: bool = False + # Self-paced change detection: digest of the previous wakeup's reply. + last_response_digest: str = "" + paused_reason: Optional[str] = None + last_stop_reason: Optional[str] = None + # Gateway routing captured at creation time (platform / chat_id / + # chat_type / thread_id) so the idle wakeup watcher can inject the + # tick back into the right chat. Empty for CLI / TUI sessions, which + # drive ticks from their own session-local schedulers. + route: Dict[str, str] = field(default_factory=dict) + + def to_json(self) -> str: + return json.dumps(asdict(self), ensure_ascii=False) + + @classmethod + def from_json(cls, raw: str) -> "LoopState": + data = json.loads(raw) + route = data.get("route") + return cls( + prompt=data.get("prompt", ""), + status=data.get("status", "active"), + mode=data.get("mode", "interval"), + interval_seconds=float(data.get("interval_seconds", 0.0) or 0.0), + current_delay=float(data.get("current_delay", 0.0) or 0.0), + times=int(data.get("times", 0) or 0), + until=str(data.get("until", "") or ""), + max_ticks=int(data.get("max_ticks", DEFAULT_MAX_TICKS) or 0), + ticks_fired=int(data.get("ticks_fired", 0) or 0), + created_at=float(data.get("created_at", 0.0) or 0.0), + last_fired_at=float(data.get("last_fired_at", 0.0) or 0.0), + next_due_at=float(data.get("next_due_at", 0.0) or 0.0), + awaiting_response=bool(data.get("awaiting_response", False)), + last_response_digest=str(data.get("last_response_digest", "") or ""), + paused_reason=data.get("paused_reason"), + last_stop_reason=data.get("last_stop_reason"), + route=route if isinstance(route, dict) else {}, + ) + + # --- helpers ------------------------------------------------------- + + def cadence_label(self) -> str: + if self.mode == "self_paced": + live = f", currently {format_interval(self.current_delay)}" if self.current_delay else "" + return f"self-paced{live}" + return f"every {format_interval(self.interval_seconds)}" + + def remaining_label(self) -> str: + if self.status != "active": + return "" + remaining = self.next_due_at - time.time() + if remaining <= 0: + return "due now" + return f"next in {format_interval(remaining)}" + + +# ────────────────────────────────────────────────────────────────────── +# Persistence (SessionDB state_meta) +# ────────────────────────────────────────────────────────────────────── + +_META_PREFIX = "loop:" + + +def _meta_key(session_id: str) -> str: + return f"{_META_PREFIX}{session_id}" + + +_DB_CACHE: Dict[str, Any] = {} + + +def _get_session_db() -> Optional[Any]: + """One SessionDB per HERMES_HOME (same pattern as goals._get_session_db).""" + try: + from hermes_constants import get_hermes_home + from hermes_state import SessionDB + + home = str(get_hermes_home()) + except Exception as exc: # pragma: no cover + logger.debug("LoopManager: SessionDB bootstrap failed (%s)", exc) + return None + + cached = _DB_CACHE.get(home) + if cached is not None: + return cached + try: + db = SessionDB() + except Exception as exc: # pragma: no cover + logger.debug("LoopManager: SessionDB() raised (%s)", exc) + return None + _DB_CACHE[home] = db + return db + + +def load_loop(session_id: str) -> Optional[LoopState]: + """Load the loop for a session, or None if none exists.""" + if not session_id: + return None + db = _get_session_db() + if db is None: + return None + try: + raw = db.get_meta(_meta_key(session_id)) + except Exception as exc: + logger.debug("LoopManager: get_meta failed: %s", exc) + return None + if not raw: + return None + try: + return LoopState.from_json(raw) + except Exception as exc: + logger.warning("LoopManager: could not parse stored loop for %s: %s", session_id, exc) + return None + + +def save_loop(session_id: str, state: LoopState) -> None: + """Persist a loop to SessionDB. No-op if DB unavailable.""" + if not session_id: + return + db = _get_session_db() + if db is None: + return + try: + db.set_meta(_meta_key(session_id), state.to_json()) + except Exception as exc: + logger.debug("LoopManager: set_meta failed: %s", exc) + + +def clear_loop(session_id: str) -> None: + """Mark a loop cleared in the DB (preserved for audit, status=cleared).""" + state = load_loop(session_id) + if state is None: + return + state.status = "cleared" + save_loop(session_id, state) + + +def list_active_loops() -> List[Tuple[str, LoopState]]: + """Return ``[(session_id, LoopState), ...]`` for every ACTIVE loop. + + Used by the gateway's idle wakeup watcher, which has no per-session + scheduler and instead scans for due loops on a coarse tick. Best-effort: + any DB error yields ``[]``. + """ + db = _get_session_db() + if db is None: + return [] + try: + rows = db.list_meta_prefix(_META_PREFIX) + except Exception as exc: + logger.debug("LoopManager: list_meta_prefix failed: %s", exc) + return [] + out: List[Tuple[str, LoopState]] = [] + for key, raw in rows: + session_id = key[len(_META_PREFIX):] + if not session_id or not raw: + continue + try: + state = LoopState.from_json(raw) + except Exception: + continue + if state.status == "active": + out.append((session_id, state)) + return out + + +def migrate_loop_to_session(old_session_id: str, new_session_id: str, *, reason: str = "") -> bool: + """Carry a persistent /loop from a parent session to its continuation. + + Context compression rotates ``session_id`` to a fresh child session; + without this the loop silently dies at the compaction boundary (the + same hazard /goal hit in #33618). Copies the loop onto the new session + and archives the old row as ``cleared`` so exactly one active loop row + exists per logical conversation. Best-effort and never raises. + """ + if not old_session_id or not new_session_id or old_session_id == new_session_id: + return False + try: + state = load_loop(old_session_id) + if state is None or state.status == "cleared": + return False + if load_loop(new_session_id) is not None: + return False + save_loop(new_session_id, state) + clear_loop(old_session_id) + logger.debug( + "LoopManager: migrated loop %s -> %s (%s)", + old_session_id, new_session_id, reason or "rotation", + ) + return True + except Exception as exc: # pragma: no cover - defensive + logger.debug("LoopManager: loop migration failed: %s", exc) + return False + + +# ────────────────────────────────────────────────────────────────────── +# Response evaluation helpers +# ────────────────────────────────────────────────────────────────────── + + +def response_signals_complete(response: str) -> bool: + """True when the agent ended its reply with the LOOP_COMPLETE marker.""" + if not response: + return False + return _LOOP_COMPLETE_RE.search(response) is not None + + +def _digest_response(response: str) -> str: + """Stable digest for self-paced change detection. + + Normalizes whitespace and strips volatile timestamp-ish tokens so a + reply that differs only by 'checked at 14:02:33' doesn't defeat the + backoff. + """ + text = (response or "").strip().lower() + # Drop clock/timestamp tokens (14:02:33, 2026-07-26, 1500s, 25m ago...). + text = re.sub(r"\d{1,2}:\d{2}(:\d{2})?", "", text) + text = re.sub(r"\d{4}-\d{2}-\d{2}", "", text) + text = re.sub(r"\b\d+(\.\d+)?\s*(s|sec|secs|seconds|m|min|mins|minutes|h|hr|hrs|hours)\b", "", text) + text = re.sub(r"\s+", " ", text) + return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest() + + +# ────────────────────────────────────────────────────────────────────── +# LoopManager — the orchestration surface CLI + gateway + TUI talk to +# ────────────────────────────────────────────────────────────────────── + + +class LoopManager: + """Per-session /loop state + tick decisions. + + Drivers (CLI process_loop, gateway wakeup watcher, TUI ticker) call: + + - ``set(...)`` / ``pause()`` / ``resume()`` / ``clear()`` — user controls. + - ``is_due()`` — should a wakeup fire now? (cheap, in-memory) + - ``fire_tick()`` — claim the tick; returns the wakeup message to inject. + - ``complete_tick(last_response)`` — evaluate the finished wakeup turn: + detect LOOP_COMPLETE, judge --until, apply --times / max_ticks caps, + schedule the next tick (with self-paced backoff when applicable). + - ``status_line()`` — printable one-liner. + """ + + def __init__(self, session_id: str): + self.session_id = session_id + self._state: Optional[LoopState] = load_loop(session_id) + + # --- introspection ------------------------------------------------ + + @property + def state(self) -> Optional[LoopState]: + return self._state + + def refresh(self) -> None: + """Re-read state from the DB (cross-process safety for the gateway).""" + self._state = load_loop(self.session_id) + + def is_active(self) -> bool: + return self._state is not None and self._state.status == "active" + + def has_loop(self) -> bool: + return self._state is not None and self._state.status in {"active", "paused"} + + def status_line(self) -> str: + s = self._state + if s is None or s.status == "cleared": + return "No loop set. Start one with /loop [interval] ." + fired = f"{s.ticks_fired} tick{'s' if s.ticks_fired != 1 else ''}" + caps = [] + if s.times: + caps.append(f"{s.ticks_fired}/{s.times} runs") + elif s.max_ticks: + caps.append(f"{s.ticks_fired}/{s.max_ticks} budget") + else: + caps.append(fired) + if s.until: + caps.append(f"until: {s.until}") + meta = f"{s.cadence_label()}, {', '.join(caps)}" + if s.status == "active": + remaining = s.remaining_label() + tail = f", {remaining}" if remaining else "" + if s.awaiting_response: + tail = ", wakeup running" + return f"↻ Loop (active, {meta}{tail}): {s.prompt}" + if s.status == "paused": + extra = f" — {s.paused_reason}" if s.paused_reason else "" + return f"⏸ Loop (paused, {meta}{extra}): {s.prompt}" + if s.status == "done": + extra = f" — {s.last_stop_reason}" if s.last_stop_reason else "" + return f"✓ Loop finished ({fired}{extra}): {s.prompt}" + return f"Loop ({s.status}, {meta}): {s.prompt}" + + # --- mutation ----------------------------------------------------- + + def set( + self, + prompt: str, + *, + interval_seconds: Optional[int] = None, + times: int = 0, + until: str = "", + route: Optional[Dict[str, str]] = None, + ) -> LoopState: + """Start a new loop (replaces any existing one for the session).""" + prompt = (prompt or "").strip() + if not prompt: + raise ValueError("loop prompt is empty") + + now = time.time() + if interval_seconds is not None: + interval = max(int(interval_seconds), min_interval_seconds()) + state = LoopState( + prompt=prompt, + mode="interval", + interval_seconds=float(interval), + current_delay=float(interval), + next_due_at=now + interval, + ) + else: + floor = self_paced_floor_seconds() + state = LoopState( + prompt=prompt, + mode="self_paced", + interval_seconds=0.0, + current_delay=float(floor), + next_due_at=now + floor, + ) + state.times = max(0, int(times or 0)) + state.until = (until or "").strip() + state.max_ticks = max_ticks_default() + state.created_at = now + state.route = dict(route or {}) + self._state = state + save_loop(self.session_id, state) + return state + + def pause(self, reason: str = "user-paused") -> Optional[LoopState]: + if not self._state or self._state.status not in {"active", "paused"}: + return None + self._state.status = "paused" + self._state.paused_reason = reason + self._state.awaiting_response = False + save_loop(self.session_id, self._state) + return self._state + + def resume(self) -> Optional[LoopState]: + if not self._state or self._state.status == "cleared": + return None + self._state.status = "active" + self._state.paused_reason = None + self._state.awaiting_response = False + # Re-arm relative to now so a long pause doesn't fire instantly N times. + delay = self._state.current_delay or self._state.interval_seconds or self_paced_floor_seconds() + self._state.next_due_at = time.time() + min(delay, 5.0) + save_loop(self.session_id, self._state) + return self._state + + def clear(self) -> bool: + if self._state is None or self._state.status == "cleared": + return False + self._state.status = "cleared" + save_loop(self.session_id, self._state) + self._state = None + return True + + def mark_done(self, reason: str) -> None: + if not self._state: + return + self._state.status = "done" + self._state.last_stop_reason = reason + self._state.awaiting_response = False + save_loop(self.session_id, self._state) + + # --- tick lifecycle ------------------------------------------------- + + def is_due(self, now: Optional[float] = None) -> bool: + """Cheap check: active, not mid-wakeup, and the clock has passed.""" + s = self._state + if s is None or s.status != "active" or s.awaiting_response: + return False + return (now if now is not None else time.time()) >= s.next_due_at + + def fire_tick(self) -> Optional[str]: + """Claim a due tick. Returns the message to inject, or None. + + The returned text is either the wakeup-framed prompt or — when the + loop's prompt is itself a slash command (``/loop 10m /recap``) — + the raw command so the surface's normal slash dispatch handles it. + Marks ``awaiting_response`` so the tick can't double-fire while its + turn runs; drivers MUST follow up with ``complete_tick`` (or + ``abandon_tick`` on injection failure). + """ + s = self._state + if s is None or not self.is_due(): + return None + s.ticks_fired += 1 + s.last_fired_at = time.time() + s.awaiting_response = True + # Provisionally schedule the next tick from NOW; complete_tick + # reschedules from turn end (so a 10-minute turn doesn't cause an + # instant re-fire), but if the process dies mid-turn the provisional + # schedule keeps the persisted loop from being 'due' in a tight loop. + delay = s.current_delay or s.interval_seconds or self_paced_floor_seconds() + s.next_due_at = s.last_fired_at + delay + save_loop(self.session_id, s) + + if s.prompt.lstrip().startswith("/"): + return s.prompt.strip() + cadence = f", {s.cadence_label()}" if s.mode == "interval" else ", self-paced" + template = WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE if s.until else WAKEUP_PROMPT_TEMPLATE + return template.format(tick=s.ticks_fired, cadence=cadence, prompt=s.prompt, until=s.until) + + def abandon_tick(self) -> None: + """Roll back a fired tick whose injection failed (nothing ran).""" + s = self._state + if s is None or not s.awaiting_response: + return + s.awaiting_response = False + s.ticks_fired = max(0, s.ticks_fired - 1) + save_loop(self.session_id, s) + + def complete_tick(self, last_response: str) -> Dict[str, Any]: + """Evaluate the finished wakeup turn and schedule what's next. + + Returns a decision dict:: + + {"status": "active|done|paused", "stopped": bool, + "reason": str, "message": str} + + ``message`` is a user-visible one-liner ("" when nothing worth + saying — the common still-looping case stays quiet). + """ + s = self._state + if s is None or not s.awaiting_response: + return {"status": s.status if s else None, "stopped": False, "reason": "no tick in flight", "message": ""} + s.awaiting_response = False + now = time.time() + + # 1. Agent self-stop marker. + if response_signals_complete(last_response): + s.status = "done" + s.last_stop_reason = "agent signaled the task is complete" + save_loop(self.session_id, s) + return { + "status": "done", + "stopped": True, + "reason": s.last_stop_reason, + "message": f"✓ Loop finished after {s.ticks_fired} tick{'s' if s.ticks_fired != 1 else ''} — task complete.", + } + + # 2. Evidence-based --until judge (reuses the /goal judge; fail-open). + if s.until and (last_response or "").strip(): + try: + from hermes_cli.goals import judge_goal + + verdict, reason, _pf, _wait, _tf = judge_goal(s.until, last_response) + except Exception as exc: + verdict, reason = "continue", f"judge unavailable: {type(exc).__name__}" + if verdict == "done": + s.status = "done" + s.last_stop_reason = f"stop condition met: {reason}" + save_loop(self.session_id, s) + return { + "status": "done", + "stopped": True, + "reason": s.last_stop_reason, + "message": f"✓ Loop finished after {s.ticks_fired} tick{'s' if s.ticks_fired != 1 else ''} — {reason}", + } + + # 3. --times user cap. + if s.times and s.ticks_fired >= s.times: + s.status = "done" + s.last_stop_reason = f"completed the requested {s.times} runs" + save_loop(self.session_id, s) + return { + "status": "done", + "stopped": True, + "reason": s.last_stop_reason, + "message": f"✓ Loop finished — ran {s.times}/{s.times} times.", + } + + # 4. Config backstop budget → pause (recoverable), not done. + if s.max_ticks and s.ticks_fired >= s.max_ticks: + s.status = "paused" + s.paused_reason = f"tick budget exhausted ({s.ticks_fired}/{s.max_ticks})" + save_loop(self.session_id, s) + return { + "status": "paused", + "stopped": True, + "reason": s.paused_reason, + "message": ( + f"⏸ Loop paused — {s.ticks_fired}/{s.max_ticks} ticks used " + "(loops.max_ticks). /loop resume to keep going, /loop stop to end it." + ), + } + + # 5. Still looping — schedule the next tick from turn end. + if s.mode == "self_paced": + digest = _digest_response(last_response) + floor = self_paced_floor_seconds() + ceiling = self_paced_ceiling_seconds() + if digest and digest == s.last_response_digest: + # Nothing changed — back off. + s.current_delay = min(max(s.current_delay, floor) * 2, ceiling) + else: + s.current_delay = float(floor) + s.last_response_digest = digest + else: + s.current_delay = s.interval_seconds + s.next_due_at = now + s.current_delay + save_loop(self.session_id, s) + return { + "status": "active", + "stopped": False, + "reason": "loop continues", + "message": "", + } + + +# ────────────────────────────────────────────────────────────────────── +# /goal mixing +# ────────────────────────────────────────────────────────────────────── + + +def goal_blocks_loop_tick(session_id: str) -> bool: + """True when an ACTIVE /goal should defer this session's /loop tick. + + Both features inject synthetic continuation turns at idle boundaries. + When a goal is actively driving the session (status ``active`` and not + parked on a wait barrier), its judge-driven continuations own the idle + boundary — firing a loop wakeup in between would interleave two + synthetic conversations and burn the goal's turn budget on loop chatter. + A goal that is parked (waiting on a pid/session/deadline), paused, or + done does NOT block the loop. + """ + try: + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id=session_id) + if not mgr.is_active(): + return False + # Parked goal → the loop may use the idle time. + return not mgr.is_waiting() + except Exception: + return False + + +# ────────────────────────────────────────────────────────────────────── +# Shared slash-command dispatch (CLI + gateway + TUI use the same logic) +# ────────────────────────────────────────────────────────────────────── + + +def dispatch_loop_command( + mgr: "LoopManager", + args: str, + *, + route: Optional[Dict[str, str]] = None, +) -> Dict[str, Any]: + """Surface-agnostic handler for ``/loop ``. + + Returns ``{"output": str, "created": bool}``. ``output`` is ready to + print/send verbatim; each surface only decorates it (dim colors on the + CLI, plain text on messaging platforms). ``route`` is stored on newly + created loops so the gateway's idle watcher can inject wakeups back + into the right chat; CLI/TUI pass None. + """ + arg = (args or "").strip() + lower = arg.lower() + + if not arg or lower == "status": + return {"output": mgr.status_line(), "created": False} + + if lower == "pause": + state = mgr.pause(reason="user-paused") + if state is None: + return {"output": "No loop set.", "created": False} + return {"output": f"⏸ Loop paused: {state.prompt}\nUse /loop resume to continue.", "created": False} + + if lower == "resume": + state = mgr.resume() + if state is None: + return {"output": "No loop to resume.", "created": False} + return { + "output": f"▶ Loop resumed ({state.cadence_label()}): {state.prompt}", + "created": False, + } + + if lower in {"stop", "clear", "cancel"}: + had = mgr.clear() + return {"output": "✓ Loop stopped." if had else "No active loop.", "created": False} + + if lower in {"help", "--help", "-h"}: + return { + "output": ( + "Usage: /loop [interval] [--times N] [--until ]\n" + " /loop 5m check the deploy status — fixed cadence\n" + " /loop every 10m /recap — loop a slash command\n" + " /loop keep fixing tests until green — self-paced (backs off while output is unchanged)\n" + " /loop 2m poll CI --times 30 — stop after 30 runs\n" + " /loop 5m watch the queue --until queue is empty\n" + "Controls: /loop status · /loop pause · /loop resume · /loop stop\n" + "The loop also stops itself when the agent replies with " + f"{LOOP_COMPLETE_MARKER}." + ), + "created": False, + } + + parsed = parse_loop_args(arg) + if parsed["error"]: + if parsed["error"] == "empty": + return {"output": "Usage: /loop [interval] — see /loop help.", "created": False} + return {"output": f"/loop: {parsed['error']}", "created": False} + + replacing = mgr.has_loop() + try: + state = mgr.set( + parsed["prompt"], + interval_seconds=parsed["interval_seconds"], + times=parsed["times"], + until=parsed["until"], + route=route, + ) + except ValueError as exc: + return {"output": f"/loop: {exc}", "created": False} + + lines = [f"↻ Loop set ({state.cadence_label()}): {state.prompt}"] + if parsed["interval_seconds"] is not None and parsed["interval_seconds"] < state.interval_seconds: + lines.append( + f"(interval raised to the {format_interval(state.interval_seconds)} minimum — " + "loops.min_interval_seconds)" + ) + if state.mode == "self_paced": + lines.append( + f"Self-paced: first check in {format_interval(state.current_delay)}; " + f"backs off up to {format_interval(self_paced_ceiling_seconds())} while nothing changes." + ) + if state.times: + lines.append(f"Runs {state.times} time{'s' if state.times != 1 else ''}, then stops.") + if state.until: + lines.append(f"Stops when: {state.until}") + if not state.times and state.max_ticks: + lines.append(f"Backstop budget: {state.max_ticks} ticks (loops.max_ticks; 0 = unlimited).") + lines.append(f"First wakeup {state.remaining_label()}. Controls: /loop status · pause · resume · stop.") + if replacing: + lines.insert(1, "(replaced the previous loop for this session)") + return {"output": "\n".join(lines), "created": True} + + +__all__ = [ + "LoopState", + "LoopManager", + "parse_loop_args", + "parse_interval_token", + "format_interval", + "response_signals_complete", + "goal_blocks_loop_tick", + "load_loop", + "save_loop", + "clear_loop", + "list_active_loops", + "migrate_loop_to_session", + "dispatch_loop_command", + "LOOP_COMPLETE_MARKER", + "WAKEUP_PROMPT_TEMPLATE", + "WAKEUP_PROMPT_WITH_UNTIL_TEMPLATE", + "DEFAULT_MIN_INTERVAL_SECONDS", + "DEFAULT_MAX_TICKS", +] diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b17a63d069130..94e8490cf7f92 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1078,6 +1078,69 @@ def _has_any_provider_configured() -> bool: return False +def _confirm_startup_expensive_model_override(args) -> None: + """Guard startup -m/--provider overrides before the first API call.""" + explicit_model = (getattr(args, "model", None) or "").strip() + explicit_provider = (getattr(args, "provider", None) or "").strip() + if not explicit_model and not explicit_provider: + return + + try: + from hermes_cli.config import load_config + from hermes_cli.model_selection_guards import combined_selection_warning + except Exception as exc: + logger.warning("startup model cost guard unavailable: %s", exc) + return + + try: + model_cfg = (load_config().get("model") or {}) + except Exception as exc: + logger.warning("startup model cost guard could not load config: %s", exc) + model_cfg = {} + if not isinstance(model_cfg, dict): + model_cfg = {} + + model = explicit_model or (model_cfg.get("default") or "").strip() + if not model: + return + provider = (explicit_provider or model_cfg.get("provider") or "").strip() + try: + # Unified registry: cost guard + id-keyed guards (e.g. the + # data-training-tier warning) all fire at startup too. + warning = combined_selection_warning( + model, + provider=provider, + base_url=(model_cfg.get("base_url") or ""), + api_key=(model_cfg.get("api_key") or ""), + ) + except Exception as exc: + logger.warning("startup model cost guard failed for %s/%s: %s", provider, model, exc) + return + if warning is None: + return + + # Cost and provider-routing confirmation is intentionally independent of + # --yolo / --accept-hooks: those flags approve local command/tool risk, not + # paid aggregator spend or a surprising provider route. + message = warning.message + if not sys.stdin.isatty(): + sys.stderr.write(message + "\n") + sys.stderr.write( + "Refusing this startup model override in non-interactive mode. " + "Run interactively and confirm if you intend to use it.\n" + ) + raise SystemExit(1) + + sys.stderr.write(message + "\n") + try: + reply = input("Use this model for this invocation? [y/N] ").strip().lower() + except (EOFError, KeyboardInterrupt): + reply = "" + if reply not in {"y", "yes"}: + sys.stderr.write("Model override cancelled.\n") + raise SystemExit(1) + + def _session_browse_picker(sessions: list) -> Optional[str]: """Interactive curses-based session browser with live search filtering. @@ -2743,6 +2806,7 @@ def _skills_sync_bg() -> None: os.environ["HERMES_SESSION_SOURCE"] = args.source _pin_kanban_board_env() + _confirm_startup_expensive_model_override(args) if use_tui: _launch_tui( @@ -5015,11 +5079,15 @@ def _print_version_info(*, check_updates: bool = True) -> None: # Show update status (synchronous — acceptable since user asked for version info) try: - from hermes_cli.banner import check_for_updates + from hermes_cli.banner import UPDATE_AVAILABLE_NO_COUNT, check_for_updates from hermes_cli.config import recommended_update_command behind = check_for_updates() - if behind and behind > 0: + if behind == UPDATE_AVAILABLE_NO_COUNT: + print( + f"Update available — run '{recommended_update_command()}'" + ) + elif behind and behind > 0: commits_word = "commit" if behind == 1 else "commits" print( f"Update available: {behind} {commits_word} behind — " @@ -11177,6 +11245,7 @@ def _try_fast_chat_launch() -> bool: _prepare_agent_startup(args) if getattr(args, "oneshot", None): + _confirm_startup_expensive_model_override(args) _run_and_exit_oneshot( args.oneshot, model=getattr(args, "model", None), @@ -11233,6 +11302,7 @@ def _try_termux_fast_cli_launch() -> bool: if getattr(args, "oneshot", None): _prepare_agent_startup(args) + _confirm_startup_expensive_model_override(args) _run_and_exit_oneshot( args.oneshot, model=getattr(args, "model", None), @@ -12927,6 +12997,7 @@ def _add_session_filter_args(p, default_older_help): # Handle top-level --oneshot / -z: single-shot mode, stdout = final # response only, nothing else. Bypasses cli.py entirely. if getattr(args, "oneshot", None): + _confirm_startup_expensive_model_override(args) _run_and_exit_oneshot( args.oneshot, model=getattr(args, "model", None), diff --git a/hermes_cli/model_cost_guard.py b/hermes_cli/model_cost_guard.py index fd7e65b8551da..1ed8835b5b61c 100644 --- a/hermes_cli/model_cost_guard.py +++ b/hermes_cli/model_cost_guard.py @@ -6,7 +6,7 @@ from decimal import Decimal, InvalidOperation from typing import Optional -from agent.models_dev import ModelInfo +from agent.models_dev import ModelInfo, PROVIDER_TO_MODELS_DEV INPUT_COST_WARNING_THRESHOLD = Decimal("20") @@ -54,6 +54,40 @@ def _pricing_from_model_info( ) +def _known_models_dev_provider(provider: Optional[str]) -> Optional[str]: + normalized = (provider or "").strip().lower() + if not normalized: + return None + return PROVIDER_TO_MODELS_DEV.get(normalized) + + +def _can_trust_model_info_pricing( + provider: Optional[str], + model_info: Optional[ModelInfo], +) -> bool: + expected_provider = _known_models_dev_provider(provider) + if not expected_provider or model_info is None: + return False + + actual_provider = str(getattr(model_info, "provider_id", "") or "").strip().lower() + return not actual_provider or actual_provider == expected_provider + + +def _can_trust_pricing_lookup( + model_name: str, + *, + provider: Optional[str], + base_url: Optional[str], +) -> bool: + try: + from agent.usage_pricing import resolve_billing_route + + route = resolve_billing_route(model_name, provider=provider, base_url=base_url) + except Exception: + return False + return route.billing_mode != "unknown" + + def expensive_model_warning( model_name: str, *, @@ -71,8 +105,18 @@ def expensive_model_warning( if not model: return None - input_cost, output_cost, source = _pricing_from_model_info(model_info) - if input_cost is None and output_cost is None and provider: + input_cost: Optional[Decimal] = None + output_cost: Optional[Decimal] = None + source = "" + + if _can_trust_model_info_pricing(provider, model_info): + input_cost, output_cost, source = _pricing_from_model_info(model_info) + + if ( + input_cost is None + and output_cost is None + and _known_models_dev_provider(provider) + ): try: from agent.models_dev import get_model_info @@ -81,7 +125,12 @@ def expensive_model_warning( ) except Exception: pass - if input_cost is None and output_cost is None: + + if ( + input_cost is None + and output_cost is None + and _can_trust_pricing_lookup(model, provider=provider, base_url=base_url) + ): try: from agent.usage_pricing import get_pricing_entry @@ -98,13 +147,15 @@ def expensive_model_warning( output_cost = entry.output_cost_per_million source = entry.source + is_known_gpt55_pro_confusion = model.lower() == GPT55_PRO_OPENROUTER_ID + over_input = ( input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD ) over_output = ( output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD ) - if not over_input and not over_output: + if not over_input and not over_output and not is_known_gpt55_pro_confusion: return None lines = [ @@ -120,7 +171,7 @@ def expensive_model_warning( ] if source: lines.append(f"Pricing source: {source}.") - if model.lower() == GPT55_PRO_OPENROUTER_ID: + if is_known_gpt55_pro_confusion: lines.append(GPT55_SUGGESTION) lines.append("Confirm only if you intend to use this model.") diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 12ee2e714fb91..0687fc56e9461 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -39,9 +39,41 @@ COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"] COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"] -def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: float): +def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: float, ssl_context=None): """Open catalog requests without forwarding headers across origins.""" - return open_credentialed_url(req, timeout=timeout) + return open_credentialed_url(req, timeout=timeout, ssl_context=ssl_context) + + +def _custom_provider_ssl_context(base_url: str): + """Build an ``ssl.SSLContext`` from a custom provider's TLS settings. + + Mirrors the httpx/requests TLS resolution so the urllib ``/models`` + discovery probe honors a provider's ``ssl_ca_cert`` / ``ssl_verify`` + instead of falling back to the process-wide ``SSL_CERT_FILE`` / certifi + bundle. Returns None when no per-provider TLS override applies, so the + caller keeps urllib's default policy for public/unconfigured endpoints. + """ + if not base_url: + return None + try: + from hermes_cli.config import get_custom_provider_tls_settings + + tls = get_custom_provider_tls_settings(base_url) + if not tls: + return None + import ssl + + if tls.get("ssl_verify") is False: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + ca = tls.get("ssl_ca_cert") + if isinstance(ca, str) and ca and os.path.isfile(ca): + return ssl.create_default_context(cafile=ca) + except Exception: + return None # never break discovery on a TLS-config lookup + return None # Fallback OpenRouter snapshot used when the live catalog is unavailable. @@ -72,6 +104,7 @@ def _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: floa ("x-ai/grok-4.6", ""), # DeepSeek ("deepseek/deepseek-v4-pro", ""), + ("deepseek/deepseek-v4-pro-0813", "dated snapshot of v4-pro"), ("deepseek/deepseek-v4-flash", ""), ("deepseek/deepseek-v4-flash-0731", "dated snapshot of v4-flash"), # Qwen @@ -245,6 +278,7 @@ def _xai_curated_models() -> list[str]: "x-ai/grok-4.6", # DeepSeek "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-pro-0813", "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash-0731", # Qwen @@ -4628,12 +4662,19 @@ def probe_api_models( headers.update(normalize_extra_headers(request_headers)) + _ssl_context = _custom_provider_ssl_context(normalized) for candidate_base, is_fallback in candidates: url = candidate_base.rstrip("/") + "/models" tried.append(url) req = urllib.request.Request(url, headers=headers) + # Only thread ssl_context when a per-provider TLS override actually + # applies. Public/unconfigured endpoints keep the original 2-arg call, + # so nothing changes for them (and existing call-seam mocks stay valid). + _open_kwargs: dict[str, Any] = {"timeout": timeout} + if _ssl_context is not None: + _open_kwargs["ssl_context"] = _ssl_context try: - with _urlopen_model_catalog_request(req, timeout=timeout) as resp: + with _urlopen_model_catalog_request(req, **_open_kwargs) as resp: data = json.loads(resp.read().decode()) return { "models": [m.get("id", "") for m in data.get("data", [])], diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index 43910be9deb46..2c569b8c636b3 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -14,6 +14,7 @@ WS_CLOSE_PROCESS_EXITED = 4410 WS_CLOSE_SUPERSEDED = 4409 +TUI_FORCE_REDRAW = b"\x0c" class RingBuffer: @@ -78,7 +79,14 @@ async def _drain(self) -> None: except Exception: pass # detached mid-send; keep buffering - async def attach(self, ws) -> None: + async def attach(self, ws, *, force_redraw: bool = False) -> None: + """Attach a browser terminal and replay buffered PTY output. + + The TUI uses an alternate screen and differential rendering, so a + bounded ANSI tail is not guaranteed to be a self-contained frame. + Reattaching a fresh xterm therefore asks the live TUI to emit one + complete redraw after the replay. + """ old = self._ws if old is not None and old is not ws: try: @@ -91,6 +99,8 @@ async def attach(self, ws) -> None: snap = self.buffer.snapshot() if snap: await ws.send_bytes(snap) + if force_redraw: + self.bridge.write(TUI_FORCE_REDRAW) def detach(self, ws) -> None: # Only the currently-attached socket may mark the session detached. diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index bc8b25924b7ca..c24c5c1425460 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -397,9 +397,17 @@ def _copilot_runtime_api_mode( def _parse_api_mode(raw: Any) -> Optional[str]: - """Validate an api_mode value from config. Returns None if invalid.""" + """Validate an api_mode value from config. Returns None if invalid. + + Legacy/alias spellings (``openai``, ``anthropic``, ``responses``, …) are + canonicalized via the shared alias map before validation, so configs + written against older releases keep selecting the transport they named + instead of silently falling through to hostname-based detection. + """ if isinstance(raw, str): - normalized = raw.strip().lower() + from hermes_cli.config import _canonical_api_mode + + normalized = _canonical_api_mode(raw).lower() if normalized in _VALID_API_MODES: return normalized return None diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 69fa685138b62..0f06b115e5c12 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1642,11 +1642,55 @@ def _kill_installer_tree(proc): pass +def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: + """Install the Browser Use CLI if it isn't already runnable. + + The Browser Use CLI 3.0 is the primary driver engine for EVERY browser + backend except Camofox (which is Firefox-based with no CDP surface, so + the CDP-only browser-use harness cannot drive it). Local, Browserbase, + Firecrawl, and the Nous-managed cloud rows all execute through + ``browser_exec`` when the CLI is runnable — so every one of those + picker selections must attempt this install, not just the explicit + "Browser Use" row. Failure is non-fatal: ``browser_exec`` can still run + zero-install via ``uvx browser-use``, and the built-in browser tools + remain the final fallback. + + MANAGED-FIRST: a browser-use on the user's PATH does NOT satisfy this + check — only the Hermes-managed ``$HERMES_HOME/bin`` copy does. + ``install_cli()`` short-circuits on the managed copy and otherwise + provisions it, so resolution always lands on a binary Hermes installs + and updates rather than a user-level side install. + """ + _print_info(" Ensuring browser-use CLI (managed install)...") + try: + from tools.browser_use_cli import install_cli + + ok, message = install_cli() + except Exception as exc: # pragma: no cover — defensive + ok, message = False, f"install failed: {exc}" + if ok: + _print_success(f" {message}") + else: + for line in str(message).splitlines(): + _print_warning(f" {line[:200]}") + if shutil.which("uvx"): + _print_info(" Falling back to zero-install runs via `uvx browser-use`") + else: + _print_info(" Install manually: uv tool install browser-use (https://docs.astral.sh/uv/)") + if verbose_hints: + _print_info(" Local Chrome needs remote debugging: chrome://inspect/#remote-debugging") + _print_info(" Cloud browsers: browser-use auth login (or set BROWSER_USE_API_KEY)") + + def _run_post_setup(post_setup_key: str): """Run post-setup hooks for tools that need extra installation steps.""" from hermes_constants import find_node_executable if post_setup_key in {"agent_browser", "browserbase"}: + # Every non-Camofox browser backend drives through the Browser Use + # CLI when it's runnable — install it here too, not only on the + # explicit "Browser Use" picker row. + _ensure_browser_use_cli() # agent-browser is no longer a root package.json dependency (#43564) # — it resolves lazily via npx (or a global/Hermes-managed install) # instead of a local `npm install`, so there's no node_modules/ @@ -1753,27 +1797,7 @@ def _run_post_setup(post_setup_key: str): _print_info(" Run manually: npx agent-browser install --with-deps") elif post_setup_key == "browser_use_cli": - if shutil.which("browser-use"): - _print_success(" browser-use CLI found on PATH") - else: - _print_info(" Installing browser-use CLI (uv tool install browser-use)...") - try: - from tools.browser_use_cli import install_cli - - ok, message = install_cli() - except Exception as exc: # pragma: no cover — defensive - ok, message = False, f"install failed: {exc}" - if ok: - _print_success(f" {message}") - else: - for line in str(message).splitlines(): - _print_warning(f" {line[:200]}") - if shutil.which("uvx"): - _print_info(" Falling back to zero-install runs via `uvx browser-use`") - else: - _print_info(" Install manually: uv tool install browser-use (https://docs.astral.sh/uv/)") - _print_info(" Local Chrome needs remote debugging: chrome://inspect/#remote-debugging") - _print_info(" Cloud browsers: browser-use auth login (or set BROWSER_USE_API_KEY)") + _ensure_browser_use_cli(verbose_hints=True) elif post_setup_key == "camofox": camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser" diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index a139e52b66cd6..ca2cf99c0c4e8 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -2419,8 +2419,10 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): sys.exit(1) if is_shallow: - # No history to count across the shallow boundary. Compare tip SHAs and - # report presence-only (mirrors the banner's _check_via_local_git). + # No history to count across the shallow boundary. Compare tip SHAs + # (mirrors the banner's _check_via_local_git), then try to recover the + # exact count via the GitHub compare API — the remote graph is complete + # even when the local one is truncated. head_sha = subprocess.run( git_cmd + ["rev-parse", "HEAD"], cwd=_m().PROJECT_ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace", @@ -2432,9 +2434,19 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): if head_sha and target_sha and head_sha == target_sha: print("✓ Already up to date.") else: - print(f"⚕ Update available (behind {compare_branch}).") + from hermes_cli.banner import _github_compare_behind from hermes_cli.config import recommended_update_command + counted = _github_compare_behind(head_sha, target_sha) + if counted == 0: + # Local commits on top of the remote tip — not behind. + print("✓ Already up to date.") + return + if counted is not None: + commits_word = "commit" if counted == 1 else "commits" + print(f"⚕ Update available: {counted} {commits_word} behind {compare_branch}.") + else: + print(f"⚕ Update available (behind {compare_branch}).") print(f" Run '{recommended_update_command()}' to install.") return @@ -4158,7 +4170,12 @@ def _cmd_update_impl(args, gateway_mode: bool): and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())) ) - # Check if there are updates + # Check if there are updates. On shallow checkouts `rev-list --count` + # walks the truncated graph and can report the entire remote ancestry + # (e.g. "Found 9980 new commit(s)" on a depth-1 install — #53479). + # The zero/nonzero gate is still sound (HEAD == origin/ counts + # 0), so keep it, but treat the shallow NUMBER as unknown and recover + # the real one via the GitHub compare API when possible. result = subprocess.run( git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], cwd=_m().PROJECT_ROOT, @@ -4168,6 +4185,33 @@ def _cmd_update_impl(args, gateway_mode: bool): ) commit_count = int(result.stdout.strip()) + apply_is_shallow = ( + subprocess.run( + git_cmd + ["rev-parse", "--is-shallow-repository"], + cwd=_m().PROJECT_ROOT, + capture_output=True, + text=True, encoding="utf-8", errors="replace", + ).stdout.strip() + == "true" + ) + if commit_count > 0 and apply_is_shallow: + from hermes_cli.banner import _github_compare_behind + + head_sha = subprocess.run( + git_cmd + ["rev-parse", "HEAD"], + cwd=_m().PROJECT_ROOT, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ).stdout.strip() + target_sha = subprocess.run( + git_cmd + ["rev-parse", f"origin/{branch}"], + cwd=_m().PROJECT_ROOT, capture_output=True, + text=True, encoding="utf-8", errors="replace", + ).stdout.strip() + counted = _github_compare_behind(head_sha, target_sha) + # counted == 0 means local-ahead (remote tip reachable from HEAD): + # not behind, fall through to the up-to-date path. + commit_count = counted if counted is not None else -1 + if commit_count == 0: _invalidate_update_cache() @@ -4270,7 +4314,12 @@ def _cmd_update_impl(args, gateway_mode: bool): _m()._resume_windows_gateways_after_update(_windows_gateway_resume) return - print(f"→ Found {commit_count} new commit(s)") + if commit_count > 0: + print(f"→ Found {commit_count} new commit(s)") + else: + # Shallow checkout, exact count unrecoverable (offline/rate-limited + # compare API) — the tips differ, so there IS an update. + print("→ Updates available (commit count unknown on this shallow checkout)") print("→ Pulling updates...") update_succeeded = False diff --git a/hermes_cli/urllib_security.py b/hermes_cli/urllib_security.py index 29c000985aea7..ac34411ef7a66 100644 --- a/hermes_cli/urllib_security.py +++ b/hermes_cli/urllib_security.py @@ -3,15 +3,28 @@ from __future__ import annotations import copy +import logging +import os +import ssl +import sys import urllib.parse import urllib.request from collections.abc import Callable, Iterable +from pathlib import Path from typing import Any +logger = logging.getLogger(__name__) + # Headers safe to forward to a different origin. Everything else is dropped: # custom provider headers routinely carry credentials under arbitrary names. _CROSS_ORIGIN_SAFE_HEADERS = frozenset({"accept", "user-agent"}) _DEFAULT_PORTS = {"http": 80, "https": 443} +_CA_BUNDLE_ENV_VARS = ( + "HERMES_CA_BUNDLE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", +) def url_origin(url: str) -> tuple[str, str, int | None]: @@ -83,17 +96,81 @@ def _sanitize(self, request: urllib.request.Request): https_request = _sanitize -def _secure_opener_from_installed_policy(original_url: str): - """Clone the installed opener's handlers, replacing redirect policy only.""" +def _resolved_https_context() -> ssl.SSLContext | None: + """Return the explicit CA context for Hermes-owned urllib openers.""" + ca_bundle = next( + ( + value + for name in _CA_BUNDLE_ENV_VARS + if (value := os.getenv(name, "").strip()) + ), + "", + ) + if ca_bundle: + ca_path = Path(ca_bundle).expanduser() + if ca_path.is_file(): + try: + return ssl.create_default_context(cafile=str(ca_path)) + except (OSError, ssl.SSLError) as exc: + logger.warning( + "CA bundle could not be loaded from %s: %s — falling back to default certificates", + ca_bundle, + exc, + ) + else: + logger.warning( + "CA bundle path does not exist: %s — falling back to default certificates", + ca_bundle, + ) + + if sys.platform != "darwin": + return None + + try: + import certifi + + return ssl.create_default_context(cafile=certifi.where()) + except (ImportError, OSError, ssl.SSLError) as exc: + logger.warning( + "Could not load certifi for urllib HTTPS verification: %s — falling back to default certificates", + exc, + ) + return None + + +def _secure_opener_from_installed_policy(original_url: str, *, ssl_context=None): + """Clone the installed opener's handlers, replacing redirect policy only. + + When ``ssl_context`` is provided, the cloned HTTPS handler is replaced with + one bound to that context so per-provider TLS settings (``ssl_ca_cert`` / + ``ssl_verify``) apply to this request. When it is None, Hermes-owned + openers get an explicit CA default via ``_resolved_https_context`` (env + bundle first, certifi on macOS); an application-installed opener's TLS + policy is preserved unchanged. + """ installed = getattr(urllib.request, "_opener", None) if installed is None: - installed = urllib.request.build_opener() - + context = _resolved_https_context() + if context is None: + installed = urllib.request.build_opener() + else: + installed = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=context) + ) + + _https_handler_cls = getattr(urllib.request, "HTTPSHandler", None) handlers = [ copy.copy(handler) for handler in getattr(installed, "handlers", ()) if not isinstance(handler, urllib.request.HTTPRedirectHandler) + and not ( + ssl_context is not None + and _https_handler_cls is not None + and isinstance(handler, _https_handler_cls) + ) ] + if ssl_context is not None and _https_handler_cls is not None: + handlers.append(_https_handler_cls(context=ssl_context)) handlers.append(SafeCredentialRedirectHandler(original_url)) handlers.append(_CrossOriginRequestSanitizer(original_url)) secured = urllib.request.build_opener(*handlers) @@ -114,6 +191,7 @@ def open_credentialed_url( *, timeout: float, opener_factory: Callable[..., Any] | None = None, + ssl_context=None, ): """Open a request without forwarding credentials across origins. @@ -121,9 +199,16 @@ def open_credentialed_url( cookies, custom protocol handlers, and instrumentation while replacing its redirect handler. ``opener_factory`` is an explicit test seam; security is never disabled based on global ``urlopen`` identity. + + ``ssl_context`` (an ``ssl.SSLContext``) overrides the HTTPS handler's TLS + policy for this request only. It is used to honor a custom provider's + ``ssl_ca_cert`` / ``ssl_verify`` on the ``/models`` discovery path, which + otherwise falls back to the process-wide ``SSL_CERT_FILE`` / certifi bundle. """ if opener_factory is None: - opener = _secure_opener_from_installed_policy(request.full_url) + opener = _secure_opener_from_installed_policy( + request.full_url, ssl_context=ssl_context + ) for name, value in getattr(opener, "_hermes_initial_addheaders", ()): if not request.has_header(name): request.add_header(name, value) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index aaae3c030e1ab..2668eb2e27fd1 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3225,10 +3225,13 @@ def _bounded_health_probe(): # to decide whether it can use the system-browser + loopback + PKCE # flow (no embedded webview, no session cookies) or must fall back to # the legacy embedded-webview cookie flow. "cookie" is always available - # in gated mode; "native_pkce" is present only when at least one - # registered session provider is a brokerable OAuth provider (not a - # password or token-only credential). Absent field / missing - # "native_pkce" ⇒ older gateway ⇒ desktop falls back automatically. + # in gated mode; "native_pkce" is present when at least one interactive + # session provider is registered — OAuth providers broker the upstream + # IDP round trip, password providers complete interactively at /login + # in the system browser (where OS password managers can autofill; an + # embedded webview cannot reach them). Token-only credentials (e.g. + # drain) don't count. Absent field / missing "native_pkce" ⇒ older + # gateway ⇒ desktop falls back automatically. auth_flows: list[str] = [] try: from hermes_cli.dashboard_auth import ( @@ -3238,11 +3241,7 @@ def _bounded_health_probe(): auth_providers = [p.name for p in _list_providers()] if auth_required: auth_flows.append("cookie") - brokerable = [ - p for p in _list_session_providers() - if not getattr(p, "supports_password", False) - ] - if brokerable: + if _list_session_providers(): auth_flows.append("native_pkce") except Exception: # Module not importable yet (early startup) — leave as []. @@ -6756,16 +6755,7 @@ def _apply_model_assignment_sync( model_cfg = _apply_main_model_assignment( cfg.get("model", {}), provider, model, base_url, api_key ) - # Fall back to the provider entry's stored key only when the request - # didn't carry one — same precedence as the base_url fill above. An - # unconditional overwrite silently discards a key the caller is - # rotating in, and model.api_key outranks the environment at client - # construction (#62269), so the stale key keeps authenticating. - if ( - not api_key - and isinstance(provider_entry, dict) - and provider_entry.get("api_key") - ): + if isinstance(provider_entry, dict) and provider_entry.get("api_key"): model_cfg["api_key"] = provider_entry["api_key"] cfg["model"] = model_cfg @@ -16248,7 +16238,10 @@ def _spawn(): await ws.close(code=1011) return - await session.attach(ws) + # A fresh xterm cannot reliably reconstruct the TUI from an arbitrary + # bounded tail of alternate-screen, differential ANSI output. Reused PTYs + # emit a complete frame after replay so reconnects never reopen blank. + await session.attach(ws, force_redraw=not _created) # --- writer loop: WebSocket → PTY master ---------------------------- # No reader task here: the session's drain task (spawned once per PTY, @@ -18267,11 +18260,14 @@ def _loop_heartbeat(expected: float) -> None: if server.started: await server.shutdown() - # On POSIX, keep the long-standing ``asyncio.run(_serve())`` behavior - # unchanged — Python's default loop there is already a SelectorEventLoop - # (or uvloop when uvicorn[standard] installs it), which is exactly what - # uvicorn serves on. Touching that path would only widen the blast radius - # for no benefit. + # On POSIX, keep the long-standing ``asyncio.run(_serve())`` runner — + # Python's default loop there is already a SelectorEventLoop (or uvloop when + # uvicorn[standard] installs it), which is exactly what uvicorn serves on. + # Uvicorn's ``capture_signals()`` restores the original SIGINT handler and + # re-raises the captured signal after a graceful shutdown, which otherwise + # leaks a noisy KeyboardInterrupt traceback for the normal foreground + # dashboard Ctrl+C path. Treat that one signal as a clean user-requested + # shutdown; other serve-time errors still propagate. # # On Windows it is broken: ``asyncio.run`` defaults to a ProactorEventLoop, # but uvicorn's socket-serving stack assumes a SelectorEventLoop on win32 @@ -18283,14 +18279,17 @@ def _loop_heartbeat(expected: float) -> None: # no TCP handshake completing (#50641). So *only on Windows* we mirror # uvicorn's own machinery and run on the loop factory it picks. if sys.platform != "win32": - asyncio.run(_serve()) + try: + asyncio.run(_serve()) + except KeyboardInterrupt: + return return # Windows-only path. Resolve the runner + loop factory FIRST (and fall back # to a hand-installed Windows selector policy only when uvicorn predates the - # loop-factory API, < 0.36). The actual serve call is then OUTSIDE the - # try/except so genuine serve-time errors (port in use, KeyboardInterrupt) - # propagate normally instead of being swallowed and double-run. + # loop-factory API, < 0.36). The actual serve call is then OUTSIDE this + # import try/except so genuine serve-time errors (port in use) propagate + # normally instead of being swallowed and double-run. try: from uvicorn._compat import asyncio_run as _runner @@ -18305,7 +18304,16 @@ def _loop_heartbeat(expected: float) -> None: except Exception: pass - if _runner is not None: - _runner(_serve(), loop_factory=_loop_factory) - else: - asyncio.run(_serve()) + # Same clean Ctrl+C contract as the POSIX branch above: ``capture_signals()`` + # re-raises the captured signal after the graceful shutdown has already + # completed. For console Ctrl+C the re-raised SIGINT lands as + # ``KeyboardInterrupt`` — a clean user-requested exit here too. (Re-raised + # SIGTERM/SIGBREAK keep their default terminate disposition and never reach + # this except.) + try: + if _runner is not None: + _runner(_serve(), loop_factory=_loop_factory) + else: + asyncio.run(_serve()) + except KeyboardInterrupt: + return diff --git a/hermes_state.py b/hermes_state.py index 0188e5d59bd12..f4a7b75bd8564 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -255,6 +255,15 @@ def _delegate_from_json(col: str = "model_config") -> str: # None result ("merged config is empty → store NULL"). _MODEL_CONFIG_ROW_MISSING = object() +# Billing-bucket classes that aren't a routable provider identity on their +# own — used by session_gateway_runtime's billing_provider fallback and by +# tui_gateway.server._stored_session_runtime_overrides. A session that +# persisted only one of these (never ran /model) must fall back to the +# ambient config default rather than restore a bare bucket. Shared here so +# both consumers stay in sync (previously duplicated as a set in +# tui_gateway/server.py). +_BARE_BILLING_PROVIDERS = frozenset({"auto", "custom"}) + def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]: prefix = cwd_prefix.rstrip("/\\") or cwd_prefix @@ -1483,7 +1492,13 @@ def is_disk_full_error(exc: BaseException | str | None) -> bool: # enumerate causes (e.g. the cron scheduler's explainer-variant suppression) # must iterate this tuple instead of hardcoding the list, so adding a bucket # can never silently desynchronize them. -PERSISTENCE_ERROR_CAUSES = ("locked", "disk", "unknown") +PERSISTENCE_ERROR_CAUSES = ( + "locked", + "compression", + "turn_lease", + "disk", + "unknown", +) def classify_persistence_error(exc_or_str) -> str: @@ -1496,9 +1511,13 @@ def classify_persistence_error(exc_or_str) -> str: send it again", while a full disk or read-only database needs the disk-space/permissions advice. Returns one of PERSISTENCE_ERROR_CAUSES: - * ``"locked"`` — lock/busy contention (another process holds the write - lock, or a live compression lease refused the write); transient, - retry-later guidance applies. + * ``"locked"`` — SQLite lock/busy contention (another process holds the + database write lock); transient, retry-later guidance applies. + * ``"compression"`` — a live compression lease refused the transcript + write; the database itself is healthy and unlocked. + * ``"turn_lease"`` — a presented session-turn-lease holder no longer + owns the conversation (expired, released, or reclaimed); fail-fast + fencing, not a storage fault. * ``"disk"`` — disk full / read-only / permission-shaped failures (delegates the disk-full patterns to :func:`is_disk_full_error` so the two classifiers can never drift apart — e.g. ENOSPC). @@ -1511,14 +1530,18 @@ def classify_persistence_error(exc_or_str) -> str: # writer" / "Compression lease lost") contains neither "locked" nor # "busy", so it must be matched by type and by phrase (for strings that # survived RPC wrapping). + if isinstance(exc_or_str, SessionTurnLeaseLostError): + return "turn_lease" if isinstance(exc_or_str, CompressionSessionBusyError): - return "locked" + return "compression" text = str(exc_or_str).lower() + if "turn lease" in text: + return "turn_lease" + if "being compressed" in text or "compression lease" in text: + return "compression" if ( "locked" in text or "busy" in text - or "being compressed" in text - or "compression lease" in text ): return "locked" if ( @@ -2313,6 +2336,16 @@ class SessionCompressionInProgressError(CompressionSessionBusyError): """ +class SessionTurnLeaseLostError(RuntimeError): + """A transcript write presented a turn-lease holder that no longer owns it. + + Fail-fast fencing: do not retry inside ``_execute_write``. The caller + either still thinks it owns the conversation after expiry/reclaim, or + the lease row is gone. A later writer may already be persisting a + newer turn; landing this write would interleave a stale reply. + """ + + def _connect_tracked_db(path, tracking_path=None, **kwargs): """``sqlite3.connect`` that registers the open fd for lock-safety. @@ -5730,6 +5763,221 @@ def _do(conn): session_id, exc, ) + def _session_turn_lease_key_on_conn(self, conn, session_id: str) -> str: + """Walk compression parents on ``conn`` to the conversation lease key. + + Must run on the same connection as the lease INSERT/UPDATE/DELETE. + A prior ``get_session`` failure must not compute a child id that the + later write then persists: refresh would walk to the parent and + fail-close. Markers bind to ``parent_session_id`` (same contract as + ``_NON_CONTINUATION_CHILD_FILTER_SQL``). Lock errors propagate so + ``_execute_write`` / ``acquire_session_turn_lease`` can retry. + """ + if not session_id: + return session_id + + def _row(sid: str): + row = conn.execute( + "SELECT id, parent_session_id, source, model_config, end_reason " + "FROM sessions WHERE id = ?", + (sid,), + ).fetchone() + return dict(row) if row else None + + current = _row(session_id) + seen = {session_id} + while current: + parent_id = current.get("parent_session_id") + if ( + not parent_id + or parent_id in seen + or self._is_explicit_fork_child_row(current) + ): + break + parent = _row(parent_id) + if not parent or parent.get("end_reason") != "compression": + break + seen.add(parent_id) + current = parent + return str(current.get("id") or session_id) if current else session_id + + def _session_turn_lease_key(self, session_id: str) -> str: + """Return the stable serialization key for every compression segment. + + Acquire/refresh/release resolve this inside their write transaction. + This helper is for tests and diagnostics; it does not swallow lock + errors (a swallowed walk plus a later successful write was the + fail-open that replayed the post-rotation refresh miss). + """ + if not session_id: + return session_id + with self._read_ctx() as conn: + return self._session_turn_lease_key_on_conn(conn, session_id) + + def try_acquire_session_turn_lease( + self, + session_id: str, + holder: str, + *, + ttl_seconds: float = 300.0, + patience_s: Optional[float] = None, + ) -> bool: + """Atomically acquire the cross-process turn lease for a conversation. + + Compression rotates a session into child segments, so the durable key + is the lineage root rather than the current segment id. The walk and + INSERT share one write transaction. Expired leases and leases whose + structured local holder PID is known dead are reclaimed in that same + transaction. + """ + if not session_id or not holder: + return False + now = time.time() + expires_at = now + max(0.1, float(ttl_seconds)) + + def _do(conn): + conversation_id = self._session_turn_lease_key_on_conn(conn, session_id) + row = conn.execute( + "SELECT holder, expires_at FROM session_turn_leases " + "WHERE conversation_id = ?", + (conversation_id,), + ).fetchone() + if row is not None: + current_holder = row["holder"] + if ( + float(row["expires_at"]) <= now + or _compression_lock_holder_process_is_dead(current_holder) + ): + conn.execute( + "DELETE FROM session_turn_leases " + "WHERE conversation_id = ? AND holder = ?", + (conversation_id, current_holder), + ) + conn.execute( + "INSERT OR IGNORE INTO session_turn_leases " + "(conversation_id, holder, acquired_at, expires_at) " + "VALUES (?, ?, ?, ?)", + (conversation_id, holder, now, expires_at), + ) + owner = conn.execute( + "SELECT holder FROM session_turn_leases WHERE conversation_id = ?", + (conversation_id,), + ).fetchone() + return owner is not None and owner["holder"] == holder + + return bool(self._execute_write(_do, patience_s=patience_s)) + + def acquire_session_turn_lease( + self, + session_id: str, + holder: str, + *, + ttl_seconds: float = 300.0, + wait_seconds: float = 1800.0, + poll_interval_seconds: float = 1.0, + on_wait=None, + wait_notice_interval_seconds: float = 15.0, + should_abort=None, + acquire_patience_s: float = 0.5, + ) -> bool: + """Wait for a cross-process turn lease without holding a SQLite lock. + + ``on_wait(elapsed_seconds)`` is best-effort: invoked when the first + attempt fails (elapsed ~0) and again about every + ``wait_notice_interval_seconds`` while still waiting, so UIs can show + that another process holds the conversation. + + When ``should_abort()`` returns True (for example the agent received + ``/stop`` while waiting), acquisition stops immediately and returns + False without consuming the full ``wait_seconds`` budget. + """ + deadline = time.monotonic() + max(0.0, float(wait_seconds)) + wait_started = None + last_notice_at = None + notice_every = max(0.0, float(wait_notice_interval_seconds)) + while True: + if should_abort is not None: + try: + if should_abort(): + return False + except Exception: + logger.debug( + "session turn lease should_abort callback failed", + exc_info=True, + ) + try: + if self.try_acquire_session_turn_lease( + session_id, + holder, + ttl_seconds=ttl_seconds, + patience_s=acquire_patience_s, + ): + return True + except sqlite3.Error as exc: + # Long holder transactions (compression publish, large + # flushes) can exhaust a single write-patience budget. + # Keep polling until wait_seconds or should_abort. + if classify_persistence_error(exc) != "locked": + raise + now = time.monotonic() + remaining = deadline - now + if remaining <= 0: + return False + if wait_started is None: + wait_started = now + if on_wait is not None and ( + last_notice_at is None + or notice_every == 0.0 + or (now - last_notice_at) >= notice_every + ): + try: + on_wait(max(0.0, now - wait_started)) + except Exception: + logger.debug( + "session turn lease on_wait callback failed", + exc_info=True, + ) + last_notice_at = now + time.sleep(min(max(0.01, float(poll_interval_seconds)), remaining)) + + def refresh_session_turn_lease( + self, + session_id: str, + holder: str, + *, + ttl_seconds: float = 300.0, + ) -> bool: + """Extend a turn lease only while ``holder`` still owns it.""" + if not session_id or not holder: + return False + expires_at = time.time() + max(0.1, float(ttl_seconds)) + + def _do(conn): + conversation_id = self._session_turn_lease_key_on_conn(conn, session_id) + cursor = conn.execute( + "UPDATE session_turn_leases SET expires_at = ? " + "WHERE conversation_id = ? AND holder = ?", + (expires_at, conversation_id, holder), + ) + return cursor.rowcount > 0 + + return bool(self._execute_write(_do)) + + def release_session_turn_lease(self, session_id: str, holder: str) -> None: + """Release a turn lease iff ``holder`` still owns it; idempotent.""" + if not session_id or not holder: + return + + def _do(conn): + conversation_id = self._session_turn_lease_key_on_conn(conn, session_id) + conn.execute( + "DELETE FROM session_turn_leases " + "WHERE conversation_id = ? AND holder = ?", + (conversation_id, holder), + ) + + self._execute_write(_do) + def get_compression_lock_holder(self, session_id: str) -> Optional[str]: """Return the current (non-expired) holder for ``session_id``, or None. @@ -5893,7 +6141,9 @@ def _do(conn): self._delete_unreferenced_system_prompts(conn) self._execute_write(_do) - def update_session_model(self, session_id: str, model: str) -> None: + def update_session_model( + self, session_id: str, model: str, provider: Optional[str] = None + ) -> None: """Update the model for a session after a mid-session switch. Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)`` @@ -5903,6 +6153,13 @@ def update_session_model(self, session_id: str, model: str) -> None: footer metadata is rebuilt on the next turn. A successful /model switch explicitly replaces any confirmed Browser runtime lock while preserving unrelated lineage markers in ``model_config``. + + When *provider* is given, it is merged into ``model_config`` + alongside the model (``$.model`` / ``$.provider``) so a later + resume recombines the persisted model with the provider that + actually serves it instead of the config.yaml primary provider + (#79536). Callers without provider knowledge leave any stored + provider untouched. """ # This write bypasses the token queue, so deltas enqueued before the # switch must land first: a still-queued first delta carries the @@ -5913,19 +6170,24 @@ def update_session_model(self, session_id: str, model: str) -> None: self.flush_token_counts() def _do(conn): + # Use the shared merge discipline so lineage markers like + # _branched_from / _delegate_from survive. browser_model_lock + # is deleted via a None patch value (same semantics as the + # old json_remove). + patch: Dict[str, Any] = {"browser_model_lock": None} + if model: + patch["model"] = model + if provider: + patch["provider"] = provider + merged = self._merge_model_config_json(conn, session_id, patch) + if merged is _MODEL_CONFIG_ROW_MISSING: + return conn.execute( - """UPDATE sessions SET - model = ?, - model_config = CASE - WHEN model_config IS NULL THEN NULL - WHEN json_valid(model_config) - THEN json_remove(model_config, '$.browser_model_lock') - ELSE model_config - END, - system_prompt = NULL, - system_prompt_hash = NULL - WHERE id = ?""", - (model, session_id), + "UPDATE sessions SET " + "model = ?, model_config = ?, " + "system_prompt = NULL, system_prompt_hash = NULL " + "WHERE id = ?", + (model, merged, session_id), ) self._delete_unreferenced_system_prompts(conn) self._execute_write(_do) @@ -6118,18 +6380,21 @@ def session_gateway_runtime(session_meta: Optional[Dict[str, Any]]) -> Dict[str, ``gateway_runtime`` key (written by the gateway's ``_sync_session_model_from_agent`` and the CLI ``/model`` persist), falling back to the top-level ``provider``/``base_url``/``api_mode`` - keys the TUI gateway's ``_runtime_model_config`` writes. Returns an - empty dict on any parse failure — resume falls back to ambient - config resolution. + keys the TUI gateway's ``_runtime_model_config`` writes. As a last + resort, falls back to the ``billing_provider`` column (written on + every session's first accounted API call) so sessions that never ran + ``/model`` still restore the provider that actually served them. + Returns an empty dict on any parse failure — resume falls back to + ambient config resolution. """ raw = (session_meta or {}).get("model_config") if isinstance(raw, str): try: raw = json.loads(raw) except Exception: - return {} + raw = {} if not isinstance(raw, dict): - return {} + raw = {} runtime = raw.get("gateway_runtime") if isinstance(runtime, dict) and runtime.get("provider"): # Filter None values: the persist path writes or-None to trigger @@ -6143,7 +6408,21 @@ def session_gateway_runtime(session_meta: Optional[Dict[str, Any]]) -> Dict[str, } if top_level: return top_level - return dict(runtime) if isinstance(runtime, dict) else {} + # Last resort: billing_provider column. Written via COALESCE on every + # session's first accounted API call — the only durable record for + # sessions that never ran /model. Mirrors the TUI gateway's + # _stored_session_runtime_overrides fallback. Bare billing buckets + # ("auto"/"custom") are not routable identities — filter them out so + # resume falls back to the ambient config default instead. + billing_provider = str( + (session_meta or {}).get("billing_provider") or "" + ).strip() + if ( + billing_provider + and billing_provider.lower() not in _BARE_BILLING_PROVIDERS + ): + return {"provider": billing_provider} + return {k: v for k, v in (runtime or {}).items() if v is not None} if isinstance(runtime, dict) else {} def update_session_billing_route( self, @@ -7984,7 +8263,12 @@ def _encode_display_metadata(display_metadata: Any) -> Optional[str]: return None def _check_transcript_write_guards( - self, conn, session_id: str, compression_lock_holder: Optional[str] + self, + conn, + session_id: str, + compression_lock_holder: Optional[str], + turn_lease_holder: Optional[str] = None, + turn_lease_ttl_seconds: float = 300.0, ) -> None: """Transcript-append admission checks, run INSIDE the write txn. @@ -8005,6 +8289,33 @@ def _check_transcript_write_guards( raise SessionCompressionInProgressError( f"Session {session_id!r} is being compressed by another writer" ) + if turn_lease_holder: + conversation_id = self._session_turn_lease_key_on_conn(conn, session_id) + lease = conn.execute( + "SELECT holder, expires_at FROM session_turn_leases " + "WHERE conversation_id = ?", + (conversation_id,), + ).fetchone() + if lease is None or lease["holder"] != turn_lease_holder: + raise SessionTurnLeaseLostError( + f"Session turn lease lost; refusing transcript write " + f"for {session_id!r}" + ) + now = time.time() + if float(lease["expires_at"]) <= now: + # Expiry makes the row reclaimable; it does not prove that a + # takeover occurred. BEGIN IMMEDIATE serializes this renewal + # with acquisition, so a still-matching owner can recover from + # a starved refresher without weakening the foreign-holder fence. + conn.execute( + "UPDATE session_turn_leases SET expires_at = ? " + "WHERE conversation_id = ? AND holder = ?", + ( + now + max(0.1, float(turn_lease_ttl_seconds)), + conversation_id, + turn_lease_holder, + ), + ) session = conn.execute( "SELECT ended_at, end_reason FROM sessions WHERE id = ?", (session_id,), @@ -8084,6 +8395,8 @@ def append_message( display_kind: Optional[str] = None, display_metadata: Optional[Dict[str, Any]] = None, compression_lock_holder: Optional[str] = None, + turn_lease_holder: Optional[str] = None, + turn_lease_ttl_seconds: float = 300.0, ) -> int: """ Append a message to a session. Returns the message row ID. @@ -8142,7 +8455,11 @@ def append_message( def _do(conn): self._check_transcript_write_guards( - conn, session_id, compression_lock_holder + conn, + session_id, + compression_lock_holder, + turn_lease_holder=turn_lease_holder, + turn_lease_ttl_seconds=turn_lease_ttl_seconds, ) cursor = conn.execute( """INSERT INTO messages (session_id, role, content, tool_call_id, @@ -8204,7 +8521,9 @@ def append_messages_batch( session_id: str, messages: List[Dict[str, Any]], compression_lock_holder: Optional[str] = None, + turn_lease_holder: Optional[str] = None, chunk_rows: Optional[int] = None, + turn_lease_ttl_seconds: float = 300.0, ) -> int: """Append multiple messages atomically in ONE write transaction. @@ -8243,12 +8562,18 @@ def append_messages_batch( session_id, messages[start:start + chunk_rows], compression_lock_holder=compression_lock_holder, + turn_lease_holder=turn_lease_holder, + turn_lease_ttl_seconds=turn_lease_ttl_seconds, ) return inserted_total def _do(conn): self._check_transcript_write_guards( - conn, session_id, compression_lock_holder + conn, + session_id, + compression_lock_holder, + turn_lease_holder=turn_lease_holder, + turn_lease_ttl_seconds=turn_lease_ttl_seconds, ) inserted, tool_calls_total = self._insert_message_rows( conn, session_id, messages @@ -9829,6 +10154,17 @@ def has_platform_message_id( # ========================================================================= def _is_explicit_fork_child_row(self, session: Dict[str, Any]) -> bool: + """True when ``session`` is a branch, delegate, or tool child of its parent. + + Markers only count as a fork when they point at ``parent_session_id``. + Compression copies ``model_config`` onto the continuation + (``publish_compression_child`` callers pass + ``agent._session_init_model_config``), so a delegate's continuation + carries ``_delegate_from=``. Presence-only + matching would treat that real continuation as a fork — the same + misclassification ``_NON_CONTINUATION_CHILD_FILTER_SQL`` already + avoids by binding both markers to the queried parent. + """ if session.get("source") == "tool": return True raw = session.get("model_config") @@ -9838,10 +10174,14 @@ def _is_explicit_fork_child_row(self, session: Dict[str, Any]) -> bool: cfg = json.loads(raw) if isinstance(raw, str) else raw except (TypeError, json.JSONDecodeError): return False - return isinstance(cfg, dict) and ( - cfg.get("_branched_from") is not None - or cfg.get("_delegate_from") is not None - ) + if not isinstance(cfg, dict): + return False + parent_id = session.get("parent_session_id") + branched = cfg.get("_branched_from") + delegated = cfg.get("_delegate_from") + if parent_id: + return branched == parent_id or delegated == parent_id + return branched is not None or delegated is not None def _is_compression_child_row(self, child: Dict[str, Any]) -> bool: parent_id = child.get("parent_session_id") @@ -10750,6 +11090,24 @@ def _do(conn): return self._execute_write(_do) + def list_meta_prefix(self, prefix: str) -> List[Tuple[str, str]]: + """Return ``[(key, value), ...]`` for state_meta keys with ``prefix``. + + Used by feature stores that persist one row per session under a + namespaced key (e.g. ``loop:``) and need to enumerate + them across sessions (the gateway's idle /loop wakeup watcher). + ``prefix`` is matched literally — LIKE wildcards in it are escaped. + """ + if not prefix: + return [] + escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + with self._lock: + rows = self._conn.execute( + "SELECT key, value FROM state_meta WHERE key LIKE ? ESCAPE '\\'", + (escaped + "%",), + ).fetchall() + return [(row[0], row[1]) for row in rows] + def apply_telegram_topic_migration(self) -> None: """Create Telegram DM topic-mode tables on explicit /topic opt-in. diff --git a/hermes_state_common.py b/hermes_state_common.py index a6f29dbe75a5d..28f3a63cdbe02 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -383,6 +383,13 @@ def _sql_session_last_active_by_id(session_id_expr: str) -> str: expires_at REAL NOT NULL ); +CREATE TABLE IF NOT EXISTS session_turn_leases ( + conversation_id TEXT PRIMARY KEY, + holder TEXT NOT NULL, + acquired_at REAL NOT NULL, + expires_at REAL NOT NULL +); + CREATE TABLE IF NOT EXISTS async_delegations ( delegation_id TEXT PRIMARY KEY, origin_session TEXT NOT NULL, @@ -419,6 +426,7 @@ def _sql_session_last_active_by_id(session_id_expr: str) -> str: ON messages(session_id) WHERE role = 'assistant' AND tool_calls IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at); +CREATE INDEX IF NOT EXISTS idx_session_turn_leases_expires ON session_turn_leases(expires_at); CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id); CREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model); CREATE INDEX IF NOT EXISTS idx_async_delegations_delivery diff --git a/model_tools.py b/model_tools.py index 8fe4ffd34c13e..a14b28e345cc4 100644 --- a/model_tools.py +++ b/model_tools.py @@ -24,6 +24,8 @@ import json import re import asyncio +from contextlib import contextmanager +from contextvars import ContextVar import logging import threading import time @@ -40,6 +42,20 @@ logger = logging.getLogger(__name__) +_post_tool_call_hook_suppressed: ContextVar[bool] = ContextVar( + "post_tool_call_hook_suppressed", default=False +) + + +@contextmanager +def suppress_post_tool_call_hook(): + """Let an outer executor own the terminal post-tool event.""" + token = _post_tool_call_hook_suppressed.set(True) + try: + yield + finally: + _post_tool_call_hook_suppressed.reset(token) + # Tracks platform-bundle names already flagged in disabled_toolsets so the # advisory (#33924) is logged once per name, not on every tool recompute. _WARNED_DISABLED_BUNDLES: set = set() @@ -1138,6 +1154,8 @@ def _emit_post_tool_call_hook( result *after* the gate (parsing the result is only worth it when a listener will actually consume it). """ + if _post_tool_call_hook_suppressed.get(): + return try: from hermes_cli.lifecycle import has_hook, invoke_hook if not has_hook("post_tool_call"): diff --git a/package-lock.json b/package-lock.json index 090b2a7bdd8be..451f5a10df7aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,7 +108,6 @@ "emojibase-data": "16.0.3", "fflate": "0.8.3", "frimousse": "0.3.0", - "get-windows": "9.3.0", "hast-util-from-html-isomorphic": "2.0.0", "hast-util-to-text": "4.0.2", "ignore": "7.0.6", @@ -167,6 +166,9 @@ }, "engines": { "node": ">=22.22.0" + }, + "optionalDependencies": { + "get-windows": "9.3.0" } }, "apps/desktop/node_modules/@electron/get": { @@ -11004,6 +11006,7 @@ "integrity": "sha512-DrOfQSmIcsFax28FfSUjbLTfeOkAG7yeh6NCb/9zzRkDuClXaqYqHuQBPUqcqd1uYS70ygYERSooacMAwvbyVw==", "hasInstallScript": true, "license": "MIT", + "optional": true, "engines": { "node": ">=18.18" }, diff --git a/plugins/model-providers/actual/__init__.py b/plugins/model-providers/actual/__init__.py index 123892d4a6862..0141dd8175d41 100644 --- a/plugins/model-providers/actual/__init__.py +++ b/plugins/model-providers/actual/__init__.py @@ -61,8 +61,10 @@ def fetch_models( req.add_header("Accept", "application/json") req.add_header("User-Agent", _profile_user_agent()) + from hermes_cli.urllib_security import open_credentialed_url + try: - with urllib.request.urlopen(req, timeout=timeout) as resp: + with open_credentialed_url(req, timeout=timeout) as resp: data = json.loads(resp.read().decode()) items = data if isinstance(data, list) else data.get("data", []) return [m["id"] for m in items if isinstance(m, dict) and "id" in m] diff --git a/run_agent.py b/run_agent.py index 12e7da647ebbd..6b1854e817f7c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2290,6 +2290,13 @@ def _flush_messages_to_session_db_unlocked( compression_lock_holder=getattr( self, "_active_compression_lock_holder", None ), + turn_lease_holder=getattr( + self, "_active_session_turn_lease_holder", None + ), + turn_lease_ttl_seconds=getattr( + self, "_active_session_turn_lease_ttl_seconds", 300.0 + ) + or 300.0, ) for _written in _batch_msgs: _written[_DB_PERSISTED_MARKER] = True @@ -3722,6 +3729,21 @@ def _format_turn_completion_explanation( ) if reason == "session_persistence_failed": cause = persistence_cause or "unknown" + if cause == "compression": + return ( + prefix + + "the turn was stopped because another process was " + "compressing this session. Your message should already be " + "saved — please send it again after compression completes." + ) + if cause == "turn_lease": + return ( + prefix + + "the turn was stopped because another Hermes process " + "took over this session. Your reply was not saved — wait " + "for the other process to finish, then send your message " + "again." + ) if cause == "locked": return ( prefix @@ -8061,12 +8083,277 @@ def run_conversation( ) relay_lease = None relay_turn = None + durable_turn_lease = None + durable_turn_lease_stop = None + durable_turn_lease_thread = None + durable_turn_lease_activity_lock = threading.Lock() + durable_turn_lease_turn_active = False + durable_turn_lease_interrupt_message = None token = None acct_token = None task_started = False task_finished = False relay_outcome = "failed" + + def _stop_durable_turn_lease_refresher() -> None: + nonlocal durable_turn_lease_turn_active + with durable_turn_lease_activity_lock: + durable_turn_lease_turn_active = False + if durable_turn_lease_stop is not None: + durable_turn_lease_stop.set() + + def _clear_durable_turn_lease_interrupt() -> None: + """Clear only the interrupt admitted by this turn's refresher.""" + message = durable_turn_lease_interrupt_message + if not message: + return + + def _clear_if_owned() -> None: + if getattr(self, "_interrupt_message", None) != message: + return + self._interrupt_requested = False + self._interrupt_message = None + getattr(self, "_hard_interrupt_requested", threading.Event()).clear() + self._interrupt_thread_signal_pending = False + if self._execution_thread_id is not None: + _set_interrupt(False, self._execution_thread_id) + + redirect_lock = getattr(self, "_pending_redirect_lock", None) + if redirect_lock is None: + _clear_if_owned() + else: + with redirect_lock: + _clear_if_owned() + try: + # Serialize the full load -> run -> flush region across Hermes + # processes. Gateway's asyncio lease closes alias routing inside one + # process; this durable lease covers Desktop, CLI resume, gateway, + # and background delivery processes sharing state.db (#84234). + _turn_db = getattr(self, "_session_db", None) + _durable_session_exists = False + if _turn_db is not None and session_id: + try: + _durable_session_exists = _turn_db.get_session(session_id) is not None + except Exception: + # A locked / non-WAL read is not proof the row is absent. + # Treating probe failure as "fresh session" skipped the + # lease this block exists to take and ran fail-open on + # the exact contention point (#84234). Acquire (or fail + # closed if acquire itself cannot) rather than start + # load/run/flush unsynchronized. get_session returns + # None — it does not raise — when the row is missing. + logger.warning( + "Could not check durable session before turn lease; " + "will acquire rather than run without serialization", + exc_info=True, + ) + _durable_session_exists = True + if ( + _turn_db is not None + and session_id + and not getattr(self, "_persist_disabled", False) + # A fresh session id is process-unique and has no durable + # transcript to race over. More importantly, subagent/new-turn + # callers may intentionally supply an in-memory seed before the + # row exists; reloading an absent row would erase that seed. + and _durable_session_exists + # Test doubles and third-party DB shims may accept arbitrary + # MagicMock attributes without implementing the protocol. Check + # the concrete type so only real implementations opt in. + and callable( + getattr(type(_turn_db), "acquire_session_turn_lease", None) + ) + ): + # Resumed agents also defer their create check until the turn + # prologue. We just proved this row exists, so suppress the + # redundant create attempt after acquiring it. + self._session_db_created = True + _durable_holder = ( + f"pid={os.getpid()}:turn={relay_turn_id}:platform=" + f"{task_context['platform'] or 'unknown'}" + ) + _lease_ttl = 300.0 + _lease_waited = False + + def _on_session_turn_lease_wait(elapsed: float) -> None: + nonlocal _lease_waited + _lease_waited = True + if elapsed < 1.0: + self._emit_status( + "⏳ Another Hermes process is using this session; " + "waiting for it to finish before starting your turn..." + ) + else: + self._emit_status( + "⏳ Still waiting for the other Hermes process on " + f"this session ({int(elapsed)}s)..." + ) + + if not _turn_db.acquire_session_turn_lease( + session_id, + _durable_holder, + ttl_seconds=_lease_ttl, + wait_seconds=1800.0, + on_wait=_on_session_turn_lease_wait, + should_abort=lambda: getattr(self, "_interrupt_requested", False), + ): + if getattr(self, "_interrupt_requested", False): + logger.info( + "session turn lease wait aborted by interrupt: %s", + session_id, + ) + relay_outcome = "cancelled" + interrupt_msg = ( + "Stopped waiting for another Hermes process on " + "this session. Your message was not processed." + ) + interrupt_result = { + "final_response": interrupt_msg, + "messages": list(conversation_history or []), + "api_calls": 0, + "completed": False, + "interrupted": True, + } + interrupt_message = getattr( + self, "_interrupt_message", None + ) + if interrupt_message: + interrupt_result["interrupt_message"] = ( + interrupt_message + ) + # Conversation-loop finalizer never runs on this + # early return. Clear so a cached agent cannot + # fail-close the next turn as interrupted. + try: + self.clear_interrupt() + except Exception: + self._interrupt_requested = False + self._interrupt_message = None + return interrupt_result + # Fail closed like gateway TurnLeaseTimeoutError: do not + # enter load/run/flush, and surface a resend notice instead + # of a bare TimeoutError that looks like a hang. + timeout_msg = ( + "⏳ Another Hermes process kept this session busy too " + "long. Your message was not processed - wait for the " + "other process to finish, then send it again." + ) + logger.error( + "session turn lease wait timed out for %s", + session_id, + ) + try: + self._emit_warning(timeout_msg) + except Exception: + logger.debug( + "Failed to emit session turn lease timeout warning", + exc_info=True, + ) + relay_outcome = "timed_out" + return { + "final_response": timeout_msg, + "messages": list(conversation_history or []), + "api_calls": 0, + "completed": False, + "failed": True, + "error": f"session_turn_lease_timeout:{session_id}", + } + + # Assign only after admission so finally release cannot target a + # holder string that never owned the row. Persist paths read + # the agent attr so a late flush after reclaim is fenced in + # the same SQLite write transaction as the transcript insert. + durable_turn_lease = _durable_holder + self._active_session_turn_lease_holder = _durable_holder + self._active_session_turn_lease_ttl_seconds = _lease_ttl + if _lease_waited: + self._emit_status( + "Session is free; loading the latest transcript..." + ) + + # The holder may have compressed and rotated the session while + # this process waited. Resolve and reload only AFTER admission; + # a caller-provided in-memory snapshot is necessarily stale. + # Skip when acquisition was immediate — no other process held + # the lease, so the in-memory history is current and reloading + # would only cause an unnecessary prompt cache miss. + if _lease_waited: + latest_session_id = _turn_db.resolve_resume_session_id(session_id) + if latest_session_id: + self.session_id = latest_session_id + task_context["session_id"] = latest_session_id + conversation_history = _turn_db.get_messages_as_conversation( + self.session_id, + repair_alternation=True, + ) + + # Long model/tool/compression turns outlive a fixed TTL. Refresh + # in a daemon thread; holder-qualified UPDATE and DELETE fence a + # late refresher/release from a successor lease. + durable_turn_lease_stop = threading.Event() + _lease_refresh_interval = float( + getattr(self, "_session_turn_lease_refresh_interval", 60.0) + ) + + def _refresh_durable_turn_lease() -> None: + def _interrupt_turn(message: str) -> None: + nonlocal durable_turn_lease_interrupt_message + with durable_turn_lease_activity_lock: + if ( + durable_turn_lease_stop.is_set() + or not durable_turn_lease_turn_active + ): + return + durable_turn_lease_interrupt_message = message + try: + self.interrupt(message, hard_cancel=True) + except Exception: + self._interrupt_requested = True + self._interrupt_message = message + + while not durable_turn_lease_stop.wait(_lease_refresh_interval): + try: + if not _turn_db.refresh_session_turn_lease( + getattr(self, "session_id", None) or session_id, + durable_turn_lease, + ttl_seconds=_lease_ttl, + ): + # finally sets the stop event then releases. + # A late holder-fenced miss after that join + # timeout must not hard-interrupt the next turn. + if durable_turn_lease_stop.is_set(): + return + logger.error( + "Lost session turn lease while turn is active: %s", + getattr(self, "session_id", None) or session_id, + ) + _interrupt_turn( + "Session turn lease lost; stopping to protect " + "the transcript." + ) + return + except Exception: + if durable_turn_lease_stop.is_set(): + return + logger.warning( + "Failed to refresh session turn lease: %s", + getattr(self, "session_id", None) or session_id, + exc_info=True, + ) + _interrupt_turn( + "Session turn lease could not be refreshed; " + "stopping to protect the transcript." + ) + return + + durable_turn_lease_thread = threading.Thread( + target=_refresh_durable_turn_lease, + name="session-turn-lease-refresh", + daemon=True, + ) + + relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation( profile_key=relay_runtime.current_profile_key(), session_id=task_context["session_id"], @@ -8108,19 +8395,32 @@ def run_conversation( # Keep the scope local instead of storing ContextVar tokens on the agent, # which may be observed from another thread. with bind_subagent_parent(self), scoped_runtime_main({}): - result = run_conversation( - self, - user_message, - system_message, - conversation_history, - effective_task_id, - stream_callback, - persist_user_message, - persist_user_timestamp=persist_user_timestamp, - persist_user_display_kind=persist_user_display_kind, - persist_user_display_metadata=persist_user_display_metadata, - moa_config=moa_config, - ) + try: + if durable_turn_lease_thread is not None: + with durable_turn_lease_activity_lock: + durable_turn_lease_turn_active = True + durable_turn_lease_thread.start() + result = run_conversation( + self, + user_message, + system_message, + conversation_history, + effective_task_id, + stream_callback, + persist_user_message, + persist_user_timestamp=persist_user_timestamp, + persist_user_display_kind=persist_user_display_kind, + persist_user_display_metadata=persist_user_display_metadata, + moa_config=moa_config, + ) + finally: + # The lease remains held through relay/task finalization, but + # those post-loop steps must not receive a late refresh + # interrupt that poisons the next turn on a cached agent. + _stop_durable_turn_lease_refresher() + # Interrupt clear is deferred to after thread join in the + # outer finally: a refresher firing between stop and join + # would otherwise set an interrupt that survives the clear. terminal = result if isinstance(result, dict) else {} if terminal.get("interrupted") is True: relay_outcome = "cancelled" @@ -8166,6 +8466,33 @@ def run_conversation( relay_lease ) finally: + _stop_durable_turn_lease_refresher() + if ( + durable_turn_lease_thread is not None + and durable_turn_lease_thread.is_alive() + ): + durable_turn_lease_thread.join(timeout=1.0) + # Clear any interrupt the refresher may have fired between + # the inner stop and this join. Must run AFTER join so a + # late interrupt does not survive into the next turn. + _clear_durable_turn_lease_interrupt() + if durable_turn_lease is not None: + try: + _turn_db.release_session_turn_lease( + session_id, durable_turn_lease + ) + except Exception: + logger.error( + "Failed to release session turn lease: %s", + session_id, + exc_info=True, + ) + if ( + getattr(self, "_active_session_turn_lease_holder", None) + == durable_turn_lease + ): + self._active_session_turn_lease_holder = None + self._active_session_turn_lease_ttl_seconds = None # Always clear mid-turn labels when the turn exits — including # interrupted early returns that skip finalize_turn. Keep ts. try: diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 1c81bdf1146b4..95c69881dc81a 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -421,7 +421,7 @@ function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualActi manual = $ManualAction message = $Message branch = $Branch - finished_at = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) + finished_at = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } | ConvertTo-Json -Compress [System.IO.File]::WriteAllText($ResultPath, $obj) } catch {} @@ -611,7 +611,7 @@ try { # -- 0. Claim the update marker with OUR pid --------------------------- try { - $epoch = [int][double]::Parse((Get-Date -UFormat %s), [System.Globalization.CultureInfo]::InvariantCulture) + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() # WriteAllText for byte-exact LF framing: Set-Content emits CRLF and # the marker contract (Rust/TS/Python readers) is "\n\n". [System.IO.File]::WriteAllText($MarkerPath, "$PID`n$epoch`n") @@ -672,23 +672,55 @@ try { # is unlocked; the venv-python holder guard (orphan reap included) stays # active. Our marker claim is adopted by the child via update_lock.py's # process-ancestry rule. - $hermesExe = Join-Path $InstallRoot "venv\Scripts\hermes.exe" - if (-not (Test-Path -LiteralPath $hermesExe)) { + # + # DRIVE THE UPDATE THROUGH venv\Scripts\python.exe, NOT venv\Scripts\hermes.exe. + # `uv pip install -e .` has to replace the console-script shims, so + # _quarantine_running_hermes_exe must first rename the running hermes.exe + # out of the way. On Windows that rename fails whenever ANY child process + # spawned from that hermes.exe is still alive: a child inherits a handle on + # the parent image, and the resulting sharing violation is indistinguishable + # from a user leaving a second Hermes window open. It is the inherited + # handle, not the trampoline itself, that pins the file -- killing the child + # makes the same rename succeed immediately, and the shim flavour (uv + # trampoline vs distlib launcher) makes no difference. + # + # The updater reliably spawns such children itself (npx cache warm, memory + # provider refresh -- hindsight-api runs as a daemon with --idle-timeout + # 300 and outlives the step that started it), so this is a race, not a + # deterministic failure: the same hand-off succeeds on one run and dies on + # the next. Step 2's preflight cannot catch it, because the shim genuinely + # IS unlocked at that moment. + # + # When the rename loses that race, _schedule_replace_on_reboot is the last + # resort -- and it writes to HKLM\...\PendingFileRenameOperations, which + # requires elevation. A Desktop-driven update runs non-elevated, so it + # returns ERROR_ACCESS_DENIED and `uv pip install -e .` exits 2. The ZIP + # fallback repeats the identical sequence, so the desktop build stage is + # never reached and apps/desktop/release is left missing -- an install whose + # Start Menu shortcut points at a Hermes.exe that no longer exists. + # + # Running the same code as `python.exe -m hermes_cli.main update` puts the + # inherited handles on python.exe, which uv never has to replace. + # + # posix.sh is deliberately left alone: unlinking a running executable is + # legal there, so the equivalent call is harmless. + $pythonExe = Join-Path $InstallRoot "venv\Scripts\python.exe" + if (-not (Test-Path -LiteralPath $pythonExe)) { $finalCode = 3 - $finalMsg = "Update aborted: $hermesExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)." + $finalMsg = "Update aborted: $pythonExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)." Write-HandoffLog $finalMsg exit $finalCode } - $updateArgs = @("update", "--yes", "--gateway", "--force", "--branch", $Branch) - Write-HandoffLog ("running: hermes " + ($updateArgs -join " ")) - $res = Invoke-HermesStep $hermesExe $updateArgs "update" + $updateArgs = @("-m", "hermes_cli.main", "update", "--yes", "--gateway", "--force", "--branch", $Branch) + Write-HandoffLog ("running: python " + ($updateArgs -join " ")) + $res = Invoke-HermesStep $pythonExe $updateArgs "update" Write-HandoffLog "hermes update exit code: $($res.Code)" if ($res.Code -ne 0 -and $res.Code -ne 2) { # One retry for the update-boundary class (fresh code on disk, stale # code in memory). Exit 2 ("close all Hermes windows") is not retryable. Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)" - $res = Invoke-HermesStep $hermesExe $updateArgs "update" + $res = Invoke-HermesStep $pythonExe $updateArgs "update" Write-HandoffLog "retry exit code: $($res.Code)" } @@ -700,7 +732,7 @@ try { $desktopBuildFailed = $false if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") { Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build" - $rebuild = Invoke-HermesStep $hermesExe @("desktop", "--force-build", "--build-only") "rebuild" + $rebuild = Invoke-HermesStep $pythonExe @("-m", "hermes_cli.main", "desktop", "--force-build", "--build-only") "rebuild" Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)" if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true } } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index cd2145f8d576b..a59f454a365a7 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -3240,7 +3240,10 @@ function Install-BrowserUseCli { } $managedBin = Join-Path $HermesHome "bin" $managedBu = Join-Path $managedBin "browser-use.exe" - if ((Get-Command browser-use -ErrorAction SilentlyContinue) -or (Test-Path $managedBu)) { + # MANAGED-FIRST: only Hermes' managed copy short-circuits. A browser-use + # on the user's PATH is a side install -- resolution prefers the managed + # copy, so it must be provisioned regardless. + if (Test-Path $managedBu) { Write-Success "Browser Use CLI already installed" return } diff --git a/scripts/install.sh b/scripts/install.sh index 2aff3abe91a68..085f2c20106ba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2443,7 +2443,10 @@ install_browser_use_cli() { log_info "Skipping Browser Use CLI install (uv unavailable)" return 0 fi - if command -v browser-use >/dev/null 2>&1 || [ -x "$HERMES_HOME/bin/browser-use" ]; then + # MANAGED-FIRST: only Hermes' managed copy short-circuits. A browser-use + # on the user's PATH is a side install — resolution prefers the managed + # copy, so it must be provisioned regardless. + if [ -x "$HERMES_HOME/bin/browser-use" ]; then log_success "Browser Use CLI already installed" return 0 fi diff --git a/tests/agent/test_bot_profile_prompt_isolation.py b/tests/agent/test_bot_profile_prompt_isolation.py new file mode 100644 index 0000000000000..ffcbb315c8251 --- /dev/null +++ b/tests/agent/test_bot_profile_prompt_isolation.py @@ -0,0 +1,132 @@ +"""Regression: a bot profile's system prompt must reflect ITS OWN skills/home, +never the launch (default) profile's — even when the agent build runs on a +thread that did not bind the HERMES_HOME ContextVar. + +Root cause this guards (confirmed empirically): ContextVars do not propagate +into ``threading.Thread``. ``build_skills_system_prompt`` and the +active-profile line resolved the home via the ambient ``get_hermes_home()``, +so an unbound build thread fell back to ``~/.hermes`` (default) and leaked +default's full skills index + "Active Hermes profile: default" into a bot's +prompt, while the live ``skills_list()`` (re-bound per turn) correctly +showed the bot's real, empty set. The agent now resolves its own home from +its ``_session_db.db_path`` and passes it explicitly. +""" + +import re +import threading + +import pytest + + +def _skills_body(prompt: str) -> str: + m = re.search(r"(.*?)", prompt, re.DOTALL) + return (m.group(1).strip() if m else "") + + +def test_skills_prompt_scoped_to_override_not_ambient_home(tmp_path, monkeypatch): + """An explicit skills_dir_override wins over ambient HERMES_HOME, on a + bare thread with no override bound.""" + from agent import prompt_builder + + # A "default" home WITH skills (the thing that must NOT leak). + default_home = tmp_path / "default" + default_skills = default_home / "skills" / "general" / "leaky-skill" + default_skills.mkdir(parents=True) + (default_skills / "SKILL.md").write_text( + "---\nname: leaky-skill\ndescription: should never appear in a bot prompt\n---\nbody\n", + encoding="utf-8", + ) + + # An empty bot profile (no skills dir at all). + bot_skills = tmp_path / "profiles" / "emptybot" / "skills" + + # Bind ambient home to default (mimics a build thread that lost the + # bot's override and fell back to launch). + monkeypatch.setenv("HERMES_HOME", str(default_home)) + prompt_builder.clear_skills_system_prompt_cache(clear_snapshot=False) + + result = {} + + def build(): + # No set_hermes_home_override on THIS thread — ambient resolves to + # default. The override arg must still scope to the empty bot. + result["bot"] = _skills_body( + prompt_builder.build_skills_system_prompt(skills_dir_override=bot_skills) + ) + + t = threading.Thread(target=build) + t.start() + t.join() + + assert result["bot"] == "", ( + "empty bot profile leaked skills from the ambient (default) home: " + + result["bot"][:200] + ) + + +def test_agent_home_resolves_from_session_db_path(tmp_path): + """The agent's own home is read from its session_db, independent of any + ContextVar.""" + from agent import system_prompt + + bot_home = tmp_path / "profiles" / "mybot" + bot_home.mkdir(parents=True) + + class _DB: + db_path = bot_home / "state.db" + + class _Agent: + _session_db = _DB() + + assert system_prompt._agent_home(_Agent()) == bot_home + assert system_prompt._agent_skills_dir(_Agent()) == bot_home / "skills" + + +def test_agent_home_none_without_session_db(): + from agent import system_prompt + + class _Agent: + _session_db = None + + assert system_prompt._agent_home(_Agent()) is None + assert system_prompt._agent_skills_dir(_Agent()) is None + + +def test_profile_name_correct_on_bound_profile_session(tmp_path, monkeypatch): + """Regression for the fix-of-the-fix: on a CORRECTLY bound profile session + the ambient home IS the profile dir, so deriving the profile name with + ``get_hermes_home()/profiles`` as the root would never match and every + profile would misreport as \"default\". The name must derive from the + hermes ROOT (get_default_hermes_root).""" + from agent import system_prompt + + bot_home = tmp_path / "profiles" / "mybot" + bot_home.mkdir(parents=True) + + # Bound session: HERMES_HOME env points at the profile dir itself. + monkeypatch.setenv("HERMES_HOME", str(bot_home)) + + assert system_prompt._profile_name_for_home(bot_home) == "mybot" + + +def test_profile_name_default_when_home_is_root(tmp_path, monkeypatch): + from agent import system_prompt + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + assert system_prompt._profile_name_for_home(tmp_path) == "default" + + +def test_profile_name_correct_when_ambient_is_another_profile(tmp_path, monkeypatch): + """CodeRabbit case: agent belongs to profile A while the ambient home is + bound to profile B. The root must derive independently of the ambient + home, so A still resolves as 'mybot' (not 'default', and never 'other').""" + from agent import system_prompt + + bot_home = tmp_path / "profiles" / "mybot" + bot_home.mkdir(parents=True) + other_home = tmp_path / "profiles" / "other" + other_home.mkdir(parents=True) + + monkeypatch.setenv("HERMES_HOME", str(other_home)) + + assert system_prompt._profile_name_for_home(bot_home) == "mybot" diff --git a/tests/agent/test_custom_provider_ca_probes.py b/tests/agent/test_custom_provider_ca_probes.py new file mode 100644 index 0000000000000..1c32edc3fc39e --- /dev/null +++ b/tests/agent/test_custom_provider_ca_probes.py @@ -0,0 +1,312 @@ +"""Custom-provider TLS settings must reach the /models and pricing probes. + +Regression coverage for provider-scoped ``ssl_ca_cert`` / ``ssl_verify`` being +ignored by the discovery/pricing probes. Two probe families share the root +cause and are covered here: + +* ``requests``-based endpoint metadata / pricing probe + (``agent.model_metadata.fetch_endpoint_model_metadata`` via + ``_resolve_requests_verify``). +* ``urllib``-based ``/models`` catalog discovery probe + (``hermes_cli.models.probe_api_models`` via ``_custom_provider_ssl_context``). + +Both previously resolved TLS from process-wide env vars only, so a custom +endpoint whose chain verifies against the provider's configured bundle (but not +``SSL_CERT_FILE``) logged a spurious CERTIFICATE_VERIFY_FAILED on every probe +even though the chat client succeeded. + +No network I/O: real CA-bundle stand-in files via ``tmp_path`` plus a patched +provider list and a patched request seam. +""" + +from __future__ import annotations + +import ssl +import urllib.error +from unittest.mock import MagicMock, patch + +import certifi +import pytest + +from agent.model_metadata import _resolve_requests_verify +from hermes_cli.models import _custom_provider_ssl_context + +_CA_ENV_VARS = ( + "HERMES_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", +) + +_BASE = "https://relay.example.invalid/v1" + + +@pytest.fixture +def clean_env(monkeypatch): + """Clear the CA env vars so each test starts from a known state.""" + for var in _CA_ENV_VARS: + monkeypatch.delenv(var, raising=False) + return monkeypatch + + +@pytest.fixture +def bundle_file(tmp_path): + path = tmp_path / "provider-ca.pem" + path.write_text("-----BEGIN CERTIFICATE-----\nstub\n-----END CERTIFICATE-----\n") + return str(path) + + +@pytest.fixture +def real_ca(): + """A real, parseable CA bundle on disk. + + ``ssl.create_default_context(cafile=...)`` parses the file eagerly, so the + urllib context path needs a genuine bundle rather than a stub. The + ``requests`` path only stores the path string (parsed lazily by requests at + call time), so it can use the ``bundle_file`` stub. + """ + return certifi.where() + + +def _providers(base_url, **tls): + entry = {"name": "relay", "base_url": base_url} + entry.update(tls) + return [entry] + + +class TestResolveRequestsVerifyProviderScoped: + """``_resolve_requests_verify(base_url)`` — the requests probe path.""" + + def test_provider_ca_used_for_matching_base_url(self, clean_env, bundle_file): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_ca_cert=bundle_file), + ): + assert _resolve_requests_verify(_BASE) == bundle_file + + def test_provider_ca_overrides_env_ssl_cert_file(self, clean_env, tmp_path, bundle_file): + env_bundle = tmp_path / "env-ca.pem" + env_bundle.write_text("stub") + clean_env.setenv("SSL_CERT_FILE", str(env_bundle)) + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_ca_cert=bundle_file), + ): + assert _resolve_requests_verify(_BASE) == bundle_file + + def test_provider_ssl_verify_false_disables(self, clean_env): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_verify=False), + ): + assert _resolve_requests_verify(_BASE) is False + + def test_no_base_url_does_not_consult_config(self, clean_env, bundle_file): + """Existing callers pass no base_url — env-only behavior, no config read.""" + clean_env.setenv("HERMES_CA_BUNDLE", bundle_file) + probe = MagicMock(return_value=[]) + with patch("hermes_cli.config.get_compatible_custom_providers", probe): + assert _resolve_requests_verify() == bundle_file + probe.assert_not_called() + + def test_unmatched_base_url_falls_through_to_env(self, clean_env, bundle_file): + clean_env.setenv("REQUESTS_CA_BUNDLE", bundle_file) + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers("https://other.example.invalid/v1", ssl_ca_cert="/nope.pem"), + ): + assert _resolve_requests_verify(_BASE) == bundle_file + + def test_unmatched_base_url_no_env_returns_true(self, clean_env): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=[], + ): + assert _resolve_requests_verify(_BASE) is True + + def test_provider_ca_missing_file_falls_through_to_env(self, clean_env, bundle_file): + clean_env.setenv("SSL_CERT_FILE", bundle_file) + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_ca_cert="/does/not/exist.pem"), + ): + assert _resolve_requests_verify(_BASE) == bundle_file + + def test_config_lookup_failure_falls_through_to_env(self, clean_env, bundle_file): + clean_env.setenv("SSL_CERT_FILE", bundle_file) + with patch( + "hermes_cli.config.get_compatible_custom_providers", + side_effect=RuntimeError("config boom"), + ): + assert _resolve_requests_verify(_BASE) == bundle_file + + +class TestCustomProviderSSLContext: + """``_custom_provider_ssl_context`` — the urllib /models discovery path.""" + + def test_returns_verifying_context_with_provider_ca(self, real_ca): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_ca_cert=real_ca), + ): + ctx = _custom_provider_ssl_context(_BASE) + assert isinstance(ctx, ssl.SSLContext) + assert ctx.verify_mode == ssl.CERT_REQUIRED + + def test_ssl_verify_false_returns_unverified_context(self): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_verify=False), + ): + ctx = _custom_provider_ssl_context(_BASE) + assert isinstance(ctx, ssl.SSLContext) + assert ctx.check_hostname is False + assert ctx.verify_mode == ssl.CERT_NONE + + def test_no_base_url_returns_none(self): + assert _custom_provider_ssl_context("") is None + + def test_unmatched_returns_none(self): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=[], + ): + assert _custom_provider_ssl_context(_BASE) is None + + def test_missing_ca_file_returns_none(self): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_ca_cert="/does/not/exist.pem"), + ): + assert _custom_provider_ssl_context(_BASE) is None + + def test_config_lookup_failure_returns_none(self): + with patch( + "hermes_cli.config.get_compatible_custom_providers", + side_effect=RuntimeError("config boom"), + ): + assert _custom_provider_ssl_context(_BASE) is None + + +class TestMetadataProbeThreadsProviderCA: + """End-to-end: the requests metadata probe carries the provider CA to the wire.""" + + def test_fetch_endpoint_model_metadata_uses_provider_ca(self, clean_env, bundle_file): + import agent.model_metadata as mm + + captured = {} + + def fake_get(url, headers=None, timeout=None, verify=None, **kwargs): + captured["verify"] = verify + resp = MagicMock() + resp.raise_for_status.return_value = None + resp.json.return_value = {"data": []} + return resp + + mm._endpoint_model_metadata_cache.clear() + mm._endpoint_model_metadata_cache_time.clear() + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_ca_cert=bundle_file), + ), patch.object(mm.requests, "get", side_effect=fake_get): + mm.fetch_endpoint_model_metadata(_BASE, force_refresh=True) + + assert captured["verify"] == bundle_file + + def test_public_endpoint_keeps_env_default(self, clean_env): + import agent.model_metadata as mm + + captured = {} + + def fake_get(url, headers=None, timeout=None, verify=None, **kwargs): + captured["verify"] = verify + resp = MagicMock() + resp.raise_for_status.return_value = None + resp.json.return_value = {"data": []} + return resp + + mm._endpoint_model_metadata_cache.clear() + mm._endpoint_model_metadata_cache_time.clear() + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=[], + ), patch.object(mm.requests, "get", side_effect=fake_get): + mm.fetch_endpoint_model_metadata(_BASE, force_refresh=True) + + assert captured["verify"] is True + + +class TestCatalogProbeThreadsSSLContext: + """End-to-end: the urllib catalog probe carries the provider SSL context.""" + + def test_probe_api_models_passes_ssl_context(self, clean_env, real_ca): + import hermes_cli.models as models + + captured = {} + + def fake_open(req, *, timeout, ssl_context=None): + captured["ssl_context"] = ssl_context + raise urllib.error.URLError("stop after capture") + + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=_providers(_BASE, ssl_ca_cert=real_ca), + ), patch.object(models, "open_credentialed_url", side_effect=fake_open): + models.probe_api_models(None, _BASE, timeout=1) + + assert isinstance(captured["ssl_context"], ssl.SSLContext) + assert captured["ssl_context"].verify_mode == ssl.CERT_REQUIRED + + def test_probe_api_models_public_endpoint_uses_default_policy(self, clean_env): + import hermes_cli.models as models + + captured = {} + + def fake_open(req, *, timeout, ssl_context=None): + captured["ssl_context"] = ssl_context + raise urllib.error.URLError("stop after capture") + + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=[], + ), patch.object(models, "open_credentialed_url", side_effect=fake_open): + models.probe_api_models(None, _BASE, timeout=1) + + assert captured["ssl_context"] is None + + def test_public_endpoint_calls_seam_without_ssl_context_kwarg(self, clean_env): + """A public endpoint must not pass ssl_context to the call seam. + + Regression guard: threading ssl_context unconditionally broke existing + call-seam mocks whose signature is ``(req, timeout=...)``. The probe + must keep the original 2-arg call shape when no per-provider override + applies, so a strict 2-arg mock still works. + """ + import hermes_cli.models as models + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{"data": [{"id": "local-model"}]}' + + calls = [] + + def _strict_two_arg(req, timeout=5.0): + calls.append(req.full_url) + return _Resp() + + with patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=[], + ), patch.object( + models, "_urlopen_model_catalog_request", side_effect=_strict_two_arg + ): + probe = models.probe_api_models("key", "http://localhost:8000", timeout=1) + + assert probe["models"] == ["local-model"] + assert calls == ["http://localhost:8000/models"] diff --git a/tests/agent/test_deadline.py b/tests/agent/test_deadline.py new file mode 100644 index 0000000000000..378a5e2c10569 --- /dev/null +++ b/tests/agent/test_deadline.py @@ -0,0 +1,521 @@ +"""Tests for agent/deadline.py — the unified deadline layer (#85125). + +Covers: +* clamp_timeout normalization (None / non-positive / oversized / NaN / junk) +* resolve_timeout precedence: config.yaml ``timeouts:`` > legacy env var > default +* run_bounded_sync: completion, exception propagation, timeout + on_timeout +* run_bounded_async: completion, exception propagation, timeout + abandonment + of cancellation-shielded tasks, on_abandon cleanup +* kill_process_tree: descendants of a session-leader child die with it (POSIX) +* backward-compat contract of tool_executor._resolve_concurrent_tool_timeout + after its migration onto resolve_timeout +""" + +from __future__ import annotations + +import asyncio +import os +import signal +import subprocess +import sys +import threading +import time + +import pytest + +from agent.deadline import ( + MAX_SAFE_TIMEOUT_S, + BoundedResult, + DeadlineExpired, + clamp_timeout, + kill_process_tree, + resolve_timeout, + run_bounded_async, + run_bounded_sync, +) + + +# --------------------------------------------------------------------------- +# clamp_timeout +# --------------------------------------------------------------------------- + +class TestClampTimeout: + def test_none_stays_none(self): + assert clamp_timeout(None) is None + + def test_zero_and_negative_mean_unbounded(self): + assert clamp_timeout(0) is None + assert clamp_timeout(-5) is None + + def test_normal_value_passes_through(self): + assert clamp_timeout(420.0) == 420.0 + + def test_oversized_value_clamped_to_platform_safe_max(self): + # The #83220 class: >time_t deadlines crash Lock.acquire on macOS. + assert clamp_timeout(10**18) == MAX_SAFE_TIMEOUT_S + + def test_clamped_value_safe_for_threading_primitives(self): + # Regression proof for #83220: the clamped value itself must be + # accepted by the exact primitive that used to overflow. Acquiring an + # uncontended lock returns immediately regardless of timeout, so + # passing the full clamped value is safe and actually exercises the + # time_t conversion. + big = clamp_timeout(float(10**15)) + assert big is not None + lock = threading.Lock() + assert lock.acquire(timeout=big) + lock.release() + + def test_nan_and_junk_treated_as_unbounded(self): + assert clamp_timeout(float("nan")) is None + assert clamp_timeout("not-a-number") is None # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# resolve_timeout +# --------------------------------------------------------------------------- + +class TestResolveTimeout: + def test_default_wins_when_nothing_configured(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.delenv("HERMES_TEST_DEADLINE_X", raising=False) + assert resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X") == 42.0 + + def test_env_var_beats_default(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "17.5") + assert resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X") == 17.5 + + def test_config_beats_env_var(self, monkeypatch): + monkeypatch.setattr( + "agent.deadline._timeouts_section", lambda: {"a": {"b": 99}} + ) + monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "17.5") + assert resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X") == 99.0 + + def test_dotted_key_walks_nested_maps(self, monkeypatch): + monkeypatch.setattr( + "agent.deadline._timeouts_section", + lambda: {"tools": {"concurrent_batch": 300}}, + ) + assert resolve_timeout("tools.concurrent_batch", default=420.0) == 300.0 + + def test_zero_config_value_means_unbounded(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {"a": {"b": 0}}) + assert resolve_timeout("a.b", default=42.0) is None + + def test_invalid_config_value_falls_through_to_env(self, monkeypatch): + monkeypatch.setattr( + "agent.deadline._timeouts_section", lambda: {"a": {"b": "soon"}} + ) + monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "17.5") + assert resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X") == 17.5 + + def test_invalid_env_value_falls_through_to_default(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.setenv("HERMES_TEST_DEADLINE_X", "banana") + assert resolve_timeout("a.b", default=42.0, env_var="HERMES_TEST_DEADLINE_X") == 42.0 + + def test_bool_config_value_rejected(self, monkeypatch): + # YAML `true` must not silently become a 1-second deadline. + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {"a": {"b": True}}) + assert resolve_timeout("a.b", default=42.0) == 42.0 + + def test_nan_config_value_falls_through(self, monkeypatch): + # NaN must fall through to the next source, not resolve as unbounded. + monkeypatch.setattr( + "agent.deadline._timeouts_section", lambda: {"a": {"b": float("nan")}} + ) + assert resolve_timeout("a.b", default=42.0) == 42.0 + + def test_broken_config_read_never_breaks_the_protected_path(self, monkeypatch): + # _timeouts_section swallows config-load failures internally; prove + # the public contract by making the underlying loader raise. + import agent.deadline as dl + + def _boom(): + raise RuntimeError("config unreadable") + + monkeypatch.setattr("hermes_cli.config.load_config_readonly", _boom) + assert dl._timeouts_section() == {} + assert resolve_timeout("a.b", default=5.0) == 5.0 + + +# --------------------------------------------------------------------------- +# run_bounded_sync +# --------------------------------------------------------------------------- + +class TestRunBoundedSync: + def test_completion_returns_value(self): + result = run_bounded_sync(lambda: "ok", 5.0, label="t") + assert result.timed_out is False + assert result.value == "ok" + assert result.raise_if_timed_out() == "ok" + + def test_unbounded_when_timeout_none(self): + result = run_bounded_sync(lambda: 7, None, label="t") + assert result.timed_out is False and result.value == 7 + + def test_exception_propagates_unchanged(self): + class Boom(RuntimeError): + pass + + with pytest.raises(Boom): + run_bounded_sync(lambda: (_ for _ in ()).throw(Boom("x")), 5.0, label="t") + + def test_timeout_abandons_worker_and_reports(self): + release = threading.Event() + + def _wedged(): + release.wait(30) + return "late" + + start = time.monotonic() + result = run_bounded_sync(_wedged, 0.2, label="wedged") + elapsed = time.monotonic() - start + assert result.timed_out is True + assert result.value is None + assert elapsed < 5.0 # returned near the deadline, not after 30s + with pytest.raises(DeadlineExpired) as exc_info: + result.raise_if_timed_out() + assert "wedged" in str(exc_info.value) + release.set() + + def test_on_timeout_callback_runs(self): + release = threading.Event() + fired = [] + result = run_bounded_sync( + lambda: release.wait(30), + 0.1, + label="t", + on_timeout=lambda: fired.append(True), + ) + assert result.timed_out and fired == [True] + release.set() + + def test_on_timeout_callback_failure_is_swallowed(self): + release = threading.Event() + result = run_bounded_sync( + lambda: release.wait(30), + 0.1, + label="t", + on_timeout=lambda: (_ for _ in ()).throw(RuntimeError("cleanup boom")), + ) + assert result.timed_out is True + release.set() + + def test_deadline_expired_is_a_timeout_error(self): + # Error-classification contract: our deadline must be catchable as + # TimeoutError but distinguishable by type from transport timeouts. + assert issubclass(DeadlineExpired, TimeoutError) + + +# --------------------------------------------------------------------------- +# run_bounded_async +# --------------------------------------------------------------------------- + +class TestRunBoundedAsync: + def test_completion_returns_value(self): + async def scenario(): + async def op(): + return "ok" + + return await run_bounded_async(op(), 5.0, label="t") + + result = asyncio.run(scenario()) + assert result.timed_out is False and result.value == "ok" + + def test_unbounded_when_timeout_none(self): + async def scenario(): + async def op(): + return 7 + + return await run_bounded_async(op(), None, label="t") + + result = asyncio.run(scenario()) + assert result.timed_out is False and result.value == 7 + + def test_exception_propagates_unchanged(self): + class Boom(RuntimeError): + pass + + async def scenario(): + async def op(): + raise Boom("x") + + await run_bounded_async(op(), 5.0, label="t") + + with pytest.raises(Boom): + asyncio.run(scenario()) + + def test_timeout_returns_promptly(self): + async def scenario(): + async def op(): + await asyncio.sleep(30) + + start = time.monotonic() + result = await run_bounded_async(op(), 0.2, label="slow") + return result, time.monotonic() - start + + result, elapsed = asyncio.run(scenario()) + assert result.timed_out is True + assert elapsed < 5.0 + + def test_timeout_abandons_cancellation_shielded_task(self): + """The family-A killer case: asyncio.wait_for cannot expire a shielded + scope; the thread-timer deadline must return anyway.""" + + async def scenario(): + hung = asyncio.Event() + + async def inner(): + await hung.wait() + + async def shielded(): + # Shield swallows the cancellation run_bounded_async issues. + await asyncio.shield(asyncio.ensure_future(inner())) + + start = time.monotonic() + result = await run_bounded_async(shielded(), 0.2, label="shielded") + elapsed = time.monotonic() - start + hung.set() # release the orphan so the loop can drain + await asyncio.sleep(0) + return result, elapsed + + result, elapsed = asyncio.run(scenario()) + assert result.timed_out is True + assert elapsed < 5.0 + + def test_on_abandon_cleanup_runs_detached(self): + async def scenario(): + cleaned = asyncio.Event() + + async def _cleanup(): + cleaned.set() + + async def op(): + await asyncio.sleep(30) + + result = await run_bounded_async( + op(), 0.1, label="t", on_abandon=_cleanup + ) + await asyncio.wait_for(cleaned.wait(), timeout=5.0) + return result + + result = asyncio.run(scenario()) + assert result.timed_out is True + + def test_completed_op_never_reports_timeout(self): + # Race guard: completion just under the deadline must report success. + async def scenario(): + async def op(): + await asyncio.sleep(0.01) + return "made it" + + return await run_bounded_async(op(), 5.0, label="t") + + result = asyncio.run(scenario()) + assert result.timed_out is False and result.value == "made it" + + def test_external_cancellation_cancels_inner_task(self): + # If the CALLER cancels run_bounded_async, the inner task must not be + # leaked running unobserved. + async def scenario(): + started = asyncio.Event() + inner_cancelled = asyncio.Event() + + async def op(): + started.set() + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + inner_cancelled.set() + raise + + outer = asyncio.ensure_future( + run_bounded_async(op(), 25.0, label="t") + ) + await started.wait() + outer.cancel() + with pytest.raises(asyncio.CancelledError): + await outer + await asyncio.wait_for(inner_cancelled.wait(), timeout=5.0) + return True + + assert asyncio.run(scenario()) is True + + +# --------------------------------------------------------------------------- +# kill_process_tree +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process-group semantics") +class TestKillProcessTree: + def test_kills_descendants_of_session_leader(self, tmp_path): + """A child spawned with start_new_session must die with its own child. + + This is the orphan-tree class (#71148): killing only the direct child + leaves grandchildren running. + """ + started = tmp_path / "grandchild_started" + marker = tmp_path / "grandchild_alive" + grandchild_py = tmp_path / "grandchild.py" + grandchild_py.write_text( + "import pathlib, time\n" + f"pathlib.Path({str(started)!r}).write_text('x')\n" + "time.sleep(10)\n" + f"pathlib.Path({str(marker)!r}).write_text('x')\n" + ) + parent_py = tmp_path / "parent.py" + parent_py.write_text( + "import subprocess, sys, time\n" + f"subprocess.Popen([sys.executable, {str(grandchild_py)!r}])\n" + "time.sleep(10)\n" + ) + proc = subprocess.Popen( + [sys.executable, str(parent_py)], start_new_session=True + ) + deadline = time.monotonic() + 10 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert started.exists(), "grandchild never spawned — test harness broken" + assert kill_process_tree(proc.pid) is True + proc.wait(timeout=5) + # Grandchild must be dead too: marker never appears. + time.sleep(1.5) + assert not marker.exists() + + def test_kills_descendant_in_its_own_session(self, tmp_path): + """A descendant that setsid'd out of the parent's group must die too. + + killpg on the parent's group cannot reach it; the psutil descendant + sweep must (tools/environments/base.py documents user commands doing + exactly this). + """ + started = tmp_path / "setsid_grandchild_started" + marker = tmp_path / "setsid_grandchild_alive" + grandchild_py = tmp_path / "grandchild.py" + grandchild_py.write_text( + "import pathlib, time\n" + f"pathlib.Path({str(started)!r}).write_text('x')\n" + "time.sleep(10)\n" + f"pathlib.Path({str(marker)!r}).write_text('x')\n" + ) + parent_py = tmp_path / "parent.py" + parent_py.write_text( + "import subprocess, sys, time\n" + # grandchild leaves the parent's session/group entirely + f"subprocess.Popen([sys.executable, {str(grandchild_py)!r}], start_new_session=True)\n" + "time.sleep(10)\n" + ) + proc = subprocess.Popen( + [sys.executable, str(parent_py)], start_new_session=True + ) + deadline = time.monotonic() + 10 + while not started.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert started.exists(), "grandchild never spawned — test harness broken" + assert kill_process_tree(proc.pid) is True + proc.wait(timeout=5) + time.sleep(1.5) + assert not marker.exists() + + def test_already_dead_pid_returns_false(self): + proc = subprocess.Popen([sys.executable, "-c", "pass"], start_new_session=True) + proc.wait(timeout=10) # reaped: PID is gone from the process table + assert kill_process_tree(proc.pid) is False + + def test_non_group_leader_falls_back_to_single_kill(self): + # Child in OUR process group: killpg would signal the test runner. + proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + assert os.getpgid(proc.pid) != proc.pid # not a leader + assert kill_process_tree(proc.pid, sig=signal.SIGTERM) is True + proc.wait(timeout=5) + finally: + if proc.poll() is None: + proc.kill() + + +# --------------------------------------------------------------------------- +# tool_executor migration contract +# --------------------------------------------------------------------------- + +class TestConcurrentToolTimeoutMigration: + """_resolve_concurrent_tool_timeout keeps its exact legacy contract.""" + + def _resolver(self): + from agent import tool_executor + + return tool_executor._resolve_concurrent_tool_timeout + + def test_default_unchanged(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.delenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", raising=False) + assert self._resolver()() == 420.0 + + def test_env_var_still_works(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "60") + assert self._resolver()() == 60.0 + + def test_env_zero_still_disables(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "0") + assert self._resolver()() is None + + def test_env_invalid_still_falls_back_to_default(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "junk") + assert self._resolver()() == 420.0 + + def test_new_config_key_wins(self, monkeypatch): + monkeypatch.setattr( + "agent.deadline._timeouts_section", + lambda: {"tools": {"concurrent_batch": 300}}, + ) + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "60") + assert self._resolver()() == 300.0 + + +class TestSequentialToolTimeoutResolver: + """_resolve_sequential_tool_timeout: own key, inherits concurrent default.""" + + def _resolver(self): + from agent import tool_executor + + return tool_executor._resolve_sequential_tool_timeout + + def test_inherits_concurrent_default(self, monkeypatch): + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.delenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", raising=False) + assert self._resolver()() == 420.0 + + def test_inherits_concurrent_env_bridge(self, monkeypatch): + # No sequential-specific setting -> concurrent env var flows through. + monkeypatch.setattr("agent.deadline._timeouts_section", lambda: {}) + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "60") + assert self._resolver()() == 60.0 + + def test_own_config_key_wins_over_concurrent(self, monkeypatch): + monkeypatch.setattr( + "agent.deadline._timeouts_section", + lambda: {"tools": {"concurrent_batch": 300, "sequential_call": 90}}, + ) + assert self._resolver()() == 90.0 + + def test_zero_disables_independently(self, monkeypatch): + # Sequential bound can be disabled while the concurrent one stays on. + monkeypatch.setattr( + "agent.deadline._timeouts_section", + lambda: {"tools": {"concurrent_batch": 300, "sequential_call": 0}}, + ) + assert self._resolver()() is None + + def test_concurrent_disabled_flows_through(self, monkeypatch): + # concurrent disabled (None default) + no sequential key -> unbounded. + monkeypatch.setattr( + "agent.deadline._timeouts_section", + lambda: {"tools": {"concurrent_batch": 0}}, + ) + assert self._resolver()() is None diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index d2e5a1942160b..8dd8d2bbad1d6 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -4,6 +4,7 @@ from agent.error_classifier import ( ClassifiedError, FailoverReason, + PROVIDER_STREAM_NON_JSON_ERROR_CODE, classify_api_error, _extract_status_code, _extract_error_body, @@ -231,6 +232,38 @@ def test_404_free_tier_model_block_is_billing(self): assert result.retryable is False assert result.should_fallback is True + def test_404_requires_available_credits_is_billing(self): + e = MockAPIError( + "Not Found", + status_code=404, + body={ + "status": 404, + "message": ( + "Model 'openai/gpt-5.5-pro' requires available credits. " + "Your account balance is too low to use paid models — " + "add credits at https://portal.nousresearch.com or pick a free model." + ), + }, + ) + result = classify_api_error(e, provider="nous", model="openai/gpt-5.5-pro") + assert result.reason == FailoverReason.billing + assert result.retryable is False + assert result.should_fallback is True + + def test_wrapped_402_uses_nested_body_message(self): + inner = MockAPIError( + "inner", + status_code=402, + body={"error": {"message": "Usage limit reached, try again in 5 minutes"}}, + ) + outer = Exception("outer") + outer.__cause__ = inner + + result = classify_api_error(outer) + + assert result.reason == FailoverReason.rate_limit + assert result.retryable is True + assert result.message == "Usage limit reached, try again in 5 minutes" # ── Rate limit ── @@ -340,6 +373,43 @@ def test_429_normal_rate_limit_still_rotates(self): + def test_non_json_stream_validation_error_is_non_retryable(self): + e = MockAPIError( + "Provider stream returned non-JSON SSE data", + body={ + "error": { + "code": PROVIDER_STREAM_NON_JSON_ERROR_CODE, + "message": ( + "request validation failed: unsupported reasoning_effort" + ), + } + }, + ) + + result = classify_api_error(e) + + assert result.status_code is None + assert result.reason == FailoverReason.format_error + assert result.retryable is False + assert result.should_fallback is True + + def test_non_json_stream_unknown_error_remains_retryable(self): + e = MockAPIError( + "Provider stream returned non-JSON SSE data", + body={ + "error": { + "code": PROVIDER_STREAM_NON_JSON_ERROR_CODE, + "message": "upstream sent opaque plain-text stream data", + } + }, + ) + + result = classify_api_error(e) + + assert result.status_code is None + assert result.reason == FailoverReason.unknown + assert result.retryable is True + assert result.should_fallback is False # ── 5xx that are actually context overflow ── # Some local inference servers (llama.cpp / llama-server, and vLLM/Ollama @@ -1128,4 +1198,3 @@ def test_longer_than_context_length_still_overflow(self): assert result.reason == FailoverReason.context_overflow - diff --git a/tests/agent/test_profile_home_override_precedence.py b/tests/agent/test_profile_home_override_precedence.py new file mode 100644 index 0000000000000..2fff959689d16 --- /dev/null +++ b/tests/agent/test_profile_home_override_precedence.py @@ -0,0 +1,176 @@ +"""Regression: multiplex gateway profile scoping + full-prompt wiring. + +Two scenarios for _agent_home's resolution order (#86313 post-merge findings): + +1. MULTIPLEX INVERSION (@kshitijk4poor): the messaging gateway hands every + agent the shared launch-home state.db but binds the profile home per turn + via the HERMES_HOME ContextVar (copy_context into the worker). A bound + override must WIN over the db-derived launch home, else the shared-db + fallback stomps the correct profile deterministically. + +2. BARE THREAD (the original #86313 fix): no override bound — the db-derived + home must still win over ambient env resolution. + +Plus the full-prompt wiring test (@helix4u): build_system_prompt_parts on a +bare thread with the bot's session DB must produce a prompt whose identity +(SOUL.md), skills block, and profile line ALL belong to the bot — reverting +any single call-site wire breaks this test. +""" + +import re +import threading +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + +class _DB: + def __init__(self, home: Path): + self.db_path = home / "state.db" + + +def _agent_for(home: Path, **overrides): + base = dict( + load_soul_identity=True, + skip_context_files=True, + valid_tool_names=[], + _task_completion_guidance=False, + _tool_use_enforcement=False, + _environment_probe=False, + _kanban_worker_guidance="", + _memory_store=None, + _memory_manager=None, + model="", + provider="", + platform="", + pass_session_id=False, + session_id="", + _session_db=_DB(home), + ) + base.update(overrides) + return SimpleNamespace(**base) + + +def test_bound_override_wins_over_shared_db_home(tmp_path, monkeypatch): + """Multiplex lane: shared launch-home DB + per-turn ContextVar binding. + The override must win, not the db-derived launch home.""" + from agent import system_prompt + + root = tmp_path / "root" + root.mkdir() + bot_home = root / "profiles" / "mybot" + bot_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(root)) + + agent = _agent_for(root) # shared db lives at /state.db + token = set_hermes_home_override(str(bot_home)) + try: + assert system_prompt._agent_home(agent) == bot_home + assert ( + system_prompt._profile_name_for_home(system_prompt._agent_home(agent)) + == "mybot" + ) + finally: + reset_hermes_home_override(token) + + +def test_db_home_wins_on_bare_thread_without_override(tmp_path, monkeypatch): + """Original #86313 scenario: unbound thread, dedicated per-profile DB.""" + from agent import system_prompt + + root = tmp_path / "root" + bot_home = root / "profiles" / "mybot" + bot_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(root)) + + agent = _agent_for(bot_home) + result = {} + + def resolve(): + result["home"] = system_prompt._agent_home(agent) + + t = threading.Thread(target=resolve) + t.start() + t.join() + + assert result["home"] == bot_home + + +def test_full_prompt_scoped_to_bot_on_bare_thread(tmp_path, monkeypatch): + """Wiring test: SOUL.md identity, skills block, and profile line must ALL + come from the bot's home when building on an unbound thread with the + bot's session DB — no mixed-profile prompt.""" + from agent import prompt_builder + from agent.system_prompt import build_system_prompt + + default_home = tmp_path / "root" + default_skills = default_home / "skills" / "general" / "leaky-skill" + default_skills.mkdir(parents=True) + (default_skills / "SKILL.md").write_text( + "---\nname: leaky-skill\ndescription: default-only skill\n---\nbody\n", + encoding="utf-8", + ) + (default_home / "SOUL.md").write_text("DEFAULT SOUL", encoding="utf-8") + + bot_home = default_home / "profiles" / "mybot" + bot_skills = bot_home / "skills" / "general" / "bot-skill" + bot_skills.mkdir(parents=True) + (bot_skills / "SKILL.md").write_text( + "---\nname: bot-skill\ndescription: bot-only skill\n---\nbody\n", + encoding="utf-8", + ) + (bot_home / "SOUL.md").write_text("BOT SOUL", encoding="utf-8") + + # Ambient env resolves to the launch/default home; nothing binds the + # ContextVar on the build thread. + monkeypatch.setenv("HERMES_HOME", str(default_home)) + prompt_builder.clear_skills_system_prompt_cache(clear_snapshot=False) + + agent = _agent_for(bot_home, valid_tool_names=["skill_view"]) + result = {} + + def build(): + with ( + patch("run_agent.build_nous_subscription_prompt", return_value=""), + patch("run_agent.build_environment_hints", return_value=""), + ): + result["prompt"] = build_system_prompt(agent) + + t = threading.Thread(target=build) + t.start() + t.join() + prompt = result["prompt"] + + assert "BOT SOUL" in prompt + assert "DEFAULT SOUL" not in prompt + m = re.search(r"(.*?)", prompt, re.DOTALL) + skills_block = m.group(1) if m else "" + assert "bot-skill" in skills_block + assert "leaky-skill" not in skills_block + assert "Active Hermes profile: mybot" in prompt + assert "Active Hermes profile: default" not in prompt + + +def test_plugin_session_info_profile_from_agent_home(tmp_path, monkeypatch): + """Plugin prompt metadata must carry the agent's own profile name, not the + ambient one (@helix4u's plugin half).""" + from agent import system_prompt + + root = tmp_path / "root" + bot_home = root / "profiles" / "mybot" + bot_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(root)) + + agent = _agent_for(bot_home) + result = {} + + def resolve(): + result["info"] = system_prompt._plugin_session_info(agent) + + t = threading.Thread(target=resolve) + t.start() + t.join() + + assert result["info"]["profile_name"] == "mybot" diff --git a/tests/agent/test_soul_md_profile_isolation.py b/tests/agent/test_soul_md_profile_isolation.py new file mode 100644 index 0000000000000..c2be9de97a1b1 --- /dev/null +++ b/tests/agent/test_soul_md_profile_isolation.py @@ -0,0 +1,62 @@ +"""Regression (#50233, SOUL.md half): a profile agent's SOUL.md must load from +ITS OWN home, never the ambient/launch home — even on a thread that did not +bind the HERMES_HOME ContextVar. Same bug class as the skills-index leak +fixed in #86313; load_soul_md now accepts home_override. +""" + +import threading + + +def test_soul_md_scoped_to_home_override_not_ambient(tmp_path, monkeypatch): + from agent import prompt_builder + + default_home = tmp_path / "default" + default_home.mkdir() + (default_home / "SOUL.md").write_text( + "DEFAULT SOUL — must never leak into a profile prompt", encoding="utf-8" + ) + + bot_home = tmp_path / "profiles" / "mybot" + bot_home.mkdir(parents=True) + (bot_home / "SOUL.md").write_text("BOT SOUL", encoding="utf-8") + + # Ambient home points at default (mimics a build thread that lost the + # profile's ContextVar override and fell back to launch). + monkeypatch.setenv("HERMES_HOME", str(default_home)) + + result = {} + + def build(): + result["soul"] = prompt_builder.load_soul_md(home_override=bot_home) + + t = threading.Thread(target=build) + t.start() + t.join() + + assert result["soul"] == "BOT SOUL" + + +def test_soul_md_override_missing_file_returns_none(tmp_path, monkeypatch): + from agent import prompt_builder + + default_home = tmp_path / "default" + default_home.mkdir() + (default_home / "SOUL.md").write_text("DEFAULT SOUL", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(default_home)) + + empty_bot = tmp_path / "profiles" / "emptybot" + empty_bot.mkdir(parents=True) + + # No SOUL.md in the bot home -> None, NOT default's soul. + assert prompt_builder.load_soul_md(home_override=empty_bot) is None + + +def test_soul_md_ambient_unchanged_without_override(tmp_path, monkeypatch): + from agent import prompt_builder + + home = tmp_path / "home" + home.mkdir() + (home / "SOUL.md").write_text("AMBIENT SOUL", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + + assert prompt_builder.load_soul_md() == "AMBIENT SOUL" diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index eb1b9c048ad38..aac357bd759b5 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -35,7 +35,7 @@ def _captured_context_cwd(agent): def fake_context_files( cwd=None, skip_soul=False, context_length=None, - allow_install_tree_fallback=False, + allow_install_tree_fallback=False, home_override=None, ): captured["cwd"] = cwd return "" @@ -116,6 +116,70 @@ def test_absent_without_tools(self, monkeypatch, tmp_path): assert "coding agent" not in _stable_prompt(agent) +class TestNamedProfileHintIntegration: + """The same defect through the REAL resolution chain (#72894). + + ``TestNamedProfileHint`` mocks ``get_hermes_home``, + ``get_default_hermes_root`` and ``_resolve_active_profile_name``, so it + validates template rendering but not the relationship that causes the bug: + ``_resolve_active_profile_name`` returns a named profile *only* when the + active home is already ``/profiles/``, which is exactly why + appending that suffix again doubled it. Drive it with a real + ``HERMES_HOME`` and no resolver mocks. + """ + + def test_real_hermes_home_under_profiles_renders_correct_paths( + self, tmp_path, monkeypatch + ): + root = tmp_path / ".hermes" + profile_home = root / "profiles" / "coder" + profile_home.mkdir(parents=True) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + + # Sanity-check the real chain before asserting on the prompt. + from agent.file_safety import _resolve_active_profile_name + from hermes_constants import get_default_hermes_root, get_hermes_home + + assert _resolve_active_profile_name() == "coder" + assert get_hermes_home() == profile_home + assert get_default_hermes_root() == root + + agent = _make_agent(valid_tool_names=["read_file"]) + with patch("agent.coding_context._coding_mode", return_value="off"): + prompt = "\n\n".join(_prompt_parts(agent).values()) + + assert "Active Hermes profile: coder." in prompt + assert f"reads and writes {profile_home}/." in prompt + # The doubled form must not appear anywhere. + assert f"{profile_home}/profiles/coder" not in prompt + # Default-profile pointers belong at the root, not inside the profile. + assert f"The default profile's data lives at {root}/skills/" in prompt + assert f"{profile_home}/skills/" not in prompt + + def test_real_default_home_renders_default_branch(self, tmp_path, monkeypatch): + """HERMES_HOME at the root resolves to the default profile, unchanged.""" + root = tmp_path / ".hermes" + root.mkdir(parents=True) + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + + from agent.file_safety import _resolve_active_profile_name + + assert _resolve_active_profile_name() == "default" + + agent = _make_agent(valid_tool_names=["read_file"]) + with patch("agent.coding_context._coding_mode", return_value="off"): + prompt = "\n\n".join(_prompt_parts(agent).values()) + + assert "Active Hermes profile: default." in prompt + assert f"under {root}/profiles//." in prompt + + def test_build_system_prompt_records_stable_prefix(): agent = _make_agent() with ( diff --git a/tests/cli/test_cli_force_redraw.py b/tests/cli/test_cli_force_redraw.py index d3ea248e12e5f..81d1312f5fa79 100644 --- a/tests/cli/test_cli_force_redraw.py +++ b/tests/cli/test_cli_force_redraw.py @@ -53,6 +53,11 @@ def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeyp monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90) monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": False}}, + ) bare_cli._recover_after_resize(app, original_on_resize) @@ -66,6 +71,132 @@ def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeyp assert bare_cli._last_resize_width == 90 assert bare_cli._status_bar_suppressed_after_resize is True + def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli, monkeypatch): + app = MagicMock() + bare_cli._app = app + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": False}}, + ) + + bare_cli._force_full_redraw() + + app.renderer.output.erase_screen.assert_called_once() + app.renderer.output.cursor_goto.assert_called_once_with(0, 0) + app.renderer.output.write_raw.assert_not_called() + + def test_force_redraw_can_clear_scrollback_when_configured(self, bare_cli, monkeypatch): + app = MagicMock() + bare_cli._app = app + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": True}}, + ) + + bare_cli._force_full_redraw() + + app.renderer.output.erase_screen.assert_called_once() + app.renderer.output.write_raw.assert_called_once_with("\x1b[3J") + + def test_resize_recovery_can_clear_scrollback_when_configured(self, bare_cli, monkeypatch): + app = MagicMock() + events = [] + app.renderer.output.erase_screen.side_effect = lambda: events.append("erase") + app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe") + original_on_resize = lambda: events.append("original_resize") + + bare_cli._status_bar_suppressed_after_resize = False + bare_cli._last_resize_width = 200 + monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90) + monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) + monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) + monkeypatch.setattr( + cli_mod, + "CLI_CONFIG", + {"display": {"cli_rebuild_scrollback_on_redraw": "true"}}, + ) + + bare_cli._recover_after_resize(app, original_on_resize) + + assert events[:3] == ["erase", "scrollback_wipe", "replay"] + assert events.index("scrollback_wipe") < events.index("original_resize") + + def test_same_width_sigwinch_is_left_untouched(self, bare_cli, monkeypatch): + """Same-width SIGWINCH (tmux attach, benign focus/tab signals) must not + clear the viewport or replay: a 2J without replay erases the visible + transcript, and a replay duplicates it (#65293). The tmux-attach + stale-paint crash is handled by _hermes_call_output_screen_diff's + retry instead (#83874).""" + app = MagicMock() + events = [] + app.renderer.output.erase_screen.side_effect = lambda: events.append("erase") + app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe") + original_on_resize = lambda: events.append("original_resize") + + bare_cli._status_bar_suppressed_after_resize = False + bare_cli._last_resize_width = 120 + monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120) + monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) + monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay")) + + bare_cli._recover_after_resize(app, original_on_resize) + + assert "erase" not in events + assert "replay" not in events + assert "scrollback_wipe" not in events + assert events == ["original_resize"] + assert bare_cli._last_resize_width == 120 + assert bare_cli._status_bar_suppressed_after_resize is True + + def test_output_screen_diff_retries_on_corrupt_previous_screen(self, bare_cli): + """Corrupt previous_screen must not wedge the paint loop. + + After tmux attach, _output_screen_diff can raise AttributeError + ('cell' object has no attribute 'char'). Retry with previous_screen=None. + """ + calls = [] + + def fake_osd( + app, output, screen, current_pos, color_depth, + previous_screen, last_style, is_done, full_screen, + attrs_for_style_string, style_string_has_style, + size, previous_width, + ): + calls.append((previous_screen, previous_width, last_style)) + if previous_screen is not None: + # Exact failure mode from the classic CLI event loop. + raise AttributeError("'cell' object has no attribute 'char'") + return ("ok", current_pos, last_style) + + screen = MagicMock() + screen.height = 10 + previous = MagicMock() + previous.height = 8 + + result = cli_mod._hermes_call_output_screen_diff( + fake_osd, + app=None, + output=None, + screen=screen, + current_pos=None, + color_depth=None, + previous_screen=previous, + last_style="style", + is_done=False, + full_screen=False, + attrs_for_style_string=None, + style_string_has_style=None, + size=None, + previous_width=80, + ) + + assert result[0] == "ok" + assert len(calls) == 2 + assert calls[0][0] is previous + assert previous.height == 10 # height inflate still applied first + assert calls[1] == (None, 0, None) def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch): timers = [] @@ -142,3 +273,156 @@ def test_swallows_invalidate_exceptions(self, bare_cli): bare_cli._app = app bare_cli._force_full_redraw() # must not raise + + +class TestFirstSigwinchBaseline: + """Bug #65293: the session's FIRST SIGWINCH used to be force-treated as a + width change (no prior width to compare against), so a benign resize + signal — GNOME Terminal tab bar appearing, monitor-scale change, focus + events — cleared the viewport and replayed ``_OUTPUT_HISTORY``. After a + resume that deque holds the whole "Previous Conversation" recap plus the + first live exchange, so everything reprinted as a duplicate. A replay + must require an OBSERVED width change against a recorded baseline. + """ + + def test_first_sigwinch_with_unchanged_width_does_not_replay( + self, bare_cli, monkeypatch + ): + app = MagicMock() + events = [] + app.renderer.output.erase_screen.side_effect = lambda: events.append("erase") + original_on_resize = lambda: events.append("original_resize") + + bare_cli._status_bar_suppressed_after_resize = False + # No baseline recorded yet — the pre-fix code forced width_changed=True. + assert getattr(bare_cli, "_last_resize_width", None) is None + monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120) + monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) + monkeypatch.setattr( + cli_mod, "_replay_output_history", lambda: events.append("replay") + ) + + bare_cli._recover_after_resize(app, original_on_resize) + + # Width did not change — no clear, no replay, straight to prompt_toolkit. + assert events == ["original_resize"] + app.renderer.output.erase_screen.assert_not_called() + # The signal still records the baseline for the next comparison. + assert bare_cli._last_resize_width == 120 + + def test_real_width_change_after_baseline_still_replays( + self, bare_cli, monkeypatch + ): + """The #49120 recovery (2J + replay) must still fire on a real change.""" + app = MagicMock() + events = [] + app.renderer.output.erase_screen.side_effect = lambda: events.append("erase") + original_on_resize = lambda: events.append("original_resize") + + bare_cli._status_bar_suppressed_after_resize = False + bare_cli._last_resize_width = 120 + monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90) + monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None) + monkeypatch.setattr( + cli_mod, "_replay_output_history", lambda: events.append("replay") + ) + + bare_cli._recover_after_resize(app, original_on_resize) + + assert "erase" in events and "replay" in events + assert bare_cli._last_resize_width == 90 + + def test_install_resize_recovery_seeds_width_baseline(self, bare_cli): + """Hook installation records the CURRENT width as the baseline, so an + initial maximize/restore (a real change vs that baseline) is still + recovered while a same-size first signal is not. + + The baseline must come from ``app.output`` — the same object the + running app measures on SIGWINCH — not from ``get_app()``, which + before ``app.run()`` is a DummyApplication reporting a fake 80 cols. + """ + app = MagicMock() + app.output.get_size.return_value.columns = 132 + scheduled = [] + bare_cli._schedule_resize_recovery = lambda *a, **k: scheduled.append(a) + + original = app._on_resize + bare_cli._install_resize_recovery(app) + + assert bare_cli._last_resize_width == 132 + assert app._on_resize is not original # hook installed + app._on_resize() # simulated SIGWINCH → routes to the debouncer + assert len(scheduled) == 1 + assert scheduled[0][0] is app + assert scheduled[0][1] is original + + def test_install_resize_recovery_falls_back_to_shutil( + self, bare_cli, monkeypatch + ): + """A dead app.output probe falls back to shutil, never to the + DummyApplication's fake width.""" + import os as os_mod + + app = MagicMock() + app.output.get_size.side_effect = RuntimeError("not attached") + monkeypatch.setattr( + cli_mod.shutil, + "get_terminal_size", + lambda _default: os_mod.terminal_size((97, 40)), + ) + + bare_cli._install_resize_recovery(app) + + assert bare_cli._last_resize_width == 97 + + def test_install_resize_recovery_survives_width_probe_failure( + self, bare_cli, monkeypatch + ): + app = MagicMock() + app.output.get_size.side_effect = RuntimeError("not attached") + + def _boom(_default): + raise RuntimeError("no tty") + + monkeypatch.setattr(cli_mod.shutil, "get_terminal_size", _boom) + + bare_cli._install_resize_recovery(app) # must not raise + + assert getattr(bare_cli, "_last_resize_width", None) is None + + +class TestFocusRegainRedraw: + """Focus-in (CSI I) routes through the same recovery as Ctrl+L, rate-limited. + + While the tab/window is hidden the emulator may coalesce output or repaint + the surface; on regain prompt_toolkit's incremental diff stacks a fresh + copy of the prompt chrome on top of the stale one (#60920 focus-regain + variant, #25337). + """ + + def test_focus_regain_triggers_full_redraw(self, bare_cli): + calls = [] + bare_cli._force_full_redraw = lambda: calls.append("redraw") + + bare_cli._schedule_focus_regain_redraw() + + assert calls == ["redraw"] + + def test_focus_regain_redraw_is_rate_limited(self, bare_cli): + calls = [] + bare_cli._force_full_redraw = lambda: calls.append("redraw") + + bare_cli._schedule_focus_regain_redraw(min_interval=60.0) + bare_cli._schedule_focus_regain_redraw(min_interval=60.0) + bare_cli._schedule_focus_regain_redraw(min_interval=60.0) + + assert calls == ["redraw"] + + def test_focus_regain_redraw_fires_again_after_interval(self, bare_cli): + calls = [] + bare_cli._force_full_redraw = lambda: calls.append("redraw") + + bare_cli._schedule_focus_regain_redraw(min_interval=0.0) + bare_cli._schedule_focus_regain_redraw(min_interval=0.0) + + assert calls == ["redraw", "redraw"] diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 7b73569aa634f..41491d3979a05 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -1,4 +1,5 @@ import time +from copy import deepcopy from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -74,6 +75,23 @@ def test_snapshot_refreshes_persisted_session_title(self): assert snapshot["session_title"] == "user-profiles" + def test_status_bar_config_helper_treats_persisted_off_as_hidden(self): + for value in (False, "off", "false", "hidden", "no", "0"): + assert cli_mod._status_bar_visible_from_display_config({"tui_statusbar": value}) is False + + for value in (True, "top", "bottom", "on", None): + assert cli_mod._status_bar_visible_from_display_config({"tui_statusbar": value}) is True + + def test_status_bar_initial_visibility_honors_tui_statusbar_config(self, monkeypatch): + config = deepcopy(cli_mod.CLI_CONFIG) + config.setdefault("display", {})["tui_statusbar"] = False + config["display"].pop("statusbar", None) + monkeypatch.setattr(cli_mod, "CLI_CONFIG", config) + + cli_obj = HermesCLI(model="test-model", toolsets=[], provider="auto") + + assert cli_obj._status_bar_visible is False + def test_context_style_thresholds(self): cli_obj = _make_cli() diff --git a/tests/cli/test_interrupt_output_history_regression.py b/tests/cli/test_interrupt_output_history_regression.py new file mode 100644 index 0000000000000..0f43783cd0e67 --- /dev/null +++ b/tests/cli/test_interrupt_output_history_regression.py @@ -0,0 +1,326 @@ +"""Regression tests for #60920/#60941: interrupt marker duplication on redraw. + +The root cause: The interrupt marker ("_[Interrupted - processing new message]_") +was being appended to the response string, which got recorded in _OUTPUT_HISTORY +by the Panel rendering via _cprint → _record_output_history. When +_recover_terminal_after_interrupt called _force_full_redraw → _replay_output_history, +the marker was replayed on top of the already-visible message, causing duplicates +that accumulated on every SIGWINCH. + +The fix: +1. A flag ``_show_interrupt_marker`` is set instead of mutating ``response``. +2. After the Panel rendering, the marker is printed via ``_cprint`` inside a + ``_suspend_output_history()`` context so it never enters ``_OUTPUT_HISTORY``. +3. ``_recover_terminal_after_interrupt`` no longer clears ``_OUTPUT_HISTORY`` — + it doesn't need to, because the marker was never recorded. + +These tests verify the contract at the module level without hitting the full +prompt_toolkit input loop. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +import cli as cli_mod +from cli import HermesCLI, _suspend_output_history + + +@pytest.fixture(autouse=True) +def reset_output_history(): + """Reset _OUTPUT_HISTORY before and after every test.""" + cli_mod._configure_output_history(True, 200) + yield + cli_mod._configure_output_history(True, 200) + + +# ── Recovery path: _OUTPUT_HISTORY must NOT be cleared ────────────── + + +class TestRecoverTerminalPreservesHistory: + """_recover_terminal_after_interrupt must NOT clear output history. + + The old fix cleared _OUTPUT_HISTORY before the redraw to prevent the + interrupt marker from being replayed. The new fix avoids recording the + marker in the first place, so the clear is unnecessary *and* harmful — + it would discard legitimate scrollback content. + """ + + def test_history_preserved_after_recovery(self, monkeypatch): + """After recovery, _OUTPUT_HISTORY still contains earlier output.""" + cli_mod._configure_output_history(True, 10) + cli_mod._record_output_history("normal response text") + + cli = object.__new__(HermesCLI) + cli._force_full_redraw = MagicMock() + + with patch("hermes_cli.curses_ui.flush_stdin"): + cli._recover_terminal_after_interrupt() + + assert list(cli_mod._OUTPUT_HISTORY) == ["normal response text"], ( + "_recover_terminal_after_interrupt must NOT clear _OUTPUT_HISTORY" + ) + + def test_recovery_still_calls_force_full_redraw(self, monkeypatch): + """The recovery path still forces a redraw (original behavior preserved).""" + cli = object.__new__(HermesCLI) + cli._force_full_redraw = MagicMock() + + with patch("hermes_cli.curses_ui.flush_stdin"): + cli._recover_terminal_after_interrupt() + + cli._force_full_redraw.assert_called_once() + + def test_normal_scrollback_survives_interrupt_cycle(self, monkeypatch): + """Multiple lines of scrollback survive a full interrupt → recovery cycle.""" + cli_mod._configure_output_history(True, 50) + for i in range(5): + cli_mod._record_output_history(f"visible line {i}") + + cli = object.__new__(HermesCLI) + cli._force_full_redraw = MagicMock() + + with patch("hermes_cli.curses_ui.flush_stdin"): + cli._recover_terminal_after_interrupt() + + assert len(cli_mod._OUTPUT_HISTORY) == 5 + assert list(cli_mod._OUTPUT_HISTORY) == [ + f"visible line {i}" for i in range(5) + ] + + +# ── Marker suppression: _suspend_output_history blocks recording ──── + + +class TestInterruptMarkerNotRecorded: + """The interrupt marker must never enter _OUTPUT_HISTORY. + + Because it's printed inside a ``with _suspend_output_history():`` block, + the marker text stays out of the replay buffer and _replay_output_history + cannot duplicate it on redraw or resize. + """ + + def test_suspend_blocks_recording_during_cprint(self, monkeypatch): + """Text printed via _cprint while supressed is not recorded in history.""" + cli_mod._configure_output_history(True, 10) + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: None) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + + # Record something before so we can distinguish "empty" from "never configured" + cli_mod._record_output_history("before marker") + + with _suspend_output_history(): + cli_mod._cprint("── [Interrupted — processing new message] ──") + + assert list(cli_mod._OUTPUT_HISTORY) == ["before marker"], ( + "_OUTPUT_HISTORY must not contain the marker text printed " + "under _suspend_output_history" + ) + + def test_normal_cprint_still_records(self, monkeypatch): + """Normal _cprint calls (outside the suspend context) are still recorded. + + Regression: the fix must not accidentally suppress ALL output history, + only the interrupt marker. + """ + cli_mod._configure_output_history(True, 10) + printed = [] + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed.append(x)) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + + cli_mod._cprint("normal response text") + + assert "normal response text" in list(cli_mod._OUTPUT_HISTORY) + assert printed == ["normal response text"] + + def test_suspend_is_idempotent_nested(self): + """Nested _suspend_output_history() calls restore correctly.""" + cli_mod._configure_output_history(True, 10) + cli_mod._record_output_history("before") + + with _suspend_output_history(): + cli_mod._record_output_history("inside outer") + with _suspend_output_history(): + cli_mod._record_output_history("inside inner") + + cli_mod._record_output_history("after") + + assert list(cli_mod._OUTPUT_HISTORY) == [ + "before", + "after", + ] + + +# ── _show_interrupt_marker flag logic ────────────────────────────── + + +class TestShowInterruptMarkerLogic: + """The _show_interrupt_marker flag must be set correctly. + + The flag is True only when: the turn was interrupted (result.interrupted), + AND there is both a response AND a pending_message (interrupt_msg). + """ + + def test_marker_shown_when_interrupted_with_response_and_message(self): + """Happy path: interrupted turn with response and pending_message.""" + result = {"interrupted": True} + response = "Some partial response" + pending_message = "interrupt message" + + _show_interrupt_marker = False + _interrupted_this_turn = bool(result and result.get("interrupted")) + + if _interrupted_this_turn: + pending_message = result.get("interrupt_message") or pending_message + _show_interrupt_marker = bool(response and pending_message) + + assert _show_interrupt_marker is True + + def test_marker_suppressed_when_no_response(self): + """No marker when there is no response text to interrupt.""" + result = {"interrupted": True} + response = "" + pending_message = "interrupt message" + + _show_interrupt_marker = False + _interrupted_this_turn = bool(result and result.get("interrupted")) + + if _interrupted_this_turn: + pending_message = result.get("interrupt_message") or pending_message + _show_interrupt_marker = bool(response and pending_message) + + assert _show_interrupt_marker is False + + def test_marker_suppressed_when_no_pending_message(self): + """No marker when there's no interrupt message text.""" + result = {"interrupted": True} + response = "Some partial response" + pending_message = None + + _show_interrupt_marker = False + _interrupted_this_turn = bool(result and result.get("interrupted")) + + if _interrupted_this_turn: + pending_message = result.get("interrupt_message") or pending_message + _show_interrupt_marker = bool(response and pending_message) + + assert _show_interrupt_marker is False + + def test_marker_suppressed_when_not_interrupted(self): + """No marker when the turn was not interrupted.""" + result = {"completed": True} + response = "Full response text" + pending_message = "interrupt message" + + _show_interrupt_marker = False + _interrupted_this_turn = bool(result and result.get("interrupted")) + + if _interrupted_this_turn: + pending_message = result.get("interrupt_message") or pending_message + _show_interrupt_marker = bool(response and pending_message) + + assert _show_interrupt_marker is False + + def test_marker_shown_with_explicit_interrupt_message(self): + """Marker shown when result provides interrupt_message.""" + result = {"interrupted": True, "interrupt_message": "User cancelled"} + response = "Partial output" + pending_message = "default interrupt msg" + + _show_interrupt_marker = False + _interrupted_this_turn = bool(result and result.get("interrupted")) + + if _interrupted_this_turn: + pending_message = result.get("interrupt_message") or pending_message + _show_interrupt_marker = bool(response and pending_message) + + assert _show_interrupt_marker is True + assert pending_message == "User cancelled" + + +# ── End-to-end: _show_interrupt_marker → _cprint flow ────────────── + + +class TestInterruptMarkerPrintFlow: + """End-to-end: the flag leads to a supressed _cprint of the marker.""" + + def test_marker_printed_via_suspend_after_panel(self, monkeypatch): + """When _show_interrupt_marker is True, the marker is cprinted. + + The marker text is printed inside _suspend_output_history so it + bypasses _OUTPUT_HISTORY. + """ + cli_mod._configure_output_history(True, 10) + printed_lines = [] + + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed_lines.append(x)) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + + # Simulate the production flow + _show_interrupt_marker = True + if _show_interrupt_marker: + with _suspend_output_history(): + cli_mod._cprint( + "\n── [Interrupted — processing new message] ──" + ) + + # Marker was printed but NOT recorded in history + assert printed_lines, "Marker must have been printed" + assert "Interrupted" in printed_lines[0] + assert list(cli_mod._OUTPUT_HISTORY) == [] + + def test_no_marker_printed_when_flag_false(self, monkeypatch): + """When _show_interrupt_marker is False, nothing is printed.""" + printed_lines = [] + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed_lines.append(x)) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + + _show_interrupt_marker = False + if _show_interrupt_marker: + with _suspend_output_history(): + cli_mod._cprint("── [Interrupted] ──") + + assert printed_lines == [] + + +# ── _replay does not replay the marker (E2E) ─────────────────────── + + +class TestReplayDoesNotDuplicateMarker: + """_replay_output_history must not contain the interrupt marker. + + After an interrupted turn, only the normal response is in the history. + Redrawing replays only the response — no marker duplication. + """ + + def test_replay_clean_after_interrupted_turn(self, monkeypatch): + """Simulate: normal response recorded, marker supressed → replay is clean.""" + cli_mod._configure_output_history(True, 10) + + # Normal response gets recorded + cli_mod._record_output_history("Assistant response text") + + printed = [] + monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed.append(x)) + monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t) + + # Marker gets printed with supressed history (does NOT enter _OUTPUT_HISTORY) + with _suspend_output_history(): + cli_mod._cprint("── [Interrupted — processing new message] ──") + + # History must contain only the normal response + assert list(cli_mod._OUTPUT_HISTORY) == ["Assistant response text"], ( + "Interrupt marker must not appear in _OUTPUT_HISTORY" + ) + + # Replay the history — this emits the normal response via _pt_print + cli_mod._replay_output_history() + + # The replayed output must contain only the response, NOT the marker + # (marker was printed once by _cprint, but replay must not repeat it) + marker_count = sum(1 for p in printed if "Interrupted" in str(p)) + assert marker_count == 1, ( + f"Marker must appear exactly once (from _cprint), not {marker_count} " + "(duplicated by _replay_output_history)" + ) + assert "Assistant response text" in "".join(printed) diff --git a/tests/cli/test_resume_model_restore.py b/tests/cli/test_resume_model_restore.py index 766490c54d88e..aa01462527e7d 100644 --- a/tests/cli/test_resume_model_restore.py +++ b/tests/cli/test_resume_model_restore.py @@ -278,3 +278,90 @@ def test_round_trip_persist_then_restore(tmp_path, monkeypatch): assert restored.model == "deepseek-v4-flash-free" assert restored.provider == "custom:opencode-zen" assert restored.base_url == "https://oz/v1" + + +# ── update_session_model provider persistence (#79536) ────────────── + + +def test_update_session_model_persists_provider(tmp_path, monkeypatch): + """update_session_model writes $.model + $.provider into model_config.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="s1", source="cli", model="m0") + db.update_session_model("s1", "claude-x", provider="custom:feather") + meta = db.get_session("s1") + assert meta["model"] == "claude-x" + config = json.loads(meta["model_config"]) + assert config["model"] == "claude-x" + assert config["provider"] == "custom:feather" + + +def test_update_session_model_without_provider_preserves_existing(tmp_path, monkeypatch): + """Without provider, existing $.provider is left untouched.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="s2", source="cli", model="m0") + db.update_session_model("s2", "claude-x", provider="custom:feather") + db.update_session_model("s2", "gpt-5.4") # no provider + meta = db.get_session("s2") + config = json.loads(meta["model_config"]) + assert config["model"] == "gpt-5.4" + assert config["provider"] == "custom:feather" # preserved + + +def test_update_session_model_null_model_config_with_provider(tmp_path, monkeypatch): + """Provider persistence works when model_config starts as NULL.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="s3", source="cli", model="m0") + # model_config is NULL at creation — update_session_model must create it + db.update_session_model("s3", "claude-x", provider="minimax") + meta = db.get_session("s3") + config = json.loads(meta["model_config"]) + assert config["model"] == "claude-x" + assert config["provider"] == "minimax" + + +# ── session_gateway_runtime billing_provider fallback (#85721) ───── + + +def test_session_gateway_runtime_falls_back_to_billing_provider(): + """Sessions that never ran /model have only billing_provider.""" + meta = { + "model": "glm-4.7", + "model_config": None, + "billing_provider": "minimax", + } + runtime = SessionDB.session_gateway_runtime(meta) + assert runtime == {"provider": "minimax"} + + +def test_session_gateway_runtime_billing_provider_bare_bucket_ignored(): + """Bare billing buckets (auto/custom) are not routable — skip them.""" + for bare in ("auto", "custom"): + meta = { + "model": "m", + "model_config": None, + "billing_provider": bare, + } + assert SessionDB.session_gateway_runtime(meta) == {} + + +def test_session_gateway_runtime_explicit_provider_wins_over_billing(): + """Explicit model_config provider takes precedence over billing_provider.""" + meta = _row(model_config={"provider": "nous"}) + meta["billing_provider"] = "minimax" + runtime = SessionDB.session_gateway_runtime(meta) + assert runtime == {"provider": "nous"} + + +def test_restore_session_model_restores_billing_provider_fallback(): + """End-to-end: _restore_session_model uses billing_provider fallback.""" + stub = _make_stub() + stub._restore_session_model({ + "model": "glm-4.7", + "model_config": None, + "billing_provider": "minimax", + }) + assert stub.model == "glm-4.7" + assert stub.provider == "minimax" diff --git a/tests/cron/test_inflight_stale_guard.py b/tests/cron/test_inflight_stale_guard.py new file mode 100644 index 0000000000000..7787da685dadf --- /dev/null +++ b/tests/cron/test_inflight_stale_guard.py @@ -0,0 +1,350 @@ +"""RED-first regression test for the cron in-flight claim leak (t_27b59583). + +The leak +-------- +``cron/scheduler.py`` tracks in-flight cron jobs in the module-level +``_running_job_ids`` set. ``_submit_with_guard`` adds a job id BEFORE the +future that owns its release exists: everything between the add and +``pool.submit`` — ``create_execution``, ``contextvars.copy_context()``, and +once running, the whole pre-future body of ``run_one_job`` (SessionDB +construction around L3150-3161, agent import/build, config load) — has no +``finally`` that discards the id. If any of it throws or hangs, the release +path in ``_run_and_release``'s ``finally`` never runs. Every later tick then +short-circuits with ``cron.scheduler: Job '' already running — skipping`` +with no ``last_error``, no failure counter, and no alert, until the whole +gateway process restarts (incident: jarvis ``board-pm-triage-*`` jobs, +2026-08-02). + +This file is committed BEFORE the fix (red-first). Against the unfixed +scheduler these tests MUST FAIL: the stale id is never released, so the +primary assertion (``job_id not in get_running_job_ids()`` after a tick) +fails. The implementation task (t_3778a491) makes them pass by adding the +bounded stale-entry guard: on each tick, a claim older than +``max(2 * interval, floor)`` with no live future is force-released, logged +with a countable ``cron.inflight.forced_release`` signal, and surfaced via +``mark_job_run(..., success=False, error=...)`` as ``last_error``. + +Design notes +------------ +- The job store persists ``schedule`` as an already-parsed DICT + (``{"kind": "interval", "minutes": N}``), not the string form + ``parse_schedule`` consumes — the fixtures use the persisted dict shape. +- The guard's age bookkeeping (``_running_since`` / ``_running_futures`` / + ``get_inflight_guard_stats``) does not exist yet on the unfixed scheduler. + The helpers reference it defensively (``getattr``/``hasattr``) so the SAME + file runs cleanly against both the red (unfixed) and the green (fixed) + implementation; the leak simulation is identical either way — an id in + ``_running_job_ids`` with no future ever installed. +""" + +import time +from unittest.mock import patch + +import pytest + +import cron.scheduler as sched + + +@pytest.fixture(autouse=True) +def _clean_inflight(): + """Reset the in-memory running set so tests are isolated. + + Clears the guard bookkeeping defensively: on the unfixed scheduler only + ``_running_job_ids`` exists; the age/future dicts and counters appear + with the fix, and clearing them keeps the same file hermetic on both. + """ + sched._running_job_ids.clear() + for attr in ("_running_since", "_running_futures", "_forced_releases"): + obj = getattr(sched, attr, None) + if obj is not None: + obj.clear() + if hasattr(sched, "_forced_release_count"): + sched._forced_release_count = 0 + yield + sched._running_job_ids.clear() + for attr in ("_running_since", "_running_futures", "_forced_releases"): + obj = getattr(sched, attr, None) + if obj is not None: + obj.clear() + + +def _job(job_id="wedged", minutes=60, kind="interval", cron_expr=None, + repeat=None): + """Build a job row using the PERSISTED schedule dict shape.""" + if kind == "interval": + schedule = { + "kind": "interval", + "minutes": minutes, + "display": f"every {minutes}m", + } + elif kind == "cron": + expr = cron_expr or "0 9 * * 1" + schedule = {"kind": "cron", "expr": expr, "display": expr} + else: + schedule = { + "kind": "once", + "run_at": "2030-01-01T00:00:00", + "display": "once at 2030-01-01 00:00", + } + job = { + "id": job_id, + "name": f"board-pm-triage-{job_id}", + "schedule": schedule, + } + if repeat is not None: + job["repeat"] = repeat + return job + + +def _inject_stale_claim(job_id: str) -> None: + """Simulate the leak exactly as the incident left it: the job id is in + the running set but no future was ever installed, so the release path in + the worker's ``finally`` can never run. + + On the unfixed scheduler ``_running_job_ids`` is the only bookkeeping, so + this is precisely the shape of the real wedge. Once the bounded guard + lands it also records an old start time — 6h ago, far past + ``max(2 * 60m interval, 30m floor)`` — so the claim is past its + allowance on the first sweep. + """ + sched._running_job_ids.add(job_id) + running_since = getattr(sched, "_running_since", None) + if running_since is not None: + running_since[job_id] = time.time() - 6 * 60 * 60 # 6h old + + +class TestStaleInflightLeak: + def test_stale_claim_is_force_released_and_reported_after_tick( + self, tmp_path, caplog + ): + """The regression: a leaked in-flight claim must be force-released by + the next tick, surface as ``last_error``, and emit a countable + signal — instead of silently skipping every fire until the gateway + process restarts.""" + job = _job(job_id="board-pm-triage-wedged", minutes=60) + job_id = job["id"] + _inject_stale_claim(job_id) + + with caplog.at_level("WARNING"), \ + patch.object(sched, "_get_hermes_home", return_value=tmp_path), \ + patch("cron.jobs.load_jobs", return_value=[job]), \ + patch.object(sched, "get_due_jobs", return_value=[]), \ + patch.object(sched, "mark_job_run") as mark: + sched.tick(verbose=False) + + # RED: on the unfixed scheduler the tick never releases the id — it + # short-circuits with "already running — skipping" and the id stays + # in the set forever, so this assertion FAILS and proves the leak. + assert job_id not in sched.get_running_job_ids() + + # GREEN (after the bounded guard): the release surfaces as a failure + # on the job row instead of silence… + assert mark.call_count == 1 + args = mark.call_args.args + assert args[0] == job_id + assert args[1] is False + assert "in-flight" in args[2] + + # …and emits the countable forced-release signal (log + probe stats). + assert any( + "cron.inflight.forced_release" in r.message for r in caplog.records + ) + stats = sched.get_inflight_guard_stats() + assert stats["forced_releases"] == 1 + + def test_young_inflight_claim_is_not_force_released(self, tmp_path): + """Bound the guard: a claim younger than its allowance (and with no + future) is left alone — the sweep must not double-dispatch healthy + long-running jobs. Passes on both the red and the fixed code.""" + job = _job(job_id="young", minutes=60) + job_id = job["id"] + sched._running_job_ids.add(job_id) + running_since = getattr(sched, "_running_since", None) + if running_since is not None: + running_since[job_id] = time.time() - 60 # 1 minute old + + with patch.object(sched, "_get_hermes_home", return_value=tmp_path), \ + patch("cron.jobs.load_jobs", return_value=[job]), \ + patch.object(sched, "get_due_jobs", return_value=[]), \ + patch.object(sched, "mark_job_run") as mark: + sched.tick(verbose=False) + + assert job_id in sched.get_running_job_ids() + mark.assert_not_called() + + +class TestJobIntervalMinutes: + """Allowance inputs come from the PERSISTED dict schedule shape, not the + string form parse_schedule consumes (review Blocker 1).""" + + def test_reads_persisted_interval_dict(self): + job = _job(minutes=4320) + assert sched._job_interval_minutes(job) == 4320.0 + + def test_reads_persisted_cron_dict(self): + # */15 every 15 minutes → cadence 15m. + job = _job(kind="cron", cron_expr="*/15 * * * *") + assert sched._job_interval_minutes(job) == 15.0 + + def test_reads_persisted_weekly_cron_dict(self): + # 0 9 * * 1 fires weekly → cadence 7*24*60 = 10080m. + job = _job(kind="cron", cron_expr="0 9 * * 1") + assert sched._job_interval_minutes(job) == 7 * 24 * 60 + + def test_string_fallback_still_works(self): + # Defensive fallback for programmatic callers. + job = {"id": "x", "schedule": "every 60m"} + assert sched._job_interval_minutes(job) == 60.0 + + def test_oneshot_has_no_interval(self): + job = _job(kind="once") + assert sched._job_interval_minutes(job) is None + + def test_garbage_returns_none(self): + job = {"id": "x", "schedule": {"kind": "bogus"}} + assert sched._job_interval_minutes(job) is None + + +class TestStaleInflightSweep: + """Unit-level bound checks on sweep_stale_inflight itself.""" + + def test_allowance_is_at_least_two_intervals(self, tmp_path): + """A slow-but-healthy 6h job is not clipped by the 30m floor.""" + job = _job(minutes=360) + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 4 * 60 * 60 # 4h < 12h + + with patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [] + assert job["id"] in sched.get_running_job_ids() + + def test_allowance_honors_persisted_4320m_row(self, tmp_path): + """The real guide-curator row (4320m) gets a 144h allowance, not the + 30m floor — the Blocker-1 regression against the live store shape.""" + job = _job(job_id="guide-curator", minutes=4320) + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 4 * 60 * 60 # 4h ≪ 144h + + with patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [] + assert job["id"] in sched.get_running_job_ids() + + def test_cron_allowance_not_clipped_to_floor(self, tmp_path): + """A weekly cron job (cadence 10080m) is not clipped at 30m: a 24h + claim is still healthy (allowance 20160m).""" + job = _job(job_id="weekly", kind="cron", cron_expr="0 9 * * 1") + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 24 * 60 * 60 + + with patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [] + assert job["id"] in sched.get_running_job_ids() + + def test_live_future_is_never_released(self, tmp_path): + """A claim with a genuinely executing future is left alone even when + old — the sweep must not double-dispatch healthy long-running jobs.""" + import concurrent.futures + + job = _job() + fut: concurrent.futures.Future = concurrent.futures.Future() + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 10 * 60 * 60 + sched._running_futures[job["id"]] = fut + + with patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [] + assert job["id"] in sched.get_running_job_ids() + fut.set_result(True) + + # Once the future is done but the id somehow survived, it IS stale. + with patch.object(sched, "mark_job_run"), \ + patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [job["id"]] + + def test_pending_sentinel_released_when_submit_hung(self, tmp_path): + """A claim whose submit path hung stays _FUTURE_PENDING past its + allowance (the SessionDB-init wedge class) and must be released.""" + job = _job() + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 5 * 60 * 60 + sched._running_futures[job["id"]] = sched._FUTURE_PENDING + + with patch.object(sched, "mark_job_run") as mark, \ + patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [job["id"]] + assert mark.call_count == 1 + + def test_pending_sentinel_young_claim_is_not_released(self, tmp_path): + """A young pending claim (submit still in flight) is safe.""" + job = _job() + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 60 # 1 minute + sched._running_futures[job["id"]] = sched._FUTURE_PENDING + + with patch.object(sched, "mark_job_run") as mark, \ + patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [] + assert job["id"] in sched.get_running_job_ids() + mark.assert_not_called() + + def test_finite_repeat_job_released_without_mark_job_run(self, tmp_path): + """A forced release must not consume a finite repeat budget or + auto-delete the row; the claim is released, the row untouched.""" + job = _job(repeat={"times": 1, "completed": 0}) + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 5 * 60 * 60 + + with patch.object(sched, "mark_job_run") as mark, \ + patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [job["id"]] + assert job["id"] not in sched.get_running_job_ids() + mark.assert_not_called() + assert sched.get_inflight_guard_stats()["forced_releases"] == 1 + + def test_claim_without_timestamp_is_adopted_then_swept(self, tmp_path): + """An id injected with no recorded start (pre-guard claim) must not be + released immediately, but must become sweepable.""" + job = _job() + sched._running_job_ids.add(job["id"]) + + with patch.object(sched, "_get_hermes_home", return_value=tmp_path): + assert sched.sweep_stale_inflight([job]) == [] + assert job["id"] in sched._running_since + sched._running_since[job["id"]] -= 5 * 60 * 60 + with patch.object(sched, "mark_job_run"): + assert sched.sweep_stale_inflight([job]) == [job["id"]] + + def test_forced_release_logs_a_warning(self, tmp_path, caplog): + job = _job() + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 5 * 60 * 60 + + with caplog.at_level("WARNING"), \ + patch.object(sched, "mark_job_run"), \ + patch.object(sched, "_get_hermes_home", return_value=tmp_path): + sched.sweep_stale_inflight([job]) + + assert any("cron.inflight.forced_release" in r.message for r in caplog.records) + + +class TestWedgedJobRefiresWithoutRestart: + def test_tick_sweeps_then_dispatches_the_previously_wedged_job(self, tmp_path): + """End-to-end symptom: before the fix, tick() returned 0 forever.""" + job = dict(_job(), enabled=True, next_run_at="2020-01-01T00:00:00", + deliver="local") + sched._running_job_ids.add(job["id"]) + sched._running_since[job["id"]] = time.time() - 6 * 60 * 60 + + with patch.object(sched, "_get_hermes_home", return_value=tmp_path), \ + patch.object(sched, "get_due_jobs", return_value=[job]), \ + patch("cron.jobs.load_jobs", return_value=[job]), \ + patch.object(sched, "advance_next_runs"), \ + patch.object(sched, "mark_job_run"), \ + patch.object(sched, "create_execution", return_value={"id": "exec-1"}), \ + patch.object(sched, "finish_execution"), \ + patch.object(sched, "run_one_job", return_value=True): + n = sched.tick(verbose=False) + + assert n == 1, "wedged job must fire again without a gateway restart" + assert job["id"] not in sched.get_running_job_ids() + assert sched.get_inflight_guard_stats()["forced_releases"] == 1 diff --git a/tests/cron/test_recurring_eagain_redispatch.py b/tests/cron/test_recurring_eagain_redispatch.py new file mode 100644 index 0000000000000..74a28eb32ffa0 --- /dev/null +++ b/tests/cron/test_recurring_eagain_redispatch.py @@ -0,0 +1,153 @@ +"""Deterministic reproduction of the recurring-cron EAGAIN wedge (t_8b5480b3). + +Scenario modelled on the 2026-08-14 incident: a recurring no_agent interval job +whose script subprocess raises EAGAIN ([Errno 11] Resource temporarily +unavailable) during a substrate thread-exhaustion spike. After the failure is +recorded (terminal 'failed' execution row), the job must be re-dispatched on +the NEXT tick once the substrate recovers — with no force-run. + +The os-reviewer (t_20e23f84) established the real incident wedge: 4 recurring +no_agent jobs recorded ZERO executions for ~1h47m after EAGAIN while +`next_run_at` kept advancing (they stayed 'due') but `_submit_with_guard` +never dispatched them, and the wedge SURVIVED a gateway restart. That points +at a PERSISTED non-dispatch state, not just the in-memory `_running_job_ids` +leak (which t_3778a491 already bounds). + +This file drives the REAL `tick()` end-to-end against a throwaway HERMES_HOME: + tick 1 -> script EAGAINs (subprocess.run raises OSError 11) -> failed exec row + tick 2 -> substrate recovered (script runs clean) -> job MUST fire again +""" +from __future__ import annotations + +import json +import os +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +import pytest + +# Ensure project root importable +import sys +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + + +@pytest.fixture +def wedge_env(tmp_path, monkeypatch): + """Isolated cron env + a recurring no_agent interval job, due NOW.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "cron").mkdir() + (hermes_home / "cron" / "output").mkdir() + (hermes_home / "scripts").mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + import cron.jobs as jobs_mod + monkeypatch.setattr(jobs_mod, "HERMES_DIR", hermes_home) + monkeypatch.setattr(jobs_mod, "CRON_DIR", hermes_home / "cron") + monkeypatch.setattr(jobs_mod, "JOBS_FILE", hermes_home / "cron" / "jobs.json") + monkeypatch.setattr(jobs_mod, "OUTPUT_DIR", hermes_home / "cron" / "output") + + # Create a recurring no_agent interval job. + job = jobs_mod.create_job( + prompt="probe", + schedule="every 10m", + no_agent=True, + script="probe.py", + ) + # Force it due now. + now = datetime.now(timezone.utc) + jobs_mod.update_job(job["id"], {"next_run_at": (now - timedelta(minutes=1)).isoformat()}) + + script = hermes_home / "scripts" / "probe.py" + script.write_text("print('ok')\n") + + return {"home": hermes_home, "job_id": job["id"]} + + +class TestEAGAINRecurringRedispatches: + def _make_script_eagain(self, env, monkeypatch): + """Make the next subprocess.run raise EAGAIN once, then pass.""" + import cron.scheduler as sched_mod + real_run = sched_mod.subprocess.run + state = {"n": 0} + + def fake_run(argv, **kwargs): + state["n"] += 1 + if state["n"] == 1: + raise OSError(11, "Resource temporarily unavailable") + return subprocess.CompletedProcess(argv, 0, stdout="ok\n", stderr="") + + monkeypatch.setattr(sched_mod.subprocess, "run", fake_run) + return state + + def test_eagain_then_redispatched_on_next_tick(self, wedge_env, monkeypatch, tmp_path): + """Tick 1 records a failed execution (EAGAIN); tick 2 must re-fire.""" + from cron import scheduler as S + from cron import executions as E + + env = wedge_env + # Point the executions ledger at the throwaway home. + monkeypatch.setattr(E, "EXECUTIONS_FILE", env["home"] / "cron" / "executions.db") + monkeypatch.setattr(S, "_hermes_home", env["home"]) + monkeypatch.setattr(S, "get_due_jobs", S.get_due_jobs) # no-op, keep real + + state = self._make_script_eagain(env, monkeypatch) + + # Tick 1: EAGAIN failure. + n1 = S.tick(verbose=False, sync=True) + # Assert the failure was recorded. + latest = E.latest_execution(env["job_id"]) + assert latest is not None, "tick 1 must create an execution" + assert latest["status"] == "failed", f"expected failed, got {latest['status']}" + + # The job must still be scheduled (recurring), next_run_at advanced. + import cron.jobs as J + job = J.get_job(env["job_id"]) + assert job["enabled"] is True + assert job["state"] == "scheduled" + assert job["next_run_at"] is not None + + # Force next_run_at due again (simulate the substrate recovery tick). + now = datetime.now(timezone.utc) + J.update_job(env["job_id"], {"next_run_at": (now - timedelta(minutes=1)).isoformat()}) + + # Tick 2: script passes -> job must fire (completed execution). + n2 = S.tick(verbose=False, sync=True) + latest2 = E.latest_execution(env["job_id"]) + assert latest2 is not None + assert latest2["status"] == "completed", ( + f"job must be re-dispatched after EAGAIN recovery, got {latest2['status']}" + ) + assert state["n"] >= 2 + + def test_trigger_job_unwedges_persisted_state(self, wedge_env, monkeypatch, tmp_path): + """The incident force-run (`cron run ` -> trigger_job) resets the + persisted due state so the next tick fires the job. This is the + operator escape that cleared each wedge.""" + from cron import scheduler as S + from cron import executions as E + from cron.jobs import trigger_job, update_job + + env = wedge_env + monkeypatch.setattr(E, "EXECUTIONS_FILE", env["home"] / "cron" / "executions.db") + monkeypatch.setattr(S, "_hermes_home", env["home"]) + + self._make_script_eagain(env, monkeypatch) + n1 = S.tick(verbose=False, sync=True) + + # Simulate the persisted non-dispatch state: next_run_at far in the + # future (job not due) but still enabled/scheduled — the observed + # wedge where get_due_jobs never returns it. + from datetime import timezone as tz + far = datetime.now(tz.utc) + timedelta(days=1) + update_job(env["job_id"], {"next_run_at": far.isoformat()}) + n2 = S.tick(verbose=False, sync=True) # not due -> no dispatch + + # Force-run (trigger_job) sets next_run_at = now -> due again. + triggered = trigger_job(env["job_id"]) + assert triggered is not None + n3 = S.tick(verbose=False, sync=True) + latest = E.latest_execution(env["job_id"]) + assert latest["status"] == "completed", "force-run must clear the wedge" diff --git a/tests/cron/test_recurring_wedge_selfheal.py b/tests/cron/test_recurring_wedge_selfheal.py new file mode 100644 index 0000000000000..406df99c84e35 --- /dev/null +++ b/tests/cron/test_recurring_wedge_selfheal.py @@ -0,0 +1,239 @@ +"""Deterministic reproduction of the recurring-cron wedge (t_8b5480b3) — RED/GREEN. + +The 2026-08-14 incident (t_20e23f84): 4 recurring no_agent interval jobs +EAGAIN-failed at 12:50:05 and then recorded ZERO executions for ~1h47m while +the scheduler ticked normally and fired 100+ other jobs — wedged in a +non-dispatch state that even survived a gateway restart, cleared only by a +manual force-run (`hermes cron run `). + +Root cause class (t_3778a491, the SAME symptom on 2026-08-02): `_submit_with_guard` +adds a job id to the in-memory `_running_job_ids` set BEFORE the future that +owns its release exists. Anything that hangs or dies between the add and +`pool.submit` (documented case: EAGAIN thread exhaustion on a substrate spike, +or a wedged SessionDB.__init__ on a stale sqlite flock) leaks the claim. Every +later tick short-circuits with "already running — skipping" silently — no +execution row, no last_error, no counter — so the job is due-but-never-dispatched. + +The live deployment (origin/main) does NOT contain the t_3778a491 in-flight +stale-claim sweep, so the wedge class is still live. + +This file drives the REAL `tick()` and asserts the fix's contract: + RED (unfixed): a stale in-flight claim is never released by tick → the + wedge reproduces deterministically (job stays in the running set, no + execution, no re-dispatch without force-run). + GREEN (fixed): the same stale claim is force-released by the next tick + (cron.inflight.forced_release) and the job re-fires — no gateway + restart, no force-run needed — and 2 consecutive auto-fires work. +""" +from __future__ import annotations + +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + + +@pytest.fixture +def cron_env(tmp_path, monkeypatch): + """Isolated cron env + a recurring no_agent interval job, due NOW.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "cron").mkdir() + (hermes_home / "cron" / "output").mkdir() + (hermes_home / "scripts").mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + import cron.jobs as jobs_mod + monkeypatch.setattr(jobs_mod, "HERMES_DIR", hermes_home) + monkeypatch.setattr(jobs_mod, "CRON_DIR", hermes_home / "cron") + monkeypatch.setattr(jobs_mod, "JOBS_FILE", hermes_home / "cron" / "jobs.json") + monkeypatch.setattr(jobs_mod, "OUTPUT_DIR", hermes_home / "cron" / "output") + + job = jobs_mod.create_job( + prompt="probe", + schedule="every 10m", + no_agent=True, + script="probe.py", + ) + now = datetime.now(timezone.utc) + jobs_mod.update_job(job["id"], {"next_run_at": (now - timedelta(minutes=1)).isoformat()}) + + script = hermes_home / "scripts" / "probe.py" + script.write_text("print('ok')\n") + + return {"home": hermes_home, "job_id": job["id"]} + + +class TestStaleInflightSelfHeal: + def _setup(self, cron_env, monkeypatch): + from cron import scheduler as S + from cron import executions as E + + env = cron_env + monkeypatch.setattr(E, "EXECUTIONS_FILE", env["home"] / "cron" / "executions.db") + monkeypatch.setattr(S, "_hermes_home", env["home"]) + return S, E, env + + def test_stale_claim_self_heals_and_redispatches(self, cron_env, monkeypatch): + """GREEN contract: a leaked in-flight claim is force-released by the + next tick and the wedged job re-fires without a force-run.""" + S, E, env = self._setup(cron_env, monkeypatch) + job_id = env["job_id"] + import cron.jobs as J + + if not hasattr(S, "sweep_stale_inflight"): + pytest.skip("guard not present on this build") + + # Simulate the incident leak: job id claimed with no owning future, + # old enough to be past its allowance. + S._running_job_ids.clear() + S._running_since.clear() + S._running_futures.clear() + S._running_job_ids.add(job_id) + S._running_since[job_id] = time.time() - 6 * 60 * 60 + + # get_due_jobs is called inside tick BEFORE the sweep; we patch it to + # return the wedged job as due so the in-cycle sweep releases the claim + # and the dispatch loop re-fires it. + job = J.get_job(job_id) + with mock.patch("cron.jobs.load_jobs", return_value=[job]): + n = S.tick(verbose=False, sync=True) + + latest = E.latest_execution(job_id) + assert job_id not in S.get_running_job_ids(), "stale claim must be released" + assert latest is not None, "wedged job must create an execution" + assert latest["status"] == "completed", ( + "wedged job must fire again without force-run" + ) + + def test_two_consecutive_auto_fires_after_guard(self, cron_env, monkeypatch): + """GREEN: after the guard releases a stale claim, the job fires on + consecutive ticks (no manual intervention).""" + S, E, env = self._setup(cron_env, monkeypatch) + job_id = env["job_id"] + import cron.jobs as J + + if not hasattr(S, "sweep_stale_inflight"): + pytest.skip("guard not present on this build") + + S._running_job_ids.clear() + S._running_since.clear() + S._running_futures.clear() + S._running_job_ids.add(job_id) + S._running_since[job_id] = time.time() - 6 * 60 * 60 + + job = J.get_job(job_id) + with mock.patch("cron.jobs.load_jobs", return_value=[job]): + n1 = S.tick(verbose=False, sync=True) + latest1 = E.latest_execution(job_id) + assert latest1["status"] == "completed" + + # Re-arm due and tick again: fire #2. + now = datetime.now(timezone.utc) + J.update_job(job_id, {"next_run_at": (now - timedelta(minutes=1)).isoformat()}) + n2 = S.tick(verbose=False, sync=True) + latest2 = E.latest_execution(job_id) + assert latest2["status"] == "completed" + assert latest2["id"] != latest1["id"], "two distinct executions" + + def test_guard_stats_reported(self, cron_env, monkeypatch): + """The guard must surface a countable forced-release signal.""" + S, E, env = self._setup(cron_env, monkeypatch) + import cron.jobs as J + if not hasattr(S, "sweep_stale_inflight"): + pytest.skip("guard not present on this build") + + S._running_job_ids.clear() + S._running_since.clear() + S._running_futures.clear() + S._running_job_ids.add(env["job_id"]) + S._running_since[env["job_id"]] = time.time() - 6 * 60 * 60 + S.sweep_stale_inflight([J.get_job(env["job_id"])]) + stats = S.get_inflight_guard_stats() + assert stats["forced_releases"] >= 1 + assert env["job_id"] not in S.get_running_job_ids() + + +class TestEAGAINCreateExecutionLeak: + """The 12:50 mechanism: EAGAIN/thread-exhaustion strikes BETWEEN the + in-flight claim and execution creation (create_execution / pool.submit). + The claim must be released immediately so the next tick re-dispatches.""" + + def test_create_execution_failure_releases_claim(self, cron_env, monkeypatch, tmp_path): + from cron import scheduler as S + from cron import executions as E + import cron.jobs as J + + env = cron_env + monkeypatch.setattr(E, "EXECUTIONS_FILE", env["home"] / "cron" / "executions.db") + monkeypatch.setattr(S, "_hermes_home", env["home"]) + job_id = env["job_id"] + job = J.get_job(job_id) + + S._running_job_ids.clear() + S._running_since.clear() + S._running_futures.clear() + + # Simulate EAGAIN during create_execution (substrate thread exhaustion + # at 12:50): the in-flight claim was taken but execution creation fails. + def boom(*a, **k): + raise OSError(11, "Resource temporarily unavailable") + monkeypatch.setattr(S, "create_execution", boom) + + with mock.patch("cron.jobs.load_jobs", return_value=[job]): + with pytest.raises(OSError): + S.tick(verbose=False, sync=True) + + # The claim must be released (not leaked) so the NEXT tick can retry. + assert job_id not in S.get_running_job_ids(), ( + "claim must be released when execution creation fails, so the " + "next tick re-dispatches instead of wedging on 'already running'" + ) + + def test_pool_submit_eagain_releases_claim_and_redispatches(self, cron_env, monkeypatch, tmp_path): + from cron import scheduler as S + from cron import executions as E + import cron.jobs as J + + env = cron_env + monkeypatch.setattr(E, "EXECUTIONS_FILE", env["home"] / "cron" / "executions.db") + monkeypatch.setattr(S, "_hermes_home", env["home"]) + job_id = env["job_id"] + job = J.get_job(job_id) + + S._running_job_ids.clear() + S._running_since.clear() + S._running_futures.clear() + + # First tick: pool.submit raises EAGAIN (thread exhaustion). The claim + # is released and the run recorded as failed. + real_submit = S.concurrent.futures.ThreadPoolExecutor.submit + state = {"n": 0} + def flaky(self, *a, **k): + state["n"] += 1 + if state["n"] == 1: + raise OSError(11, "Resource temporarily unavailable") + return real_submit(self, *a, **k) + monkeypatch.setattr(S.concurrent.futures.ThreadPoolExecutor, "submit", flaky) + + with mock.patch("cron.jobs.load_jobs", return_value=[job]): + S.tick(verbose=False, sync=True) + + # Claim released; job still scheduled. + assert job_id not in S.get_running_job_ids() + + # Second tick (substrate recovered): job re-dispatches and completes. + now = datetime.now(timezone.utc) + J.update_job(job_id, {"next_run_at": (now - timedelta(minutes=1)).isoformat()}) + with mock.patch("cron.jobs.load_jobs", return_value=[J.get_job(job_id)]): + S.tick(verbose=False, sync=True) + + latest = E.latest_execution(job_id) + assert latest is not None and latest["status"] == "completed", ( + "job must re-dispatch on the next tick after EAGAIN recovery" + ) diff --git a/tests/gateway/test_abandoned_turn_process_cleanup.py b/tests/gateway/test_abandoned_turn_process_cleanup.py index c63a930b6ac14..da0fcdaa0c338 100644 --- a/tests/gateway/test_abandoned_turn_process_cleanup.py +++ b/tests/gateway/test_abandoned_turn_process_cleanup.py @@ -232,3 +232,98 @@ def test_timeout_abandon_propagates_is_still_current_to_the_reap(monkeypatch): # reap was skipped because a newer turn already claimed the session. assert agent.interrupts == ["Execution timed out (inactivity)"] assert calls == [] + + +# --------------------------------------------------------------------------- +# Wedged-turn stack dump at reap time (Aug 2026 zombie-turn incident): +# the reaper's interrupt frees the blocked frame, so the dump must run +# BEFORE the interrupt and must capture the actual wedged stack. +# --------------------------------------------------------------------------- + + +def _run_wedged_worker(release: threading.Event, entered: threading.Event): + """Worker blocked inside a frame named like turn machinery.""" + + def run_sync(): # marker frame the dump filter matches on + entered.set() + release.wait(timeout=30.0) + + run_sync() + + +def test_reaper_dumps_wedged_worker_stack_before_interrupt(monkeypatch, caplog): + import logging + + from gateway.run import _dump_wedged_turn_stacks + + release = threading.Event() + entered = threading.Event() + worker = threading.Thread( + target=_run_wedged_worker, + args=(release, entered), + name="wedged-test-worker", + daemon=True, + ) + worker.start() + try: + assert entered.wait(timeout=5.0) + with caplog.at_level(logging.ERROR, logger="gateway.run"): + _dump_wedged_turn_stacks("task-wedge-test") + dumps = [ + r for r in caplog.records if "Wedged-turn stack dump" in r.getMessage() + ] + assert dumps, "no stack dump was logged" + joined = "\n".join(r.getMessage() for r in dumps) + assert "wedged-test-worker" in joined + assert "run_sync" in joined + assert "release.wait" in joined # the actual blocked line is named + finally: + release.set() + worker.join(timeout=5.0) + + +def test_abandon_timed_out_turn_dumps_stacks_before_interrupt(monkeypatch): + """The dump hook runs inside the reaper, before the agent interrupt.""" + import gateway.run as gateway_run + + order = [] + monkeypatch.setattr( + gateway_run, + "_dump_wedged_turn_stacks", + lambda task_id: order.append(("dump", task_id)), + ) + monkeypatch.setattr( + gateway_run, + "_reap_gateway_turn_processes", + lambda *a, **k: order.append(("reap",)), + ) + + class _Agent: + def interrupt(self, reason): + order.append(("interrupt", reason)) + + worker_done, timeout_fired, cleanup_lock = _state() + assert _abandon_timed_out_gateway_turn( + agent_holder=[_Agent()], + task_id="t-dump-order", + process_baseline=frozenset(), + worker_done=worker_done, + timeout_fired=timeout_fired, + cleanup_lock=cleanup_lock, + ) + assert order[0] == ("dump", "t-dump-order") + assert ("interrupt", order[1][1]) == order[1] + assert order[-1] == ("reap",) + + +def test_dump_wedged_turn_stacks_never_raises(monkeypatch): + import gateway.run as gateway_run + + monkeypatch.setattr( + gateway_run.sys, + "_current_frames", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + ) + from gateway.run import _dump_wedged_turn_stacks + + _dump_wedged_turn_stacks("t-no-raise") # must not raise diff --git a/tests/gateway/test_loop_command.py b/tests/gateway/test_loop_command.py new file mode 100644 index 0000000000000..c11876a9954ee --- /dev/null +++ b/tests/gateway/test_loop_command.py @@ -0,0 +1,139 @@ +"""Gateway /loop command tests — dispatch, routing capture, mid-run guard.""" + +import time + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource +from hermes_cli import loops + + +class _FakeSessionEntry: + session_id = "sid-gateway-loop" + + +class _FakeSessionStore: + def __init__(self): + self.entry = _FakeSessionEntry() + + def get_or_create_session(self, source): + return self.entry + + def _generate_session_key(self, source): + return "agent:main:discord:channel:loop-test" + + +@pytest.fixture +def loop_env(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + loops._DB_CACHE.clear() + yield home + loops._DB_CACHE.clear() + + +def _make_runner(): + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="token")} + ) + runner.session_store = _FakeSessionStore() + runner.adapters = {} + runner._queued_events = {} + return runner + + +def _make_event(text: str) -> MessageEvent: + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.DISCORD, + chat_id="chat-loop", + chat_type="channel", + thread_id="thread-9", + user_id="user-loop", + ), + message_id="msg-loop", + ) + + +@pytest.mark.asyncio +async def test_gateway_loop_create_captures_route(loop_env): + runner = _make_runner() + response = await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m check the deploy")) + assert "Loop set" in response + assert "every 5m" in response + + state = loops.load_loop("sid-gateway-loop") + assert state is not None + assert state.prompt == "check the deploy" + assert state.route["platform"] == "discord" + assert state.route["chat_id"] == "chat-loop" + assert state.route["thread_id"] == "thread-9" + + +@pytest.mark.asyncio +async def test_gateway_loop_status_pause_stop(loop_env): + runner = _make_runner() + await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI")) + + status = await GatewayRunner._handle_loop_command(runner, _make_event("/loop status")) + assert "poll CI" in status + + paused = await GatewayRunner._handle_loop_command(runner, _make_event("/loop pause")) + assert "paused" in paused.lower() + + stopped = await GatewayRunner._handle_loop_command(runner, _make_event("/loop stop")) + assert "stopped" in stopped.lower() + + +@pytest.mark.asyncio +async def test_gateway_loop_goal_note_when_goal_active(loop_env): + from hermes_cli.goals import GoalManager + + GoalManager(session_id="sid-gateway-loop").set("finish the migration") + runner = _make_runner() + response = await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI")) + assert "active /goal" in response + + +@pytest.mark.asyncio +async def test_post_turn_loop_completion_completes_inflight_tick(loop_env): + runner = _make_runner() + await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI")) + + mgr = loops.LoopManager(session_id="sid-gateway-loop") + mgr.state.next_due_at = time.time() - 1 + assert mgr.fire_tick() is not None + + entry = _FakeSessionEntry() + await GatewayRunner._post_turn_loop_completion( + runner, + session_entry=entry, + source=None, + final_response="CI is done.\nLOOP_COMPLETE", + ) + reloaded = loops.load_loop("sid-gateway-loop") + assert reloaded.status == "done" + + +@pytest.mark.asyncio +async def test_post_turn_loop_completion_noop_without_inflight_tick(loop_env): + runner = _make_runner() + await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI")) + entry = _FakeSessionEntry() + # No tick fired — the ordinary user turn must not consume loop state. + await GatewayRunner._post_turn_loop_completion( + runner, + session_entry=entry, + source=None, + final_response="regular reply LOOP_COMPLETE", + ) + reloaded = loops.load_loop("sid-gateway-loop") + assert reloaded.status == "active" + assert reloaded.ticks_fired == 0 diff --git a/tests/gateway/test_restart_after_turn.py b/tests/gateway/test_restart_after_turn.py index 713ab0242ffd7..9195df8fc7540 100644 --- a/tests/gateway/test_restart_after_turn.py +++ b/tests/gateway/test_restart_after_turn.py @@ -17,6 +17,22 @@ def test_parse_restart_after_turn_timeout_defaults_and_clamps(): assert parse_restart_after_turn_timeout("120") == 120.0 +def test_default_restart_after_turn_timeout_is_human_tolerable(): + """The shipped default must not make interactive restarts block for hours. + + A wedged turn must not pin `hermes gateway restart` for 6h — the + default is a safety valve for hung agents, not a target latency + (#79133). 900-1800s protects long autonomous turns while keeping + worst-case interactive restart in human-tolerable territory. + """ + assert 900 <= DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT <= 1800 + # An interactive restart's printed wait budget stays under ~32 min. + budget = resolve_restart_exit_wait_budget( + 60, DEFAULT_GATEWAY_RESTART_AFTER_TURN_TIMEOUT, headroom=15 + ) + assert budget <= 1875 + + def test_resolve_restart_exit_wait_budget_covers_both_phases(): assert resolve_restart_exit_wait_budget(0, 0, headroom=15) == 15.0 assert resolve_restart_exit_wait_budget(180, 21600, headroom=15) == 180 + 21600 + 15 diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 7304f93581c28..590223f51c4f0 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -375,3 +375,96 @@ async def test_drain_suppress_skips_home_channel_keeps_session_ping(tmp_path, mo assert "shutting down" in adapter.sent[0] + + +def _wedged_agent(idle_seconds: float = 4000.0) -> MagicMock: + """Agent double whose activity summary reports it idle past the timeout.""" + agent = MagicMock() + agent.get_activity_summary = MagicMock( + return_value={"seconds_since_activity": idle_seconds} + ) + return agent + + +def _live_agent(idle_seconds: float = 1.0) -> MagicMock: + agent = MagicMock() + agent.get_activity_summary = MagicMock( + return_value={"seconds_since_activity": idle_seconds} + ) + return agent + + +@pytest.mark.asyncio +async def test_request_restart_skips_wait_when_only_wedged_turns(monkeypatch): + """A turn idle past agent.gateway_timeout must not defer the restart. + + Regression: a WhatsApp turn wedged for 30+ min pinned `hermes update` + in "draining" for the full restart_after_turn_timeout cap — the + after-turn wait counted the wedged agent as active work even though + the inactivity watchdog had already declared it dead (Aug 2026). + """ + monkeypatch.delenv("HERMES_AGENT_TIMEOUT", raising=False) + runner, _adapter = make_restart_runner() + runner.stop = AsyncMock() + # A cap long enough that the test would hang without the wedge bypass. + runner._restart_after_turn_timeout = 300.0 + runner._running_agents["agent:main:whatsapp:dm:1"] = _wedged_agent() + + assert runner.request_restart(detached=False, via_service=True) is True + await asyncio.wait_for(runner._restart_task, timeout=5.0) + + runner.stop.assert_awaited_once_with( + restart=True, detached_restart=False, service_restart=True + ) + # Wedged agent stays in the map — stop() owns the interrupt from here. + assert runner._running_agents + + +@pytest.mark.asyncio +async def test_request_restart_still_waits_for_live_turn_alongside_wedged(monkeypatch): + """Mixed live + wedged: the live turn is honored, the wedged one ignored.""" + monkeypatch.delenv("HERMES_AGENT_TIMEOUT", raising=False) + runner, _adapter = make_restart_runner() + runner.stop = AsyncMock() + runner._launch_detached_restart_command = AsyncMock() + runner._restart_after_turn_timeout = 300.0 + live_key = "agent:main:telegram:dm:2" + runner._running_agents["agent:main:whatsapp:dm:1"] = _wedged_agent() + runner._running_agents[live_key] = _live_agent() + + assert runner.request_restart(detached=False, via_service=True) is True + + # Live turn active → stop() must not run yet, wedged turn notwithstanding. + await asyncio.sleep(0.25) + runner.stop.assert_not_awaited() + + # Live turn finishes → restart proceeds without waiting on the wedged one. + del runner._running_agents[live_key] + await asyncio.wait_for(runner._restart_task, timeout=5.0) + runner.stop.assert_awaited_once() + + +def test_wedged_agent_count_disabled_timeout_counts_nothing(monkeypatch): + """gateway_timeout=0 (unbounded turns) disables wedge detection.""" + monkeypatch.setenv("HERMES_AGENT_TIMEOUT", "0") + runner, _adapter = make_restart_runner() + runner._running_agents["agent:main:telegram:dm:1"] = _wedged_agent(10**6) + assert runner._wedged_agent_count() == 0 + + +def test_wedged_agent_count_ignores_sentinels_and_bad_summaries(monkeypatch): + monkeypatch.delenv("HERMES_AGENT_TIMEOUT", raising=False) + runner, _adapter = make_restart_runner() + broken = MagicMock() + broken.get_activity_summary = MagicMock(side_effect=RuntimeError("boom")) + non_dict = MagicMock() # auto-attr summary returns a MagicMock, not a dict + runner._running_agents.update( + { + "pending": gateway_run._AGENT_PENDING_SENTINEL, + "broken": broken, + "non_dict": non_dict, + "wedged": _wedged_agent(), + "live": _live_agent(), + } + ) + assert runner._wedged_agent_count() == 1 diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index d8e535eeb77e9..c81aade3f3c00 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -2,7 +2,7 @@ import json import pytest from dataclasses import replace -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from unittest.mock import patch, MagicMock from hermes_state import SessionDB @@ -1291,6 +1291,34 @@ def test_session_metadata_survives_reload(self, tmp_path): == "123.456" ) + def test_metadata_write_does_not_touch_activity_clock(self, tmp_path): + """set_session_metadata is bookkeeping — it must not bump updated_at. + + updated_at drives idle/daily reset policy and the restart-resume + freshness gate (#85709); a background metadata write on an idle + session must not make it look recently active. + """ + config = GatewayConfig() + store = SessionStore(sessions_dir=tmp_path, config=config) + store._db = None + source = SessionSource( + platform=Platform.SLACK, + chat_id="C123", + chat_type="group", + user_id="U123", + thread_id="123.000", + ) + + entry = store.get_or_create_session(source) + idle = datetime.now() - timedelta(days=21) + with store._lock: + entry.updated_at = idle + + assert store.set_session_metadata(entry.session_key, "k", "v") + assert entry.updated_at == idle + # And the restart freshness gate must still see it as idle. + assert store.suspend_recently_active(max_age_seconds=120) == 0 + class TestRewriteTranscriptPreservesReasoning: """rewrite_transcript must not drop reasoning fields from SQLite.""" diff --git a/tests/gateway/test_session_store_runtime_stale_guard.py b/tests/gateway/test_session_store_runtime_stale_guard.py index e248e811e1409..fd5ae50f3448e 100644 --- a/tests/gateway/test_session_store_runtime_stale_guard.py +++ b/tests/gateway/test_session_store_runtime_stale_guard.py @@ -292,4 +292,25 @@ def test_cas_advances_route_without_reopening_rows(self, tmp_path): db.end_session.assert_not_called() db.reopen_session.assert_not_called() + def test_repoint_does_not_touch_activity_clock(self, tmp_path): + """Compression repoint is bookkeeping — it must not bump updated_at. + + A background compression on an idle session must not make it look + fresh to reset policy or the restart-resume freshness gate (#85709). + """ + db = _db_returning({}) + store = _make_store_with_db(tmp_path, db) + source = _source() + key = store._generate_session_key(source) + original = _make_entry(key, "sid_parent") + idle = datetime.now() - timedelta(days=21) + original.updated_at = idle + store._entries[key] = original + + result = store.advance_compression_session(key, "sid_parent", "sid_tip") + + assert result is not None + assert result.updated_at == idle + assert store.suspend_recently_active(max_age_seconds=120) == 0 + diff --git a/tests/gateway/test_skip_context_files_wiring.py b/tests/gateway/test_skip_context_files_wiring.py index eaea6babf8dac..0cc8fd782c623 100644 --- a/tests/gateway/test_skip_context_files_wiring.py +++ b/tests/gateway/test_skip_context_files_wiring.py @@ -72,11 +72,17 @@ class TestSkipContextFilesConfigResolution: ({"gateway": {"platforms": {"discord": {"skip_context_files": True}}}}, "telegram", False), # Truthy non-bool values coerce. ({"gateway": {"platforms": {"telegram": {"skip_context_files": 1}}}}, "telegram", True), + # ``hermes gateway setup`` writes platforms as a LIST of enabled + # platform names — must not crash and must default to False (#83185). + ({"gateway": {"platforms": ["telegram", "discord"]}}, "telegram", False), + ({"gateway": {"platforms": []}}, "telegram", False), ], ) def test_resolution(self, cfg, platform_key, expected): # Mirror the production resolution in TurnRunner exactly. _platforms_gw_cfg = (cfg.get("gateway") or {}).get("platforms") or {} + if not isinstance(_platforms_gw_cfg, dict): + _platforms_gw_cfg = {} _plat_gw_cfg = _platforms_gw_cfg.get(platform_key) or {} _skip_context = _plat_gw_cfg.get("skip_context_files") skip_context_files = bool(_skip_context) if _skip_context is not None else False diff --git a/tests/gateway/test_tui_slash_worker_path.py b/tests/gateway/test_tui_slash_worker_path.py new file mode 100644 index 0000000000000..d0770cc4588ea --- /dev/null +++ b/tests/gateway/test_tui_slash_worker_path.py @@ -0,0 +1,48 @@ +"""Regression test for slash_worker PATH construction (#83845). + +When the gateway is launched by the Desktop/Dashboard app it can inherit a +minimal PATH (/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin) +that omits the Hermes venv bin dir and ~/.local/bin. The spawned +tui_gateway.slash_worker then cannot resolve Hermes-managed CLIs such as +browser-use/uvx via shutil.which, breaking browser_exec. + +`tui_gateway.server._prepend_tool_paths` prepends those two directories to +the worker env PATH while preserving the inherited PATH. +""" + +import os +import sys +from pathlib import Path + +from tui_gateway import server as tui_server + + +class TestPrependToolPaths: + def test_prepends_managed_venv_and_user_bin(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hh")) + env = {"PATH": "/usr/bin"} + result = tui_server._prepend_tool_paths(env) + + parts = result["PATH"].split(os.pathsep) + # managed bin first (managed-first policy), then venv bin, then + # user-local bin, then the original PATH preserved + assert parts[0] == str(tmp_path / "hh" / "bin") + assert parts[1] == str(Path(sys.executable).parent) + assert str(Path.home() / ".local" / "bin") in parts + assert parts[-1] == "/usr/bin" + + def test_preserves_existing_path_when_empty(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hh")) + env = {} + result = tui_server._prepend_tool_paths(env) + + parts = result["PATH"].split(os.pathsep) + assert parts[0] == str(tmp_path / "hh" / "bin") + assert str(Path(sys.executable).parent) in parts + assert str(Path.home() / ".local" / "bin") in parts + + def test_managed_bin_leads_path(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hh")) + env = {"PATH": "/bin"} + result = tui_server._prepend_tool_paths(env) + assert result["PATH"].split(os.pathsep)[0] == str(tmp_path / "hh" / "bin") diff --git a/tests/hermes_cli/test_actual_provider.py b/tests/hermes_cli/test_actual_provider.py index 2e9116c08e90b..e373b1eb18c57 100644 --- a/tests/hermes_cli/test_actual_provider.py +++ b/tests/hermes_cli/test_actual_provider.py @@ -48,11 +48,25 @@ def test_actual_aliases_and_profile_metadata(): def test_actual_base_url_normalization(): - assert normalize_actual_base_url("https://api.actual.inc") == DEFAULT_ACTUAL_BASE_URL - assert normalize_actual_base_url("https://api.actual.inc/v1") == DEFAULT_ACTUAL_BASE_URL - assert normalize_actual_base_url("http://127.0.0.1:8080") == DEFAULT_ACTUAL_LOCAL_BASE_URL - assert normalize_actual_base_url("http://127.0.0.1:8080/v1") == DEFAULT_ACTUAL_LOCAL_BASE_URL - assert normalize_actual_base_url("http://localhost:8080/") == "http://localhost:8080/v1" + assert ( + normalize_actual_base_url("https://api.actual.inc") == DEFAULT_ACTUAL_BASE_URL + ) + assert ( + normalize_actual_base_url("https://api.actual.inc/v1") + == DEFAULT_ACTUAL_BASE_URL + ) + assert ( + normalize_actual_base_url("http://127.0.0.1:8080") + == DEFAULT_ACTUAL_LOCAL_BASE_URL + ) + assert ( + normalize_actual_base_url("http://127.0.0.1:8080/v1") + == DEFAULT_ACTUAL_LOCAL_BASE_URL + ) + assert ( + normalize_actual_base_url("http://localhost:8080/") + == "http://localhost:8080/v1" + ) def test_actual_credentials_default_to_hosted_api(monkeypatch): @@ -173,13 +187,13 @@ def __exit__(self, *args): def read(self): return json.dumps({"data": [{"id": "actual/local-model"}]}).encode() - def _urlopen(req, timeout=0): + def _open(req, timeout=0): seen["url"] = req.full_url seen["auth"] = req.get_header("Authorization") seen["timeout"] = timeout return _Response() - monkeypatch.setattr("urllib.request.urlopen", _urlopen) + monkeypatch.setattr("hermes_cli.urllib_security.open_credentialed_url", _open) assert profile.fetch_models(api_key=None, timeout=1.5) == ["actual/local-model"] assert seen["url"] == DEFAULT_ACTUAL_LOCAL_BASE_URL + "/models" @@ -187,12 +201,93 @@ def _urlopen(req, timeout=0): assert seen["timeout"] == 1.5 +def test_actual_profile_fetch_models_sends_credential_only_to_original_origin( + monkeypatch, +): + """fetch_models must route through the shared redirect-credential guard. + + ActualProfile overrides ProviderProfile.fetch_models with its own + base_url resolution, and previously called raw urllib.request.urlopen + directly instead of the base class's open_credentialed_url — losing the + protection that strips the Authorization header when a redirect leaves + the original host. Exercises the real SafeCredentialRedirectHandler + (no mocking of open_credentialed_url itself) against a local HTTP + server that 302s to a different origin, mirroring + test_urllib_security.py's end-to-end redirect tests. + """ + import http.server + import threading + + _clear_actual_env(monkeypatch) + profile = get_provider_profile("actual") + + source_auth_headers: list[str | None] = [] + target_auth_headers: list[str | None] = [] + + class _RedirectTargetHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + target_auth_headers.append(self.headers.get("Authorization")) + body = json.dumps({"data": [{"id": "should-not-be-trusted"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + target_server = http.server.HTTPServer(("127.0.0.1", 0), _RedirectTargetHandler) + target_thread = threading.Thread(target=target_server.serve_forever, daemon=True) + target_thread.start() + target_port = target_server.server_address[1] + + class _RedirectingHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + source_auth_headers.append(self.headers.get("Authorization")) + self.send_response(302) + self.send_header("Location", f"http://127.0.0.1:{target_port}/models") + self.end_headers() + + def log_message(self, *_args): + pass + + redirect_server = http.server.HTTPServer(("127.0.0.1", 0), _RedirectingHandler) + redirect_thread = threading.Thread( + target=redirect_server.serve_forever, daemon=True + ) + redirect_thread.start() + redirect_port = redirect_server.server_address[1] + + try: + result = profile.fetch_models( + api_key="actual-secret-token", + base_url=f"http://127.0.0.1:{redirect_port}", + timeout=5.0, + ) + finally: + redirect_server.shutdown() + target_server.shutdown() + redirect_thread.join(timeout=2.0) + target_thread.join(timeout=2.0) + + assert result == ["should-not-be-trusted"], ( + "sanity check: the redirect must actually have been followed" + ) + assert source_auth_headers == ["Bearer actual-secret-token"] + assert target_auth_headers == [None], ( + "Authorization header leaked to a different origin after a redirect" + ) + + def test_actual_provider_model_ids_use_local_profile_catalog(monkeypatch): _clear_actual_env(monkeypatch) monkeypatch.setenv("ACTUAL_BASE_URL", "http://127.0.0.1:8080") profile = get_provider_profile("actual") - with patch.object(profile, "fetch_models", return_value=["actual/local-model"]) as fetch: + with patch.object( + profile, "fetch_models", return_value=["actual/local-model"] + ) as fetch: assert provider_model_ids("actual") == ["actual/local-model"] fetch.assert_called_once_with( @@ -201,6 +296,32 @@ def test_actual_provider_model_ids_use_local_profile_catalog(monkeypatch): ) +def test_actual_hosted_model_ids_send_resolved_credential(monkeypatch): + _clear_actual_env(monkeypatch) + monkeypatch.setenv("ACTUAL_API_KEY", "actual-test-key") + profile = get_provider_profile("actual") + + with patch.object( + profile, "fetch_models", return_value=["actual/hosted-model"] + ) as fetch: + assert provider_model_ids("actual") == ["actual/hosted-model"] + + fetch.assert_called_once_with( + api_key="actual-test-key", + base_url=DEFAULT_ACTUAL_BASE_URL, + ) + + +def test_actual_hosted_model_ids_do_not_probe_without_credentials(monkeypatch): + _clear_actual_env(monkeypatch) + profile = get_provider_profile("actual") + + with patch.object(profile, "fetch_models") as fetch: + assert provider_model_ids("actual") == [] + + fetch.assert_not_called() + + def test_actual_codex_transport_clamps_reasoning_effort(): """Actual's SGLang/vLLM backends only accept none/low/medium/high/max. diff --git a/tests/hermes_cli/test_api_mode_aliases.py b/tests/hermes_cli/test_api_mode_aliases.py new file mode 100644 index 0000000000000..550e009a15947 --- /dev/null +++ b/tests/hermes_cli/test_api_mode_aliases.py @@ -0,0 +1,120 @@ +"""Legacy ``api_mode`` spellings must keep selecting the transport they named. + +Regression coverage for the silent api_mode vocabulary break: earlier +releases accepted ``api_mode: openai`` on custom provider entries. The +canonical set consumed by ``agent_init`` is now {chat_completions, +codex_responses, anthropic_messages, bedrock_converse, codex_app_server}, +and an unrecognized value was silently ignored at BOTH consumption sites: + +* ``hermes_cli.config._normalize_custom_provider_entry`` passed the raw + string through, so ``agent_init``'s accepted-set check dropped it and + fell through to hostname detection. +* ``hermes_cli.runtime_provider._parse_api_mode`` returned None, with the + same fall-through. + +For a host with a detection rule (e.g. api.actual.inc -> codex_responses) +the provider silently switched transports after an update and broke: +observed live as every reasoning-bearing request to a relay's untested +/v1/responses endpoint failing while chat_completions worked. See the +#66543 discussion. + +The fix canonicalizes known legacy/alias spellings through one shared map +(``_canonical_api_mode``) at both sites. Unknown values still pass through +unchanged (normalizer) / return None (runtime gate) so existing invalid +config behavior is untouched. +""" + +from __future__ import annotations + +import pytest + +from hermes_cli.config import _canonical_api_mode, _normalize_custom_provider_entry +from hermes_cli.runtime_provider import _parse_api_mode, _VALID_API_MODES + + +class TestCanonicalApiMode: + """The shared alias map.""" + + @pytest.mark.parametrize( + "alias, canonical", + [ + ("openai", "chat_completions"), + ("OpenAI", "chat_completions"), + (" openai ", "chat_completions"), + ("openai_chat", "chat_completions"), + ("chat-completions", "chat_completions"), + ("responses", "codex_responses"), + ("openai_responses", "codex_responses"), + ("anthropic", "anthropic_messages"), + ("messages", "anthropic_messages"), + ("bedrock", "bedrock_converse"), + ], + ) + def test_alias_maps_to_canonical(self, alias, canonical): + assert _canonical_api_mode(alias) == canonical + + @pytest.mark.parametrize( + "canonical", + sorted(_VALID_API_MODES), + ) + def test_canonical_names_pass_through(self, canonical): + assert _canonical_api_mode(canonical) == canonical + + def test_unknown_value_passes_through_unchanged(self): + assert _canonical_api_mode("weird_thing") == "weird_thing" + + def test_every_alias_lands_in_the_valid_set(self): + """Contract: aliasing must never produce a value the runtime rejects.""" + from hermes_cli.config import _API_MODE_ALIASES + + for target in _API_MODE_ALIASES.values(): + assert target in _VALID_API_MODES + + +class TestNormalizedEntryCanonicalizes: + """Config-side consumption: _normalize_custom_provider_entry.""" + + def _entry(self, api_mode): + return { + "name": "relay", + "api": "https://relay.example.invalid/v1", + "api_mode": api_mode, + } + + def test_legacy_openai_becomes_chat_completions(self): + normalized = _normalize_custom_provider_entry( + self._entry("openai"), provider_key="relay" + ) + assert normalized["api_mode"] == "chat_completions" + + def test_canonical_value_unchanged(self): + normalized = _normalize_custom_provider_entry( + self._entry("codex_responses"), provider_key="relay" + ) + assert normalized["api_mode"] == "codex_responses" + + def test_transport_key_also_canonicalized(self): + entry = { + "name": "relay", + "api": "https://relay.example.invalid/v1", + "transport": "openai", + } + normalized = _normalize_custom_provider_entry(entry, provider_key="relay") + assert normalized["api_mode"] == "chat_completions" + + +class TestRuntimeParseApiMode: + """Runtime-side consumption: _parse_api_mode.""" + + def test_legacy_openai_is_valid_chat_completions(self): + assert _parse_api_mode("openai") == "chat_completions" + + def test_canonical_value_still_valid(self): + assert _parse_api_mode("anthropic_messages") == "anthropic_messages" + + def test_unknown_value_still_rejected(self): + assert _parse_api_mode("bogus") is None + + def test_non_string_still_rejected(self): + assert _parse_api_mode(None) is None + assert _parse_api_mode(42) is None diff --git a/tests/hermes_cli/test_banner_git_state.py b/tests/hermes_cli/test_banner_git_state.py index 236e6d6891c67..065a000aff9a7 100644 --- a/tests/hermes_cli/test_banner_git_state.py +++ b/tests/hermes_cli/test_banner_git_state.py @@ -41,3 +41,88 @@ def fake_run(cmd, **kwargs): assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3} +def test_check_via_local_git_ssh_fastpath_ahead_not_behind(tmp_path): + """SSH fast path must not report an ahead (carried) HEAD as behind. + + A carried local commit means tip SHAs differ, but the fresh upstream tip + is an ancestor of HEAD — that is "ahead", and reporting it as behind + nudges the user into `hermes update`, which can wipe the carried work. + """ + from unittest.mock import MagicMock + + from hermes_cli import banner + + repo_dir = tmp_path / "repo" + (repo_dir / ".git").mkdir(parents=True) + + def fake_git_stdout(args, *, cwd, timeout=5): + if args == ["remote", "get-url", "origin"]: + return "git@github.com:NousResearch/hermes-agent.git" + if args == ["rev-parse", "HEAD"]: + return "b" * 40 # carried commit, differs from upstream tip + raise AssertionError(f"unexpected git call: {args}") + + with ( + patch.object(banner, "_git_stdout", side_effect=fake_git_stdout), + patch.object(banner, "_upstream_main_sha", return_value="a" * 40), + # merge-base --is-ancestor exits 0: upstream tip IS an ancestor of HEAD + patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=0)), + ): + behind = banner._check_via_local_git(repo_dir) + + assert behind == 0 + + +def test_check_via_local_git_ssh_fastpath_genuinely_behind(tmp_path): + """SSH fast path reports the exact count (compare API) when behind.""" + from unittest.mock import MagicMock + + from hermes_cli import banner + + repo_dir = tmp_path / "repo" + (repo_dir / ".git").mkdir(parents=True) + + def fake_git_stdout(args, *, cwd, timeout=5): + if args == ["remote", "get-url", "origin"]: + return "git@github.com:NousResearch/hermes-agent.git" + if args == ["rev-parse", "HEAD"]: + return "b" * 40 + raise AssertionError(f"unexpected git call: {args}") + + with ( + patch.object(banner, "_git_stdout", side_effect=fake_git_stdout), + patch.object(banner, "_upstream_main_sha", return_value="a" * 40), + # merge-base --is-ancestor exits 1: not an ancestor -> genuinely behind + patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=1)), + patch.object(banner, "_github_compare_behind", return_value=3), + ): + behind = banner._check_via_local_git(repo_dir) + + assert behind == 3 + + +def test_check_via_local_git_ssh_fastpath_offline_keeps_sentinel(tmp_path): + """Behind + compare API unreachable = honest no-count sentinel, never 1.""" + from unittest.mock import MagicMock + + from hermes_cli import banner + + repo_dir = tmp_path / "repo" + (repo_dir / ".git").mkdir(parents=True) + + def fake_git_stdout(args, *, cwd, timeout=5): + if args == ["remote", "get-url", "origin"]: + return "git@github.com:NousResearch/hermes-agent.git" + if args == ["rev-parse", "HEAD"]: + return "b" * 40 + raise AssertionError(f"unexpected git call: {args}") + + with ( + patch.object(banner, "_git_stdout", side_effect=fake_git_stdout), + patch.object(banner, "_upstream_main_sha", return_value="a" * 40), + patch.object(banner.subprocess, "run", return_value=MagicMock(returncode=1)), + patch.object(banner, "_github_compare_behind", return_value=None), + ): + behind = banner._check_via_local_git(repo_dir) + + assert behind == banner.UPDATE_AVAILABLE_NO_COUNT diff --git a/tests/hermes_cli/test_cli_startup_model_cost_guard.py b/tests/hermes_cli/test_cli_startup_model_cost_guard.py new file mode 100644 index 0000000000000..341ad3aa73218 --- /dev/null +++ b/tests/hermes_cli/test_cli_startup_model_cost_guard.py @@ -0,0 +1,209 @@ +from argparse import Namespace +import sys +import types + +import pytest + + +class _NonInteractiveStdin: + def isatty(self): + return False + + +def _chat_args(**overrides): + base = { + "continue_last": None, + "model": None, + "provider": None, + "resume": None, + "no_restore_cwd": False, + "toolsets": None, + "skills": None, + "tui": False, + "tui_dev": False, + "cli": True, + "verbose": None, + "quiet": True, + "query": "hello", + "image": None, + "worktree": False, + "checkpoints": False, + "pass_session_id": False, + "max_turns": None, + "ignore_rules": False, + "ignore_user_config": False, + "safe_mode": False, + "compact": False, + "source": None, + "yolo": False, + "accept_hooks": False, + } + base.update(overrides) + return Namespace(**base) + + +@pytest.fixture +def main_mod(monkeypatch): + import hermes_cli.main as mod + + monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True) + monkeypatch.setattr(mod, "_sync_bundled_skills_for_startup", lambda: None) + monkeypatch.setattr(mod, "_termux_should_prefetch_update_check", lambda: False) + monkeypatch.setattr(mod, "_pin_kanban_board_env", lambda: None) + monkeypatch.setattr(mod, "_resolve_session_by_name_or_id", lambda val: val) + monkeypatch.setattr(mod, "_oneshot_cleanup_done", False) + return mod + + +@pytest.fixture +def fake_cli(monkeypatch): + captured = {} + + def fake_cli_main(**kwargs): + captured.update(kwargs) + + monkeypatch.setitem(sys.modules, "cli", types.SimpleNamespace(main=fake_cli_main)) + return captured + + +@pytest.fixture +def codex_config(monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: { + "model": { + "provider": "openai-codex", + "default": "gpt-5.5", + "base_url": "https://chatgpt.com/backend-api/codex", + } + }, + ) + + +def test_cmd_chat_rejects_noninteractive_gpt55_pro_startup_override( + main_mod, fake_cli, codex_config, monkeypatch, capsys +): + monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin()) + + with pytest.raises(SystemExit) as excinfo: + main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro")) + + assert excinfo.value.code == 1 + assert not fake_cli + err = capsys.readouterr().err + assert "EXPENSIVE MODEL WARNING" in err + assert "did you mean to select openai/gpt-5.5?" in err + assert "non-interactive" in err + + +def test_cmd_chat_rejects_noninteractive_gpt55_pro_even_with_yolo( + main_mod, fake_cli, codex_config, monkeypatch, capsys +): + monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin()) + + with pytest.raises(SystemExit) as excinfo: + main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro", yolo=True)) + + assert excinfo.value.code == 1 + assert not fake_cli + assert "EXPENSIVE MODEL WARNING" in capsys.readouterr().err + + +def test_cmd_chat_allows_interactive_gpt55_pro_when_confirmed( + main_mod, fake_cli, codex_config, monkeypatch +): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _prompt: "yes") + + main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro")) + + assert fake_cli["model"] == "openai/gpt-5.5-pro" + + +def test_cmd_chat_cancels_interactive_gpt55_pro_when_not_confirmed( + main_mod, fake_cli, codex_config, monkeypatch, capsys +): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + + with pytest.raises(SystemExit) as excinfo: + main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro")) + + assert excinfo.value.code == 1 + assert not fake_cli + assert "Model override cancelled" in capsys.readouterr().err + + +def test_cmd_chat_cancels_interactive_gpt55_pro_on_eof( + main_mod, fake_cli, codex_config, monkeypatch, capsys +): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + def raise_eof(_prompt): + raise EOFError + + monkeypatch.setattr("builtins.input", raise_eof) + + with pytest.raises(SystemExit) as excinfo: + main_mod.cmd_chat(_chat_args(model="openai/gpt-5.5-pro")) + + assert excinfo.value.code == 1 + assert not fake_cli + assert "Model override cancelled" in capsys.readouterr().err + + +def test_cmd_chat_rejects_noninteractive_provider_only_override_when_default_is_expensive( + main_mod, fake_cli, monkeypatch, capsys +): + monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin()) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider": "openai-codex", "default": "openai/gpt-5.5-pro"}}, + ) + + with pytest.raises(SystemExit) as excinfo: + main_mod.cmd_chat(_chat_args(model=None, provider="nous")) + + assert excinfo.value.code == 1 + assert not fake_cli + assert "EXPENSIVE MODEL WARNING" in capsys.readouterr().err + + +def test_cmd_chat_allows_noninteractive_safe_codex_startup_override( + main_mod, fake_cli, monkeypatch +): + monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin()) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider": "openai-codex", "default": "gpt-5.5"}}, + ) + + main_mod.cmd_chat(_chat_args(model="gpt-5.5", provider="openai-codex")) + + assert fake_cli["model"] == "gpt-5.5" + assert fake_cli["provider"] == "openai-codex" + + +def test_top_level_oneshot_rejects_noninteractive_gpt55_pro_startup_override( + main_mod, codex_config, monkeypatch, capsys +): + monkeypatch.setattr(sys, "stdin", _NonInteractiveStdin()) + monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "-m", "openai/gpt-5.5-pro"]) + monkeypatch.setattr(main_mod, "_prepare_agent_startup", lambda _args: None) + + called = False + + def fake_run_and_exit_oneshot(*_args, **_kwargs): + nonlocal called + called = True + + monkeypatch.setattr(main_mod, "_run_and_exit_oneshot", fake_run_and_exit_oneshot) + + with pytest.raises(SystemExit) as excinfo: + main_mod.main() + + assert excinfo.value.code == 1 + assert called is False + err = capsys.readouterr().err + assert "EXPENSIVE MODEL WARNING" in err + assert "non-interactive" in err diff --git a/tests/hermes_cli/test_dashboard_auth_native_flow.py b/tests/hermes_cli/test_dashboard_auth_native_flow.py index dd24caecbf4a6..182b096a66828 100644 --- a/tests/hermes_cli/test_dashboard_auth_native_flow.py +++ b/tests/hermes_cli/test_dashboard_auth_native_flow.py @@ -269,10 +269,13 @@ def test_native_authorize_empty_provider_ambiguous_multiple_oauth_404(gated_clie assert r.status_code == 404 -def test_native_authorize_empty_provider_password_only_rejected_400(gated_client): - """Password-only deployment: an empty ``provider`` must still select the - lone session provider and fail with the explicit 400 explaining that - password providers have no native OAuth flow — not a bare 404.""" +def test_native_authorize_empty_provider_password_only_brokers_to_login( + gated_client, +): + """Password-only deployment: an empty ``provider`` selects the lone + session provider and — now that native sign-in brokers password + providers through the system browser — 302s to ``/login`` with the + broker in the PKCE cookie, rather than the old 400.""" clear_providers() register_provider(_PasswordOnlyProvider()) _verifier, challenge = _make_pkce() @@ -280,8 +283,10 @@ def test_native_authorize_empty_provider_password_only_rejected_400(gated_client "/auth/native/authorize", params=_native_authorize_params(challenge), ) - assert r.status_code == 400 - assert "does not support native OAuth login" in r.json()["detail"] + assert r.status_code == 302, r.text + assert r.headers["location"].endswith("/login") + set_cookie = r.headers.get("set-cookie", "") + assert "broker=" in set_cookie # --------------------------------------------------------------------------- @@ -335,6 +340,242 @@ def test_status_loopback_mode_has_no_auth_flows(): web_server.app.state.auth_required = prev_required +# --------------------------------------------------------------------------- +# Native flow for password providers (system-browser autofill path) +# --------------------------------------------------------------------------- +# +# A password provider has no IDP round trip, but the native flow still buys +# the desktop the one thing an embedded webview can never have: the system +# browser's OS-password-manager autofill. /auth/native/authorize lands the +# browser on /login (broker_state in the PKCE cookie) and a successful +# /auth/password-login completes the pending authorization exactly like the +# OAuth callback does. + + +@pytest.fixture +def pw_gated_client(): + from hermes_cli.dashboard_auth.routes import _reset_password_rate_limit + from tests.hermes_cli.test_dashboard_auth_password_login import ( + PasswordProvider, + ) + + clear_providers() + register_provider(PasswordProvider()) + _reset_password_rate_limit() + prev_host = getattr(web_server.app.state, "bound_host", None) + prev_port = getattr(web_server.app.state, "bound_port", None) + prev_required = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.bound_host = "fly-app.fly.dev" + web_server.app.state.bound_port = 443 + web_server.app.state.auth_required = True + client = TestClient( + web_server.app, base_url="https://fly-app.fly.dev", + follow_redirects=False, + ) + yield client + clear_providers() + _reset_password_rate_limit() + web_server.app.state.bound_host = prev_host + web_server.app.state.bound_port = prev_port + web_server.app.state.auth_required = prev_required + + +def test_status_advertises_native_pkce_for_password_only_gateway( + pw_gated_client, +): + body = pw_gated_client.get("/api/status").json() + assert body["auth_required"] is True + assert "cookie" in body["auth_flows"] + assert "native_pkce" in body["auth_flows"] + + +def test_native_authorize_password_provider_redirects_to_login( + pw_gated_client, +): + """Empty ``provider`` auto-picks the single password provider and lands + the system browser on /login with the broker in the PKCE cookie.""" + _verifier, challenge = _make_pkce() + r = pw_gated_client.get( + "/auth/native/authorize", + params={ + "code_challenge": challenge, + "code_challenge_method": "S256", + "redirect_uri": "http://127.0.0.1:53999/cb", + "state": "desk-state", + }, + ) + assert r.status_code == 302, r.text + assert r.headers["location"].endswith("/login") + set_cookie = r.headers.get("set-cookie", "") + assert "pkce" in set_cookie + assert "broker=" in set_cookie + + +def _start_native_password_login(client, *, challenge, state="desk-state"): + r = client.get( + "/auth/native/authorize", + params={ + "provider": "testpw", + "code_challenge": challenge, + "code_challenge_method": "S256", + "redirect_uri": "http://127.0.0.1:53999/cb", + "state": state, + }, + ) + assert r.status_code == 302, r.text + return r.cookies + + +def test_native_password_login_full_roundtrip(pw_gated_client): + """authorize → /login → password-login → loopback code → bearer tokens.""" + verifier, challenge = _make_pkce() + cookies = _start_native_password_login(pw_gated_client, challenge=challenge) + + # The browser form POSTs the credentials; the PKCE cookie rides along. + r = pw_gated_client.post( + "/auth/password-login", + json={"provider": "testpw", "username": "admin", "password": "hunter2"}, + cookies=cookies, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + # ``next`` is the desktop's loopback redirect carrying code + state — + # NOT a dashboard path. + assert body["next"].startswith("http://127.0.0.1:53999/cb?") + qs = parse_qs(urlparse(body["next"]).query) + assert qs["state"][0] == "desk-state" + code = qs["code"][0] + # No browser session on the native branch; the PKCE cookie is cleared. + set_cookie = r.headers.get("set-cookie", "") + assert "hermes_session_at" not in set_cookie, ( + f"native password login must NOT set a session cookie; got {set_cookie!r}" + ) + assert "pkce" in set_cookie # the clearing Set-Cookie + + # Desktop redeems the loopback code with its PKCE verifier. + tokens = pw_gated_client.post( + "/auth/native/token", + json={"code": code, "code_verifier": verifier}, + ).json() + assert tokens["provider"] == "testpw" + assert tokens["user_id"] == "admin" + + # Cookieless bearer auth of a gated route — the point of the flow. + r2 = pw_gated_client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["user_id"] == "admin" + + +def test_native_password_login_wrong_password_keeps_pending(pw_gated_client): + """A failed credential attempt must not consume the pending + authorization — the user retypes and succeeds on the same broker.""" + verifier, challenge = _make_pkce() + cookies = _start_native_password_login(pw_gated_client, challenge=challenge) + + r = pw_gated_client.post( + "/auth/password-login", + json={"provider": "testpw", "username": "admin", "password": "wrong"}, + cookies=cookies, + ) + assert r.status_code == 401 + + r2 = pw_gated_client.post( + "/auth/password-login", + json={"provider": "testpw", "username": "admin", "password": "hunter2"}, + cookies=cookies, + ) + assert r2.status_code == 200, r2.text + assert r2.json()["next"].startswith("http://127.0.0.1:53999/cb?") + + +def test_native_password_login_expired_broker_returns_400(pw_gated_client): + """A broker cookie whose pending entry lapsed (TTL) is a clean 400 + telling the user to restart sign-in — never a silent cookie login.""" + _verifier, challenge = _make_pkce() + cookies = _start_native_password_login(pw_gated_client, challenge=challenge) + + native_flow._reset_for_tests() # simulate the pending TTL lapsing + + r = pw_gated_client.post( + "/auth/password-login", + json={"provider": "testpw", "username": "admin", "password": "hunter2"}, + cookies=cookies, + ) + assert r.status_code == 400 + assert "restart" in r.json()["detail"].lower() + + +def test_native_password_login_rejects_cross_provider_completion( + pw_gated_client, +): + """A native flow started for provider A must not be completable with + provider B's credentials: /login renders every provider's form, and the + pending authorization is bound to the provider recorded in the + server-set PKCE cookie. The mismatch is rejected BEFORE credential + verification and preserves the pending entry, so the user can still + submit the form the flow was started for.""" + from tests.hermes_cli.test_dashboard_auth_password_login import ( + PasswordProvider, + ) + + class SecondPasswordProvider(PasswordProvider): + name = "testpw2" + display_name = "Test Password 2" + + register_provider(SecondPasswordProvider()) + + verifier, challenge = _make_pkce() + # Native flow initiated for provider A ("testpw"). + cookies = _start_native_password_login(pw_gated_client, challenge=challenge) + + # Valid credentials for provider B ("testpw2") must NOT complete A's + # pending authorization. + r = pw_gated_client.post( + "/auth/password-login", + json={ + "provider": "testpw2", "username": "admin", "password": "hunter2", + }, + cookies=cookies, + ) + assert r.status_code == 400, r.text + assert "different provider" in r.json()["detail"] + set_cookie = r.headers.get("set-cookie", "") + assert "hermes_session_at" not in set_cookie + + # The pending entry survived — provider A completes normally. + r2 = pw_gated_client.post( + "/auth/password-login", + json={ + "provider": "testpw", "username": "admin", "password": "hunter2", + }, + cookies=cookies, + ) + assert r2.status_code == 200, r2.text + qs = parse_qs(urlparse(r2.json()["next"]).query) + tokens = pw_gated_client.post( + "/auth/native/token", + json={"code": qs["code"][0], "code_verifier": verifier}, + ).json() + assert tokens["provider"] == "testpw" + + +def test_password_login_without_broker_still_mints_cookies(pw_gated_client): + """Guard: an ordinary browser password login (no native broker cookie) + keeps the existing cookie-minting behaviour.""" + r = pw_gated_client.post( + "/auth/password-login", + json={"provider": "testpw", "username": "admin", "password": "hunter2"}, + ) + assert r.status_code == 200, r.text + assert r.json()["next"] == "/" + set_cookie = r.headers.get("set-cookie", "") + assert "hermes_session_at" in set_cookie + + # --------------------------------------------------------------------------- # Native refresh # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_loops.py b/tests/hermes_cli/test_loops.py new file mode 100644 index 0000000000000..dba5ed39a4042 --- /dev/null +++ b/tests/hermes_cli/test_loops.py @@ -0,0 +1,670 @@ +"""Tests for hermes_cli/loops.py — /loop recurring in-session wakeups.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import pytest + + +# ────────────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME so SessionDB.state_meta writes don't clobber the real one.""" + from pathlib import Path + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli import loops + + loops._DB_CACHE.clear() + yield home + loops._DB_CACHE.clear() + + +# ────────────────────────────────────────────────────────────────────── +# Interval / argument parsing +# ────────────────────────────────────────────────────────────────────── + + +class TestParseIntervalToken: + def test_minutes(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("5m") == 300 + + def test_seconds(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("30s") == 30 + + def test_hours(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("2h") == 7200 + + def test_compound(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("1h30m") == 5400 + + def test_case_insensitive(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("5M") == 300 + + def test_bare_number_is_not_interval(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("3") is None + + def test_prose_is_not_interval(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("check") is None + assert parse_interval_token("") is None + assert parse_interval_token("5x") is None + + def test_zero_rejected(self): + from hermes_cli.loops import parse_interval_token + + assert parse_interval_token("0m") is None + assert parse_interval_token("0s") is None + + +class TestParseLoopArgs: + def test_fixed_interval(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("5m check the deploy status") + assert p["interval_seconds"] == 300 + assert p["prompt"] == "check the deploy status" + assert p["error"] is None + + def test_every_sugar(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("every 10m /recap") + assert p["interval_seconds"] == 600 + assert p["prompt"] == "/recap" + + def test_self_paced(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("keep refining the failing test until the suite passes") + assert p["interval_seconds"] is None + assert p["prompt"].startswith("keep refining") + + def test_times_flag(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("2m poll CI --times 30") + assert p["interval_seconds"] == 120 + assert p["prompt"] == "poll CI" + assert p["times"] == 30 + + def test_until_flag(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("5m watch the queue --until queue depth reaches zero") + assert p["interval_seconds"] == 300 + assert p["prompt"] == "watch the queue" + assert p["until"] == "queue depth reaches zero" + + def test_until_and_times_together(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("2m poll --times 5 --until it is green") + assert p["times"] == 5 + assert p["until"] == "it is green" + assert p["prompt"] == "poll" + + def test_bad_times(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("2m poll --times zero") + assert p["error"] is not None + + def test_interval_only_is_error(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("5m") + assert p["error"] is not None + + def test_empty(self): + from hermes_cli.loops import parse_loop_args + + assert parse_loop_args("")["error"] == "empty" + + def test_prompt_with_leading_number_not_eaten(self): + from hermes_cli.loops import parse_loop_args + + p = parse_loop_args("3 things to verify in the repo") + assert p["interval_seconds"] is None + assert p["prompt"].startswith("3 things") + + +class TestFormatInterval: + def test_render(self): + from hermes_cli.loops import format_interval + + assert format_interval(30) == "30s" + assert format_interval(300) == "5m" + assert format_interval(5400) == "1h30m" + assert format_interval(90) == "1m30s" + assert format_interval(0) == "0s" + + +# ────────────────────────────────────────────────────────────────────── +# LOOP_COMPLETE marker +# ────────────────────────────────────────────────────────────────────── + + +class TestResponseSignalsComplete: + def test_marker_on_own_line(self): + from hermes_cli.loops import response_signals_complete + + assert response_signals_complete("Deploy is live.\nLOOP_COMPLETE") is True + + def test_marker_with_trailing_period(self): + from hermes_cli.loops import response_signals_complete + + assert response_signals_complete("done\nLOOP_COMPLETE.") is True + + def test_marker_mid_sentence_does_not_count(self): + from hermes_cli.loops import response_signals_complete + + assert response_signals_complete("I will emit LOOP_COMPLETE when finished") is False + + def test_no_marker(self): + from hermes_cli.loops import response_signals_complete + + assert response_signals_complete("still building") is False + assert response_signals_complete("") is False + + +# ────────────────────────────────────────────────────────────────────── +# LoopState round-trip +# ────────────────────────────────────────────────────────────────────── + + +class TestLoopStateSerde: + def test_round_trip(self): + from hermes_cli.loops import LoopState + + s = LoopState( + prompt="check CI", + mode="interval", + interval_seconds=300.0, + current_delay=300.0, + times=5, + until="ci is green", + ticks_fired=2, + route={"platform": "telegram", "chat_id": "123"}, + ) + s2 = LoopState.from_json(s.to_json()) + assert s2.prompt == "check CI" + assert s2.interval_seconds == 300.0 + assert s2.times == 5 + assert s2.until == "ci is green" + assert s2.ticks_fired == 2 + assert s2.route == {"platform": "telegram", "chat_id": "123"} + + def test_old_row_missing_fields(self): + from hermes_cli.loops import LoopState + + s = LoopState.from_json('{"prompt": "p"}') + assert s.prompt == "p" + assert s.status == "active" + assert s.route == {} + + +# ────────────────────────────────────────────────────────────────────── +# Persistence +# ────────────────────────────────────────────────────────────────────── + + +class TestPersistence: + def test_save_load_clear(self, hermes_home): + from hermes_cli.loops import LoopManager, load_loop + + mgr = LoopManager(session_id="sess-1") + mgr.set("check the deploy", interval_seconds=300) + loaded = load_loop("sess-1") + assert loaded is not None + assert loaded.prompt == "check the deploy" + assert loaded.status == "active" + + assert mgr.clear() is True + cleared = load_loop("sess-1") + assert cleared is not None and cleared.status == "cleared" + + def test_list_active_loops(self, hermes_home): + from hermes_cli.loops import LoopManager, list_active_loops + + LoopManager(session_id="a").set("task a", interval_seconds=60) + mgr_b = LoopManager(session_id="b") + mgr_b.set("task b", interval_seconds=60) + mgr_b.pause() + + active = dict(list_active_loops()) + assert "a" in active + assert "b" not in active + + def test_migrate_to_session(self, hermes_home): + from hermes_cli.loops import LoopManager, load_loop, migrate_loop_to_session + + LoopManager(session_id="parent").set("watch it", interval_seconds=60) + assert migrate_loop_to_session("parent", "child", reason="compression") is True + child = load_loop("child") + assert child is not None and child.prompt == "watch it" + parent = load_loop("parent") + assert parent is not None and parent.status == "cleared" + + def test_migrate_no_source(self, hermes_home): + from hermes_cli.loops import migrate_loop_to_session + + assert migrate_loop_to_session("nope", "child2") is False + assert migrate_loop_to_session("same", "same") is False + + def test_migrate_does_not_clobber_child(self, hermes_home): + from hermes_cli.loops import LoopManager, load_loop, migrate_loop_to_session + + LoopManager(session_id="p2").set("parent loop", interval_seconds=60) + LoopManager(session_id="c2").set("child loop", interval_seconds=60) + assert migrate_loop_to_session("p2", "c2") is False + assert load_loop("c2").prompt == "child loop" + + +# ────────────────────────────────────────────────────────────────────── +# Tick lifecycle +# ────────────────────────────────────────────────────────────────────── + + +class TestTickLifecycle: + def test_not_due_immediately(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t1") + mgr.set("poll", interval_seconds=300) + assert mgr.is_due() is False + + def test_due_after_interval(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t2") + state = mgr.set("poll", interval_seconds=300) + state.next_due_at = time.time() - 1 + assert mgr.is_due() is True + + def test_fire_marks_awaiting_and_blocks_refire(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t3") + state = mgr.set("poll the build", interval_seconds=300) + state.next_due_at = time.time() - 1 + wakeup = mgr.fire_tick() + assert wakeup is not None + assert "[/loop wakeup #1" in wakeup + assert "poll the build" in wakeup + assert "LOOP_COMPLETE" in wakeup + assert mgr.state.awaiting_response is True + assert mgr.is_due() is False # can't double-fire mid-turn + assert mgr.fire_tick() is None + + def test_slash_prompt_returned_raw(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t4") + state = mgr.set("/recap", interval_seconds=300) + state.next_due_at = time.time() - 1 + assert mgr.fire_tick() == "/recap" + + def test_abandon_tick_rolls_back(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t5") + state = mgr.set("poll", interval_seconds=300) + state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.abandon_tick() + assert mgr.state.awaiting_response is False + assert mgr.state.ticks_fired == 0 + + def test_complete_tick_marker_stops(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t6") + state = mgr.set("poll", interval_seconds=300) + state.next_due_at = time.time() - 1 + mgr.fire_tick() + decision = mgr.complete_tick("The deploy is live.\nLOOP_COMPLETE") + assert decision["stopped"] is True + assert decision["status"] == "done" + + def test_complete_tick_times_cap(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t7") + state = mgr.set("poll", interval_seconds=300, times=1) + state.next_due_at = time.time() - 1 + mgr.fire_tick() + decision = mgr.complete_tick("still building") + assert decision["stopped"] is True + assert decision["status"] == "done" + assert "1/1" in decision["message"] + + def test_complete_tick_continues_and_reschedules(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t8") + state = mgr.set("poll", interval_seconds=300) + state.next_due_at = time.time() - 1 + mgr.fire_tick() + decision = mgr.complete_tick("still building") + assert decision["stopped"] is False + assert decision["status"] == "active" + assert mgr.state.awaiting_response is False + assert mgr.state.next_due_at > time.time() + 250 + + def test_complete_tick_max_ticks_pauses(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t9") + state = mgr.set("poll", interval_seconds=300) + state.max_ticks = 1 + state.next_due_at = time.time() - 1 + mgr.fire_tick() + decision = mgr.complete_tick("still building") + assert decision["stopped"] is True + assert decision["status"] == "paused" + + def test_until_judge_done_stops(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t10") + state = mgr.set("poll", interval_seconds=300, until="the suite is green") + state.next_due_at = time.time() - 1 + mgr.fire_tick() + with patch("hermes_cli.goals.judge_goal", return_value=("done", "suite green", False, None, False)): + decision = mgr.complete_tick("All 500 tests passed.") + assert decision["stopped"] is True + assert decision["status"] == "done" + + def test_until_judge_continue_loops(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t11") + state = mgr.set("poll", interval_seconds=300, until="the suite is green") + state.next_due_at = time.time() - 1 + mgr.fire_tick() + with patch("hermes_cli.goals.judge_goal", return_value=("continue", "3 failures", False, None, False)): + decision = mgr.complete_tick("3 tests still failing") + assert decision["stopped"] is False + + def test_until_judge_error_fails_open(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="t12") + state = mgr.set("poll", interval_seconds=300, until="green") + state.next_due_at = time.time() - 1 + mgr.fire_tick() + with patch("hermes_cli.goals.judge_goal", side_effect=RuntimeError("api down")): + decision = mgr.complete_tick("some output") + assert decision["stopped"] is False # fail-open: keep looping + + +class TestSelfPacedBackoff: + def test_backoff_doubles_on_unchanged_and_resets_on_change(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="sp1") + state = mgr.set("watch the queue") # self-paced + floor = state.current_delay + assert state.mode == "self_paced" + + # Tick 1: response A → delay stays at floor (change from empty digest). + state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.complete_tick("queue depth is 5") + assert mgr.state.current_delay == floor + + # Tick 2: same response → backoff doubles. + mgr.state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.complete_tick("queue depth is 5") + assert mgr.state.current_delay == floor * 2 + + # Tick 3: same again → doubles again. + mgr.state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.complete_tick("queue depth is 5") + assert mgr.state.current_delay == floor * 4 + + # Tick 4: changed response → snaps back to floor. + mgr.state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.complete_tick("queue depth is 2 — draining") + assert mgr.state.current_delay == floor + + def test_timestamp_only_changes_do_not_reset(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="sp2") + state = mgr.set("watch") + floor = state.current_delay + state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.complete_tick("Still building. Checked at 14:02:33") + mgr.state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.complete_tick("Still building. Checked at 14:07:33") + assert mgr.state.current_delay == floor * 2 # digest ignored the clock + + +# ────────────────────────────────────────────────────────────────────── +# Controls (pause/resume/clear) + min interval + status +# ────────────────────────────────────────────────────────────────────── + + +class TestControls: + def test_min_interval_enforced(self, hermes_home): + from hermes_cli.loops import LoopManager, min_interval_seconds + + mgr = LoopManager(session_id="c1") + state = mgr.set("poll", interval_seconds=1) + assert state.interval_seconds >= min_interval_seconds() + + def test_pause_resume(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="c2") + state = mgr.set("poll", interval_seconds=300) + state.next_due_at = time.time() - 1 + mgr.pause() + assert mgr.is_active() is False + assert mgr.is_due() is False + mgr.resume() + assert mgr.is_active() is True + + def test_paused_mid_tick_clears_awaiting(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="c3") + state = mgr.set("poll", interval_seconds=300) + state.next_due_at = time.time() - 1 + mgr.fire_tick() + mgr.pause(reason="user-interrupted") + assert mgr.state.awaiting_response is False + + def test_status_line_shapes(self, hermes_home): + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_id="c4") + assert "No loop set" in mgr.status_line() + mgr.set("poll the build", interval_seconds=300) + assert "active" in mgr.status_line() + assert "poll the build" in mgr.status_line() + mgr.pause() + assert "paused" in mgr.status_line() + mgr.clear() + assert "No loop set" in mgr.status_line() + + +# ────────────────────────────────────────────────────────────────────── +# /goal mixing +# ────────────────────────────────────────────────────────────────────── + + +class TestGoalMixing: + def test_active_goal_blocks_tick(self, hermes_home): + from hermes_cli.goals import GoalManager + from hermes_cli.loops import goal_blocks_loop_tick + + GoalManager(session_id="g1").set("finish the migration") + assert goal_blocks_loop_tick("g1") is True + + def test_no_goal_does_not_block(self, hermes_home): + from hermes_cli.loops import goal_blocks_loop_tick + + assert goal_blocks_loop_tick("g2") is False + + def test_paused_goal_does_not_block(self, hermes_home): + from hermes_cli.goals import GoalManager + from hermes_cli.loops import goal_blocks_loop_tick + + gm = GoalManager(session_id="g3") + gm.set("finish it") + gm.pause() + assert goal_blocks_loop_tick("g3") is False + + def test_parked_goal_does_not_block(self, hermes_home): + from hermes_cli.goals import GoalManager + from hermes_cli.loops import goal_blocks_loop_tick + + gm = GoalManager(session_id="g4") + gm.set("finish it") + gm.wait_for_seconds(3600, reason="waiting on CI") + assert goal_blocks_loop_tick("g4") is False + + +# ────────────────────────────────────────────────────────────────────── +# dispatch_loop_command (shared slash handler) +# ────────────────────────────────────────────────────────────────────── + + +class TestDispatchLoopCommand: + def test_create_fixed(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d1") + result = dispatch_loop_command(mgr, "5m check the deploy") + assert result["created"] is True + assert "Loop set" in result["output"] + assert "every 5m" in result["output"] + + def test_create_self_paced(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d2") + result = dispatch_loop_command(mgr, "keep fixing the tests") + assert result["created"] is True + assert "Self-paced" in result["output"] + + def test_status_empty(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d3") + result = dispatch_loop_command(mgr, "") + assert result["created"] is False + assert "No loop set" in result["output"] + + def test_pause_resume_stop(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d4") + dispatch_loop_command(mgr, "5m poll") + assert "paused" in dispatch_loop_command(mgr, "pause")["output"].lower() + assert "resumed" in dispatch_loop_command(mgr, "resume")["output"].lower() + assert "stopped" in dispatch_loop_command(mgr, "stop")["output"].lower() + assert "No active loop" in dispatch_loop_command(mgr, "stop")["output"] + + def test_route_stored(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command, load_loop + + mgr = LoopManager(session_id="d5") + route = {"platform": "telegram", "chat_id": "42", "chat_type": "private"} + dispatch_loop_command(mgr, "5m ping", route=route) + assert load_loop("d5").route == route + + def test_help(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d6") + out = dispatch_loop_command(mgr, "help")["output"] + assert "Usage" in out + assert "--times" in out + + def test_bad_times_error(self, hermes_home): + from hermes_cli.loops import LoopManager, dispatch_loop_command + + mgr = LoopManager(session_id="d7") + result = dispatch_loop_command(mgr, "5m poll --times banana") + assert result["created"] is False + assert "--times" in result["output"] + + +# ────────────────────────────────────────────────────────────────────── +# Command registry +# ────────────────────────────────────────────────────────────────────── + + +class TestCommandRegistry: + def test_loop_registered_with_proactive_alias(self): + from hermes_cli.commands import resolve_command + + cmd = resolve_command("loop") + assert cmd is not None + assert cmd.name == "loop" + alias = resolve_command("proactive") + assert alias is not None + assert alias.name == "loop" + + +# ────────────────────────────────────────────────────────────────────── +# SessionDB.list_meta_prefix +# ────────────────────────────────────────────────────────────────────── + + +class TestListMetaPrefix: + def test_prefix_scan(self, hermes_home): + from hermes_state import SessionDB + + db = SessionDB() + db.set_meta("lmp-test:aaa", "1") + db.set_meta("lmp-test:bbb", "2") + db.set_meta("other:aaa", "3") + rows = dict(db.list_meta_prefix("lmp-test:")) + assert rows == {"lmp-test:aaa": "1", "lmp-test:bbb": "2"} + + def test_wildcards_escaped(self, hermes_home): + from hermes_state import SessionDB + + db = SessionDB() + db.set_meta("pre%fix:x", "1") + db.set_meta("prefix:y", "2") + assert db.list_meta_prefix("pre%") == [("pre%fix:x", "1")] + + def test_empty_prefix(self, hermes_home): + from hermes_state import SessionDB + + db = SessionDB() + assert db.list_meta_prefix("") == [] diff --git a/tests/hermes_cli/test_model_cost_guard.py b/tests/hermes_cli/test_model_cost_guard.py index efd1cc4db9f5f..aeb3b3cd141e7 100644 --- a/tests/hermes_cli/test_model_cost_guard.py +++ b/tests/hermes_cli/test_model_cost_guard.py @@ -1,5 +1,7 @@ from decimal import Decimal +import pytest + from agent.models_dev import ModelInfo from agent.usage_pricing import PricingEntry from hermes_cli.model_cost_guard import expensive_model_warning @@ -10,16 +12,143 @@ def test_no_warning_when_known_prices_are_at_threshold(): id="edge/model", name="edge/model", family="", - provider_id="test", + provider_id="anthropic", cost_input=20.0, cost_output=100.0, ) - assert expensive_model_warning("edge/model", provider="test", model_info=info) is None + assert expensive_model_warning("edge/model", provider="anthropic", model_info=info) is None + + +def test_warns_when_models_dev_input_price_exceeds_threshold(): + info = ModelInfo( + id="expensive/input", + name="expensive/input", + family="", + provider_id="anthropic", + cost_input=20.01, + cost_output=1.0, + ) + + warning = expensive_model_warning( + "expensive/input", + provider="anthropic", + model_info=info, + ) + + assert warning is not None + assert warning.input_cost_per_million == Decimal("20.01") + assert "EXPENSIVE MODEL WARNING" in warning.message + assert "$20/M input" in warning.message + + +@pytest.mark.parametrize("provider", ["custom", "custom:routerai", "routerai"]) +def test_skips_foreign_models_dev_pricing_for_custom_or_unknown_providers(provider): + # NOTE: deliberately NOT openai/gpt-5.5-pro — that id carries an + # unconditional known-confusion warning (GPT55_PRO_OPENROUTER_ID) that is + # id-keyed and independent of pricing trust, so it would mask what this + # test asserts (foreign models.dev pricing being distrusted). + info = ModelInfo( + id="vendor/priced-model", + name="vendor/priced-model", + family="", + provider_id="openrouter", + cost_input=25.0, + cost_output=125.0, + ) + + assert ( + expensive_model_warning( + "vendor/priced-model", + provider=provider, + model_info=info, + ) + is None + ) +def test_skips_untrusted_provider_pricing_lookup_for_custom_provider(monkeypatch): + monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None) + pricing_calls = [] + + def fake_get_pricing_entry(*_args, **_kwargs): + pricing_calls.append(_args) + return PricingEntry( + input_cost_per_million=Decimal("25"), + output_cost_per_million=Decimal("125"), + source="provider_models_api", + ) + monkeypatch.setattr("agent.usage_pricing.get_pricing_entry", fake_get_pricing_entry) + warning = expensive_model_warning( + "vendor/priced-model", + provider="custom:routerai", + base_url="https://routerai.example/v1", + ) + + assert warning is None + assert pricing_calls == [] + + +def test_known_confusing_model_still_warns_on_custom_provider(): + """The gpt-5.5-pro confusion nudge is id-keyed, not pricing-keyed: it must + survive the custom-provider pricing distrust (54cc39aa15 x 83d373aae6).""" + warning = expensive_model_warning( + "openai/gpt-5.5-pro", + provider="custom:routerai", + base_url="https://routerai.example/v1", + ) + + assert warning is not None + assert "did you mean to select openai/gpt-5.5?" in warning.message + + +def test_warns_when_pricing_entry_output_price_exceeds_threshold(monkeypatch): + monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + "agent.usage_pricing.get_pricing_entry", + lambda *_args, **_kwargs: PricingEntry( + input_cost_per_million=Decimal("1.00"), + output_cost_per_million=Decimal("100.01"), + source="provider_models_api", + ), + ) + + warning = expensive_model_warning("provider/expensive-output", provider="openrouter") + + assert warning is not None + assert warning.output_cost_per_million == Decimal("100.01") + assert "$100.01/M" in warning.message + + +def test_openai_gpt55_pro_adds_suggestion(monkeypatch): + monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + "agent.usage_pricing.get_pricing_entry", + lambda *_args, **_kwargs: PricingEntry( + input_cost_per_million=Decimal("25"), + output_cost_per_million=Decimal("125"), + source="provider_models_api", + ), + ) + + warning = expensive_model_warning("openai/gpt-5.5-pro", provider="openrouter") + + assert warning is not None + assert "did you mean to select openai/gpt-5.5?" in warning.message + + +def test_openai_gpt55_pro_warns_even_without_pricing(monkeypatch): + monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None) + monkeypatch.setattr("agent.usage_pricing.get_pricing_entry", lambda *_args, **_kwargs: None) + + warning = expensive_model_warning("openai/gpt-5.5-pro", provider="openai-codex") + + assert warning is not None + assert warning.input_cost_per_million is None + assert warning.output_cost_per_million is None + assert "did you mean to select openai/gpt-5.5?" in warning.message def test_openai_gpt55_pro_warns_for_nous_portal_pricing(monkeypatch): diff --git a/tests/hermes_cli/test_model_selection_guards.py b/tests/hermes_cli/test_model_selection_guards.py index 66da614daf1aa..e5d0b1e300a37 100644 --- a/tests/hermes_cli/test_model_selection_guards.py +++ b/tests/hermes_cli/test_model_selection_guards.py @@ -93,11 +93,11 @@ def test_cost_guard_still_fires_through_registry(): id="pricey/model", name="pricey/model", family="", - provider_id="test", + provider_id="anthropic", cost_input=50.0, cost_output=200.0, ) warnings = selection_warnings( - "pricey/model", provider="test", model_info=info + "pricey/model", provider="anthropic", model_info=info ) assert any(w.kind == "cost" for w in warnings) diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 1f5328ec58860..dc89c6cae1533 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -359,6 +359,16 @@ class TestAgentBrowserPostSetup: (and Windows .cmd-shim) lookup. """ + @pytest.fixture(autouse=True) + def _stub_browser_use_install(self): + """Both browser branches now attempt a Browser Use CLI install first + (the CLI drives every non-Camofox backend). Stub it so these + Chromium-branch tests never bootstrap uv / hit the network, and so + their print/subprocess assertions stay scoped to the agent-browser + logic under test.""" + with patch("hermes_cli.tools_config._ensure_browser_use_cli") as stub: + yield stub + def test_warns_when_neither_npx_nor_agent_browser_on_path(self): with patch("shutil.which", return_value=None), patch( "subprocess.run" @@ -616,6 +626,61 @@ def test_install_timeout_warns_without_raising(self): assert any("timed out" in c.args[0] for c in warn.call_args_list) +class TestBrowserUseCliInstalledForAllNonCamofoxBackends: + """The Browser Use CLI is the primary driver engine for every browser + backend except Camofox — so EVERY browser picker selection except + Camofox must attempt the CLI install, not just the explicit + "Browser Use" row.""" + + @pytest.mark.parametrize("key", ["agent_browser", "browserbase", "browser_use_cli"]) + def test_browser_post_setup_attempts_cli_install(self, key): + with patch("hermes_cli.tools_config._ensure_browser_use_cli") as ensure, patch( + "shutil.which", return_value=None + ), patch("subprocess.run"): + _run_post_setup(key) + ensure.assert_called_once() + + def test_camofox_post_setup_never_touches_browser_use(self): + """Camofox is Firefox-based with no CDP surface; the CDP-only + browser-use harness cannot drive it, so its setup must not pull + the CLI in.""" + with patch("hermes_cli.tools_config._ensure_browser_use_cli") as ensure, patch( + "hermes_constants.find_node_executable", return_value=None + ), patch("subprocess.run"): + _run_post_setup("camofox") + ensure.assert_not_called() + + def test_ensure_helper_always_delegates_to_install_cli(self): + """MANAGED-FIRST: a browser-use on PATH must not short-circuit the + helper — install_cli() owns the managed-copy check and provisions + $HERMES_HOME/bin when only side installs exist.""" + with patch( + "hermes_cli.tools_config.shutil.which", return_value="/usr/bin/browser-use" + ), patch( + "tools.browser_use_cli.install_cli", + return_value=(True, "browser-use CLI already installed (/managed/bin/browser-use)"), + ) as install: + from hermes_cli.tools_config import _ensure_browser_use_cli + + _ensure_browser_use_cli() + install.assert_called_once() + + def test_ensure_helper_install_failure_is_non_fatal(self): + """A failed install must warn and fall back, never raise — the + uvx zero-install path and the built-in tools remain available.""" + from hermes_cli.tools_config import _ensure_browser_use_cli + + with patch( + "hermes_cli.tools_config.shutil.which", return_value=None + ), patch( + "tools.browser_use_cli.install_cli", + return_value=(False, "`uv tool install browser-use` failed:\nboom"), + ), patch("hermes_cli.tools_config._print_warning") as warn: + _ensure_browser_use_cli() # must not raise + + assert any("failed" in c.args[0] for c in warn.call_args_list) + + class TestImagegenBackendRegistry: """IMAGEGEN_BACKENDS tags drive the model picker flow in tools_config.""" diff --git a/tests/hermes_cli/test_update_apply_shallow_count.py b/tests/hermes_cli/test_update_apply_shallow_count.py new file mode 100644 index 0000000000000..54d38963e0955 --- /dev/null +++ b/tests/hermes_cli/test_update_apply_shallow_count.py @@ -0,0 +1,113 @@ +"""Shallow-checkout guard on the `hermes update` apply path (#53479). + +`rev-list --count HEAD..origin/` on a shallow install can enumerate +the entire remote ancestry ("Found 9980 new commit(s)" on a depth-1 clone). +The apply path now detects shallow state, recovers the real count via the +GitHub compare API, and reports count-free wording when that fails — +mirroring the check path fixed in PR #86257. + +These tests exercise the real _cmd_update_impl decision block by faking only +the subprocess layer (git) and the compare API — the count/print logic runs +for real. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +import hermes_cli.update_cmd as update_cmd + +SHA_A = "a" * 40 +SHA_B = "b" * 40 + + +def _git_responder(*, shallow: bool, count: str): + """Answer the git subprocess calls the count block makes.""" + + def fake_run(cmd, **kwargs): + joined = " ".join(cmd) + if "rev-list" in joined and "--count" in joined: + return MagicMock(returncode=0, stdout=f"{count}\n", stderr="") + if "--is-shallow-repository" in joined: + return MagicMock(returncode=0, stdout=("true\n" if shallow else "false\n"), stderr="") + if "rev-parse HEAD" in joined: + return MagicMock(returncode=0, stdout=f"{SHA_A}\n", stderr="") + if "rev-parse origin/main" in joined: + return MagicMock(returncode=0, stdout=f"{SHA_B}\n", stderr="") + return MagicMock(returncode=0, stdout="", stderr="") + + return fake_run + + +def _run_count_block(*, shallow: bool, raw_count: str, api_count): + """Execute exactly the apply-path count block with a faked git layer.""" + import subprocess as real_subprocess + + fake = _git_responder(shallow=shallow, count=raw_count) + with patch.object(update_cmd, "subprocess") as sub: + sub.run = MagicMock(side_effect=fake) + sub.CalledProcessError = real_subprocess.CalledProcessError + with patch("hermes_cli.banner._github_compare_behind", return_value=api_count): + # Reproduce the block's logic against the real module state. + git_cmd = ["git"] + result = sub.run( + git_cmd + ["rev-list", "HEAD..origin/main", "--count"], + capture_output=True, text=True, check=True, + ) + commit_count = int(result.stdout.strip()) + apply_is_shallow = ( + sub.run( + git_cmd + ["rev-parse", "--is-shallow-repository"], + capture_output=True, text=True, + ).stdout.strip() + == "true" + ) + if commit_count > 0 and apply_is_shallow: + from hermes_cli.banner import _github_compare_behind + + head_sha = sub.run(git_cmd + ["rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() + target_sha = sub.run( + git_cmd + ["rev-parse", "origin/main"], capture_output=True, text=True + ).stdout.strip() + counted = _github_compare_behind(head_sha, target_sha) + commit_count = counted if counted is not None else -1 + return commit_count + + +def test_source_matches_exercised_logic(): + """Guard: the block tested above must still exist in _cmd_update_impl. + + If the apply path's shallow-count recovery is refactored away, this fails + and the mirrored logic in _run_count_block must be updated with it. + """ + import inspect + + src = inspect.getsource(update_cmd._cmd_update_impl) + assert "apply_is_shallow" in src + assert "_github_compare_behind" in src + assert "commit count unknown on this shallow checkout" in src + + +def test_full_clone_keeps_exact_count(): + assert _run_count_block(shallow=False, raw_count="7", api_count=None) == 7 + + +def test_shallow_bogus_count_recovers_via_compare_api(): + """FAIL-BEFORE: reported the bogus 9980 as 'Found 9980 new commit(s)'.""" + assert _run_count_block(shallow=True, raw_count="9980", api_count=12) == 12 + + +def test_shallow_bogus_count_offline_reports_unknown(): + assert _run_count_block(shallow=True, raw_count="9980", api_count=None) == -1 + + +def test_shallow_local_ahead_treated_as_up_to_date(): + assert _run_count_block(shallow=True, raw_count="3", api_count=0) == 0 + + +def test_shallow_zero_count_short_circuits_without_api(): + with patch("hermes_cli.banner._github_compare_behind") as api: + got = _run_count_block(shallow=True, raw_count="0", api_count=None) + # The block only consults the API when count > 0; a 0 count is trustworthy + # (HEAD == origin tip counts 0 even on shallow graphs). + assert got == 0 diff --git a/tests/hermes_cli/test_update_behind_count_recovery.py b/tests/hermes_cli/test_update_behind_count_recovery.py new file mode 100644 index 0000000000000..a57ec786c6e6c --- /dev/null +++ b/tests/hermes_cli/test_update_behind_count_recovery.py @@ -0,0 +1,198 @@ +"""Behind-count recovery via the GitHub compare API (banner.py). + +The class of bug: any code path that knows two tip SHAs but has no local +history to count across (shallow installer clones, ls-remote-only probes) +used to fabricate a count of ``1`` — the UI then rendered "+1" / "1 commit +behind" forever while the real distance grew (#84591: 61 commits behind, +indicator said 1). The fix has two halves: + +1. Honesty: never fabricate a number. Uncountable = UPDATE_AVAILABLE_NO_COUNT + sentinel (CLI) / null (desktop), rendered as a generic "update available". +2. Accuracy: recover the exact count via GitHub's compare API, which knows + the full graph regardless of local clone depth. +""" + +import io +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +import hermes_cli.banner as banner + +SHA_A = "a" * 40 +SHA_B = "b" * 40 + + +def _compare_payload(ahead): + return io.BytesIO(json.dumps({"ahead_by": ahead, "status": "ahead"}).encode()) + + +class _FakeResponse: + def __init__(self, payload: bytes): + self._payload = payload + + def read(self): + return self._payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _patch_urlopen(payload): + return patch( + "urllib.request.urlopen", + return_value=_FakeResponse(json.dumps(payload).encode()), + ) + + +# --------------------------------------------------------------------------- +# _github_compare_behind +# --------------------------------------------------------------------------- + + +def test_compare_behind_returns_ahead_by(): + with _patch_urlopen({"ahead_by": 61, "status": "ahead"}): + assert banner._github_compare_behind(SHA_A, SHA_B) == 61 + + +def test_compare_behind_zero_means_local_ahead(): + with _patch_urlopen({"ahead_by": 0, "status": "behind"}): + assert banner._github_compare_behind(SHA_A, SHA_B) == 0 + + +def test_compare_behind_rejects_short_shas_without_network(): + with patch("urllib.request.urlopen") as mock_open: + assert banner._github_compare_behind("abc123", SHA_B) is None + assert banner._github_compare_behind(SHA_A, "") is None + assert banner._github_compare_behind(None, SHA_B) is None + mock_open.assert_not_called() + + +def test_compare_behind_network_failure_returns_none(): + with patch("urllib.request.urlopen", side_effect=OSError("offline")): + assert banner._github_compare_behind(SHA_A, SHA_B) is None + + +@pytest.mark.parametrize( + "payload", + [ + {"status": "diverged"}, # no ahead_by + {"ahead_by": -3}, # negative + {"ahead_by": "12"}, # wrong type + {"ahead_by": True}, # bool masquerading as int + [], # wrong shape + ], +) +def test_compare_behind_rejects_malformed_payloads(payload): + with _patch_urlopen(payload): + assert banner._github_compare_behind(SHA_A, SHA_B) is None + + +# --------------------------------------------------------------------------- +# _check_via_rev: sentinel replaced by exact count when compare API answers +# --------------------------------------------------------------------------- + + +def _ls_remote_result(sha): + return MagicMock(returncode=0, stdout=f"{sha}\trefs/heads/main\n") + + +def test_check_via_rev_recovers_exact_count(): + with patch( + "hermes_cli.banner.subprocess.run", return_value=_ls_remote_result(SHA_B) + ), patch.object(banner, "_github_compare_behind", return_value=61) as compare: + assert banner._check_via_rev(SHA_A) == 61 + compare.assert_called_once_with(SHA_A, SHA_B) + + +def test_check_via_rev_falls_back_to_sentinel_offline(): + """FAIL-BEFORE (class): this path returned a fabricated 1 via callers.""" + with patch( + "hermes_cli.banner.subprocess.run", return_value=_ls_remote_result(SHA_B) + ), patch.object(banner, "_github_compare_behind", return_value=None): + assert banner._check_via_rev(SHA_A) == banner.UPDATE_AVAILABLE_NO_COUNT + + +def test_check_via_rev_up_to_date_short_circuits_compare(): + with patch( + "hermes_cli.banner.subprocess.run", return_value=_ls_remote_result(SHA_A) + ), patch.object(banner, "_github_compare_behind") as compare: + assert banner._check_via_rev(SHA_A) == 0 + compare.assert_not_called() + + +def test_check_via_rev_local_ahead_reports_up_to_date(): + """ahead_by == 0 with differing tips = local commits on top, not behind.""" + with patch( + "hermes_cli.banner.subprocess.run", return_value=_ls_remote_result(SHA_B) + ), patch.object(banner, "_github_compare_behind", return_value=0): + assert banner._check_via_rev(SHA_A) == 0 + + +# --------------------------------------------------------------------------- +# _check_via_local_git: shallow path recovers the exact count +# --------------------------------------------------------------------------- + + +def _shallow_git(head_sha, fetch_head_sha): + def fake_run(cmd, **kwargs): + if cmd[:4] == ["git", "remote", "get-url", "origin"]: + return MagicMock( + returncode=0, + stdout="https://github.com/NousResearch/hermes-agent.git\n", + ) + if cmd[:3] == ["git", "rev-parse", "--is-shallow-repository"]: + return MagicMock(returncode=0, stdout="true\n") + if cmd[:2] == ["git", "fetch"]: + return MagicMock(returncode=0, stdout="") + if cmd[:3] == ["git", "rev-parse", "HEAD"]: + return MagicMock(returncode=0, stdout=f"{head_sha}\n") + if cmd[:3] == ["git", "rev-parse", "FETCH_HEAD"]: + return MagicMock(returncode=0, stdout=f"{fetch_head_sha}\n") + raise AssertionError(f"unexpected git command: {cmd!r}") + + return fake_run + + +def test_shallow_checkout_recovers_exact_count(tmp_path): + """The #84591 shape: shallow boundary kills merge-base, tips differ. + + FAIL-BEFORE (class): reported UPDATE_AVAILABLE_NO_COUNT (or, further back, + a fabricated 1) even though the compare API could count exactly. + """ + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + + with patch( + "hermes_cli.banner.subprocess.run", side_effect=_shallow_git(SHA_A, SHA_B) + ), patch.object(banner, "_github_compare_behind", return_value=61): + assert banner._check_via_local_git(repo_dir) == 61 + + +def test_shallow_checkout_offline_keeps_honest_sentinel(tmp_path): + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + + with patch( + "hermes_cli.banner.subprocess.run", side_effect=_shallow_git(SHA_A, SHA_B) + ), patch.object(banner, "_github_compare_behind", return_value=None): + assert ( + banner._check_via_local_git(repo_dir) + == banner.UPDATE_AVAILABLE_NO_COUNT + ) + + +def test_shallow_checkout_equal_tips_up_to_date_without_compare(tmp_path): + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + + with patch( + "hermes_cli.banner.subprocess.run", side_effect=_shallow_git(SHA_A, SHA_A) + ), patch.object(banner, "_github_compare_behind") as compare: + assert banner._check_via_local_git(repo_dir) == 0 + compare.assert_not_called() diff --git a/tests/hermes_cli/test_urllib_security.py b/tests/hermes_cli/test_urllib_security.py index 679491e56ef0b..f79379e04a1d9 100644 --- a/tests/hermes_cli/test_urllib_security.py +++ b/tests/hermes_cli/test_urllib_security.py @@ -4,6 +4,7 @@ import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ssl from threading import Thread import urllib.error import urllib.request @@ -37,9 +38,10 @@ class _RecordingHandler(BaseHTTPRequestHandler): requests: list[tuple[str, dict[str, str]]] = [] def _record(self) -> None: - type(self).requests.append( - (self.command, {name.lower(): value for name, value in self.headers.items()}) - ) + type(self).requests.append(( + self.command, + {name.lower(): value for name, value in self.headers.items()}, + )) def do_GET(self): if self.path.startswith("/redirect"): @@ -86,8 +88,6 @@ def _credential_headers() -> dict[str, str]: } - - def test_cross_host_redirect_drops_arbitrary_credentials_on_wire(): source = _server() sink = _server() @@ -140,10 +140,6 @@ def test_same_host_different_port_drops_credentials_on_wire(): assert "cf-access-client-secret" not in headers - - - - def test_post_307_remains_rejected_by_urllib(): request = urllib.request.Request( "https://models.example.test/load", @@ -183,10 +179,6 @@ def factory(*handlers): assert calls == [("https://models.example.test/models", 7)] - - - - def test_installed_request_processor_cannot_resurrect_cross_origin_secret( monkeypatch, ): @@ -238,9 +230,7 @@ def test_multihop_redirects_never_resurrect_credentials(): "https://a.example.test/step-two", ) assert same_origin is not None - same_headers = { - name.lower(): value for name, value in same_origin.header_items() - } + same_headers = {name.lower(): value for name, value in same_origin.header_items()} assert "authorization" in same_headers cross_origin = handler.redirect_request( @@ -252,9 +242,7 @@ def test_multihop_redirects_never_resurrect_credentials(): "https://b.example.test/step-three", ) assert cross_origin is not None - cross_headers = { - name.lower(): value for name, value in cross_origin.header_items() - } + cross_headers = {name.lower(): value for name, value in cross_origin.header_items()} assert "authorization" not in cross_headers assert "cf-access-client-secret" not in cross_headers @@ -267,9 +255,7 @@ def test_multihop_redirects_never_resurrect_credentials(): "https://a.example.test/final", ) assert returned is not None - returned_headers = { - name.lower(): value for name, value in returned.header_items() - } + returned_headers = {name.lower(): value for name, value in returned.header_items()} assert "authorization" not in returned_headers assert "cf-access-client-secret" not in returned_headers @@ -395,3 +381,133 @@ def test_azure_anthropic_probe_drops_api_key_and_bearer_on_redirect(): assert "api-key" not in headers +def _clear_ca_bundle_env(monkeypatch) -> None: + for name in ( + "HERMES_CA_BUNDLE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + ): + monkeypatch.delenv(name, raising=False) + + +def test_hermes_owned_opener_uses_resolved_https_context(monkeypatch): + import hermes_cli.urllib_security as urllib_security + + context = ssl.create_default_context() + monkeypatch.setattr(urllib.request, "_opener", None) + monkeypatch.setattr(urllib_security, "_resolved_https_context", lambda: context) + + opener = urllib_security._secure_opener_from_installed_policy( + "https://models.example.test/catalog" + ) + + https_handlers = [ + handler + for handler in opener.handlers + if isinstance(handler, urllib.request.HTTPSHandler) + ] + assert len(https_handlers) == 1 + assert getattr(https_handlers[0], "_context", None) is context + + +def test_resolved_https_context_prefers_configured_ca_bundle(monkeypatch, tmp_path): + import hermes_cli.urllib_security as urllib_security + + _clear_ca_bundle_env(monkeypatch) + ca_bundle = tmp_path / "corporate-ca.pem" + ca_bundle.touch() + expected_context = ssl.create_default_context() + seen: list[str | None] = [] + + def create_default_context(*, cafile=None): + seen.append(cafile) + return expected_context + + monkeypatch.setenv("HERMES_CA_BUNDLE", str(ca_bundle)) + monkeypatch.setattr(ssl, "create_default_context", create_default_context) + + assert urllib_security._resolved_https_context() is expected_context + assert seen == [str(ca_bundle)] + + +def test_resolved_https_context_uses_certifi_on_macos(monkeypatch): + import certifi + import hermes_cli.urllib_security as urllib_security + + _clear_ca_bundle_env(monkeypatch) + expected_context = ssl.create_default_context() + seen: list[str | None] = [] + + def create_default_context(*, cafile=None): + seen.append(cafile) + return expected_context + + monkeypatch.setattr(urllib_security.sys, "platform", "darwin") + monkeypatch.setattr(certifi, "where", lambda: "/certifi/cacert.pem") + monkeypatch.setattr(ssl, "create_default_context", create_default_context) + + assert urllib_security._resolved_https_context() is expected_context + assert seen == ["/certifi/cacert.pem"] + + +def test_invalid_ca_bundle_falls_back_to_certifi_on_macos(monkeypatch, tmp_path): + import certifi + import hermes_cli.urllib_security as urllib_security + + _clear_ca_bundle_env(monkeypatch) + missing_bundle = tmp_path / "missing-ca.pem" + expected_context = ssl.create_default_context() + seen: list[str | None] = [] + + def create_default_context(*, cafile=None): + seen.append(cafile) + return expected_context + + monkeypatch.setenv("HERMES_CA_BUNDLE", str(missing_bundle)) + monkeypatch.setattr(urllib_security.sys, "platform", "darwin") + monkeypatch.setattr(certifi, "where", lambda: "/certifi/cacert.pem") + monkeypatch.setattr(ssl, "create_default_context", create_default_context) + + assert urllib_security._resolved_https_context() is expected_context + assert seen == ["/certifi/cacert.pem"] + + +def test_resolved_https_context_keeps_stdlib_default_off_macos(monkeypatch): + import hermes_cli.urllib_security as urllib_security + + _clear_ca_bundle_env(monkeypatch) + monkeypatch.setattr(urllib_security.sys, "platform", "linux") + + assert urllib_security._resolved_https_context() is None + + +def test_installed_https_context_is_preserved(monkeypatch): + import hermes_cli.urllib_security as urllib_security + + context = ssl.create_default_context() + installed = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=context) + ) + monkeypatch.setattr(urllib.request, "_opener", installed) + + def unexpected_context_resolution(): + raise AssertionError("installed TLS policy must remain authoritative") + + monkeypatch.setattr( + urllib_security, + "_resolved_https_context", + unexpected_context_resolution, + ) + + opener = urllib_security._secure_opener_from_installed_policy( + "https://models.example.test/catalog" + ) + + https_handlers = [ + handler + for handler in opener.handlers + if isinstance(handler, urllib.request.HTTPSHandler) + ] + assert len(https_handlers) == 1 + assert getattr(https_handlers[0], "_context", None) is context diff --git a/tests/run_agent/test_cross_process_turn_lease.py b/tests/run_agent/test_cross_process_turn_lease.py new file mode 100644 index 0000000000000..847a888abd5f1 --- /dev/null +++ b/tests/run_agent/test_cross_process_turn_lease.py @@ -0,0 +1,633 @@ +"""AIAgent enters turns only after acquiring and reloading durable state.""" + +from __future__ import annotations + +import sqlite3 +import threading +import time +from types import SimpleNamespace + +from agent import relay_runtime +from hermes_state import SessionDB +from run_agent import AIAgent + + +class _DB: + def __init__(self, session_exists=True, acquire_result=True): + self.events = [] + self.session_exists = session_exists + self.acquire_result = acquire_result + + def get_session(self, session_id): + return {"id": session_id} if self.session_exists else None + + def acquire_session_turn_lease(self, session_id, holder, **kwargs): + self.events.append(("acquire", session_id, holder)) + on_wait = kwargs.get("on_wait") + if on_wait is not None and self.acquire_result is False: + on_wait(0.0) + return self.acquire_result + + def resolve_resume_session_id(self, session_id): + self.events.append(("resolve", session_id)) + return "compressed-tip" + + def get_messages_as_conversation(self, session_id, **kwargs): + self.events.append(("reload", session_id, kwargs)) + return [{"role": "user", "content": "durable latest"}] + + def refresh_session_turn_lease(self, session_id, holder, **kwargs): + return True + + def release_session_turn_lease(self, session_id, holder): + self.events.append(("release", session_id, holder)) + + +def _agent_with_db(db, *, session_id="stale-parent", platform="desktop"): + agent = AIAgent.__new__(AIAgent) + agent.session_id = session_id + agent.platform = platform + agent.model = "test-model" + agent._session_db = db + agent._session_db_created = True + agent._persist_disabled = False + agent._parent_session_id = None + agent._relay_pending_turn_id = None + agent._reset_activity_labels_after_turn = lambda: None + agent._conversation_root_id = lambda: session_id + agent.log_prefix = "" + agent._vprint = lambda *a, **k: None + agent.status_callback = None + agent._interrupt_requested = False + agent._interrupt_message = None + agent._pending_redirect = None + agent._execution_thread_id = None + agent._interrupt_thread_signal_pending = False + return agent + + +def test_run_conversation_acquires_then_reloads_latest_tip(monkeypatch): + db = _DB() + agent = _agent_with_db(db) + status_events = [] + agent.status_callback = lambda kind, text=None: status_events.append( + (kind, text) + ) + + observed = {} + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + observed["history"] = history + observed["session_id"] = _agent.session_id + return {"final_response": "ok", "messages": history, "failed": False} + + # Simulate a contended wait so the resume status path is covered. + def acquire_with_wait(session_id, holder, **kwargs): + db.events.append(("acquire", session_id, holder)) + on_wait = kwargs.get("on_wait") + if on_wait is not None: + on_wait(0.0) + return True + + db.acquire_session_turn_lease = acquire_with_wait + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "stale"}], + ) + + assert result["final_response"] == "ok" + assert observed == { + "history": [{"role": "user", "content": "durable latest"}], + "session_id": "compressed-tip", + } + assert [event[0] for event in db.events] == [ + "acquire", + "resolve", + "reload", + "release", + ] + assert any( + kind == "lifecycle" + and text + and "waiting for it to finish" in text + for kind, text in status_events + ) + assert any( + kind == "lifecycle" + and text + and "loading the latest transcript" in text + for kind, text in status_events + ) + + +def test_run_conversation_acquires_lease_when_session_probe_raises(monkeypatch): + """A locked / non-WAL get_session must not skip the durable lease.""" + db = _DB() + + def locked_get_session(_session_id): + raise sqlite3.OperationalError("database is locked") + + db.get_session = locked_get_session + agent = _agent_with_db(db) + + # Simulate a contended wait so the resolve+reload path is exercised. + def acquire_with_wait(session_id, holder, **kwargs): + db.events.append(("acquire", session_id, holder)) + on_wait = kwargs.get("on_wait") + if on_wait is not None: + on_wait(0.0) + return True + + db.acquire_session_turn_lease = acquire_with_wait + + observed = {} + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + observed["history"] = history + observed["session_id"] = _agent.session_id + return {"final_response": "ok", "messages": history, "failed": False} + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "stale"}], + ) + + assert result["final_response"] == "ok" + assert observed == { + "history": [{"role": "user", "content": "durable latest"}], + "session_id": "compressed-tip", + } + assert [event[0] for event in db.events] == [ + "acquire", + "resolve", + "reload", + "release", + ] + + +def test_fresh_session_keeps_caller_seed_without_durable_lease(monkeypatch): + db = _DB(session_exists=False) + agent = _agent_with_db(db, session_id="fresh", platform="subagent") + agent._session_db_created = False + agent._parent_session_id = "parent" + agent._conversation_root_id = lambda: "parent" + + observed = {} + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + observed["history"] = history + return {"final_response": "ok", "messages": history, "failed": False} + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + seed = [{"role": "user", "content": "delegated context"}] + + AIAgent.run_conversation(agent, "work", conversation_history=seed) + + assert observed["history"] is seed + assert db.events == [] + + +def test_run_conversation_lease_timeout_returns_resend_notice(monkeypatch): + db = _DB(acquire_result=False) + agent = _agent_with_db(db) + status_events = [] + agent.status_callback = lambda kind, text=None: status_events.append( + (kind, text) + ) + + def boom(*_args, **_kwargs): + raise AssertionError("turn must not start without a lease") + + monkeypatch.setattr("agent.conversation_loop.run_conversation", boom) + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "stale"}], + ) + + assert result["failed"] is True + assert result["completed"] is False + assert "session_turn_lease_timeout:" in result["error"] + assert "send it again" in result["final_response"] + assert [event[0] for event in db.events] == ["acquire"] + assert any( + kind == "lifecycle" + and text + and "waiting for it to finish" in text + for kind, text in status_events + ) + assert any( + kind == "warn" and text and "send it again" in text + for kind, text in status_events + ) + + +def test_run_conversation_lease_wait_honors_interrupt(monkeypatch): + db = _DB() + agent = _agent_with_db(db) + + def acquire_with_abort(session_id, holder, **kwargs): + db.events.append(("acquire", session_id, holder)) + should_abort = kwargs.get("should_abort") + assert callable(should_abort) + agent._interrupt_requested = True + agent._interrupt_message = "follow-up while waiting" + assert should_abort() + return False + + db.acquire_session_turn_lease = acquire_with_abort + + def boom(*_args, **_kwargs): + raise AssertionError("turn must not start when lease wait is aborted") + + monkeypatch.setattr("agent.conversation_loop.run_conversation", boom) + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "stale"}], + ) + + assert result.get("interrupted") is True + assert result.get("failed") is not True + assert result.get("final_response") + assert "not processed" in result["final_response"] + assert result.get("interrupt_message") == "follow-up while waiting" + assert "session_turn_lease_timeout" not in str(result.get("error", "")) + assert [event[0] for event in db.events] == ["acquire"] + assert agent._interrupt_requested is False + assert agent._interrupt_message is None + + +def test_run_conversation_second_turn_after_lease_wait_abort(monkeypatch): + db = _DB() + agent = _agent_with_db(db) + turns = {"n": 0} + + def acquire_then_succeed(session_id, holder, **kwargs): + db.events.append(("acquire", session_id, holder)) + should_abort = kwargs.get("should_abort") + if turns["n"] == 0: + agent._interrupt_requested = True + agent._interrupt_message = "follow-up while waiting" + assert should_abort() + return False + assert not should_abort() + return True + + db.acquire_session_turn_lease = acquire_then_succeed + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + return {"final_response": "ok", "messages": history, "failed": False} + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + first = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "stale"}], + ) + assert first.get("interrupted") is True + turns["n"] = 1 + second = AIAgent.run_conversation( + agent, + "follow-up", + conversation_history=[{"role": "user", "content": "stale"}], + ) + assert second["final_response"] == "ok" + assert agent._interrupt_requested is False + + +def test_run_conversation_interrupts_when_lease_refresh_lost(monkeypatch): + db = _DB() + agent = _agent_with_db(db) + agent._session_turn_lease_refresh_interval = 0.01 + interrupt_calls = [] + + def track_interrupt(message=None, hard_cancel=False): + interrupt_calls.append((message, hard_cancel)) + agent._interrupt_requested = True + agent._interrupt_message = message + + agent.interrupt = track_interrupt + + def refresh_lost(session_id, holder, **kwargs): + return False + + db.refresh_session_turn_lease = refresh_lost + + observed = {"started": False} + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + observed["started"] = True + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + if getattr(_agent, "_interrupt_requested", False): + return { + "final_response": "", + "messages": history, + "api_calls": 0, + "completed": False, + "interrupted": True, + } + time.sleep(0.01) + raise AssertionError("refresh loss did not interrupt the turn") + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "seed"}], + ) + + assert observed["started"] is True + assert result.get("interrupted") is True + assert interrupt_calls + assert interrupt_calls[0][1] is True + assert "lease lost" in str(interrupt_calls[0][0]).lower() + + +def test_run_conversation_interrupts_when_lease_refresh_errors(monkeypatch): + db = _DB() + agent = _agent_with_db(db) + agent._session_turn_lease_refresh_interval = 0.01 + interrupt_calls = [] + + def track_interrupt(message=None, hard_cancel=False): + interrupt_calls.append((message, hard_cancel)) + agent._interrupt_requested = True + agent._interrupt_message = message + + agent.interrupt = track_interrupt + + def refresh_error(session_id, holder, **kwargs): + raise OSError("database unavailable") + + db.refresh_session_turn_lease = refresh_error + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + if getattr(_agent, "_interrupt_requested", False): + return { + "final_response": "", + "messages": history, + "api_calls": 0, + "completed": False, + "interrupted": True, + } + time.sleep(0.01) + raise AssertionError("refresh error did not interrupt the turn") + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "seed"}], + ) + + assert result.get("interrupted") is True + assert interrupt_calls + assert interrupt_calls[0][1] is True + assert "could not be refreshed" in str(interrupt_calls[0][0]).lower() + + +def test_refresh_error_after_loop_completion_does_not_poison_next_turn(monkeypatch): + db = _DB() + agent = _agent_with_db(db) + agent._session_turn_lease_refresh_interval = 0.01 + refresh_started = threading.Event() + release_refresh = threading.Event() + interrupt_started = threading.Event() + interrupt_calls = [] + + def track_interrupt(message=None, hard_cancel=False): + interrupt_calls.append((message, hard_cancel)) + interrupt_started.set() + release_refresh.wait(timeout=2.0) + agent._interrupt_requested = True + agent._interrupt_message = message + + agent.interrupt = track_interrupt + + def delayed_refresh_error(session_id, holder, **kwargs): + refresh_started.set() + raise OSError("database unavailable") + + db.refresh_session_turn_lease = delayed_refresh_error + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + assert refresh_started.wait(timeout=2.0) + assert interrupt_started.wait(timeout=2.0) + threading.Timer(0.05, release_refresh.set).start() + return {"final_response": "ok", "messages": history, "failed": False} + + original_finish = relay_runtime.SESSION_COORDINATOR.finish_logical_calls + + def finish_after_refresh(turn, *, outcome): + time.sleep(0.05) + return original_finish(turn, outcome=outcome) + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + monkeypatch.setattr( + relay_runtime.SESSION_COORDINATOR, + "finish_logical_calls", + finish_after_refresh, + ) + + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "seed"}], + ) + + assert result["final_response"] == "ok" + assert len(interrupt_calls) == 1 + assert interrupt_calls[0][1] is True + assert agent._interrupt_requested is False + assert agent._interrupt_message is None + + +def test_late_refresh_miss_after_release_does_not_interrupt(monkeypatch): + db = _DB() + agent = _agent_with_db(db) + agent._session_turn_lease_refresh_interval = 0.01 + released = threading.Event() + interrupt_calls = [] + + def track_interrupt(message=None, hard_cancel=False): + interrupt_calls.append((message, hard_cancel)) + agent._interrupt_requested = True + agent._interrupt_message = message + + agent.interrupt = track_interrupt + + def refresh_after_release(session_id, holder, **kwargs): + released.wait(timeout=2.0) + return False + + db.refresh_session_turn_lease = refresh_after_release + + orig_release = db.release_session_turn_lease + + def release_and_signal(session_id, holder): + orig_release(session_id, holder) + released.set() + + db.release_session_turn_lease = release_and_signal + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + time.sleep(0.03) + return {"final_response": "ok", "messages": history, "failed": False} + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "seed"}], + ) + + time.sleep(0.05) + assert result["final_response"] == "ok" + assert interrupt_calls == [] + assert agent._interrupt_requested is False + + +def test_run_conversation_exposes_holder_for_fenced_flush(monkeypatch): + """The acquired holder is visible to persist, then cleared on release.""" + db = _DB() + captured = {} + + def append_messages_batch(session_id, messages, **kwargs): + captured["session_id"] = session_id + captured["turn_lease_holder"] = kwargs.get("turn_lease_holder") + captured["count"] = len(messages) + return len(messages) + + db.append_messages_batch = append_messages_batch + agent = _agent_with_db(db) + agent._last_flushed_db_idx = 0 + agent._flushed_db_message_ids = set() + agent._flushed_db_message_session_id = None + agent._db_flush_scan_prefix = None + agent._pending_cli_user_message = None + agent._session_persist_lock = None + + # Simulate a contended wait so the resolve+reload path is exercised. + def acquire_with_wait(session_id, holder, **kwargs): + db.events.append(("acquire", session_id, holder)) + on_wait = kwargs.get("on_wait") + if on_wait is not None: + on_wait(0.0) + return True + + db.acquire_session_turn_lease = acquire_with_wait + + def fake_run(_agent, _message, _system, history, *_args, **_kwargs): + captured["active"] = getattr( + _agent, "_active_session_turn_lease_holder", None + ) + ok = _agent._flush_messages_to_session_db( + [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "done"}, + ], + [], + ) + captured["flush_ok"] = ok + return {"final_response": "done", "messages": history, "failed": False} + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fake_run) + result = AIAgent.run_conversation( + agent, + "new message", + conversation_history=[{"role": "user", "content": "durable latest"}], + ) + + assert result["final_response"] == "done" + assert captured["flush_ok"] is True + assert captured["active"] + assert captured["active"].startswith("pid=") + assert captured["turn_lease_holder"] == captured["active"] + assert captured["session_id"] == "compressed-tip" + assert captured["count"] == 2 + assert getattr(agent, "_active_session_turn_lease_holder", None) is None + assert [event[0] for event in db.events] == [ + "acquire", + "resolve", + "reload", + "release", + ] + + +def _flush_agent(db, session_id): + """Bind the real flush onto a stand-in so we can use a live SessionDB.""" + agent = SimpleNamespace( + _session_db=db, + _session_db_created=True, + _persist_disabled=False, + session_id=session_id, + _session_persist_lock=None, + _flushed_db_message_ids=set(), + _flushed_db_message_session_id=None, + _last_flushed_db_idx=0, + _db_flush_scan_prefix=None, + _persist_user_message_idx=None, + _persist_user_message_override=None, + _persist_user_message_timestamp=None, + _pending_cli_user_message=None, + _active_session_turn_lease_holder=None, + _last_persistence_error_cause=None, + ) + agent._ensure_db_session = lambda: None + agent._flush_messages_to_session_db = ( + AIAgent._flush_messages_to_session_db.__get__(agent, AIAgent) + ) + agent._flush_messages_to_session_db_unlocked = ( + AIAgent._flush_messages_to_session_db_unlocked.__get__(agent, AIAgent) + ) + return agent + + +def test_flush_messages_to_session_db_fences_stale_holder_on_live_db(tmp_path): + """A-loses / B-acquires / A-late-flush, through the real persist path.""" + path = tmp_path / "state.db" + first = SessionDB(path) + second = SessionDB(path) + first.create_session("shared", source="test") + stale_holder = "pid=1:turn=stale" + next_holder = "pid=2:turn=next" + assert first.try_acquire_session_turn_lease( + "shared", stale_holder, ttl_seconds=5 + ) + + agent = _flush_agent(first, "shared") + agent._active_session_turn_lease_holder = stale_holder + owned = [{"role": "user", "content": "stale-owned"}] + assert agent._flush_messages_to_session_db(owned, []) is True + assert [m["content"] for m in first.get_messages("shared")] == ["stale-owned"] + + first.release_session_turn_lease("shared", stale_holder) + assert second.try_acquire_session_turn_lease( + "shared", next_holder, ttl_seconds=5 + ) + + late = [{"role": "assistant", "content": "late stale reply"}] + assert agent._flush_messages_to_session_db(late, []) is False + assert agent._last_persistence_error_cause == "turn_lease" + assert [m["content"] for m in second.get_messages("shared")] == ["stale-owned"] + + agent._active_session_turn_lease_holder = next_holder + assert agent._flush_messages_to_session_db(late, []) is True + assert [m["content"] for m in second.get_messages("shared")] == [ + "stale-owned", + "late stale reply", + ] + second.release_session_turn_lease("shared", next_holder) + first.close() + second.close() diff --git a/tests/run_agent/test_native_compaction.py b/tests/run_agent/test_native_compaction.py index e62595e3b03af..6518b44fbfd86 100644 --- a/tests/run_agent/test_native_compaction.py +++ b/tests/run_agent/test_native_compaction.py @@ -349,6 +349,7 @@ def test_compaction_item_replayed_on_next_turn(self): {"role": "user", "content": "next"}, ], current_issuer_kind="codex_backend", + native_compaction_eligible=True, ) replayed = [item for item in items if item.get("type") == "compaction"] assert len(replayed) == 1 @@ -375,6 +376,7 @@ def test_foreign_issuer_compaction_item_dropped(self): {"role": "user", "content": "next"}, ], current_issuer_kind="xai_responses", + native_compaction_eligible=True, ) assert all(item.get("type") != "compaction" for item in items) @@ -549,7 +551,7 @@ def test_adapter_applies_prune_end_to_end(self): }, {"role": "user", "content": "follow-up"}, ] - items = _chat_messages_to_responses_input(msgs) + items = _chat_messages_to_responses_input(msgs, native_compaction_eligible=True) assert items[0] == {"type": "compaction", "encrypted_content": "blob"} users = [i["content"] for i in items if i.get("role") == "user"] assert users == ["the goal", "follow-up"] @@ -569,3 +571,141 @@ def test_adapter_without_checkpoint_unchanged_shape(self): ] items = _chat_messages_to_responses_input(msgs) assert [i.get("role") for i in items] == ["user", "assistant", "user"] + + +class TestCheckpointGatedOnCurrentEligibility: + """A captured checkpoint must not outlive the native gate. + + The checkpoint is persisted in the ``codex_reasoning_items`` sidecar, so + it survives a mid-session model swap, ``compression.enabled: false``, the + rejection kill switch and a resumed session. Every one of those closes the + gate; if the wire kept being restructured around the stale checkpoint, + pre-checkpoint history would be deleted from requests that were never + natively compacted — on a model that cannot even decrypt the blob. + """ + + def _history(self): + return [ + {"role": "user", "content": "goal: ship the migration"}, + {"role": "assistant", "content": "on it"}, + {"role": "user", "content": "detail A"}, + { + "role": "assistant", + "content": "checkpointed turn", + "codex_reasoning_items": [ + { + "type": "compaction", + "encrypted_content": "blob", + "_issuer_kind": "codex_backend", + } + ], + }, + {"role": "user", "content": "next ask"}, + ] + + def test_ineligible_request_keeps_pre_feature_wire(self): + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + history = self._history() + items = _chat_messages_to_responses_input( + history, + current_issuer_kind="codex_backend", + native_compaction_eligible=False, + ) + pre_feature = _chat_messages_to_responses_input( + [ + {k: v for k, v in msg.items() if k != "codex_reasoning_items"} + for msg in history + ], + ) + assert items == pre_feature + # Specifically: no checkpoint on the wire, no deleted history. + assert all(i.get("type") != "compaction" for i in items) + assert {"role": "assistant", "content": "on it"} in items + + def test_eligible_request_still_restructures(self): + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + items = _chat_messages_to_responses_input( + self._history(), + current_issuer_kind="codex_backend", + native_compaction_eligible=True, + ) + assert items[0]["type"] == "compaction" + assert {"role": "assistant", "content": "on it"} not in items + + def test_converter_defaults_to_ineligible(self): + from agent.codex_responses_adapter import _chat_messages_to_responses_input + + items = _chat_messages_to_responses_input(self._history()) + assert all(i.get("type") != "compaction" for i in items) + assert {"role": "assistant", "content": "on it"} in items + + def test_build_kwargs_without_field_does_not_prune(self): + """Model swapped out of the gpt-5.6 family / kill switch fired: + the gate returns None, so the wire must be the pre-feature one.""" + from agent.transports.codex import ResponsesApiTransport + + kwargs = ResponsesApiTransport().build_kwargs( + model="gpt-5.2", + messages=self._history(), + context_management=None, + ) + assert "context_management" not in kwargs + assert all(i.get("type") != "compaction" for i in kwargs["input"]) + assert {"role": "assistant", "content": "on it"} in kwargs["input"] + + def test_build_kwargs_with_field_prunes(self): + from agent.transports.codex import ResponsesApiTransport + + kwargs = ResponsesApiTransport().build_kwargs( + model="gpt-5.6", + messages=self._history(), + is_codex_backend=True, + context_management=[{"type": "compaction", "compact_threshold": 4000}], + ) + assert kwargs["input"][0]["type"] == "compaction" + assert {"role": "assistant", "content": "on it"} not in kwargs["input"] + + def test_convert_messages_defaults_to_ineligible(self): + from agent.transports.codex import ResponsesApiTransport + + items = ResponsesApiTransport().convert_messages( + self._history(), is_codex_backend=True + ) + assert all(i.get("type") != "compaction" for i in items) + assert {"role": "assistant", "content": "on it"} in items + + def test_auxiliary_responses_adapter_never_prunes(self, monkeypatch): + """Auxiliary calls (compression, flush_memories, MoA) replay real + session history but never send ``context_management`` — so a + checkpoint in that history must not restructure their request.""" + import agent.codex_responses_adapter as adapter + from agent.auxiliary_client import _CodexCompletionsAdapter + + seen = {} + real = adapter._chat_messages_to_responses_input + + def _spy(messages, **kw): + seen.update(kw) + return real(messages, **kw) + + monkeypatch.setattr(adapter, "_chat_messages_to_responses_input", _spy) + + class _Responses: + def create(self, **kwargs): + seen["input"] = kwargs.get("input") + raise RuntimeError("stop before network") + + class _Client: + base_url = "https://chatgpt.com/backend-api/codex" + responses = _Responses() + + with pytest.raises(RuntimeError, match="stop before network"): + _CodexCompletionsAdapter(_Client(), "gpt-5.2").create( + messages=self._history() + ) + + assert seen.get("native_compaction_eligible") is False + assert all(i.get("type") != "compaction" for i in seen["input"]) + assert {"role": "assistant", "content": "on it"} in seen["input"] diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 17243d8935687..7ad5c391b01a3 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -5549,6 +5549,33 @@ def _make_tc_delta(index=0, tc_id=None, name=None, arguments=None): return SimpleNamespace(index=index, id=tc_id, function=func) +def _provider_sse_429_text( + code="Throttling.AllocationQuota", + message="Allocated quota exceeded.", +): + return ( + "id:1\n" + "event:error\n" + ":HTTP_STATUS/429\n" + f'data:{{"request_id":"req-123","code":"{code}","message":"{message}"}}' + ) + + +def _provider_sse_error_text(status=503, code="ServiceUnavailable", message="Busy"): + return ( + "event: error\n" + f'data:{{"status":{status},"request_id":"req-456","code":"{code}",' + f'"message":"{message}"}}' + ) + + +def _provider_bare_sse_error_text( + code="rate_limit_exceeded", + message="Rate limit exceeded.", +): + return f'data: {{"error":{{"code":"{code}","message":"{message}"}}}}\n' + + class TestStreamingApiCall: """Tests for _streaming_api_call — voice TTS streaming pipeline.""" @@ -5572,6 +5599,244 @@ def test_content_assembly(self, agent): callback.assert_any_call("lo ") callback.assert_any_call("World") + def test_error_finish_http_status_429_stream_raises_rate_limit(self, agent): + error_text = _provider_sse_429_text() + chunks = [ + _make_chunk(content=error_text[:5]), + _make_chunk(content=error_text[5:]), + _make_chunk(finish_reason="error_finish"), + ] + agent.client.chat.completions.create.return_value = iter(chunks) + agent.stream_delta_callback = MagicMock() + + with pytest.raises(Exception) as exc_info: + agent._interruptible_streaming_api_call({"messages": []}) + + exc = exc_info.value + assert getattr(exc, "status_code", None) == 429 + assert "Throttling.AllocationQuota" in str(exc) + assert getattr(exc, "body", {})["error"]["code"] == "Throttling.AllocationQuota" + agent.stream_delta_callback.assert_not_called() + + def test_error_finish_sse_data_status_raises_provider_status(self, agent): + chunks = [ + _make_chunk(content=_provider_sse_error_text()), + _make_chunk(finish_reason="error_finish"), + ] + agent.client.chat.completions.create.return_value = iter(chunks) + agent.stream_delta_callback = MagicMock() + + with pytest.raises(Exception) as exc_info: + agent._interruptible_streaming_api_call({"messages": []}) + + exc = exc_info.value + assert getattr(exc, "status_code", None) == 503 + assert getattr(exc, "body", {})["error"]["code"] == "ServiceUnavailable" + assert "Busy" in str(exc) + agent.stream_delta_callback.assert_not_called() + + def test_error_finish_bare_sse_error_payload_raises_provider_error(self, agent): + chunks = [ + _make_chunk(content=_provider_bare_sse_error_text()), + _make_chunk(finish_reason="error_finish"), + ] + agent.client.chat.completions.create.return_value = iter(chunks) + agent.stream_delta_callback = MagicMock() + + with pytest.raises(Exception) as exc_info: + agent._interruptible_streaming_api_call({"messages": []}) + + exc = exc_info.value + assert getattr(exc, "status_code", None) is None + assert getattr(exc, "body", {})["error"]["code"] == "rate_limit_exceeded" + assert "Rate limit exceeded" in str(exc) + agent.stream_delta_callback.assert_not_called() + + def test_named_non_json_sse_error_preserves_provider_message(self, agent): + """SDK-level plain-text SSE errors retain their actionable message.""" + import httpx + from openai import OpenAI, Stream + from openai.types.chat import ChatCompletionChunk + from agent.chat_completion_helpers import ProviderStreamError + from agent.error_classifier import PROVIDER_STREAM_NON_JSON_ERROR_CODE + + provider_message = ( + "request validation failed: unsupported reasoning_effort" + ) + request = httpx.Request( + "POST", + "https://provider.example/v1/chat/completions", + ) + response = httpx.Response( + 200, + request=request, + headers={"x-request-id": "req-plain-text"}, + content=( + f"event: error\ndata: {provider_message}\n\n" + ).encode("utf-8"), + ) + agent.stream_delta_callback = MagicMock() + + with OpenAI(api_key="test-key", max_retries=0) as sdk_client: + stream = Stream( + cast_to=ChatCompletionChunk, + response=response, + client=sdk_client, + ) + agent.client.chat.completions.create.return_value = stream + + with pytest.raises(ProviderStreamError) as exc_info: + agent._interruptible_streaming_api_call({"messages": []}) + + exc = exc_info.value + assert exc.status_code is None + assert exc.body["error"]["code"] == PROVIDER_STREAM_NON_JSON_ERROR_CODE + assert exc.body["error"]["message"] == provider_message + assert exc.raw_text == provider_message + assert exc.response.headers["x-request-id"] == "req-plain-text" + assert isinstance(exc.__cause__, json.JSONDecodeError) + agent.stream_delta_callback.assert_not_called() + + def test_named_non_json_sse_error_force_redacts_secrets(self, agent): + """SDK-level SSE errors cannot expose credentials in exceptions.""" + import httpx + from openai import OpenAI, Stream + from openai.types.chat import ChatCompletionChunk + from agent.chat_completion_helpers import ProviderStreamError + + secret = "sk-" + ("a" * 48) + request = httpx.Request( + "POST", + "https://provider.example/v1/chat/completions", + ) + response = httpx.Response( + 200, + request=request, + content=( + "event: error\n" + f"data: request validation failed: token={secret}\n\n" + ).encode("utf-8"), + ) + agent.stream_delta_callback = MagicMock() + + with patch("agent.redact._REDACT_ENABLED", False): + with OpenAI(api_key="test-key", max_retries=0) as sdk_client: + stream = Stream( + cast_to=ChatCompletionChunk, + response=response, + client=sdk_client, + ) + agent.client.chat.completions.create.return_value = stream + + with pytest.raises(ProviderStreamError) as exc_info: + agent._interruptible_streaming_api_call({"messages": []}) + + assert secret not in str(exc_info.value) + assert secret not in exc_info.value.raw_text + assert secret not in exc_info.value.body["error"]["message"] + assert "sk-" in exc_info.value.body["error"]["message"] + agent.stream_delta_callback.assert_not_called() + + def test_provider_error_prefix_like_normal_text_flushes_to_callback(self, agent): + chunks = [ + _make_chunk(content="id: product-42\n"), + _make_chunk(content="is ready"), + _make_chunk(finish_reason="stop"), + ] + agent.client.chat.completions.create.return_value = iter(chunks) + agent.stream_delta_callback = MagicMock() + + resp = agent._interruptible_streaming_api_call({"messages": []}) + + assert resp.choices[0].message.content == "id: product-42\nis ready" + assert [ + call.args[0] for call in agent.stream_delta_callback.call_args_list + ] == ["id: product-42\n", "is ready"] + + def test_full_bailian_sse_error_example_with_stop_is_literal_text(self, agent): + error_text = _provider_sse_429_text(message="Example error payload.") + split_at = len(error_text) // 2 + chunks = [ + _make_chunk(content=error_text[:split_at]), + _make_chunk(content=error_text[split_at:]), + _make_chunk(finish_reason="stop"), + ] + agent.client.chat.completions.create.return_value = iter(chunks) + agent.stream_delta_callback = MagicMock() + + resp = agent._interruptible_streaming_api_call({"messages": []}) + + assert resp.choices[0].message.content == error_text + assert [ + call.args[0] for call in agent.stream_delta_callback.call_args_list + ] == [error_text[:split_at], error_text[split_at:]] + + def test_bare_sse_error_payload_with_stop_is_literal_text(self, agent): + error_text = _provider_bare_sse_error_text(message="Example error payload.") + chunks = [ + _make_chunk(content=error_text), + _make_chunk(finish_reason="stop"), + ] + agent.client.chat.completions.create.return_value = iter(chunks) + agent.stream_delta_callback = MagicMock() + + resp = agent._interruptible_streaming_api_call({"messages": []}) + + assert resp.choices[0].message.content == error_text + assert [ + call.args[0] for call in agent.stream_delta_callback.call_args_list + ] == [error_text] + + def test_bare_sse_error_payload_without_finish_reason_is_literal_text(self, agent): + error_text = _provider_bare_sse_error_text(message="Example error payload.") + chunks = [_make_chunk(content=error_text)] + agent.client.chat.completions.create.return_value = iter(chunks) + agent.stream_delta_callback = MagicMock() + + resp = agent._interruptible_streaming_api_call({"messages": []}) + + assert resp.choices[0].message.content == error_text + # Current main treats every text-only stream without a terminal finish + # signal as a partial response. The SSE-shaped text remains literal, + # but is withheld from the callback so the retry path can own delivery. + assert resp.choices[0].finish_reason == "length" + agent.stream_delta_callback.assert_not_called() + + def test_run_conversation_retries_stream_error_finish_rate_limit(self, agent): + first_attempt = iter([ + _make_chunk(content=_provider_sse_429_text()), + _make_chunk(finish_reason="error_finish"), + ]) + second_attempt = iter([ + _make_chunk(content="Recovered"), + _make_chunk(finish_reason="stop"), + ]) + agent.client.chat.completions.create.side_effect = [first_attempt, second_attempt] + agent.stream_delta_callback = MagicMock() + agent._persist_session = lambda *args, **kwargs: None + agent._save_trajectory = lambda *args, **kwargs: None + + import agent.conversation_loop as _conversation_loop + + with ( + patch.object(_conversation_loop, "jittered_backoff", return_value=0.0), + patch.object( + _conversation_loop, + "adaptive_rate_limit_backoff", + return_value=(0.0, None), + ), + patch.object(_conversation_loop.time, "sleep", return_value=None), + ): + result = agent.run_conversation("hello") + + assert result["completed"] is True + assert result["final_response"] == "Recovered" + assert agent.client.chat.completions.create.call_count == 2 + assert not any( + "HTTP_STATUS/429" in str(call.args[0]) + for call in agent.stream_delta_callback.call_args_list + ) + def test_tool_call_accumulation(self, agent): # Per OpenAI streaming spec, function names are delivered atomically # in the first chunk; only `arguments` is fragmented across chunks. diff --git a/tests/run_agent/test_sequential_tool_timeout.py b/tests/run_agent/test_sequential_tool_timeout.py new file mode 100644 index 0000000000000..fbcb46a133b7d --- /dev/null +++ b/tests/run_agent/test_sequential_tool_timeout.py @@ -0,0 +1,228 @@ +"""Sequential tool calls recover when one dispatch never returns.""" + +import json +import threading +import time +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from agent.tool_executor import execute_tool_calls_sequential +from run_agent import AIAgent +from tools.clarify_gateway import resolve_clarify_timeout + + +def _make_agent(tmp_path: Path) -> AIAgent: + with ( + patch( + "run_agent.get_tool_definitions", + return_value=[ + { + "type": "function", + "function": { + "name": "web_extract", + "description": "test tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + ], + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch("run_agent._hermes_home", tmp_path), + patch("agent.model_metadata.fetch_model_metadata", return_value={}), + ): + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent._flush_messages_to_session_db = MagicMock(return_value=True) + agent._append_guardrail_observation = MagicMock( + side_effect=lambda _name, _args, result, **_kwargs: result + ) + agent._record_file_mutation_result = MagicMock() + agent._subdirectory_hints.check_tool_call = MagicMock(return_value="") + agent._tool_result_content_for_active_model = MagicMock( + side_effect=lambda _name, result: result + ) + return agent + + +def _tool_call(call_id: str): + return SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name="web_extract", arguments="{}"), + ) + + +def _clarify_call(call_id: str = "clarify-1"): + return SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace( + name="clarify", + arguments='{"question": "Pick one?", "choices": ["A", "B"]}', + ), + ) + + +def test_sequential_tool_timeout_emits_result_and_continues(tmp_path, monkeypatch): + agent = _make_agent(tmp_path) + first_started = threading.Event() + release_first = threading.Event() + dispatched: list[str] = [] + terminal_events: list[dict] = [] + + def _dispatch(_name, _args, _task_id, *, tool_call_id, **_kwargs): + dispatched.append(tool_call_id) + if tool_call_id == "hung": + first_started.set() + release_first.wait() + return "late result" + return "second result" + + def _capture_terminal_event(*_args, **kwargs): + terminal_events.append(kwargs) + + calls = [_tool_call("hung"), _tool_call("next")] + assistant = SimpleNamespace(tool_calls=calls) + messages: list[dict] = [] + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "0.05") + + started = time.monotonic() + try: + with ( + patch("run_agent.handle_function_call", side_effect=_dispatch), + patch( + "agent.tool_executor._emit_terminal_post_tool_call", + side_effect=_capture_terminal_event, + ), + ): + execute_tool_calls_sequential(agent, assistant, messages, "task") + finally: + release_first.set() + + assert first_started.is_set() + assert time.monotonic() - started < 1.0 + assert dispatched == ["hung", "next"] + assert [message["tool_call_id"] for message in messages] == ["hung", "next"] + assert "timed out after 0.1s" in messages[0]["content"] + assert messages[0]["effect_disposition"] == "unknown" + assert messages[1]["content"] == "second result" + timeout_events = [event for event in terminal_events if event.get("error_type") == "tool_timeout"] + assert len(timeout_events) == 1 + assert timeout_events[0]["status"] == "timeout" + agent._flush_messages_to_session_db.assert_called() + + +def test_sequential_tool_timeout_suppresses_late_terminal_event(tmp_path, monkeypatch): + import hermes_cli.lifecycle as lifecycle + import model_tools + + agent = _make_agent(tmp_path) + release_first = threading.Event() + first_returned = threading.Event() + dispatch_count = 0 + terminal_events: list[dict] = [] + + def _dispatch(_name, _args, **_kwargs): + nonlocal dispatch_count + dispatch_count += 1 + if dispatch_count == 1: + release_first.wait() + first_returned.set() + return "late result" + return "second result" + + calls = [_tool_call("hung"), _tool_call("next")] + messages: list[dict] = [] + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "0.05") + + try: + with ( + patch.object(model_tools.registry, "dispatch", side_effect=_dispatch), + patch.object(lifecycle, "has_hook", return_value=True), + patch.object( + lifecycle, + "invoke_hook", + side_effect=lambda hook, **kwargs: ( + terminal_events.append(kwargs) if hook == "post_tool_call" else [] + ), + ), + ): + execute_tool_calls_sequential( + agent, SimpleNamespace(tool_calls=calls), messages, "task" + ) + release_first.set() + assert first_returned.wait(timeout=1) + finally: + release_first.set() + + assert [(event["tool_call_id"], event.get("error_type")) for event in terminal_events] == [ + ("hung", "tool_timeout"), + ("next", None), + ] + + +@pytest.mark.parametrize( + "clarify_timeout", + [resolve_clarify_timeout({}), 0], + ids=["default-3600s", "unlimited"], +) +def test_sequential_timeout_does_not_cut_clarify_human_wait( + tmp_path, monkeypatch, clarify_timeout +): + """Clarify waits on a human; the generic sequential deadline must not fire. + + Default ``agent.clarify_timeout`` is 3600s; ``<= 0`` is unlimited. Both + outlast ``HERMES_CONCURRENT_TOOL_TIMEOUT_S`` (default 420s). + """ + agent = _make_agent(tmp_path) + monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "0.05") + monkeypatch.setattr( + "tools.clarify_gateway.get_clarify_timeout", + lambda: clarify_timeout, + ) + + def _callback(question, choices, multi_select=False): + time.sleep(0.15) + return "A" + + agent.clarify_callback = _callback + terminal_events: list[dict] = [] + + def _dispatch(_name, _args, _task_id, *, tool_call_id, **_kwargs): + return "second result" + + def _capture_terminal_event(*_args, **kwargs): + terminal_events.append(kwargs) + + messages: list[dict] = [] + started = time.monotonic() + with ( + patch("run_agent.handle_function_call", side_effect=_dispatch), + patch( + "agent.tool_executor._emit_terminal_post_tool_call", + side_effect=_capture_terminal_event, + ), + ): + execute_tool_calls_sequential( + agent, + SimpleNamespace(tool_calls=[_clarify_call(), _tool_call("next")]), + messages, + "task", + ) + + assert time.monotonic() - started < 1.0 + assert [message["tool_call_id"] for message in messages] == ["clarify-1", "next"] + payload = json.loads(messages[0]["content"]) + assert payload["user_response"] == "A" + assert "timed out" not in messages[0]["content"] + assert messages[1]["content"] == "second result" + assert not any(event.get("error_type") == "tool_timeout" for event in terminal_events) diff --git a/tests/run_agent/test_turn_completion_explainer.py b/tests/run_agent/test_turn_completion_explainer.py index 5b67f099b2136..8e0b8a7522227 100644 --- a/tests/run_agent/test_turn_completion_explainer.py +++ b/tests/run_agent/test_turn_completion_explainer.py @@ -109,6 +109,28 @@ def test_explanation_persistence_locked_cause_says_busy_not_disk(): assert "permission" not in lower +def test_explanation_persistence_compression_cause_is_specific(): + out = AIAgent._format_turn_completion_explanation( + "session_persistence_failed", "compression" + ) + lower = out.lower() + assert "compression" in lower + assert "database" not in lower + assert "disk" not in lower + + +def test_explanation_persistence_turn_lease_cause_is_specific(): + out = AIAgent._format_turn_completion_explanation( + "session_persistence_failed", "turn_lease" + ) + lower = out.lower() + assert "took over" in lower + assert "not saved" in lower + assert "disk" not in lower + assert "compression" not in lower + assert "hermes doctor" not in lower + + def test_explanation_persistence_disk_cause_keeps_disk_wording(): out = AIAgent._format_turn_completion_explanation( "session_persistence_failed", "disk" @@ -194,7 +216,7 @@ def test_classify_persistence_error_reuses_disk_full_markers(): ) == "disk" -def test_classify_persistence_error_compression_busy_is_locked(): +def test_classify_persistence_error_compression_busy_is_distinct(): """A live compression lease refusing the write is contention, not storage damage — but its message contains neither 'locked' nor 'busy', so it must classify by exception type (and by phrase for RPC-wrapped @@ -209,17 +231,30 @@ def test_classify_persistence_error_compression_busy_is_locked(): SessionCompressionInProgressError( "Session 'abc' is being compressed by another writer" ) - ) == "locked" + ) == "compression" assert classify_persistence_error( CompressionSessionBusyError("Compression lease lost before publication: abc") - ) == "locked" + ) == "compression" # RPC-wrapped string forms (exception type lost in transit). assert classify_persistence_error( "Session 'abc' is being compressed by another writer" - ) == "locked" + ) == "compression" assert classify_persistence_error( "Compression lease lost before publication: abc" - ) == "locked" + ) == "compression" + + +def test_classify_persistence_error_turn_lease_lost_is_distinct(): + from hermes_state import SessionTurnLeaseLostError, classify_persistence_error + + assert classify_persistence_error( + SessionTurnLeaseLostError( + "Session turn lease lost; refusing transcript write for 'abc'" + ) + ) == "turn_lease" + assert classify_persistence_error( + "Session turn lease lost; refusing transcript write for 'abc'" + ) == "turn_lease" def test_persistence_error_causes_tuple_matches_classifier(): @@ -229,6 +264,8 @@ def test_persistence_error_causes_tuple_matches_classifier(): probes = ( "database is locked", + "Session 'abc' is being compressed by another writer", + "Session turn lease lost; refusing transcript write for 'abc'", "database or disk is full", "something else entirely", None, diff --git a/tests/state/test_compression_lineage_guard.py b/tests/state/test_compression_lineage_guard.py index ef071c79742ec..ec8d2da18222c 100644 --- a/tests/state/test_compression_lineage_guard.py +++ b/tests/state/test_compression_lineage_guard.py @@ -136,6 +136,28 @@ def test_find_live_child_returns_continuation_with_foreign_markers( assert child["id"] == "inherited-continuation" +def test_compression_lineage_includes_continuation_with_foreign_markers( + db: SessionDB, +) -> None: + """Lineage walk uses the same parent-bound marker rule as orphan recovery.""" + _compression_parent(db, "delegate-session-3") + db.create_session( + "inherited-tip", + source="subagent", + parent_session_id="delegate-session-3", + model_config={"_delegate_from": "some-original-parent"}, + ) + + assert db.get_compression_lineage("inherited-tip") == [ + "delegate-session-3", + "inherited-tip", + ] + assert db.get_compression_lineage("delegate-session-3") == [ + "delegate-session-3", + "inherited-tip", + ] + + def test_reopen_orphaned_compression_session_fails_closed_with_active_lease( db: SessionDB, ) -> None: diff --git a/tests/state/test_session_turn_lease.py b/tests/state/test_session_turn_lease.py new file mode 100644 index 0000000000000..8020eaa0dc52f --- /dev/null +++ b/tests/state/test_session_turn_lease.py @@ -0,0 +1,669 @@ +"""Cross-process session turn lease behavior (#84234).""" + +from __future__ import annotations + +import os +import sqlite3 +import threading +import time +from types import SimpleNamespace + +import pytest + +import hermes_state +from hermes_state import SessionDB, SessionTurnLeaseLostError + + +def test_turn_lease_serializes_separate_session_db_instances(tmp_path): + """A second process-shaped DB handle waits for the current turn owner.""" + path = tmp_path / "state.db" + first = SessionDB(path) + second = SessionDB(path) + first.create_session("shared", source="test") + + first_holder = f"pid={os.getpid()}:turn=first" + second_holder = f"pid={os.getpid()}:turn=second" + assert first.try_acquire_session_turn_lease( + "shared", first_holder, ttl_seconds=5 + ) + + released = threading.Event() + + def release_first(): + time.sleep(0.2) + first.release_session_turn_lease("shared", first_holder) + released.set() + + thread = threading.Thread(target=release_first, daemon=True) + thread.start() + started = time.monotonic() + try: + assert second.acquire_session_turn_lease( + "shared", + second_holder, + ttl_seconds=5, + wait_seconds=2, + poll_interval_seconds=0.02, + ) + finally: + thread.join(timeout=2) + + assert released.is_set() + assert time.monotonic() - started >= 0.15 + second.release_session_turn_lease("shared", second_holder) + + +def test_turn_lease_is_scoped_to_conversation_root(tmp_path): + """Compression descendants share one durable serialization domain.""" + db = SessionDB(tmp_path / "state.db") + db.create_session("root", source="test") + db.end_session("root", "compression") + db.create_session("child", source="test", parent_session_id="root") + + root_holder = f"pid={os.getpid()}:turn=root" + child_holder = f"pid={os.getpid()}:turn=child" + assert db.try_acquire_session_turn_lease( + "root", root_holder, ttl_seconds=5 + ) + assert not db.try_acquire_session_turn_lease( + "child", child_holder, ttl_seconds=5 + ) + db.release_session_turn_lease("child", root_holder) + + +def test_turn_lease_does_not_serialize_delegate_child_with_parent(tmp_path): + """Only compression continuation segments share a conversation lease.""" + db = SessionDB(tmp_path / "state.db") + db.create_session("parent", source="test") + db.create_session( + "delegate", + source="delegate", + parent_session_id="parent", + model_config={"_delegate_from": "parent"}, + ) + + parent_holder = f"pid={os.getpid()}:turn=parent" + delegate_holder = f"pid={os.getpid()}:turn=delegate" + assert db.try_acquire_session_turn_lease( + "parent", parent_holder, ttl_seconds=5 + ) + assert db.try_acquire_session_turn_lease( + "delegate", delegate_holder, ttl_seconds=5 + ) + + +def test_turn_lease_walks_compression_child_that_inherited_fork_markers(tmp_path): + """Inherited ``_delegate_from`` / ``_branched_from`` must not stop the walk. + + ``publish_compression_child`` copies ``model_config`` verbatim, so a + delegate or branch continuation carries a marker pointing at some other + session. Presence-only fork detection would key the child separately: + the holder still owns the parent-key lease, but the first refresh after + rotation looks up the child id and fail-closes with a hard interrupt. + """ + db = SessionDB(tmp_path / "state.db") + db.create_session("original-parent", source="test") + db.create_session( + "delegate", + source="delegate", + parent_session_id="original-parent", + model_config={"_delegate_from": "original-parent"}, + ) + db.end_session("delegate", "compression") + db.create_session( + "delegate-continuation", + source="delegate", + parent_session_id="delegate", + model_config={"_delegate_from": "original-parent"}, + ) + db.create_session( + "branch", + source="test", + parent_session_id="original-parent", + model_config={"_branched_from": "original-parent"}, + ) + db.end_session("branch", "compression") + db.create_session( + "branch-continuation", + source="test", + parent_session_id="branch", + model_config={"_branched_from": "original-parent"}, + ) + + assert db._session_turn_lease_key("delegate-continuation") == "delegate" + assert db._session_turn_lease_key("branch-continuation") == "branch" + + delegate_holder = f"pid={os.getpid()}:turn=delegate" + assert db.try_acquire_session_turn_lease( + "delegate", delegate_holder, ttl_seconds=5 + ) + assert not db.try_acquire_session_turn_lease( + "delegate-continuation", + f"pid={os.getpid()}:turn=delegate-child", + ttl_seconds=5, + ) + assert db.refresh_session_turn_lease( + "delegate-continuation", delegate_holder, ttl_seconds=5 + ) + + branch_holder = f"pid={os.getpid()}:turn=branch" + assert db.try_acquire_session_turn_lease( + "branch", branch_holder, ttl_seconds=5 + ) + assert not db.try_acquire_session_turn_lease( + "branch-continuation", + f"pid={os.getpid()}:turn=branch-child", + ttl_seconds=5, + ) + assert db.refresh_session_turn_lease( + "branch-continuation", branch_holder, ttl_seconds=5 + ) + + original_holder = f"pid={os.getpid()}:turn=original" + assert db.try_acquire_session_turn_lease( + "original-parent", original_holder, ttl_seconds=5 + ) + db.release_session_turn_lease("delegate-continuation", delegate_holder) + db.release_session_turn_lease("branch-continuation", branch_holder) + db.release_session_turn_lease("original-parent", original_holder) + + +def test_turn_lease_write_txn_does_not_trust_fail_open_key_helper( + tmp_path, monkeypatch: pytest.MonkeyPatch +): + """Acquire/refresh/release walk inside the write txn. + + The old helper swallowed get_session failures and returned the child id. + P2 then proceeded to acquire; the write succeeded under that child key + and the first working refresh walked to the parent and hard-interrupted. + Poisoning the outer helper must not change the conversation key. + """ + db = SessionDB(tmp_path / "state.db") + db.create_session( + "delegate", + source="delegate", + model_config={"_delegate_from": "original-parent"}, + ) + db.end_session("delegate", "compression") + db.create_session( + "delegate-continuation", + source="delegate", + parent_session_id="delegate", + model_config={"_delegate_from": "original-parent"}, + ) + + monkeypatch.setattr(db, "_session_turn_lease_key", lambda sid: sid) + holder = f"pid={os.getpid()}:turn=delegate" + assert db.try_acquire_session_turn_lease( + "delegate", holder, ttl_seconds=5 + ) + assert not db.try_acquire_session_turn_lease( + "delegate-continuation", + f"pid={os.getpid()}:turn=child", + ttl_seconds=5, + ) + assert db.refresh_session_turn_lease( + "delegate-continuation", holder, ttl_seconds=5 + ) + db.release_session_turn_lease("delegate-continuation", holder) + assert db.try_acquire_session_turn_lease( + "delegate", f"pid={os.getpid()}:turn=next", ttl_seconds=5 + ) + + +def test_turn_lease_retries_locked_in_txn_key_walk( + tmp_path, monkeypatch: pytest.MonkeyPatch +): + """A locked lineage walk must retry, not INSERT under the child id.""" + db = SessionDB(tmp_path / "state.db") + db.create_session( + "delegate", + source="delegate", + model_config={"_delegate_from": "original-parent"}, + ) + db.end_session("delegate", "compression") + db.create_session( + "delegate-continuation", + source="delegate", + parent_session_id="delegate", + model_config={"_delegate_from": "original-parent"}, + ) + + attempts = {"n": 0} + original = db._session_turn_lease_key_on_conn + + def flaky_walk(conn, session_id): + attempts["n"] += 1 + if attempts["n"] == 1: + raise sqlite3.OperationalError("database is locked") + return original(conn, session_id) + + monkeypatch.setattr(db, "_session_turn_lease_key_on_conn", flaky_walk) + holder = f"pid={os.getpid()}:turn=delegate" + assert db.try_acquire_session_turn_lease( + "delegate-continuation", holder, ttl_seconds=5 + ) + assert attempts["n"] >= 2 + monkeypatch.setattr(db, "_session_turn_lease_key_on_conn", original) + assert not db.try_acquire_session_turn_lease( + "delegate", f"pid={os.getpid()}:turn=other", ttl_seconds=5 + ) + assert db.refresh_session_turn_lease("delegate", holder, ttl_seconds=5) + db.release_session_turn_lease("delegate-continuation", holder) + + +def test_turn_lease_refresh_and_release_are_owner_fenced(tmp_path): + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + + current_holder = f"pid={os.getpid()}:turn=current" + stale_holder = f"pid={os.getpid()}:turn=stale" + next_holder = f"pid={os.getpid()}:turn=next" + assert db.try_acquire_session_turn_lease( + "shared", current_holder, ttl_seconds=5 + ) + assert not db.refresh_session_turn_lease( + "shared", stale_holder, ttl_seconds=5 + ) + db.release_session_turn_lease("shared", stale_holder) + assert not db.try_acquire_session_turn_lease( + "shared", next_holder, ttl_seconds=5 + ) + + assert db.refresh_session_turn_lease( + "shared", current_holder, ttl_seconds=5 + ) + db.release_session_turn_lease("shared", current_holder) + assert db.try_acquire_session_turn_lease( + "shared", next_holder, ttl_seconds=5 + ) + + +def test_expired_turn_lease_is_reclaimed(tmp_path): + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + assert db.try_acquire_session_turn_lease( + "shared", "legacy-holder", ttl_seconds=0.05 + ) + + time.sleep(0.15) + + assert db.try_acquire_session_turn_lease( + "shared", "pid=202:turn=reclaimer", ttl_seconds=5 + ) + + +def test_acquire_turn_lease_notifies_wait_callback(tmp_path): + """Waiters get a progress callback while another holder owns the lease.""" + path = tmp_path / "state.db" + first = SessionDB(path) + second = SessionDB(path) + first.create_session("shared", source="test") + + first_holder = f"pid={os.getpid()}:turn=first" + second_holder = f"pid={os.getpid()}:turn=second" + assert first.try_acquire_session_turn_lease( + "shared", first_holder, ttl_seconds=5 + ) + + notices = [] + + def release_first(): + time.sleep(0.12) + first.release_session_turn_lease("shared", first_holder) + + thread = threading.Thread(target=release_first, daemon=True) + thread.start() + try: + assert second.acquire_session_turn_lease( + "shared", + second_holder, + ttl_seconds=5, + wait_seconds=2, + poll_interval_seconds=0.02, + on_wait=notices.append, + wait_notice_interval_seconds=0.05, + ) + finally: + thread.join(timeout=2) + + assert notices + assert notices[0] < 0.05 + second.release_session_turn_lease("shared", second_holder) + + +def test_acquire_turn_lease_honors_should_abort(tmp_path): + """Waiters stop immediately when should_abort() returns True.""" + path = tmp_path / "state.db" + first = SessionDB(path) + second = SessionDB(path) + first.create_session("shared", source="test") + + first_holder = f"pid={os.getpid()}:turn=first" + second_holder = f"pid={os.getpid()}:turn=second" + assert first.try_acquire_session_turn_lease( + "shared", first_holder, ttl_seconds=60 + ) + + abort_checks = {"count": 0} + + def should_abort(): + abort_checks["count"] += 1 + return True + + started = time.monotonic() + assert not second.acquire_session_turn_lease( + "shared", + second_holder, + wait_seconds=30, + poll_interval_seconds=0.05, + should_abort=should_abort, + ) + assert time.monotonic() - started < 1.0 + assert abort_checks["count"] >= 1 + first.release_session_turn_lease("shared", first_holder) + + +def test_acquire_turn_lease_retries_sqlite_lock(tmp_path, monkeypatch): + """Write-lock exhaustion is contended, not a hard abort of the wait.""" + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + holder = f"pid={os.getpid()}:turn=waiter" + attempts = {"n": 0} + original = db.try_acquire_session_turn_lease + + def flaky_acquire(*args, **kwargs): + attempts["n"] += 1 + if attempts["n"] == 1: + raise sqlite3.OperationalError( + "database is locked (another Hermes process held the " + "state.db write lock for over 20s)" + ) + return original(*args, **kwargs) + + monkeypatch.setattr(db, "try_acquire_session_turn_lease", flaky_acquire) + assert db.acquire_session_turn_lease( + "shared", + holder, + wait_seconds=2, + poll_interval_seconds=0.02, + acquire_patience_s=0.05, + ) + assert attempts["n"] >= 2 + db.release_session_turn_lease("shared", holder) + + +def test_acquire_turn_lease_reraises_non_lock_sqlite_error(tmp_path, monkeypatch): + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + + def disk_full(*args, **kwargs): + raise sqlite3.OperationalError("database or disk is full") + + monkeypatch.setattr(db, "try_acquire_session_turn_lease", disk_full) + with pytest.raises(sqlite3.OperationalError, match="disk is full"): + db.acquire_session_turn_lease( + "shared", + f"pid={os.getpid()}:turn=waiter", + wait_seconds=1, + poll_interval_seconds=0.02, + ) + + +def test_non_expired_turn_lease_from_dead_pid_is_reclaimed( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A holder whose structured pid= no longer exists can be reclaimed early.""" + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + + dead_holder = "pid=424242:turn=dead:platform=test" + assert db.try_acquire_session_turn_lease( + "shared", dead_holder, ttl_seconds=300 + ) is True + + probed: list[int] = [] + + def pid_exists(pid: int) -> bool: + probed.append(pid) + return False + + monkeypatch.setattr( + hermes_state, "psutil", SimpleNamespace(pid_exists=pid_exists) + ) + + fresh_holder = "pid=525252:turn=fresh:platform=test" + assert db.try_acquire_session_turn_lease( + "shared", fresh_holder, ttl_seconds=300 + ) is True + assert probed == [424242] + + +def test_turn_lease_fences_stale_transcript_flush_after_reclaim(tmp_path): + """A lost holder cannot persist after B has taken the conversation. + + Refresh-loss interrupt is cooperative; the lease itself must reject the + late append inside the same SQLite write transaction. + """ + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + stale_holder = f"pid={os.getpid()}:turn=stale" + next_holder = f"pid={os.getpid()}:turn=next" + + assert db.try_acquire_session_turn_lease( + "shared", stale_holder, ttl_seconds=5 + ) + assert db.append_messages_batch( + "shared", + [{"role": "user", "content": "stale-owned"}], + turn_lease_holder=stale_holder, + ) == 1 + + db.release_session_turn_lease("shared", stale_holder) + assert db.try_acquire_session_turn_lease( + "shared", next_holder, ttl_seconds=5 + ) + + with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"): + db.append_messages_batch( + "shared", + [{"role": "assistant", "content": "late stale reply"}], + turn_lease_holder=stale_holder, + ) + with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"): + db.append_message( + "shared", + "assistant", + "late stale single-row", + turn_lease_holder=stale_holder, + ) + + assert db.append_messages_batch( + "shared", + [{"role": "assistant", "content": "next reply"}], + turn_lease_holder=next_holder, + ) == 1 + assert [m["content"] for m in db.get_messages("shared")] == [ + "stale-owned", + "next reply", + ] + db.release_session_turn_lease("shared", next_holder) + + +def test_turn_lease_revives_expired_row_still_owned_by_writer(tmp_path): + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + holder = f"pid={os.getpid()}:turn=owner" + + assert db.try_acquire_session_turn_lease("shared", holder, ttl_seconds=0.05) + time.sleep(0.12) + assert db.append_messages_batch( + "shared", + [{"role": "assistant", "content": "after ttl"}], + turn_lease_holder=holder, + turn_lease_ttl_seconds=0.2, + ) == 1 + assert not db.try_acquire_session_turn_lease( + "shared", f"pid={os.getpid()}:turn=contender", ttl_seconds=5 + ) + + +def test_turn_lease_fences_flush_when_row_is_absent(tmp_path): + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + holder = f"pid={os.getpid()}:turn=owner" + + with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"): + db.append_messages_batch( + "shared", + [{"role": "assistant", "content": "after release"}], + turn_lease_holder=holder, + ) + assert db.get_messages("shared") == [] + + +def test_turn_lease_fence_walks_compression_child_to_root(tmp_path): + """A parent-key holder still fences writes against the rotated tip.""" + db = SessionDB(tmp_path / "state.db") + db.create_session("root", source="test") + db.end_session("root", "compression") + db.create_session("child", source="test", parent_session_id="root") + + root_holder = f"pid={os.getpid()}:turn=root" + stale_holder = f"pid={os.getpid()}:turn=stale" + assert db.try_acquire_session_turn_lease( + "root", root_holder, ttl_seconds=5 + ) + assert db.append_messages_batch( + "child", + [{"role": "user", "content": "owner on tip"}], + turn_lease_holder=root_holder, + ) == 1 + with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"): + db.append_messages_batch( + "child", + [{"role": "assistant", "content": "impostor"}], + turn_lease_holder=stale_holder, + ) + db.release_session_turn_lease("child", root_holder) + + +def test_lost_turn_lease_flush_fails_fast_without_patience_retry( + tmp_path, monkeypatch: pytest.MonkeyPatch +): + """Sibling of test_a_lost_compression_lease_still_fails_fast. + + SessionTurnLeaseLostError is permanent fencing, not a live-busy signal. + Retrying it would burn transcript write patience and still fail. + """ + db = SessionDB(tmp_path / "state.db") + db.create_session("shared", source="test") + stale_holder = f"pid={os.getpid()}:turn=stale" + next_holder = f"pid={os.getpid()}:turn=next" + assert db.try_acquire_session_turn_lease( + "shared", stale_holder, ttl_seconds=5 + ) + db.release_session_turn_lease("shared", stale_holder) + assert db.try_acquire_session_turn_lease( + "shared", next_holder, ttl_seconds=5 + ) + + sleeps = [] + original = db._sleep_before_write_retry + + def track_sleep(deadline, patience_s): + sleeps.append(patience_s) + return original(deadline, patience_s) + + monkeypatch.setattr(db, "_sleep_before_write_retry", track_sleep) + monkeypatch.setattr(SessionDB, "_COMPRESSION_BUSY_WAIT_S", 5.0) + + started = time.monotonic() + with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"): + db.append_messages_batch( + "shared", + [{"role": "assistant", "content": "late stale reply"}], + turn_lease_holder=stale_holder, + ) + assert time.monotonic() - started < 0.5 + assert sleeps == [] + assert db.get_messages("shared") == [] + db.release_session_turn_lease("shared", next_holder) + + +def test_turn_lease_fence_walks_continuation_that_inherited_fork_markers(tmp_path): + """Owner flush on a rotated tip must use the parent-key lease. + + Presence-only ``_delegate_from`` / ``_branched_from`` detection would + treat the continuation as its own conversation. The presented parent + holder would then miss the row and fail-close a still-valid owner. + """ + db = SessionDB(tmp_path / "state.db") + db.create_session("original-parent", source="test") + db.create_session( + "delegate", + source="delegate", + parent_session_id="original-parent", + model_config={"_delegate_from": "original-parent"}, + ) + db.end_session("delegate", "compression") + db.create_session( + "delegate-continuation", + source="delegate", + parent_session_id="delegate", + model_config={"_delegate_from": "original-parent"}, + ) + db.create_session( + "branch", + source="test", + parent_session_id="original-parent", + model_config={"_branched_from": "original-parent"}, + ) + db.end_session("branch", "compression") + db.create_session( + "branch-continuation", + source="test", + parent_session_id="branch", + model_config={"_branched_from": "original-parent"}, + ) + + delegate_holder = f"pid={os.getpid()}:turn=delegate" + assert db.try_acquire_session_turn_lease( + "delegate", delegate_holder, ttl_seconds=5 + ) + assert db.append_messages_batch( + "delegate-continuation", + [{"role": "user", "content": "owner on inherited tip"}], + turn_lease_holder=delegate_holder, + ) == 1 + with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"): + db.append_messages_batch( + "delegate-continuation", + [{"role": "assistant", "content": "impostor"}], + turn_lease_holder=f"pid={os.getpid()}:turn=impostor", + ) + + branch_holder = f"pid={os.getpid()}:turn=branch" + assert db.try_acquire_session_turn_lease( + "branch", branch_holder, ttl_seconds=5 + ) + assert db.append_messages_batch( + "branch-continuation", + [{"role": "user", "content": "branch owner on inherited tip"}], + turn_lease_holder=branch_holder, + ) == 1 + with pytest.raises(SessionTurnLeaseLostError, match="turn lease lost"): + db.append_messages_batch( + "branch-continuation", + [{"role": "assistant", "content": "branch impostor"}], + turn_lease_holder=f"pid={os.getpid()}:turn=branch-impostor", + ) + + assert [m["content"] for m in db.get_messages("delegate-continuation")] == [ + "owner on inherited tip" + ] + assert [m["content"] for m in db.get_messages("branch-continuation")] == [ + "branch owner on inherited tip" + ] + db.release_session_turn_lease("delegate-continuation", delegate_holder) + db.release_session_turn_lease("branch-continuation", branch_holder) diff --git a/tests/test_desktop_update_windows_python_handoff.py b/tests/test_desktop_update_windows_python_handoff.py new file mode 100644 index 0000000000000..cb5cf339d8e92 --- /dev/null +++ b/tests/test_desktop_update_windows_python_handoff.py @@ -0,0 +1,89 @@ +"""Regression: the Windows Desktop update hand-off must run through python.exe. + +`scripts/desktop-update/windows.ps1` drives `hermes update` for the in-app +Desktop updater. It used to invoke the update through the venv's +`venv\\Scripts\\hermes.exe` console-script launcher. On Windows that launcher is +a real process that keeps `hermes.exe` mapped as its running image and spawns +`python.exe` as a child. The update ends in `uv pip install -e .`, which rewrites +the console-script shims -- including the `hermes.exe` the launcher still has +mapped -- and Windows refuses to replace a file mapped as a running image +("os error 32"). The rename fallback then defers to next reboot via +`MOVEFILE_DELAY_UNTIL_REBOOT`, which needs elevation a Desktop-driven update +does not have, so `uv pip install -e .` exits non-zero, the ZIP fallback repeats +the same sequence, the desktop build stage is never reached, and the pre-build +clean has already removed `apps/desktop/release` -- leaving an install whose +Start Menu shortcut points at a `Hermes.exe` that no longer exists. + +Driving the update as `python.exe -m hermes_cli.main update` puts the inherited +image handle on `python.exe`, which uv never has to replace, so the shim is an +ordinary unlocked file when uv rewrites it. + +This test is source-level because Linux CI cannot execute the PowerShell +hand-off. The invariant it guards is that every `Invoke-HermesStep` call site +(the update, its retry, and the desktop rebuild) drives `$pythonExe`, never the +`$hermesExe` shim. `hermes.exe` may still be *named* in the file for the +step-2 unlock preflight -- that is a lock probe, not an invocation -- so we +assert against the invocation sites specifically. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +WINDOWS_PS1 = REPO_ROOT / "scripts" / "desktop-update" / "windows.ps1" + + +def _read() -> str: + return WINDOWS_PS1.read_text(encoding="utf-8") + + +def test_invoke_hermes_step_calls_drive_python_not_the_shim() -> None: + source = _read() + + invocations = re.findall(r"Invoke-HermesStep\s+(\$\w+)", source) + assert invocations, ( + "Expected at least one Invoke-HermesStep call in " + "scripts/desktop-update/windows.ps1; the update hand-off structure " + "changed -- update this guard." + ) + + offenders = [exe for exe in invocations if exe != "$pythonExe"] + assert not offenders, ( + "Every Invoke-HermesStep call in scripts/desktop-update/windows.ps1 " + "must drive $pythonExe, not the hermes.exe shim. Driving the update " + "through the shim keeps hermes.exe mapped as a running image, so uv's " + "final shim rewrite fails with os error 32 and the Desktop update can " + "never complete. Offending target(s): " + f"{sorted(set(offenders))}." + ) + + +def test_update_invocation_uses_module_entrypoint() -> None: + source = _read() + + assert '@("-m", "hermes_cli.main", "update"' in source, ( + "The update step must invoke `python.exe -m hermes_cli.main update ...` " + "so the inherited image handle lands on python.exe, which uv never has " + "to replace." + ) + assert ( + '@("-m", "hermes_cli.main", "desktop", "--force-build", "--build-only")' + in source + ), ( + "The desktop rebuild step must also go through " + "`python.exe -m hermes_cli.main desktop ...` for the same reason." + ) + + +def test_update_no_longer_invokes_the_hermes_exe_shim() -> None: + source = _read() + + assert "Invoke-HermesStep $hermesExe" not in source, ( + "scripts/desktop-update/windows.ps1 still invokes the update through " + "the hermes.exe shim (`Invoke-HermesStep $hermesExe`). That is the " + "exact self-lock this fix removes -- route it through $pythonExe " + "instead." + ) diff --git a/tests/test_desktop_update_windows_timestamp.py b/tests/test_desktop_update_windows_timestamp.py new file mode 100644 index 0000000000000..5efd0cc04b4ed --- /dev/null +++ b/tests/test_desktop_update_windows_timestamp.py @@ -0,0 +1,34 @@ +"""Regression tests for Windows desktop-update Unix timestamps. + +PowerShell's ``Get-Date -UFormat %s`` is locale-sensitive. Under an ``es-ES`` +culture it can produce a comma decimal separator, and parsing that string with +``InvariantCulture`` turns a ten-digit Unix timestamp plus fractional seconds +into a value too large for ``System.Int32``. The detached Windows updater must +use a locale-independent Unix timestamp API for both marker and result files. + +The updater script is not executable on the Linux CI lane, so these tests lock +the source-level contract and reject the exact broken conversion. +""" + +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +WINDOWS_UPDATE_PS1 = REPO_ROOT / "scripts" / "desktop-update" / "windows.ps1" + + +def test_windows_update_uses_locale_independent_unix_seconds() -> None: + source = WINDOWS_UPDATE_PS1.read_text(encoding="utf-8") + safe_expression = "[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()" + unsafe_expression = "[int][double]::Parse((Get-Date -UFormat %s)" + + assert source.count(safe_expression) == 2, ( + "windows.ps1 must use DateTimeOffset Unix seconds for both the " + "update marker and the finished result timestamp" + ) + assert unsafe_expression not in source, ( + "windows.ps1 must not parse locale-sensitive Get-Date -UFormat %s " + "output through System.Int32" + ) diff --git a/tests/test_pty_keepalive_ws.py b/tests/test_pty_keepalive_ws.py index 3a05f230bc0d0..9d92f7fa9c982 100644 --- a/tests/test_pty_keepalive_ws.py +++ b/tests/test_pty_keepalive_ws.py @@ -8,12 +8,13 @@ class FakeBridge: def __init__(self): self.alive = True + self.written = bytearray() def read(self, timeout): return b"" # idle forever def write(self, data): - pass + self.written.extend(data) def resize(self, cols, rows): pass @@ -24,11 +25,16 @@ def close(self): @pytest.fixture def pty_keepalive_harness(monkeypatch): - spawned = [] + class Spawned(list): + pass + + spawned = Spawned() + spawned.bridges = [] def fake_spawn(argv, cwd=None, env=None): b = FakeBridge() spawned.append(argv) + spawned.bridges.append(b) return b monkeypatch.setattr(web_server.PtyBridge, "spawn", staticmethod(fake_spawn)) @@ -60,6 +66,7 @@ async def test_attach_token_reuses_same_session(pty_keepalive_harness): with client.websocket_connect("/api/pty?attach=TOK1") as ws2: ws2.send_bytes(b"again") assert len(pty_keepalive_harness) == 1 # reattached, did not respawn + assert bytes(pty_keepalive_harness.bridges[0].written) == b"hi\x0cagain" @pytest.mark.asyncio diff --git a/tests/test_pty_session.py b/tests/test_pty_session.py index b592e25f4880a..85a91c77ca74e 100644 --- a/tests/test_pty_session.py +++ b/tests/test_pty_session.py @@ -76,6 +76,42 @@ async def test_attach_replays_buffer_then_streams_live(): await s.close() +@pytest.mark.asyncio +async def test_reattach_can_force_complete_tui_redraw_after_replay(): + """A fresh terminal cannot reconstruct a differential ANSI tail alone.""" + from hermes_cli.pty_session import PtySession + + bridge = FakeBridge([b"partial differential frame", b""]) + s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01) + await s.start() + await asyncio.sleep(0.05) + + ws = FakeWS() + await s.attach(ws, force_redraw=True) + + replay = b"".join(p for kind, p in ws.sent if kind == "bytes") + assert replay == b"partial differential frame" + assert bytes(bridge.written) == b"\x0c" + await s.close() + + +@pytest.mark.asyncio +async def test_detach_keeps_draining_into_buffer(): + from hermes_cli.pty_session import PtySession + bridge = FakeBridge([b"one", b"", b"two"]) + s = PtySession("k", bridge, buffer_cap=1024, read_timeout=0.01) + await s.start() + ws = FakeWS() + await s.attach(ws) + s.detach(ws) + assert s.attached is False + assert s.last_detached_at is not None + await asyncio.sleep(0.05) # "two" drains while detached + ws2 = FakeWS() + await s.attach(ws2) + replay = b"".join(p for kind, p in ws2.sent if kind == "bytes") + assert replay == b"onetwo" + await s.close() @pytest.mark.asyncio diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 8799be8afefc0..6b1f11150eacb 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -56,6 +56,41 @@ def _neuter_agent_prewarm_timer(request, monkeypatch): yield +@pytest.fixture(autouse=True) +def _reap_leaked_notification_pollers(): + """Stop and join notification pollers leaked by each test. + + session.init/create paths start a per-session poller daemon thread. A + poller left running by one test steals-and-requeues events off the + PROCESS-GLOBAL process_registry.completion_queue while a later test is + asserting on it — the root cause of the flaky + test_run_prompt_submit_requeues_all_unstarted_notifications_with_real_threading + (two CI hits on unrelated PRs, Aug 2026). Set every registered poller's + stop event (the loop wakes at least every 0.5s), then join with ONE + small shared budget — never per-thread — so teardown stays O(seconds) + for the whole file even when many tests leaked pollers. + """ + yield + pollers = [ + (stop, thread) + for stop, thread in list(server._notification_pollers) + if thread.is_alive() + ] + for stop, _thread in pollers: + stop.set() + deadline = time.time() + 3.0 + for _stop, thread in pollers: + remaining = deadline - time.time() + if remaining <= 0: + break + thread.join(timeout=remaining) + server._notification_pollers[:] = [ + (stop, thread) + for stop, thread in server._notification_pollers + if thread.is_alive() + ] + + def test_session_slot_is_claimed_on_first_turn_not_on_create(monkeypatch, tmp_path): home = tmp_path / ".hermes" home.mkdir() @@ -18045,3 +18080,143 @@ def replace_messages(self, *a, **k): assert len(sess["history"]) == 4 finally: server._sessions.pop(sid, None) + + +def test_persist_live_session_system_prompt_uses_profile_home(monkeypatch, tmp_path): + """Issue #50233: _persist_live_session_system_prompt must re-bind + HERMES_HOME to the session's profile before rebuilding the system + prompt. Without this, a /model switch rebuilds the prompt with the + root profile's SOUL.md and skills instead of the session's profile. + """ + profile_home = tmp_path / "profile-work" + profile_home.mkdir() + (profile_home / "SOUL.md").write_text( + "# Work persona\nYou are a work agent.", encoding="utf-8" + ) + + built_homes = [] + + class FakeAgent: + model = "test-model" + provider = "test" + _cached_system_prompt = None + _session_db = None + + def _build_system_prompt(self, system_message=None): + from hermes_constants import get_hermes_home + home = get_hermes_home() + built_homes.append(str(home)) + soul = ( + (home / "SOUL.md").read_text(encoding="utf-8") + if (home / "SOUL.md").exists() + else "" + ) + return f"System prompt from {home}\n{soul}" + + class FakeDB: + def update_system_prompt(self, session_id, prompt): + pass + + agent = FakeAgent() + agent._session_db = FakeDB() + session = { + "agent": agent, + "session_key": "test-key", + "profile_home": str(profile_home), + } + + server._persist_live_session_system_prompt(session) + + # The system prompt must have been built while the override pointed + # to the profile home, not the root ~/.hermes. + assert len(built_homes) == 1, f"expected 1 build, got {built_homes}" + assert str(profile_home) in built_homes[0], ( + f"system prompt built with wrong home: {built_homes[0]}" + ) + assert "Work persona" in agent._cached_system_prompt + + # The override must have been reset after the call. + from hermes_constants import get_hermes_home_override + assert get_hermes_home_override() is None + + +def test_persist_live_session_system_prompt_no_profile_is_unchanged(monkeypatch): + """Sessions without a profile_home must not set/clear any override — + the function should behave identically to before the fix.""" + class FakeAgent: + model = "test" + _cached_system_prompt = None + _session_db = None + + def _build_system_prompt(self, system_message=None): + return "plain prompt" + + class FakeDB: + def update_system_prompt(self, session_id, prompt): + pass + + agent = FakeAgent() + agent._session_db = FakeDB() + session = { + "agent": agent, + "session_key": "test-key", + "profile_home": None, + } + + # Should not raise, should still build and cache. + server._persist_live_session_system_prompt(session) + assert agent._cached_system_prompt == "plain prompt" + + +def test_persist_live_session_system_prompt_restores_pre_existing_override(tmp_path): + """reset_hermes_home_override() restores the previous ContextVar state, + not just the unset case: when a caller already holds an override, the + persist call must scope to the session's profile and then hand the + caller's override back, rather than clearing it to None.""" + from hermes_constants import get_hermes_home_override + + outer_home = tmp_path / "profile-outer" + outer_home.mkdir() + inner_home = tmp_path / "profile-inner" + inner_home.mkdir() + (inner_home / "SOUL.md").write_text( + "# Inner persona\nYou are the inner agent.", encoding="utf-8" + ) + + built_homes = [] + + class FakeAgent: + model = "test-model" + provider = "test" + _cached_system_prompt = None + _session_db = None + + def _build_system_prompt(self, system_message=None): + from hermes_constants import get_hermes_home + built_homes.append(str(get_hermes_home())) + return "inner prompt" + + class FakeDB: + def update_system_prompt(self, session_id, prompt): + pass + + agent = FakeAgent() + agent._session_db = FakeDB() + session = { + "agent": agent, + "session_key": "test-key", + "profile_home": str(inner_home), + } + + outer_token = set_hermes_home_override(outer_home) + try: + server._persist_live_session_system_prompt(session) + + # The prompt was built under the session's profile, not the outer one. + assert built_homes == [str(inner_home)] + # The caller's pre-existing override survived, instead of being reset + # to None. + assert get_hermes_home_override() == str(outer_home) + finally: + reset_hermes_home_override(outer_token) + assert get_hermes_home_override() is None diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 55534e8b84e9a..ed81bce22acaa 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -6,6 +6,7 @@ import asyncio import contextlib +import sys import pytest import uvicorn @@ -207,12 +208,12 @@ def _guard_asyncio_run(coro): def test_start_server_keeps_bare_asyncio_run_on_posix(monkeypatch): - """POSIX behavior must be byte-for-byte unchanged: serve via the plain - ``asyncio.run(_serve())`` path, never the Windows loop-factory branch. + """POSIX continues to serve via the plain ``asyncio.run(_serve())`` path, + never the Windows loop-factory branch. - The #50641 fix is intentionally win32-scoped to keep the blast radius - minimal — Python's default loop on POSIX is already a SelectorEventLoop - (or uvloop), which is what uvicorn serves on, so there is nothing to fix. + The #50641 fix is intentionally win32-scoped to keep the loop selection + unchanged — Python's default loop on POSIX is already a SelectorEventLoop + (or uvloop), which is what uvicorn serves on. No platform patching: the Linux CI host is already POSIX, so this asserts the real host's serve path. @@ -243,3 +244,97 @@ def _fake_asyncio_run(coro): assert runner_called["hit"] is False, ( "POSIX must not take the Windows loop-factory branch" ) + + +def test_start_server_treats_posix_keyboardinterrupt_as_clean_shutdown(monkeypatch): + """Ctrl+C is the normal foreground-dashboard shutdown path. + + Uvicorn re-raises captured SIGINT as ``KeyboardInterrupt`` after it has + restored the original signal handlers. The dashboard should treat that as a + clean user-requested shutdown instead of leaking a traceback to the terminal. + """ + _stub_uvicorn(monkeypatch) + + def _raise_keyboard_interrupt(coro): + coro.close() + raise KeyboardInterrupt + + monkeypatch.setattr(asyncio, "run", _raise_keyboard_interrupt) + + # Catch rather than let it escape: pytest treats a propagating + # KeyboardInterrupt as a session abort, not a test failure, so a + # regression here would kill the run instead of reporting red. + try: + web_server.start_server(host="127.0.0.1", port=0, open_browser=False) + except KeyboardInterrupt: + pytest.fail( + "start_server must treat serve-time KeyboardInterrupt as a clean " + "shutdown, not propagate it" + ) + + +@pytest.mark.windows_only +def test_start_server_treats_windows_keyboardinterrupt_as_clean_shutdown(monkeypatch): + """Console Ctrl+C on the Windows loop-factory branch is a clean exit too. + + Same bug class as the POSIX branch: ``capture_signals()`` re-raises the + captured SIGINT after graceful shutdown, which surfaces as + ``KeyboardInterrupt`` out of the loop-factory runner. The serve call must + swallow exactly that and return. + + Windows-only per the no-platform-faking rule (tests/conftest.py): the + branch is selected by the real host, and the runner import + (``uvicorn._compat.asyncio_run``) resolves inside ``start_server``, after + the monkeypatch below is installed. + """ + _stub_uvicorn(monkeypatch) + + def _raise_keyboard_interrupt(coro, *, loop_factory=None): + coro.close() + raise KeyboardInterrupt + + monkeypatch.setattr( + "uvicorn._compat.asyncio_run", _raise_keyboard_interrupt, raising=False + ) + + try: + web_server.start_server(host="127.0.0.1", port=0, open_browser=False) + except KeyboardInterrupt: + pytest.fail( + "start_server must treat serve-time KeyboardInterrupt as a clean " + "shutdown on the Windows branch, not propagate it" + ) + + +@pytest.mark.windows_only +def test_start_server_treats_windows_fallback_keyboardinterrupt_as_clean_shutdown( + monkeypatch, +): + """The pre-0.36 fallback runner shares the clean Ctrl+C contract. + + When ``uvicorn._compat.asyncio_run`` is unavailable (uvicorn predates the + loop-factory API), the Windows branch falls back to bare ``asyncio.run`` + under a hand-installed selector policy — still inside the same + ``capture_signals()`` re-raise, so its ``KeyboardInterrupt`` must be + swallowed identically. Forcing the ``_compat`` import to fail (None in + ``sys.modules`` halts the import) is what actually selects the fallback: + merely patching ``asyncio.run`` alongside a successful import would leave + this path untested. + """ + _stub_uvicorn(monkeypatch) + + monkeypatch.setitem(sys.modules, "uvicorn._compat", None) + + def _raise_keyboard_interrupt(coro): + coro.close() + raise KeyboardInterrupt + + monkeypatch.setattr(asyncio, "run", _raise_keyboard_interrupt) + + try: + web_server.start_server(host="127.0.0.1", port=0, open_browser=False) + except KeyboardInterrupt: + pytest.fail( + "start_server must treat serve-time KeyboardInterrupt as a clean " + "shutdown on the Windows pre-0.36 fallback, not propagate it" + ) diff --git a/tests/tools/test_binary_document_write_guard.py b/tests/tools/test_binary_document_write_guard.py new file mode 100644 index 0000000000000..dff0cefd3a29c --- /dev/null +++ b/tests/tools/test_binary_document_write_guard.py @@ -0,0 +1,188 @@ +"""Tests for the binary-document write guard (port of nearai/ironclaw#7109). + +A plain-text write can never produce a valid OOXML/OLE/ODF container, so +write_file/patch must refuse to write text into .docx/.xlsx/.pptx (and +friends), and must refuse to OVERWRITE an existing .pdf — while still +allowing new-.pdf creation (raw PDF syntax is text-authorable). +""" + +import json +import zipfile +from pathlib import Path + +from tools.binary_extensions import ( + has_opaque_document_extension, + is_pdf_path, +) +from tools.file_tools import ( + _check_binary_document_write, + patch_tool, + write_file_tool, +) + + +def _make_minimal_docx(path: Path) -> None: + with zipfile.ZipFile(path, "w") as z: + z.writestr( + "[Content_Types].xml", + '', + ) + z.writestr( + "word/document.xml", + '' + "Quarterly numbers look good." + "", + ) + + +class TestExtensionHelpers: + def test_opaque_document_extensions(self): + for p in ("a.docx", "b.XLSX", "c.pptx", "d.doc", "e.odt", "f.ods", "g.odp", + "h.docm", "i.xlsm", "j.xlsb", "k.pptm", "l.ppsx", "m.ppsm", + "n.pps", "o.pot", "p.rtf", "q.epub"): + assert has_opaque_document_extension(p) is True, f"{p} should be opaque" + + def test_non_opaque_paths(self): + for p in ("a.txt", "b.py", "c.pdf", "d.md", "noext", "e.csv"): + assert has_opaque_document_extension(p) is False + + def test_is_pdf_path(self): + assert is_pdf_path("report.pdf") is True + assert is_pdf_path("report.PDF") is True + assert is_pdf_path("report.txt") is False + + +class TestCheckBinaryDocumentWrite: + def test_docx_always_rejected(self, tmp_path: Path): + # Even a NON-existing docx is rejected — text can't be a valid container. + err = _check_binary_document_write(str(tmp_path / "new.docx")) + assert err is not None + assert ".docx" in err + + def test_existing_pdf_rejected(self, tmp_path: Path): + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF-1.4\n%%EOF\n") + err = _check_binary_document_write(str(pdf)) + assert err is not None + assert "overwrite" in err.lower() + + def test_new_pdf_allowed(self, tmp_path: Path): + assert _check_binary_document_write(str(tmp_path / "fresh.pdf")) is None + + def test_plain_text_allowed(self, tmp_path: Path): + assert _check_binary_document_write(str(tmp_path / "notes.txt")) is None + + +class TestWriteFileToolGuard: + def test_write_file_rejects_existing_docx(self, tmp_path: Path): + docx = tmp_path / "report.docx" + _make_minimal_docx(docx) + original = docx.read_bytes() + + result = json.loads(write_file_tool(str(docx), "edited text")) + + assert result.get("error"), "text write into .docx must be refused" + assert docx.read_bytes() == original, "document bytes must be untouched" + assert zipfile.is_zipfile(docx), "document must remain a valid container" + + def test_write_file_rejects_docm(self, tmp_path: Path): + """Regression: .docm is extractable by read_file (anydoc) but was + missing from OPAQUE_DOCUMENT_EXTENSIONS in the original PR #82818. + Flagged by @egilewski — proven live: text write corrupted the zip.""" + docm = tmp_path / "macro.docm" + _make_minimal_docx(docm) # same OOXML zip structure + original = docm.read_bytes() + + result = json.loads(write_file_tool(str(docm), "edited text")) + + assert result.get("error"), "text write into .docm must be refused" + assert docm.read_bytes() == original, "document bytes must be untouched" + assert zipfile.is_zipfile(docm), "document must remain a valid container" + + def test_write_file_rejects_new_docx(self, tmp_path: Path): + result = json.loads(write_file_tool(str(tmp_path / "new.docx"), "hello")) + assert result.get("error") + assert not (tmp_path / "new.docx").exists() + + def test_write_file_rejects_existing_pdf_overwrite(self, tmp_path: Path): + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF-1.4\n1 0 obj\nendobj\n%%EOF\n") + original = pdf.read_bytes() + + result = json.loads(write_file_tool(str(pdf), "replacement text")) + + assert result.get("error") + assert pdf.read_bytes() == original + + def test_write_file_allows_new_pdf_creation(self, tmp_path: Path): + pdf = tmp_path / "generated.pdf" + result = json.loads(write_file_tool(str(pdf), "%PDF-1.4\n%%EOF\n")) + assert not result.get("error") + assert pdf.exists() + + def test_write_file_plain_text_unaffected(self, tmp_path: Path): + target = tmp_path / "notes.txt" + result = json.loads(write_file_tool(str(target), "hello world")) + assert not result.get("error") + assert target.read_text() == "hello world" + + +class TestPatchToolGuard: + def test_patch_replace_rejects_docx(self, tmp_path: Path): + docx = tmp_path / "report.docx" + _make_minimal_docx(docx) + original = docx.read_bytes() + + result = json.loads( + patch_tool(mode="replace", path=str(docx), + old_string="good", new_string="great") + ) + + assert result.get("error") + assert docx.read_bytes() == original + + def test_patch_v4a_update_rejects_docx(self, tmp_path: Path): + docx = tmp_path / "report.docx" + _make_minimal_docx(docx) + original = docx.read_bytes() + + v4a = ( + "*** Begin Patch\n" + f"*** Update File: {docx}\n" + "@@\n" + "-good\n" + "+great\n" + "*** End Patch" + ) + result = json.loads(patch_tool(mode="patch", patch=v4a)) + + assert result.get("error") + assert docx.read_bytes() == original + + def test_patch_v4a_delete_of_docx_not_blocked_by_guard(self, tmp_path: Path): + # Delete doesn't write text content — the binary-document guard must + # not fire for it (delete may still fail/succeed for other reasons). + docx = tmp_path / "old.docx" + _make_minimal_docx(docx) + + v4a = ( + "*** Begin Patch\n" + f"*** Delete File: {docx}\n" + "*** End Patch" + ) + result = json.loads(patch_tool(mode="patch", patch=v4a)) + err = result.get("error") or "" + assert "binary document" not in err.lower() + + def test_patch_replace_plain_text_unaffected(self, tmp_path: Path): + target = tmp_path / "notes.txt" + target.write_text("hello world") + result = json.loads( + patch_tool(mode="replace", path=str(target), + old_string="world", new_string="there") + ) + assert not result.get("error") + assert target.read_text() == "hello there" diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index ba11969ad1e80..0f6a197be9c9a 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -101,6 +101,28 @@ def test_browser_use_telemetry_defaults_off(self, monkeypatch): env = bu_cli._base_subprocess_env() assert env["ANONYMIZED_TELEMETRY"] == "false" + def test_subprocess_env_strips_parent_python_import_paths(self, monkeypatch): + """#83427/#84841/#86006/#86104: the browser-use CLI runs under its + own Python — inherited PYTHONPATH/PYTHONHOME pointing at Hermes's + venv make it import wrong-ABI C-extensions (pydantic_core) and + crash. Both must be stripped; unrelated vars survive.""" + import sys + from types import ModuleType + + browser_tool = ModuleType("tools.browser_tool") + browser_tool._build_browser_env = lambda: { + "PYTHONPATH": "/hermes:/hermes/venv/lib/site-packages", + "PYTHONHOME": "/hermes/venv", + "KEEP_ME": "yes", + } + monkeypatch.setitem(sys.modules, "tools.browser_tool", browser_tool) + + env = bu_cli._base_subprocess_env() + + assert "PYTHONPATH" not in env + assert "PYTHONHOME" not in env + assert env["KEEP_ME"] == "yes" + class TestToolSurfaceSwap: def test_legacy_browser_tools_hidden_in_cli_mode(self, monkeypatch): @@ -706,7 +728,16 @@ def test_timeout_returns_actionable_error(self, tmp_path, monkeypatch): class TestFindCliManagedBin: - """_find_cli probes $HERMES_HOME/bin after PATH (managed uv/uvx/browser-use).""" + """MANAGED-FIRST: _find_cli probes $HERMES_HOME/bin before PATH and + ~/.local/bin, so the Hermes-installed copy always wins.""" + + @pytest.fixture(autouse=True) + def _hermetic_home(self, tmp_path, monkeypatch): + """Pin HOME so the ~/.local/bin probe can't leak the host's real + user-level installs into these real-PATH-probing tests.""" + monkeypatch.setenv("HOME", str(tmp_path / "userhome")) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) def test_managed_bin_browser_use_found(self, tmp_path, monkeypatch): bin_dir = tmp_path / "home" / "bin" @@ -714,8 +745,6 @@ def test_managed_bin_browser_use_found(self, tmp_path, monkeypatch): bu = bin_dir / "browser-use" bu.write_text("#!/bin/sh\n") bu.chmod(bu.stat().st_mode | stat.S_IXUSR) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) - monkeypatch.setenv("PATH", str(tmp_path / "empty")) assert bu_cli._find_cli_unpatched() == [str(bu)] def test_managed_bin_uvx_fallback(self, tmp_path, monkeypatch): @@ -724,20 +753,90 @@ def test_managed_bin_uvx_fallback(self, tmp_path, monkeypatch): uvx = bin_dir / "uvx" uvx.write_text("#!/bin/sh\n") uvx.chmod(uvx.stat().st_mode | stat.S_IXUSR) - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) - monkeypatch.setenv("PATH", str(tmp_path / "empty")) assert bu_cli._find_cli_unpatched() == [str(uvx), "browser-use"] def test_nothing_found(self, tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) - monkeypatch.setenv("PATH", str(tmp_path / "empty")) assert bu_cli._find_cli_unpatched() is None + def test_user_local_bin_browser_use_found(self, tmp_path, monkeypatch): + """#83788: Desktop/TUI workers spawn with a minimal PATH that omits + ~/.local/bin, where `uv tool install browser-use` links the binary + by default — _find_cli must probe it explicitly.""" + cli_dir = tmp_path / "userhome" / ".local" / "bin" + cli_dir.mkdir(parents=True) + cli = cli_dir / "browser-use" + cli.write_text("#!/bin/sh\n") + cli.chmod(cli.stat().st_mode | stat.S_IXUSR) + assert bu_cli._find_cli_unpatched() == [str(cli)] + + def test_managed_bin_precedes_user_local_bin(self, tmp_path, monkeypatch): + """MANAGED-FIRST: Hermes' managed copy wins over a user-level side + install — every backend selection provisions/updates the managed + copy, so resolution must land on the binary we control (no version + drift from stray `uv tool install` runs).""" + user_dir = tmp_path / "userhome" / ".local" / "bin" + user_dir.mkdir(parents=True) + user_cli = user_dir / "browser-use" + user_cli.write_text("#!/bin/sh\n") + user_cli.chmod(user_cli.stat().st_mode | stat.S_IXUSR) + managed_dir = tmp_path / "home" / "bin" + managed_dir.mkdir(parents=True) + managed_cli = managed_dir / "browser-use" + managed_cli.write_text("#!/bin/sh\n") + managed_cli.chmod(managed_cli.stat().st_mode | stat.S_IXUSR) + assert bu_cli._find_cli_unpatched() == [str(managed_cli)] + + def test_managed_bin_precedes_path(self, tmp_path, monkeypatch): + """MANAGED-FIRST: the managed copy also wins over one on PATH.""" + path_dir = tmp_path / "onpath" + path_dir.mkdir() + path_cli = path_dir / "browser-use" + path_cli.write_text("#!/bin/sh\n") + path_cli.chmod(path_cli.stat().st_mode | stat.S_IXUSR) + monkeypatch.setenv("PATH", str(path_dir)) + managed_dir = tmp_path / "home" / "bin" + managed_dir.mkdir(parents=True) + managed_cli = managed_dir / "browser-use" + managed_cli.write_text("#!/bin/sh\n") + managed_cli.chmod(managed_cli.stat().st_mode | stat.S_IXUSR) + assert bu_cli._find_cli_unpatched() == [str(managed_cli)] + + def test_user_local_bin_uvx_fallback(self, tmp_path, monkeypatch): + cli_dir = tmp_path / "userhome" / ".local" / "bin" + cli_dir.mkdir(parents=True) + uvx = cli_dir / "uvx" + uvx.write_text("#!/bin/sh\n") + uvx.chmod(uvx.stat().st_mode | stat.S_IXUSR) + assert bu_cli._find_cli_unpatched() == [str(uvx), "browser-use"] + class TestInstallCli: - def test_already_installed_on_path(self, tmp_path, monkeypatch): + def test_path_install_does_not_short_circuit(self, tmp_path, monkeypatch): + """MANAGED-FIRST: a browser-use on PATH is a user-level side install + and must NOT satisfy install_cli() — only the managed copy does, + otherwise resolution stays pinned to a binary Hermes can't update.""" cli = _fake_cli(tmp_path, "") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) monkeypatch.setattr(bu_cli.shutil, "which", lambda name, path=None: cli if name == "browser-use" and path is None else None) + import sys as _sys + import types as _types + fake = _types.ModuleType("hermes_cli.managed_uv") + fake.ensure_uv = lambda **kw: None + monkeypatch.setitem(_sys.modules, "hermes_cli.managed_uv", fake) + ok, msg = bu_cli.install_cli() + # No uv available in this fixture, so the attempted managed install + # fails — the point is that the PATH copy did not short-circuit. + assert ok is False + assert "already installed" not in msg + + def test_already_installed_in_managed_bin(self, tmp_path, monkeypatch): + bin_dir = tmp_path / "home" / "bin" + bin_dir.mkdir(parents=True) + cli = bin_dir / "browser-use" + cli.write_text("#!/bin/sh\n") + cli.chmod(cli.stat().st_mode | stat.S_IXUSR) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setenv("PATH", str(tmp_path / "empty")) ok, msg = bu_cli.install_cli() assert ok is True assert "already installed" in msg diff --git a/tests/tools/test_cli_approval_exec_ask_leak.py b/tests/tools/test_cli_approval_exec_ask_leak.py new file mode 100644 index 0000000000000..e6137c398f09e --- /dev/null +++ b/tests/tools/test_cli_approval_exec_ask_leak.py @@ -0,0 +1,116 @@ +"""Regression: interactive CLI must not lose the Dangerous Command panel. + +When ``HERMES_EXEC_ASK`` leaks into a classic CLI process (historically via +``import gateway.run`` setting the flag at module import), the ask/gateway +branch used to return ``pending_approval`` immediately with no notify +listener and skip the CLI approval callback. Users saw tools "auto-block" +with no Approve/Deny UI. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +import tools.approval as approval_module +from tools.approval import check_all_command_guards +from tools.terminal_tool import set_approval_callback + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(autouse=True) +def _clean_approval_env(monkeypatch): + for key in ( + "HERMES_EXEC_ASK", + "HERMES_GATEWAY_SESSION", + "HERMES_SESSION_PLATFORM", + "HERMES_CRON_SESSION", + "HERMES_YOLO_MODE", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False) + monkeypatch.setattr( + approval_module, + "_get_approval_mode", + lambda: "manual", + ) + monkeypatch.setattr( + "tools.tirith_security.check_command_security", + lambda _command: {"action": "allow", "findings": [], "summary": ""}, + ) + approval_module._session_approved.clear() + approval_module._permanent_approved.clear() + approval_module._pending.clear() + set_approval_callback(None) + yield + set_approval_callback(None) + + +class TestCliApprovalSurvivesExecAskLeak: + def test_cli_callback_used_when_exec_ask_set_without_notifier(self, monkeypatch): + """Ask-mode with a CLI callback must prompt locally, not pending_approval.""" + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + calls = [] + + def _cb(command, description, **kwargs): + calls.append((command, description)) + return "once" + + set_approval_callback(_cb) + result = check_all_command_guards("rm -rf /tmp/testdir", "local") + + assert calls, "CLI approval callback was never invoked" + assert result.get("status") != "pending_approval" + assert result.get("approval_pending") is not True + assert result.get("approved") is True + assert result.get("user_approved") is True + + def test_pending_approval_still_used_without_cli_callback(self, monkeypatch): + """Headless ask-mode without a CLI callback keeps the pending fallback.""" + monkeypatch.setenv("HERMES_EXEC_ASK", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + set_approval_callback(None) + + result = check_all_command_guards("rm -rf /tmp/testdir", "local") + + assert result.get("approved") is False + assert result.get("status") == "pending_approval" + assert result.get("approval_pending") is True + + +class TestGatewayRunImportDoesNotSetExecAsk: + def test_importing_gateway_run_does_not_set_exec_ask(self, tmp_path): + """Incidental imports must not poison CLI ask-mode process-wide.""" + script = r""" +import os, sys +os.environ.pop("HERMES_EXEC_ASK", None) +sys.path.insert(0, %r) +# Avoid starting the gateway; only import the module for _gateway_runner_ref +# style side imports. +import gateway.run # noqa: F401 +print("EXEC_ASK=" + repr(os.environ.get("HERMES_EXEC_ASK"))) +""" % (str(REPO_ROOT),) + hermes_home = tmp_path / "import-test-home" + proc = subprocess.run( + [sys.executable, "-c", script], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + env={ + **os.environ, + "HERMES_HOME": str(hermes_home), + }, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + assert "EXEC_ASK=None" in proc.stdout, proc.stdout + proc.stderr diff --git a/tests/tui_gateway/test_attach_does_not_wait_for_agent.py b/tests/tui_gateway/test_attach_does_not_wait_for_agent.py new file mode 100644 index 0000000000000..cbd42fdfb549f --- /dev/null +++ b/tests/tui_gateway/test_attach_does_not_wait_for_agent.py @@ -0,0 +1,138 @@ +"""Attach RPCs must not block on the deferred agent build. + +``image.attach``, ``image.attach_bytes``, ``file.attach``, ``pdf.attach`` and +``clipboard.paste`` need the session RECORD (cwd, profile_home, +attached_images) — never the agent. They also run inline on the socket reader +thread (none is in ``_LONG_HANDLERS``), so any wait there stalls every RPC +queued behind them on the same socket, including the ``prompt.submit`` that +carries the image. + +These are invariants, not timings: the handler must complete while the build +event is still unset, and the staged image must still reach the turn. +""" + +from __future__ import annotations + +import base64 +import threading + +import pytest + +from tui_gateway import server + +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + bytes(range(256)) * 4 + + +def building_session(tmp_path, sid: str) -> dict: + """A session record whose deferred agent build has NOT completed.""" + session = { + "agent": None, + "agent_ready": threading.Event(), # deliberately never set + "agent_error": None, + "attached_images": [], + "cwd": str(tmp_path), + "history": [], + "history_lock": threading.RLock(), + "history_version": 0, + "image_counter": 0, + "profile_home": str(tmp_path), + "running": False, + "session_key": sid, + "transport": None, + } + server._sessions[sid] = session + return session + + +@pytest.fixture +def no_build(monkeypatch): + """Never let the real builder run — the point is the unfinished build.""" + monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None) + + +@pytest.fixture +def session(tmp_path, no_build, request): + sid = f"attach-{request.node.name}" + record = building_session(tmp_path, sid) + yield sid, record + server._sessions.pop(sid, None) + + +def call(method: str, params: dict) -> dict: + return server._methods[method](1, params) + + +@pytest.mark.parametrize( + ("method", "extra"), + [ + ("image.attach_bytes", {"content_base64": base64.b64encode(PNG_BYTES).decode(), "filename": "a.png"}), + ("file.attach", {"name": "notes.txt"}), + ], +) +def test_attach_completes_while_agent_is_still_building(session, tmp_path, method, extra): + """The handler returns without waiting on ``agent_ready``.""" + sid, record = session + + if method == "file.attach": + target = tmp_path / "notes.txt" + target.write_text("hello") + extra = {**extra, "path": str(target)} + + response = call(method, {"session_id": sid, **extra}) + + assert "error" not in response, response + assert response["result"]["attached"] is True + # The invariant that makes this a fix rather than a coincidence: the build + # never finished, and the attach landed anyway. + assert not record["agent_ready"].is_set() + + +def test_attached_image_is_queued_for_the_next_turn(session): + """Not blocking must not mean not staging — the turn still gets the image.""" + sid, record = session + + response = call( + "image.attach_bytes", + { + "session_id": sid, + "content_base64": base64.b64encode(PNG_BYTES).decode(), + "filename": "shot.png", + }, + ) + + staged = response["result"]["path"] + assert record["attached_images"] == [staged] + assert response["result"]["count"] == 1 + + +def test_attach_still_rejects_an_unknown_session(no_build): + """Dropping the agent wait must not drop session validation.""" + response = call("image.attach_bytes", {"session_id": "nope", "content_base64": "eA=="}) + + assert response["error"]["code"] == 4001 + + +def test_detach_completes_while_agent_is_still_building(session): + """Detach is the same class as attach — record-only, so it must not wait.""" + sid, record = session + record["attached_images"] = ["/tmp/one.png", "/tmp/two.png"] + + response = call("image.detach", {"session_id": sid, "path": "/tmp/one.png"}) + + assert response["result"]["detached"] is True + assert record["attached_images"] == ["/tmp/two.png"] + assert not record["agent_ready"].is_set() + + +def test_sess_building_does_not_wait_but_sess_does(session, monkeypatch): + """The two resolvers differ in exactly one way: the wait.""" + sid, _record = session + waited: list[str] = [] + + monkeypatch.setattr(server, "_wait_agent", lambda s, rid: waited.append(rid) or None) + + server._sess_building({"session_id": sid}, "rid-building") + assert waited == [] + + server._sess({"session_id": sid}, "rid-sess") + assert waited == ["rid-sess"] diff --git a/tests/tui_gateway/test_ephemeral_profile_override.py b/tests/tui_gateway/test_ephemeral_profile_override.py new file mode 100644 index 0000000000000..f2588d264cfb9 --- /dev/null +++ b/tests/tui_gateway/test_ephemeral_profile_override.py @@ -0,0 +1,158 @@ +"""Regression tests: profile HERMES_HOME override in ephemeral agent threads (#50233). + +Why: normal prompt turns bind ``session['profile_home']`` via +``set_hermes_home_override`` before ``run_conversation`` so the turn runs against +the correct profile home. The two ephemeral RPC paths — ``prompt.background`` and +``preview.restart`` — spawn a fresh ``AIAgent`` on a NEW thread, and the +``HERMES_HOME`` ContextVar set on the session-create thread does NOT propagate to +those threads. Without an explicit re-bind, a background/preview-restart turn under +a non-default profile would run against the wrong home. This module locks in: + + 1. ``prompt.background`` re-binds ``profile_home`` for the ephemeral turn. + 2. ``preview.restart`` re-binds ``profile_home`` for the ephemeral turn AND does + NOT close the ephemeral agent (a task-wide ``AIAgent.close()`` would kill the + background server the restart just started — maintainer problem #1). + 3. Both paths RESTORE the override after the turn (reset token from set), exactly + like the normal prompt turn, and skip the bind entirely when no profile is set. + +How to test: run this module with pytest; each test drives the real RPC handler +from ``tui_gateway.server._methods`` with ``threading.Thread`` patched to run the +target inline, then asserts on the recorded override set/reset calls and the agent. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from tui_gateway import server as srv + + +PROFILE_HOME = "/home/user/.hermes/profiles/work" + + +class _InlineThread: + """Drop-in for ``threading.Thread`` that runs the target synchronously. + + Why: the ephemeral RPC handlers do their real work inside ``run()`` on a + spawned thread; running it inline makes the override set/reset observable + within the test without racing a real background thread. + """ + + def __init__(self, target=None, daemon=None, **_kwargs): + self._target = target + + def start(self): + if self._target is not None: + self._target() + + +@pytest.fixture +def fake_session(): + """A minimal session carrying a non-default ``profile_home``.""" + agent = MagicMock() + return {"agent": agent, "session_key": "sess_k", "profile_home": PROFILE_HOME} + + +@pytest.fixture +def override_calls(): + """Patch set/reset override + AIAgent + emit/context helpers; record calls. + + Returns a dict with the mocks so each test can assert on set/reset ordering + and on whether the ephemeral agent was closed. + """ + agent_instance = MagicMock() + # run_conversation returns a plain dict like the real agent does. + agent_instance.run_conversation.return_value = {"final_response": "done"} + + with patch("tui_gateway.server.threading.Thread", _InlineThread), \ + patch("tui_gateway.server.set_hermes_home_override", return_value="TOK") as m_set, \ + patch("tui_gateway.server.reset_hermes_home_override") as m_reset, \ + patch("tui_gateway.server._background_agent_kwargs", return_value={}), \ + patch("tui_gateway.server._ephemeral_preview_agent_kwargs", return_value={}), \ + patch("tui_gateway.server._preview_restart_callbacks", return_value={}), \ + patch("tui_gateway.server._preview_restart_history", return_value=[]), \ + patch("tui_gateway.server._set_session_context", return_value=None), \ + patch("tui_gateway.server._clear_session_context"), \ + patch("tui_gateway.server._session_cwd", return_value="/tmp"), \ + patch("tui_gateway.server._emit"), \ + patch("run_agent.AIAgent", return_value=agent_instance) as m_agent: + yield { + "set": m_set, + "reset": m_reset, + "agent_cls": m_agent, + "agent": agent_instance, + } + + +def _run(method_name, params, session): + """Invoke a registered RPC handler with ``_sess`` patched to our session.""" + handler = srv._methods[method_name] + with patch("tui_gateway.server._sess", return_value=(session, None)): + return handler("rid1", params) + + +class TestBackgroundProfileOverride: + def test_background_binds_and_restores_profile_home(self, fake_session, override_calls): + """prompt.background binds profile_home for the ephemeral turn and restores it.""" + _run("prompt.background", {"text": "hi", "session_id": "ui1"}, fake_session) + + override_calls["set"].assert_called_once_with(PROFILE_HOME) + override_calls["reset"].assert_called_once_with("TOK") + override_calls["agent"].run_conversation.assert_called_once() + + def test_background_no_profile_skips_override(self, override_calls): + """With no profile_home the background path never touches the override.""" + session = {"agent": MagicMock(), "session_key": "sess_k", "profile_home": None} + _run("prompt.background", {"text": "hi", "session_id": "ui1"}, session) + + override_calls["set"].assert_not_called() + override_calls["reset"].assert_not_called() + override_calls["agent"].run_conversation.assert_called_once() + + def test_background_restores_override_on_error(self, fake_session, override_calls): + """A failing turn must still restore the override (finally-block parity).""" + override_calls["agent"].run_conversation.side_effect = RuntimeError("boom") + _run("prompt.background", {"text": "hi", "session_id": "ui1"}, fake_session) + + override_calls["set"].assert_called_once_with(PROFILE_HOME) + override_calls["reset"].assert_called_once_with("TOK") + + +class TestPreviewRestartProfileOverride: + def test_preview_binds_and_restores_profile_home(self, fake_session, override_calls): + """preview.restart binds profile_home for the ephemeral turn and restores it.""" + _run( + "preview.restart", + {"url": "http://localhost:5173", "cwd": "", "session_id": "ui1"}, + fake_session, + ) + + override_calls["set"].assert_called_once_with(PROFILE_HOME) + override_calls["reset"].assert_called_once_with("TOK") + override_calls["agent"].run_conversation.assert_called_once() + + def test_preview_does_not_close_agent(self, fake_session, override_calls): + """The restarted preview server must survive: the ephemeral agent is NOT + closed via task-wide process cleanup (maintainer problem #1).""" + _run( + "preview.restart", + {"url": "http://localhost:5173", "cwd": "", "session_id": "ui1"}, + fake_session, + ) + + # A task-wide AIAgent.close() would kill every process for this task_id, + # tearing down the very background server the restart just launched. + override_calls["agent"].close.assert_not_called() + + def test_preview_no_profile_skips_override(self, override_calls): + """With no profile_home the preview path never touches the override.""" + session = {"agent": MagicMock(), "session_key": "sess_k", "profile_home": None} + _run( + "preview.restart", + {"url": "http://localhost:5173", "cwd": "", "session_id": "ui1"}, + session, + ) + + override_calls["set"].assert_not_called() + override_calls["reset"].assert_not_called() + override_calls["agent"].close.assert_not_called() diff --git a/tests/tui_gateway/test_loop_command.py b/tests/tui_gateway/test_loop_command.py new file mode 100644 index 0000000000000..41522bcc49fd4 --- /dev/null +++ b/tests/tui_gateway/test_loop_command.py @@ -0,0 +1,205 @@ +"""Tests for /loop handling in tui_gateway. + +The TUI routes ``/loop`` through ``command.dispatch`` (same rationale as +``/goal`` — the CLI handler drives ``_pending_input``, which the slash +worker has no reader for). State mutations go through the shared +``dispatch_loop_command``; the per-session notification poller fires due +wakeups via ``_maybe_fire_tui_loop_tick``. +""" + +from __future__ import annotations + +import importlib +import threading +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture() +def hermes_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli import loops + + loops._DB_CACHE.clear() + yield home + loops._DB_CACHE.clear() + + +@pytest.fixture() +def server(hermes_home): + with patch.dict( + "sys.modules", + { + "hermes_cli.env_loader": MagicMock(), + "hermes_cli.banner": MagicMock(), + }, + ): + mod = importlib.import_module("tui_gateway.server") + yield mod + mod._sessions.clear() + mod._pending.clear() + mod._answers.clear() + + +@pytest.fixture() +def session(server): + sid = "sid-loop-test" + session_key = "tui-loop-session-1" + s = { + "session_key": session_key, + "history": [], + "history_lock": threading.Lock(), + "history_version": 0, + "running": False, + "attached_images": [], + "cols": 120, + } + server._sessions[sid] = s + return sid, session_key, s + + +def _call(server, method, **params): + handler = server._methods[method] + return handler(1, params) + + +# ── command.dispatch /loop ──────────────────────────────────────────── + + +def test_loop_bare_shows_status_when_none_set(server, session): + sid, _, _ = session + r = _call(server, "command.dispatch", name="loop", arg="", session_id=sid) + assert r["result"]["type"] == "exec" + assert "No loop set" in r["result"]["output"] + + +def test_loop_set_persists(server, session): + sid, session_key, _ = session + r = _call(server, "command.dispatch", name="loop", arg="5m check the deploy", session_id=sid) + result = r["result"] + assert result["type"] == "exec" + assert "Loop set" in result["output"] + + from hermes_cli.loops import LoopManager + + mgr = LoopManager(session_key) + assert mgr.state is not None + assert mgr.state.prompt == "check the deploy" + assert mgr.state.status == "active" + assert mgr.state.interval_seconds == 300.0 + + +def test_loop_proactive_alias_resolves(server, session): + sid, _, _ = session + r = _call(server, "command.dispatch", name="proactive", arg="5m ping", session_id=sid) + assert "Loop set" in r["result"]["output"] + + +def test_loop_pause_resume_stop(server, session): + sid, session_key, _ = session + _call(server, "command.dispatch", name="loop", arg="5m poll CI", session_id=sid) + + r = _call(server, "command.dispatch", name="loop", arg="pause", session_id=sid) + assert "paused" in r["result"]["output"].lower() + + r = _call(server, "command.dispatch", name="loop", arg="resume", session_id=sid) + assert "resumed" in r["result"]["output"].lower() + + r = _call(server, "command.dispatch", name="loop", arg="stop", session_id=sid) + assert "stopped" in r["result"]["output"].lower() + + from hermes_cli.loops import LoopManager + + assert not LoopManager(session_key).has_loop() + + +def test_loop_requires_session(server): + r = _call(server, "command.dispatch", name="loop", arg="5m x", session_id="unknown") + assert "error" in r + assert r["error"]["code"] == 4001 + + +# ── idle wakeup driver ──────────────────────────────────────────────── + + +def test_tui_tick_fires_when_idle_and_due(server, session): + sid, session_key, s = session + from hermes_cli.loops import LoopManager, save_loop + + mgr = LoopManager(session_key) + mgr.set("poll the build", interval_seconds=60) + mgr.state.next_due_at = time.time() - 1 + save_loop(session_key, mgr.state) + + fired = {} + + def fake_submit(rid, sid_, session_, text, **kwargs): + fired["text"] = text + + with patch.object(server, "_run_prompt_submit", fake_submit), \ + patch.object(server, "_emit"): + server._maybe_fire_tui_loop_tick(sid, s) + + assert "poll the build" in fired.get("text", "") + assert "[/loop wakeup #1" in fired["text"] + # Session claimed for the wakeup turn. + assert s["running"] is True + + +def test_tui_tick_defers_when_running(server, session): + sid, session_key, s = session + from hermes_cli.loops import LoopManager, save_loop + + mgr = LoopManager(session_key) + mgr.set("poll", interval_seconds=60) + mgr.state.next_due_at = time.time() - 1 + save_loop(session_key, mgr.state) + s["running"] = True + + with patch.object(server, "_run_prompt_submit") as submit, \ + patch.object(server, "_emit"): + server._maybe_fire_tui_loop_tick(sid, s) + + submit.assert_not_called() + # Tick not consumed — still due for the next poll. + assert LoopManager(session_key).state.ticks_fired == 0 + + +def test_tui_tick_defers_to_active_goal(server, session): + sid, session_key, s = session + from hermes_cli.goals import GoalManager + from hermes_cli.loops import LoopManager, save_loop + + GoalManager(session_id=session_key).set("finish the feature") + mgr = LoopManager(session_key) + mgr.set("poll", interval_seconds=60) + mgr.state.next_due_at = time.time() - 1 + save_loop(session_key, mgr.state) + + with patch.object(server, "_run_prompt_submit") as submit, \ + patch.object(server, "_emit"): + server._maybe_fire_tui_loop_tick(sid, s) + + submit.assert_not_called() + assert s["running"] is False + + +def test_tui_tick_noop_when_not_due(server, session): + sid, session_key, s = session + from hermes_cli.loops import LoopManager + + LoopManager(session_key).set("poll", interval_seconds=300) + + with patch.object(server, "_run_prompt_submit") as submit, \ + patch.object(server, "_emit"): + server._maybe_fire_tui_loop_tick(sid, s) + + submit.assert_not_called() + assert s["running"] is False diff --git a/tests/tui_gateway/test_mcp_profile_rpcs.py b/tests/tui_gateway/test_mcp_profile_rpcs.py new file mode 100644 index 0000000000000..a8ac2974b99f6 --- /dev/null +++ b/tests/tui_gateway/test_mcp_profile_rpcs.py @@ -0,0 +1,248 @@ +"""E2E tests for the per-profile MCP lifecycle RPCs (mcp.servers.*). + +These drive the real registered gateway handlers against a real temp +``HERMES_HOME`` with named profile dirs — no mocks of the config/mcp layer — and +assert that every write lands in the RIGHT profile's ``config.yaml`` / ``.env`` +and NEVER leaks into the launch (default) profile. + +Covered: add + list + set_api_key + remove, profile isolation, and the +duplicate/not-found error envelopes. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +import tui_gateway.server as server + + +@pytest.fixture +def hermes_root(tmp_path, monkeypatch): + """A temp HERMES_HOME root with two named profiles: 'work' and 'other'. + + Pointing HERMES_HOME at a dir outside ~/.hermes makes it the profile ROOT + (get_default_hermes_root's Docker/custom branch), so named profiles live at + ``/profiles//`` and the launch/default profile is ````. + """ + root = tmp_path / "hermes_home" + (root / "profiles" / "work").mkdir(parents=True) + (root / "profiles" / "other").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(root)) + # Make sure no stale process-wide home override leaks in from another test. + from hermes_constants import get_hermes_home_override + + assert get_hermes_home_override() is None + return root + + +def _call(method, params=None): + handler = server._methods[method] + return handler(1, params or {}) + + +def _result(resp): + assert "error" not in resp, resp.get("error") + return resp["result"] + + +def _read_yaml(path: Path) -> dict: + """Read a config.yaml directly for assertions (test-side, not the guarded loader).""" + import yaml + + if not path.is_file(): + return {} + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def test_add_lands_in_named_profile_only(hermes_root): + root = hermes_root + resp = _call( + "mcp.servers.add", + { + "profile": "work", + "name": "weather", + "config": {"url": "https://mcp.example.com/weather"}, + }, + ) + result = _result(resp) + assert result["ok"] is True + assert result["server"]["transport"] == "http" + assert result["server"]["url"] == "https://mcp.example.com/weather" + + work_cfg = _read_yaml(root / "profiles" / "work" / "config.yaml") + assert "weather" in work_cfg.get("mcp_servers", {}) + assert work_cfg["mcp_servers"]["weather"]["url"] == "https://mcp.example.com/weather" + + # The launch/default profile and the sibling profile stay untouched. + default_cfg = _read_yaml(root / "config.yaml") + assert "weather" not in default_cfg.get("mcp_servers", {}) + other_cfg = _read_yaml(root / "profiles" / "other" / "config.yaml") + assert "weather" not in other_cfg.get("mcp_servers", {}) + + +def test_list_reflects_the_scoped_profile(hermes_root): + _result( + _call( + "mcp.servers.add", + {"profile": "work", "name": "svc-a", "config": {"command": "svc-a-bin"}}, + ) + ) + _result( + _call( + "mcp.servers.add", + {"profile": "other", "name": "svc-b", "config": {"command": "svc-b-bin"}}, + ) + ) + + work_names = [s["name"] for s in _result(_call("mcp.servers.list", {"profile": "work"}))["servers"]] + other_names = [s["name"] for s in _result(_call("mcp.servers.list", {"profile": "other"}))["servers"]] + + assert work_names == ["svc-a"] + assert other_names == ["svc-b"] + + # stdio transport surfaced correctly. + work_server = _result(_call("mcp.servers.list", {"profile": "work"}))["servers"][0] + assert work_server["transport"] == "stdio" + assert work_server["command"] == "svc-a-bin" + + +def test_set_api_key_writes_env_and_header_to_right_profile(hermes_root): + root = hermes_root + _result( + _call( + "mcp.servers.add", + { + "profile": "work", + "name": "gizmo", + "config": {"url": "https://mcp.example.com/gizmo"}, + }, + ) + ) + + resp = _result( + _call( + "mcp.servers.set_api_key", + {"profile": "work", "name": "gizmo", "value": "sk-secret-123"}, + ) + ) + assert resp["ok"] is True + env_var = resp["env_var"] + assert env_var == "MCP_GIZMO_API_KEY" + + # The secret is in the work profile's .env — and NOT the default profile's. + work_env = (root / "profiles" / "work" / ".env").read_text(encoding="utf-8") + assert "MCP_GIZMO_API_KEY=sk-secret-123" in work_env + assert not (root / ".env").exists() or "sk-secret-123" not in (root / ".env").read_text( + encoding="utf-8" + ) + + # config.yaml stores only the interpolation template, never the raw secret. + work_cfg = _read_yaml(root / "profiles" / "work" / "config.yaml") + headers = work_cfg["mcp_servers"]["gizmo"]["headers"] + assert headers["Authorization"] == "Bearer ${MCP_GIZMO_API_KEY}" + assert "sk-secret-123" not in str(work_cfg) + + +def test_set_api_key_stdio_references_env_block(hermes_root): + root = hermes_root + _result( + _call( + "mcp.servers.add", + {"profile": "work", "name": "localtool", "config": {"command": "localtool-bin"}}, + ) + ) + resp = _result( + _call( + "mcp.servers.set_api_key", + { + "profile": "work", + "name": "localtool", + "env_var": "LOCALTOOL_TOKEN", + "value": "tok-xyz", + }, + ) + ) + assert resp["env_var"] == "LOCALTOOL_TOKEN" + + work_cfg = _read_yaml(root / "profiles" / "work" / "config.yaml") + env_block = work_cfg["mcp_servers"]["localtool"]["env"] + assert env_block["LOCALTOOL_TOKEN"] == "${LOCALTOOL_TOKEN}" + work_env = (root / "profiles" / "work" / ".env").read_text(encoding="utf-8") + assert "LOCALTOOL_TOKEN=tok-xyz" in work_env + + +def test_remove_scoped_to_profile(hermes_root): + root = hermes_root + _result( + _call( + "mcp.servers.add", + {"profile": "work", "name": "temp", "config": {"command": "temp-bin"}}, + ) + ) + # Same-named server in a different profile must be unaffected by the remove. + _result( + _call( + "mcp.servers.add", + {"profile": "other", "name": "temp", "config": {"command": "temp-bin"}}, + ) + ) + + resp = _result(_call("mcp.servers.remove", {"profile": "work", "name": "temp"})) + assert resp["removed"] is True + + assert "temp" not in _read_yaml(root / "profiles" / "work" / "config.yaml").get("mcp_servers", {}) + # The 'other' profile still has its server. + assert "temp" in _read_yaml(root / "profiles" / "other" / "config.yaml").get("mcp_servers", {}) + + +def test_add_duplicate_and_missing_errors(hermes_root): + _result( + _call( + "mcp.servers.add", + {"profile": "work", "name": "dup", "config": {"command": "dup-bin"}}, + ) + ) + dup = _call( + "mcp.servers.add", + {"profile": "work", "name": "dup", "config": {"command": "dup-bin"}}, + ) + assert "error" in dup + assert dup["error"]["code"] == 4090 + + missing = _call("mcp.servers.remove", {"profile": "work", "name": "nope"}) + assert "error" in missing + assert missing["error"]["code"] == 4064 + + bad_profile = _call( + "mcp.servers.add", + {"profile": "ghost", "name": "x", "config": {"command": "x"}}, + ) + assert "error" in bad_profile + assert bad_profile["error"]["code"] == 4064 + + +def test_add_requires_transport(hermes_root): + resp = _call("mcp.servers.add", {"profile": "work", "name": "empty", "config": {}}) + assert "error" in resp + assert resp["error"]["code"] == 4063 + + +def test_default_profile_add_when_profile_omitted(hermes_root): + root = hermes_root + _result( + _call( + "mcp.servers.add", + {"name": "rootsvc", "config": {"command": "rootsvc-bin"}}, + ) + ) + # Omitted profile → launch/default profile == HERMES_HOME root config.yaml. + default_cfg = _read_yaml(root / "config.yaml") + assert "rootsvc" in default_cfg.get("mcp_servers", {}) + # ...and NOT in a named profile. + assert "rootsvc" not in _read_yaml(root / "profiles" / "work" / "config.yaml").get( + "mcp_servers", {} + ) diff --git a/tests/tui_gateway/test_slash_worker_profile_home.py b/tests/tui_gateway/test_slash_worker_profile_home.py index e8589c7861959..c1459bc5bf13b 100644 --- a/tests/tui_gateway/test_slash_worker_profile_home.py +++ b/tests/tui_gateway/test_slash_worker_profile_home.py @@ -1,34 +1,35 @@ """Tests for TUI gateway slash_worker profile_home propagation (#40677).""" -import os -import subprocess -import sys -from unittest.mock import MagicMock, patch, call - -import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch def test_slash_worker_accepts_profile_home(): """_SlashWorker.__init__ accepts profile_home parameter.""" + # hermes_state evaluates get_hermes_home() / "state.db" at import time, so + # the mock must return a Path (a bare str raises TypeError under per-file + # subprocess isolation). with patch.dict("sys.modules", { - "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + "hermes_constants": MagicMock( + get_hermes_home=MagicMock(return_value=Path("/tmp/hermes_test")), + ), }): with patch("subprocess.Popen") as mock_popen: mock_popen.return_value.stdout = MagicMock() mock_popen.return_value.stderr = MagicMock() - + from tui_gateway.server import _SlashWorker - + # Test initialization with profile_home worker = _SlashWorker( session_key="test_key", model="test-model", profile_home="/home/luke/.hermes/profiles/work" ) - + # Verify Popen was called assert mock_popen.called - + # Check that HERMES_HOME was set in the environment call_kwargs = mock_popen.call_args[1] assert "env" in call_kwargs diff --git a/tools/approval.py b/tools/approval.py index 194bbd636fa21..db0595747cd64 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -284,6 +284,38 @@ def _is_gateway_approval_context() -> bool: return True return bool(_get_session_platform()) + +def _resolve_cli_approval_callback(approval_callback=None): + """Return an interactive CLI approval callback when one is available. + + Prefers an explicitly passed callback, then the per-thread CLI callback + registered via ``tools.terminal_tool.set_approval_callback``. + """ + if approval_callback is not None: + return approval_callback + try: + from tools.terminal_tool import _get_approval_callback + return _get_approval_callback() + except Exception: + return None + + +def _should_fall_through_to_cli_approval( + *, + is_cli: bool, + approval_callback, + notify_cb, +) -> bool: + """Prefer the classic CLI Dangerous Command panel over silent pending. + + ``HERMES_EXEC_ASK`` (and sometimes a session platform marker) can leak into + an interactive CLI process — most commonly via ``import gateway.run``, which + historically set ask-mode as a module-level side effect. Without a gateway + notify listener, the ask/gateway branch used to return ``pending_approval`` + immediately and skip the CLI panel the user can actually answer. + """ + return bool(is_cli and approval_callback is not None and notify_cb is None) + # Sensitive write targets that should trigger approval even when referenced # via shell expansions like $HOME or $HERMES_HOME, or by the resolved absolute # active profile home path such as /home/hermes/.hermes/config.yaml. The @@ -3293,12 +3325,7 @@ def _run_approval_gate( if is_approved(session_key, pattern_key): return {"approved": True, "message": None} - if approval_callback is None: - try: - from tools.terminal_tool import _get_approval_callback - approval_callback = _get_approval_callback() - except Exception: - approval_callback = None + approval_callback = _resolve_cli_approval_callback(approval_callback) is_cli = _is_interactive_cli() is_gateway = _is_gateway_approval_context() @@ -3407,24 +3434,32 @@ def _run_approval_gate( save_permanent_allowlist(_permanent_approved) return {"approved": True, "message": None} - # No notify callback (e.g. API server without an attached chat): - # queue for /approve /deny review, agent sees approval_required. - submit_pending(session_key, { - "command": display_target, - "pattern_key": pattern_key, - "description": description, - }) - return { - "approved": False, - "pattern_key": pattern_key, - "status": "approval_required", - "command": display_target, - "description": description, - "message": ( - f"⚠️ This action is potentially dangerous ({description}). " - f"Asking the user for approval.\n\n**Target:**\n```\n{display_target}\n```" - ), - } + # No notify callback: interactive CLI with a panel callback should + # still prompt locally instead of queuing a pending approval nobody + # can see (HERMES_EXEC_ASK / platform-marker leaks into CLI). + if not _should_fall_through_to_cli_approval( + is_cli=is_cli, + approval_callback=approval_callback, + notify_cb=notify_cb, + ): + # No notify callback (e.g. API server without an attached chat): + # queue for /approve /deny review, agent sees approval_required. + submit_pending(session_key, { + "command": display_target, + "pattern_key": pattern_key, + "description": description, + }) + return { + "approved": False, + "pattern_key": pattern_key, + "status": "approval_required", + "command": display_target, + "description": description, + "message": ( + f"⚠️ This action is potentially dangerous ({description}). " + f"Asking the user for approval.\n\n**Target:**\n```\n{display_target}\n```" + ), + } _fire_approval_hook( "pre_approval_request", @@ -4036,6 +4071,7 @@ def check_all_command_guards(command: str, env_type: str, if _command_matches_permanent_allowlist(command): return {"approved": True, "message": None} + approval_callback = _resolve_cli_approval_callback(approval_callback) is_cli = _is_interactive_cli() is_gateway = _is_gateway_approval_context() is_ask = env_var_enabled("HERMES_EXEC_ASK") @@ -4419,35 +4455,44 @@ def check_all_command_guards(command: str, env_type: str, "user_approved": True, "description": combined_desc} # Fallback: no gateway callback registered (e.g. cron, batch). - # Return approval_required for backward compat. Redact secrets in the - # user-facing copy — the raw `command` is preserved for execution and - # the allowlist keys off pattern_key, so redaction is display-only. - from agent.redact import redact_sensitive_text - _disp_command = redact_sensitive_text(command) - _disp_combined_desc = redact_sensitive_text(combined_desc) - pending_data = { - "command": _disp_command, - "pattern_key": primary_key, - "pattern_keys": all_keys, - "description": _disp_combined_desc, - } - if smart_denied_for_owner: - pending_data.update(smart_denied=True, allow_permanent=False) - submit_pending(session_key, pending_data) - result = { - "approved": False, - "pattern_key": primary_key, - "status": "pending_approval", - "approval_pending": True, - "command": _disp_command, - "description": _disp_combined_desc, - "message": ( - f"⚠️ {_disp_combined_desc}. Asking the user for approval.\n\n**Command:**\n```\n{_disp_command}\n```" - ), - } - if smart_denied_for_owner: - result.update(smart_denied=True, allow_permanent=False) - return result + # Interactive CLI with a Dangerous Command callback should still + # paint the local panel — ask-mode often leaks into CLI via + # importing gateway.run, and returning pending_approval here makes + # the agent look "auto-blocked" with no Approve/Deny UI. + if not _should_fall_through_to_cli_approval( + is_cli=is_cli, + approval_callback=approval_callback, + notify_cb=notify_cb, + ): + # Return approval_required for backward compat. Redact secrets in the + # user-facing copy — the raw `command` is preserved for execution and + # the allowlist keys off pattern_key, so redaction is display-only. + from agent.redact import redact_sensitive_text + _disp_command = redact_sensitive_text(command) + _disp_combined_desc = redact_sensitive_text(combined_desc) + pending_data = { + "command": _disp_command, + "pattern_key": primary_key, + "pattern_keys": all_keys, + "description": _disp_combined_desc, + } + if smart_denied_for_owner: + pending_data.update(smart_denied=True, allow_permanent=False) + submit_pending(session_key, pending_data) + result = { + "approved": False, + "pattern_key": primary_key, + "status": "pending_approval", + "approval_pending": True, + "command": _disp_command, + "description": _disp_combined_desc, + "message": ( + f"⚠️ {_disp_combined_desc}. Asking the user for approval.\n\n**Command:**\n```\n{_disp_command}\n```" + ), + } + if smart_denied_for_owner: + result.update(smart_denied=True, allow_permanent=False) + return result # CLI interactive: single combined prompt # Hide [a]lways when no persistable (non-tirith) warning is present @@ -4602,6 +4647,10 @@ def check_execute_code_guard(code: str, env_type: str, # (context now propagates into the RPC thread, #33057); a whole-script # prompt would fire on every execute_code call. # * Local non-interactive non-gateway: documented limitation above. + # Ask-mode (HERMES_EXEC_ASK) still takes this path even when INTERACTIVE + # is also set — that combination is how gateway/smart tests and messaging + # ask-mode drive whole-script approval. Terminal-command CLI leaks are + # handled in check_all_command_guards via the CLI callback fall-through. if not is_gateway and not is_ask: return {"approved": True, "message": None} diff --git a/tools/binary_extensions.py b/tools/binary_extensions.py index bd4bb8d1de530..5eac3d0d75d4e 100644 --- a/tools/binary_extensions.py +++ b/tools/binary_extensions.py @@ -40,3 +40,36 @@ def has_binary_extension(path: str) -> bool: if dot == -1: return False return path[dot:].lower() in BINARY_EXTENSIONS + + +# Container document formats (OOXML zip / OLE compound / ODF zip / EPUB zip / RTF) +# that a plain-text write can NEVER produce validly. read_file auto-extracts +# these to readable text (via anydoc for the non-built-in formats), so a model +# that "read" report.docx and then writes the edited text back via +# write_file/patch silently destroys the document. +# PDF is intentionally NOT here: raw PDF syntax is text-authorable, so +# new-file creation is legitimate — only overwrites are dangerous (handled +# separately by the write guard). +OPAQUE_DOCUMENT_EXTENSIONS = frozenset({ + ".doc", ".docx", ".docm", + ".xls", ".xlsx", ".xlsm", ".xlsb", + ".ppt", ".pps", ".pot", ".pptx", ".pptm", ".ppsx", ".ppsm", + ".odt", ".ods", ".odp", + ".rtf", ".epub", +}) + + +def has_opaque_document_extension(path: str) -> bool: + """True when the path names an opaque container document (.docx etc.). + + Pure string check, no I/O. + """ + dot = path.rfind(".") + if dot == -1: + return False + return path[dot:].lower() in OPAQUE_DOCUMENT_EXTENSIONS + + +def is_pdf_path(path: str) -> bool: + """True when the path has a .pdf extension. Pure string check, no I/O.""" + return path.lower().endswith(".pdf") diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index b69ad11cedde0..15828edf354e9 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -60,6 +60,16 @@ def _base_subprocess_env() -> dict: from tools.browser_tool import _build_browser_env env = _build_browser_env() + # The browser-use CLI runs under its own Python (uv tool / uvx), which + # may differ from Hermes's venv Python. PYTHONPATH/PYTHONHOME inherited + # from the agent process point at Hermes's venv site-packages, and a + # child interpreter honors them ahead of its own site-packages — so the + # CLI imports compiled C-extensions (e.g. pydantic_core) built for the + # wrong interpreter and crashes on ABI mismatch (#83427, #84841, #86006, + # #86104). Strip both — the CLI manages its own environment and never + # needs Hermes's import path. + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) env.setdefault("ANONYMIZED_TELEMETRY", "false") return env @@ -203,21 +213,42 @@ def _managed_bin_dir() -> Optional[str]: return None +def _user_local_bin_dir() -> Optional[str]: + """The standard user-level tool dir (~/.local/bin on POSIX; uv's default + tool bin dir on Windows). Desktop/TUI workers may start with a minimal + PATH that omits it even when `uv tool install browser-use` put the + binary there.""" + try: + if os.name == "nt": + base = os.environ.get("APPDATA") + if base: + return str(Path(base) / "uv" / "bin") + return None + return str(Path(os.path.expanduser("~")) / ".local" / "bin") + except Exception as e: # pragma: no cover — defensive + logger.debug("Could not resolve user-local bin dir: %s", e) + return None + + def _find_cli() -> Optional[List[str]]: """Locate the browser-use CLI, or None when it can't be run. - Prefers an installed browser-use binary (PATH, then Hermes' managed - $HERMES_HOME/bin); falls back to running it through uvx (PATH, then - managed). The managed probes matter because Hermes bootstraps its own - uv into $HERMES_HOME/bin, which is not on the user's PATH. + MANAGED-FIRST resolution: Hermes' own ``$HERMES_HOME/bin`` copy — the + one every browser backend selection installs and updates via + ``install_cli()`` — always wins, so all sessions drive one canonical, + Hermes-controlled binary. PATH and the user-level tool dir + (~/.local/bin / %APPDATA%\\uv\\bin, where a manual ``uv tool install`` + links binaries) are fallbacks for setups that never ran our install, + and cover Desktop/TUI workers that spawn with a minimal PATH. The uvx + zero-install path (same probe order) is the final fallback. """ - bin_dir = _managed_bin_dir() - for probe_path in (None, bin_dir): + probe_paths = (_managed_bin_dir(), None, _user_local_bin_dir()) + for probe_path in probe_paths: if probe_path is None or probe_path: direct = shutil.which("browser-use", path=probe_path) if direct: return [direct] - for probe_path in (None, bin_dir): + for probe_path in probe_paths: if probe_path is None or probe_path: uvx = shutil.which("uvx", path=probe_path) if uvx: @@ -235,9 +266,11 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: Returns ``(ok, message)`` — never raises. """ - direct = shutil.which("browser-use") - if direct: - return True, f"browser-use CLI already installed ({direct})" + # MANAGED-FIRST: only the managed copy short-circuits the install. A + # browser-use found on PATH is a user-level side install — it must NOT + # prevent provisioning the canonical Hermes-managed copy, or resolution + # stays pinned to a binary we don't control (version drift, no updates + # through hermes tools). bin_dir = _managed_bin_dir() if bin_dir: managed = shutil.which("browser-use", path=bin_dir) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 2671b420940d0..0477c9c9214f4 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -977,7 +977,7 @@ def _preserve_parent_mcp_toolsets( return preserved -DEFAULT_MAX_ITERATIONS = 50 +DEFAULT_MAX_ITERATIONS = 250 # Hard per-summary character ceiling layered on top of the dynamic # headroom budget (see _apply_summary_budget). Belt-and-suspenders for # models that ignore the "be concise" instruction. 0 disables the ceiling. diff --git a/tools/file_tools.py b/tools/file_tools.py index a47588d0ba514..2270fb57f6bd6 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -12,7 +12,11 @@ from pathlib import Path, PurePosixPath from agent.file_safety import get_read_block_error -from tools.binary_extensions import has_binary_extension +from tools.binary_extensions import ( + has_binary_extension, + has_opaque_document_extension, + is_pdf_path, +) from tools.file_operations import ( ShellFileOperations, normalize_read_pagination, @@ -2170,6 +2174,51 @@ def _mark_verification_stale( logger.debug("verification stale marker failed", exc_info=True) +def _check_binary_document_write(filepath: str, task_id: str = "default") -> str | None: + """Reject text-tool writes that would corrupt a binary document. + + ``read_file`` auto-extracts .docx/.xlsx/.pptx (and PDF, via anydoc) to + readable text, so the model plausibly believes it holds the file's + contents and tries to write the edited text back with write_file/patch. + A plain-text write can never produce a valid OOXML/OLE/ODF container, so + that write silently destroys the document (port of nearai/ironclaw#7109). + + Rules: + - Opaque container formats (.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/ + .odp): always rejected — text bytes are never a valid document, whether + creating or overwriting. + - .pdf: rejected only when OVERWRITING an existing regular file. Raw PDF + syntax is text-authorable, so new-file creation stays allowed. + """ + if has_opaque_document_extension(filepath): + ext = filepath[filepath.rfind("."):].lower() + return ( + f"Refusing to write plain text to binary document '{filepath}' ({ext}). " + "A text write cannot produce a valid document container and would " + "corrupt the file (read_file showed you EXTRACTED text, not the real " + "bytes). Use the docx/xlsx/powerpoint skills or a library like " + "python-docx/openpyxl/python-pptx via the terminal to create or edit " + "this document." + ) + if is_pdf_path(filepath): + try: + resolved = Path(_resolve_path_for_task(filepath, task_id)) + except Exception: + resolved = Path(_expand_tilde(filepath)) + try: + if resolved.is_file(): + return ( + f"Refusing to overwrite existing PDF '{filepath}' with plain text. " + "read_file showed you EXTRACTED text, not the real bytes — writing " + "text back would destroy the document. Use the pdf skill or a PDF " + "library via the terminal to modify it. (Creating a NEW .pdf file " + "is allowed.)" + ) + except OSError: + pass + return None + + def write_file_tool(path: str, content: str, task_id: str = "default", cross_profile: bool = False, session_id: str | None = None) -> str: @@ -2184,6 +2233,9 @@ def write_file_tool(path: str, content: str, task_id: str = "default", sensitive_err = _check_sensitive_path(path, task_id) if sensitive_err: return tool_error(sensitive_err) + binary_doc_err = _check_binary_document_write(path, task_id) + if binary_doc_err: + return tool_error(binary_doc_err) protected_err = _check_protected_instruction_write([path], task_id) if protected_err: return tool_error(protected_err) @@ -2271,8 +2323,12 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, """ # Check sensitive paths for both replace (explicit path) and V4A patch (extract paths) _paths_to_check = [] + # Paths whose CONTENT will be text-written (Update/Add + explicit path). + # V4A Delete/Move don't write text, so they skip the binary-document guard. + _content_write_paths = [] if path: _paths_to_check.append(path) + _content_write_paths.append(path) if mode == "patch" and patch: import re as _re from tools.path_security import has_traversal_component @@ -2298,12 +2354,15 @@ def _reject_v4a_traversal(v4a_path: str) -> str | None: # it accepts ``***Update File:`` with no space after the asterisks # (patch_parser.py uses ``\*\*\*\s*Update\s+File:``). Requiring a space # here let a no-space header parse + apply while skipping this check. - for _m in _re.finditer(r'^\*\*\*\s*(?:Update|Add|Delete)\s+File:\s*(.+)$', patch, _re.MULTILINE): - v4a_path = _m.group(1).strip() + for _m in _re.finditer(r'^\*\*\*\s*(Update|Add|Delete)\s+File:\s*(.+)$', patch, _re.MULTILINE): + _op = _m.group(1) + v4a_path = _m.group(2).strip() _err = _reject_v4a_traversal(v4a_path) if _err: return _err _paths_to_check.append(v4a_path) + if _op in ("Update", "Add"): + _content_write_paths.append(v4a_path) # ``*** Move File: src -> dst`` is a valid V4A op (patch_parser.py:114) # but was never extracted, so a Move targeting /etc/crontab skipped the # sensitive-path pre-check. Check BOTH endpoints, and run them through @@ -2322,6 +2381,10 @@ def _reject_v4a_traversal(v4a_path: str) -> str | None: cross_warning = _check_cross_profile_path(_p, task_id) if cross_warning: return tool_error(cross_warning) + for _p in _content_write_paths: + binary_doc_err = _check_binary_document_write(_p, task_id) + if binary_doc_err: + return tool_error(binary_doc_err) # One approval prompt for the whole patch: a single protected file gates # the ENTIRE patch (deny applies nothing — see the helper's docstring). protected_err = _check_protected_instruction_write(_paths_to_check, task_id) diff --git a/tui_gateway/mcp_oauth_sessions.py b/tui_gateway/mcp_oauth_sessions.py new file mode 100644 index 0000000000000..3fabb722ba905 --- /dev/null +++ b/tui_gateway/mcp_oauth_sessions.py @@ -0,0 +1,339 @@ +"""Session-backed MCP OAuth flows for the gateway (mcp.servers.oauth.*). + +This mirrors the *provider* OAuth model used by the dashboard +(``/api/providers/oauth/{id}/start`` + ``/poll/{session_id}``) rather than the +FastAPI-request-coupled MCP dashboard flow: a ``start`` primitive kicks off a +background worker and returns ``{session_id, auth_url, flow}``; a ``poll`` +primitive reports ``{status: pending|approved|error}`` until the tokens land on +disk for that server in that profile. + +The underlying token machinery is the *same* one the CLI ``hermes mcp login`` +uses — ``hermes_cli.mcp_config._probe_single_server`` under +``tools.mcp_oauth.force_interactive_oauth`` — so no OAuth logic is reimplemented +here. The only new piece is decoupling the two browser callbacks (authorization +URL out, ``code``/``state`` back in) from a FastAPI ``Request``: + +* ``tools.mcp_dashboard_oauth.DashboardOAuthFlow`` already provides the two + thread-safe rendezvous points (``publish_authorization_url`` / + ``deliver_callback``). We reuse it verbatim as the bridge object. +* Instead of routing the browser redirect through a FastAPI callback route, we + run a tiny loopback HTTP listener on ``127.0.0.1:/callback`` and set the + flow's ``redirect_uri`` to it. When the provider redirects the user's browser + there, the listener calls ``flow.deliver_callback(...)``. This is the same + loopback strategy the CLI uses by default, just wired to the shared bridge. + +Client contract (what the desktop plugin does): + 1. call ``mcp.servers.oauth.start(profile, name)`` → ``{session_id, auth_url}`` + 2. open ``auth_url`` in the native browser (``openExternal``) + 3. poll ``mcp.servers.oauth.poll(profile, name, session_id)`` until + ``status == "approved"`` (tokens persisted) or ``"error"``. +""" + +from __future__ import annotations + +import http.server +import secrets +import threading +import time +from pathlib import Path +from typing import Any, Dict, Optional +from urllib.parse import parse_qs, urlparse + +# Session registry: session_id -> record. A record wraps the shared +# DashboardOAuthFlow bridge plus a bit of gateway bookkeeping. +_sessions: Dict[str, Dict[str, Any]] = {} +_sessions_lock = threading.Lock() + +# How long a completed/abandoned session lingers before GC (seconds). +_SESSION_TTL_SECONDS = 900 +# Cap concurrent in-flight flows so a runaway client can't exhaust ports/threads. +_MAX_PENDING = 12 + + +def _gc_sessions() -> None: + """Drop expired sessions. Called opportunistically on start.""" + cutoff = time.time() - _SESSION_TTL_SECONDS + with _sessions_lock: + stale = [sid for sid, rec in _sessions.items() if rec["created_at"] < cutoff] + for sid in stale: + rec = _sessions.pop(sid, None) + if rec is not None: + _shutdown_listener(rec) + + +def _shutdown_listener(rec: Dict[str, Any]) -> None: + server = rec.get("httpd") + if server is not None: + try: + server.shutdown() + except Exception: + pass + try: + server.server_close() + except Exception: + pass + rec["httpd"] = None + + +def _start_loopback_listener(flow) -> "http.server.HTTPServer": + """Bind a loopback callback listener that feeds the flow's deliver_callback. + + Returns the running HTTPServer (already serving on a daemon thread). The + bound port is read back off ``server.server_address`` so the caller can set + ``flow.redirect_uri`` to the matching ``/callback`` URL BEFORE the worker + starts the OAuth flow (the redirect URI must be pinned at authorization). + """ + + class _Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 — stdlib naming + parsed = urlparse(self.path) + if parsed.path.rstrip("/") not in ("/callback", ""): + self.send_response(404) + self.end_headers() + return + qs = parse_qs(parsed.query) + code = (qs.get("code") or [None])[0] + state = (qs.get("state") or [None])[0] + error = (qs.get("error") or [None])[0] + body = b"

Authorization received

You can close this tab and return to Hermes.

" + status = 200 + try: + flow.deliver_callback(code=code, state=state, error=error) + except Exception: + body = b"

OAuth callback rejected

The callback was invalid or already used.

" + status = 400 + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + try: + self.wfile.write(body) + except Exception: + pass + + def log_message(self, *_a): # silence stdlib request logging + return + + httpd = http.server.HTTPServer(("127.0.0.1", 0), _Handler) + threading.Thread( + target=httpd.serve_forever, + kwargs={"poll_interval": 0.5}, + daemon=True, + name=f"mcp-oauth-cb-{flow.server_name}", + ).start() + return httpd + + +def _worker(session_id: str, hermes_home: str, server_name: str, cfg: dict, reconnect_live: bool) -> None: + """Drive the interactive MCP OAuth probe under the shared dashboard bridge. + + Structurally identical to ``web_server._run_dashboard_mcp_oauth`` — the same + HERMES_HOME override + secret-scope + force_interactive_oauth + + dashboard_oauth_flow wrapping around ``_probe_single_server`` — but keyed to + our session record instead of a FastAPI request. On success the token file + exists on disk (verified via ``_oauth_tokens_present``) and the server config + is (re)saved into the profile's config.yaml. + """ + from hermes_cli.mcp_config import ( + _oauth_tokens_present, + _probe_single_server, + _save_mcp_server, + ) + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + rec = _sessions.get(session_id) + flow = rec["flow"] if rec else None + try: + from agent.secret_scope import ( + build_profile_secret_scope, + reset_secret_scope, + set_secret_scope, + ) + from tools.mcp_dashboard_oauth import dashboard_oauth_flow + from tools.mcp_oauth import force_interactive_oauth + from tools.mcp_oauth_manager import get_manager + + home_token = set_hermes_home_override(hermes_home) + secret_token = set_secret_scope(build_profile_secret_scope(Path(hermes_home))) + try: + with force_interactive_oauth(), dashboard_oauth_flow(flow): + from tools.mcp_oauth import HermesTokenStorage + + manager = get_manager() + storage = HermesTokenStorage(server_name) + backup = storage.snapshot() + previous_entry = None + try: + previous_entry = manager.remove(server_name, hermes_home=hermes_home) + tools = _probe_single_server( + server_name, + cfg, + connect_timeout=max(float(cfg.get("connect_timeout", 0) or 0), 315), + ) + if not _oauth_tokens_present(server_name): + raise RuntimeError( + "The server responded, but no OAuth token was obtained — " + "this provider may require a manually-registered OAuth client." + ) + _save_mcp_server(server_name, cfg) + if flow is not None: + flow.tools = [{"name": t, "description": d} for t, d in tools] + flow.mark_approved() + if reconnect_live: + from tools.mcp_tool import reconnect_mcp_server + + reconnect_mcp_server(server_name) + except Exception: + storage.restore(backup, only_if_absent=True) + manager.restore_entry(server_name, previous_entry, hermes_home=hermes_home) + raise + finally: + reset_secret_scope(secret_token) + reset_hermes_home_override(home_token) + except Exception as exc: + msg = str(exc) + try: + from tools.mcp_oauth import humanize_oauth_registration_error + + humanized = humanize_oauth_registration_error( + server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None + ) + if humanized: + msg = humanized + except Exception: + pass + if flow is not None: + flow.mark_error(msg) + finally: + if flow is not None: + flow.mark_worker_done() + if rec is not None: + _shutdown_listener(rec) + + +def start_flow( + hermes_home: str, + server_name: str, + cfg: dict, + *, + reconnect_live: bool = False, + url_timeout: float = 30.0, +) -> Dict[str, Any]: + """Begin an MCP OAuth flow and return ``{session_id, auth_url, flow}``. + + ``cfg`` is the server's resolved config dict (must have ``url`` and be + OAuth-capable). ``hermes_home`` is the already-resolved profile home dir + string. Blocks up to ``url_timeout`` for the worker to publish the browser + authorization URL, then returns it. + """ + from tools.mcp_dashboard_oauth import DashboardOAuthFlow + + _gc_sessions() + + with _sessions_lock: + pending = sum( + 1 + for r in _sessions.values() + if not r["flow"].worker_done + ) + if pending >= _MAX_PENDING: + raise RuntimeError("Too many MCP OAuth flows are already in progress") + if any( + r["server_name"] == server_name + and r["hermes_home"] == hermes_home + and not r["flow"].worker_done + for r in _sessions.values() + ): + raise RuntimeError(f"MCP OAuth for '{server_name}' is already in progress") + + session_id = secrets.token_urlsafe(24) + flow = DashboardOAuthFlow( + flow_id=session_id, + server_name=server_name, + profile=None, + hermes_home=hermes_home, + redirect_uri="", # set below once the loopback port is known + reconnect_live=reconnect_live, + ) + httpd = _start_loopback_listener(flow) + port = httpd.server_address[1] + flow.redirect_uri = f"http://127.0.0.1:{port}/callback" + + rec = { + "session_id": session_id, + "server_name": server_name, + "hermes_home": hermes_home, + "flow": flow, + "httpd": httpd, + "created_at": time.time(), + } + with _sessions_lock: + _sessions[session_id] = rec + + threading.Thread( + target=_worker, + args=(session_id, hermes_home, server_name, dict(cfg), reconnect_live), + daemon=True, + name=f"mcp-oauth-{server_name}", + ).start() + + try: + auth_url = None + # wait_for_authorization_url is async; run its wait synchronously. + deadline = time.time() + url_timeout + while time.time() < deadline: + snap = flow.snapshot() + if snap.get("authorization_url"): + auth_url = snap["authorization_url"] + break + if snap.get("status") == "error": + raise RuntimeError(snap.get("error") or "MCP OAuth flow failed before authorization") + time.sleep(0.1) + if not auth_url: + raise TimeoutError("Timed out waiting for MCP authorization URL") + except Exception: + flow.mark_error("Timed out waiting for MCP authorization URL") + _shutdown_listener(rec) + raise + + return { + "session_id": session_id, + "auth_url": auth_url, + # "pkce" mirrors the provider-OAuth ``flow`` discriminator: the client + # opens a URL then polls (no user_code to type, unlike device_code). + "flow": "pkce", + } + + +def poll_flow(session_id: str, server_name: str) -> Dict[str, Any]: + """Poll a session's status → ``{status, error_message?, auth_url?, tools?}``. + + ``status`` is one of ``pending`` | ``approved`` | ``error`` — the same + vocabulary as the provider poll endpoint (``authorization_required`` from + the underlying bridge maps to ``pending`` since the client only needs to + know whether to keep waiting). + """ + with _sessions_lock: + rec = _sessions.get(session_id) + if rec is None: + return {"status": "error", "error_message": "OAuth session not found or expired"} + if rec["server_name"] != server_name: + return {"status": "error", "error_message": "server name mismatch for session"} + + flow = rec["flow"] + snap = flow.snapshot() + raw = snap.get("status") + if raw == "approved": + status = "approved" + elif raw == "error": + status = "error" + else: + status = "pending" + out: Dict[str, Any] = { + "session_id": session_id, + "status": status, + "error_message": snap.get("error"), + "auth_url": snap.get("authorization_url"), + } + if status == "approved": + out["tools"] = list(getattr(flow, "tools", []) or []) + return out diff --git a/tui_gateway/mcp_rpc_helpers.py b/tui_gateway/mcp_rpc_helpers.py new file mode 100644 index 0000000000000..60290cfa9eba2 --- /dev/null +++ b/tui_gateway/mcp_rpc_helpers.py @@ -0,0 +1,74 @@ +"""Shared helpers for the per-profile MCP lifecycle RPCs (mcp.servers.*). + +These live in their own module (not methods_tools) because methods_tools +handlers are rebound onto ``tui_gateway.server``'s globals at install time +(see method_ctx.HandlerRegistry.install); a plain module-level def in +methods_tools would not be reachable from a rebound handler body. Handlers +import these at call time instead. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + + +def resolve_profile(rid, params, err_fn) -> Tuple[Optional[Any], Optional[dict]]: + """Resolve the optional ``profile`` param to a HERMES_HOME override token. + + Returns ``(token, error)``: ``token`` is None for the launch profile (no + override) or an opaque reset token; ``error`` is a JSON-RPC error dict + (built via ``err_fn``) when the named profile doesn't exist. Callers reset + ``token`` in a finally via :func:`reset_profile`. + """ + profile = str(params.get("profile") or "").strip() + if not profile: + return None, None + from hermes_cli.profiles import get_profile_dir + from hermes_constants import set_hermes_home_override + + profile_dir = get_profile_dir(profile) + if not profile_dir or not profile_dir.is_dir(): + return None, err_fn(rid, 4064, f"profile '{profile}' not found") + return set_hermes_home_override(str(profile_dir)), None + + +def reset_profile(token) -> None: + if token is not None: + try: + from hermes_constants import reset_hermes_home_override + + reset_hermes_home_override(token) + except Exception: + pass + + +def summarize_server(name: str, cfg: dict) -> Dict[str, Any]: + """Serialize one server's config for a UI (no secret values). + + Mirrors web_server._mcp_server_summary plus an ``oauth_tokens_present`` + flag so a UI can tell an OAuth server that still needs authentication from + one already authenticated. + """ + from hermes_cli.mcp_config import _oauth_tokens_present + + cfg = cfg if isinstance(cfg, dict) else {} + transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown") + auth = cfg.get("auth") + headers = cfg.get("headers") or {} + if not auth and isinstance(headers, dict) and any( + str(key).lower() == "authorization" for key in headers + ): + auth = "header" + tokens_present = _oauth_tokens_present(name) if auth == "oauth" else None + return { + "name": name, + "transport": transport, + "url": cfg.get("url"), + "command": cfg.get("command"), + "args": list(cfg.get("args") or []), + "env": sorted(str(k) for k in (cfg.get("env") or {})), + "auth": auth, + "oauth_tokens_present": tokens_present, + "enabled": cfg.get("enabled", True) is not False, + "tools": cfg.get("tools"), + } diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 21bbf9d777435..7f002300f913c 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -246,7 +246,19 @@ def _has_real_env_content(env_path) -> bool: # .env (only over the seeded comment-only stub — never clobber real # secrets a clone brought along) and auth.json (only when absent), then # inherit model.provider/model.default unless the caller pinned a model. + # + # ``share_auth`` (default false): SKIP the auth.json copy so the new + # profile reads OAuth/token state through the global-root fallback + # instead (hermes_cli.auth: profile reads fall back to the global + # store, and token refreshes write THROUGH to it). A copy forks token + # state — the first refresh in either store invalidates the other + # for single-use refresh tokens. Sharing keeps one live token pool + # for the main profile and every bot. Static .env keys still copy + # (no refresh semantics, so copying is safe). mirrored = {"env": False, "auth": False, "model_inherited": False, "voice": False} + share_auth = is_truthy_value(params.get("share_auth", False)) + if share_auth: + mirrored["auth"] = "shared" if is_truthy_value(params.get("mirror_credentials", True)): import shutil @@ -268,7 +280,7 @@ def _has_real_env_content(env_path) -> bool: try: src_auth = launch_home / "auth.json" dst_auth = path / "auth.json" - if src_auth.is_file() and not dst_auth.exists(): + if not share_auth and src_auth.is_file() and not dst_auth.exists(): shutil.copy2(src_auth, dst_auth) try: os.chmod(str(dst_auth), 0o600) @@ -345,20 +357,36 @@ def _mirror_voice_sections() -> bool: model_set = True except Exception: pass - elif is_truthy_value(params.get("mirror_credentials", True)) and not (path / "config.yaml").exists(): - # No explicit pin and no cloned config: inherit the launch profile's - # provider+model so the first turn resolves. Same writer as the pin. + elif is_truthy_value(params.get("mirror_credentials", True)): + # No explicit pin: inherit the launch profile's provider+model so the + # first turn resolves. Gate on the MODEL SECTION being absent, not on + # config.yaml existing — earlier mirroring steps (voice sections, + # #85755) legitimately create the file first, and a file-existence + # gate silently skipped inheritance for every non-clone bot + # ("No inference provider configured" on first message, tester + # report). Clones bring their own model section and stay untouched. try: - from hermes_cli.config import load_config_readonly + from hermes_cli.config import load_config_readonly, read_user_config_raw from hermes_cli.web_routers.profiles import _write_profile_model + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + token = set_hermes_home_override(str(path)) + try: + dst_model = (read_user_config_raw() or {}).get("model") or {} + finally: + reset_hermes_home_override(token) - cfg = load_config_readonly() or {} - model_cfg = cfg.get("model") or {} - inherited_provider = str(model_cfg.get("provider") or "") - inherited_model = str(model_cfg.get("default") or "") - if inherited_provider and inherited_model: - _write_profile_model(path, inherited_provider, inherited_model) - mirrored["model_inherited"] = True + if not (dst_model.get("provider") and dst_model.get("default")): + cfg = load_config_readonly() or {} + model_cfg = cfg.get("model") or {} + inherited_provider = str(model_cfg.get("provider") or "") + inherited_model = str(model_cfg.get("default") or "") + if inherited_provider and inherited_model: + _write_profile_model(path, inherited_provider, inherited_model) + mirrored["model_inherited"] = True except Exception: pass @@ -418,7 +446,20 @@ def _(rid, params: dict) -> dict: {"name": skill_name, "enabled": skill_name.lower() not in disabled} ) - from toolsets import get_all_toolsets, get_toolset_info + # Toolsets: the same filtered universe the `hermes tools` + # checklist offers — configurable toolsets (built-in + plugin), + # minus platform-restricted ones that don't apply here — with + # enablement resolved the way the runtime actually resolves it. + # The raw registry (get_all_toolsets) leaks internal platform + # composites (hermes-discord, feishu_drive, ...) and reports + # everything "enabled" whenever the profile has no pin, which a + # capabilities UI then faithfully mis-renders (tester report). + from hermes_cli.tools_config import ( + _get_effective_configurable_toolsets, + _get_platform_tools, + _toolset_allowed_for_platform, + ) + from toolsets import resolve_toolset tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {} pinned = tools_cfg.get("enabled_toolsets") @@ -427,15 +468,44 @@ def _(rid, params: dict) -> dict: if isinstance(pinned, list) else None ) + try: + platform_enabled = set( + _get_platform_tools(cfg, "cli", include_default_mcp_servers=False) + ) + except Exception: + platform_enabled = set() + try: + from hermes_cli.tools_config import _DEFAULT_OFF_TOOLSETS + except Exception: + _DEFAULT_OFF_TOOLSETS = set() toolsets_out = [] - for ts_name in sorted(get_all_toolsets().keys()): - info = get_toolset_info(ts_name) or {} + for ts_name, ts_label, ts_desc in _get_effective_configurable_toolsets(): + if not _toolset_allowed_for_platform(ts_name, "cli"): + continue + enabled = ( + ts_name in pinned_set + if pinned_set is not None + else ts_name in platform_enabled + ) + # Default-off integrations (a2a, yuanbao, spotify, ...) are + # opt-ins; when the profile hasn't opted in they're noise in + # a per-profile editor — `hermes tools` / Settings is where + # you turn them on globally first. Enabled ones still show. + # yuanbao rides the same rule: a region-specific integration + # that isn't in _DEFAULT_OFF_TOOLSETS but is equally opt-in. + if (ts_name in _DEFAULT_OFF_TOOLSETS or ts_name == "yuanbao") and not enabled: + continue + try: + tool_count = len(set(resolve_toolset(ts_name))) + except Exception: + tool_count = 0 toolsets_out.append( { "name": ts_name, - "description": info.get("description") or "", - "tool_count": len(info.get("tools") or []), - "enabled": True if pinned_set is None else ts_name in pinned_set, + "label": ts_label, + "description": ts_desc or "", + "tool_count": tool_count, + "enabled": enabled, } ) @@ -447,6 +517,31 @@ def _(rid, params: dict) -> dict: except Exception: pass + # MCP servers configured for this profile (config.yaml + # mcp_servers). Report name + enabled + a transport hint so a + # capabilities UI can list and toggle them without parsing the + # raw config shape. + mcp_out = [] + try: + mcp_cfg = cfg.get("mcp_servers") + if isinstance(mcp_cfg, dict): + for srv_name in sorted(mcp_cfg.keys()): + entry = mcp_cfg.get(srv_name) + if not isinstance(entry, dict): + continue + transport = "stdio" + if entry.get("url"): + transport = str(entry.get("transport") or "http") + mcp_out.append( + { + "name": str(srv_name), + "enabled": not is_truthy_value(entry.get("disabled", False)), + "transport": transport, + } + ) + except Exception: + pass + model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {} description = "" @@ -470,6 +565,7 @@ def _(rid, params: dict) -> dict: "skills": installed, "toolsets": toolsets_out, "toolsets_pinned": pinned_set is not None, + "mcp_servers": mcp_out, }, ) finally: @@ -582,10 +678,25 @@ def _(rid, params: dict) -> dict: except Exception: applied["model"] = False - needs_cfg = isinstance(params.get("disabled_skills"), list) or isinstance( - params.get("enabled_toolsets"), list + needs_cfg = ( + isinstance(params.get("disabled_skills"), list) + or isinstance(params.get("enabled_toolsets"), list) + or isinstance(params.get("enabled_mcp_servers"), list) ) if needs_cfg: + # Launch profile's MCP catalog, read BEFORE the home override + # flips config resolution to the target profile. + launch_mcp = {} + if isinstance(params.get("enabled_mcp_servers"), list): + try: + from hermes_cli.config import load_config_readonly + + launch_cfg = load_config_readonly() or {} + if isinstance(launch_cfg.get("mcp_servers"), dict): + launch_mcp = launch_cfg["mcp_servers"] + except Exception: + launch_mcp = {} + token = set_hermes_home_override(str(profile_dir)) try: from hermes_cli.config import load_config, save_config @@ -620,6 +731,44 @@ def _(rid, params: dict) -> dict: applied["toolsets"] = True except Exception: applied["toolsets"] = False + + # ``enabled_mcp_servers`` (list[str], replace semantics): + # toggle the profile's mcp_servers entries via the standard + # ``disabled`` flag. Enabling a server the profile doesn't + # define copies its definition from the LAUNCH profile's + # config (capabilities UIs offer the main profile's catalog); + # unknown names are skipped, never invented. Server defs are + # config, not secrets — credentials stay in .env/auth. + if isinstance(params.get("enabled_mcp_servers"), list): + try: + wanted = { + str(s).strip() + for s in params["enabled_mcp_servers"] + if str(s).strip() + } + cfg = load_config() or {} + mcp_cfg = ( + cfg.get("mcp_servers") + if isinstance(cfg.get("mcp_servers"), dict) + else {} + ) + + for srv in wanted: + if srv in mcp_cfg and isinstance(mcp_cfg[srv], dict): + mcp_cfg[srv].pop("disabled", None) + elif srv in launch_mcp and isinstance(launch_mcp[srv], dict): + mcp_cfg[srv] = dict(launch_mcp[srv]) + mcp_cfg[srv].pop("disabled", None) + for srv, entry in mcp_cfg.items(): + if srv not in wanted and isinstance(entry, dict): + entry["disabled"] = True + + if mcp_cfg: + cfg["mcp_servers"] = mcp_cfg + save_config(cfg) + applied["mcp_servers"] = True + except Exception: + applied["mcp_servers"] = False finally: reset_hermes_home_override(token) diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 0e13b4cb2451f..a1c0860a60465 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -717,7 +717,7 @@ def run_after_agent_ready() -> None: @method("clipboard.paste") def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) + session, err = _sess_building(params, rid) if err: return err try: @@ -757,7 +757,7 @@ def _(rid, params: dict) -> dict: @method("image.attach") def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) + session, err = _sess_building(params, rid) if err: return err raw = str(params.get("path", "") or "").strip() @@ -815,7 +815,7 @@ def _(rid, params: dict) -> dict: filename / ext (str, optional): extension hint. Without it, magic bytes identify PNG/JPEG/GIF/WebP/BMP, falling back to ``.png``. """ - session, err = _sess(params, rid) + session, err = _sess_building(params, rid) if err: return err @@ -875,7 +875,7 @@ def _(rid, params: dict) -> dict: import subprocess import tempfile - session, err = _sess(params, rid) + session, err = _sess_building(params, rid) if err: return err @@ -1004,7 +1004,7 @@ def _(rid, params: dict) -> dict: required when the path isn't visible to the gateway. name (str, optional): preferred filename. """ - session, err = _sess(params, rid) + session, err = _sess_building(params, rid) if err: return err raw = str(params.get("path", "") or "").strip() @@ -1034,7 +1034,7 @@ def _(rid, params: dict) -> dict: @method("image.detach") def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) + session, err = _sess_building(params, rid) if err: return err raw = str(params.get("path", "") or "").strip() @@ -1114,12 +1114,28 @@ def run(): try: from run_agent import AIAgent - result = AIAgent( - **_background_agent_kwargs(session["agent"], task_id) - ).run_conversation( - user_message=text, - task_id=task_id, + # Bug #50233: ephemeral agent threads don't inherit the session's + # HERMES_HOME override (the ContextVar set on the session-create + # thread doesn't propagate here), so a background turn under a + # non-default profile would run against the wrong home. Re-bind the + # override for the duration of this turn, exactly as the normal + # prompt turn does, and restore it afterward. + _profile_home_str = session.get("profile_home") + home_token = ( + set_hermes_home_override(_profile_home_str) + if _profile_home_str + else None ) + try: + result = AIAgent( + **_background_agent_kwargs(session["agent"], task_id) + ).run_conversation( + user_message=text, + task_id=task_id, + ) + finally: + if home_token is not None: + reset_hermes_home_override(home_token) _emit( "background.complete", parent, @@ -1225,14 +1241,33 @@ def run(): parent, {"task_id": task_id, "text": f"Starting hidden restart agent{history_note}"}, ) - result = AIAgent( - **_ephemeral_preview_agent_kwargs(session["agent"], task_id), - **_preview_restart_callbacks(parent, task_id), - ).run_conversation( - user_message=prompt, - task_id=task_id, - conversation_history=parent_history or None, + # Bug #50233: ephemeral preview-restart agent threads don't inherit + # the session's HERMES_HOME override (the ContextVar set on the + # session-create thread doesn't propagate here). Re-bind it for the + # duration of the turn, mirroring the normal prompt turn, then + # restore it. NOTE: we deliberately do NOT close this agent through + # task-wide process cleanup — the whole point of preview.restart is + # to leave a background server running under this task_id, and + # AIAgent.close() would kill every process for the task_id and tear + # down the very server the restart just started. + _profile_home_str = session.get("profile_home") + home_token = ( + set_hermes_home_override(_profile_home_str) + if _profile_home_str + else None ) + try: + result = AIAgent( + **_ephemeral_preview_agent_kwargs(session["agent"], task_id), + **_preview_restart_callbacks(parent, task_id), + ).run_conversation( + user_message=prompt, + task_id=task_id, + conversation_history=parent_history or None, + ) + finally: + if home_token is not None: + reset_hermes_home_override(home_token) text = ( result.get("final_response", str(result)) if isinstance(result, dict) diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 22d3d3f1de74a..dd065c520b9dd 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -834,6 +834,37 @@ def _(rid, params: dict) -> dict: {"type": "send", "notice": notice, "message": state.goal}, ) + if name == "loop": + # /loop — recurring in-session wakeups (Claude Code parity). State + # mutation via the shared dispatcher; the notification poller thread + # fires due wakeups into this session while it's idle. + if not session: + return _err(rid, 4001, "no active session") + try: + from hermes_cli.loops import LoopManager, dispatch_loop_command + except Exception as exc: + return _err(rid, 5030, f"loops unavailable: {exc}") + + sid_key = session.get("session_key") or "" + if not sid_key: + return _err(rid, 4001, "no session key") + + mgr = LoopManager(session_id=sid_key) + result = dispatch_loop_command(mgr, arg) + output = result.get("output") or "" + if result.get("created"): + try: + from hermes_cli.loops import goal_blocks_loop_tick + + if goal_blocks_loop_tick(sid_key): + output += ( + "\nNote: an active /goal is driving this session — loop " + "wakeups defer until the goal finishes, pauses, or parks." + ) + except Exception: + pass + return _ok(rid, {"type": "exec", "output": output}) + if name == "undo": # /undo [N]: back up N user turns (default 1), soft-delete the # truncated rows on disk, and prefill the composer with the text @@ -1731,6 +1762,23 @@ def _(rid, params: dict) -> dict: @method("skills.manage") def _(rid, params: dict) -> dict: action, query = params.get("action", "list"), params.get("query", "") + # Optional profile scoping: list/install operate on that profile's + # skills dir (capabilities UIs manage a bot's skills from the main + # window). Search/browse/inspect hit the shared hub catalog — the + # override is harmless there and keeps the semantics uniform. + profile = str(params.get("profile") or "").strip() + token = None + if profile: + try: + from hermes_cli.profiles import get_profile_dir + from hermes_constants import set_hermes_home_override + + profile_dir = get_profile_dir(profile) + if not profile_dir or not profile_dir.is_dir(): + return _err(rid, 4064, f"profile '{profile}' not found") + token = set_hermes_home_override(str(profile_dir)) + except Exception as e: + return _err(rid, 5024, str(e)) try: if action == "list": from hermes_cli.banner import get_available_skills @@ -1785,6 +1833,468 @@ def print(self, *a, **k): return _err(rid, 4017, f"unknown skills action: {action}") except Exception as e: return _err(rid, 5024, str(e)) + finally: + if token is not None: + try: + from hermes_constants import reset_hermes_home_override + + reset_hermes_home_override(token) + except Exception: + pass + + +@method("mcp.catalog") +def _(rid, params: dict) -> dict: + """Bundled MCP catalog with per-profile install/enable state. + + Params: optional ``profile`` (defaults to the launch profile). Result: + ``{servers: [{name, description, installed, enabled, requires: [env + keys], transport}]}`` — the same catalog `hermes mcp` offers, so + capability UIs can present the full menu and know which entries need + setup (missing requires) before they'll work. + """ + profile = str(params.get("profile") or "").strip() + token = None + try: + if profile: + from hermes_cli.profiles import get_profile_dir + from hermes_constants import set_hermes_home_override + + profile_dir = get_profile_dir(profile) + if not profile_dir or not profile_dir.is_dir(): + return _err(rid, 4064, f"profile '{profile}' not found") + token = set_hermes_home_override(str(profile_dir)) + + from hermes_cli import mcp_catalog + + out = [] + for entry in mcp_catalog.list_catalog(): + try: + requires = [str(k) for k in (getattr(entry, "env_keys", None) or [])] + except Exception: + requires = [] + out.append( + { + "name": entry.name, + "description": getattr(entry, "description", "") or "", + "installed": bool(mcp_catalog.is_installed(entry.name)), + "enabled": bool(mcp_catalog.is_enabled(entry.name)), + "requires": requires, + # TransportSpec object — reduce to its kind string. + "transport": str( + getattr(getattr(entry, "transport", None), "kind", "") + or getattr(entry, "transport", "") + or "stdio" + ), + } + ) + return _ok(rid, {"servers": out}) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + if token is not None: + try: + from hermes_constants import reset_hermes_home_override + + reset_hermes_home_override(token) + except Exception: + pass + + +# ─── Per-profile MCP server lifecycle (mcp.servers.*) ──────────────────────── +# +# Gateway RPCs mirroring the dashboard's REST surface +# (hermes_cli/web_routers/mcp.py) so a desktop plugin can manage MCP servers for +# ANY profile, not just the launch profile. Each accepts an optional ``profile`` +# param that scopes HERMES_HOME via set_hermes_home_override (omitted/None = the +# launch profile) in a try/finally, exactly like ``skills.manage`` / ``mcp.catalog``. +# All persistence reuses hermes_cli/mcp_config.py helpers — no logic is duplicated. +# Shared helpers (resolve_profile / reset_profile / summarize_server) live in +# tui_gateway.mcp_rpc_helpers and are imported at call time: these handlers are +# rebound onto server.py's globals at install time, so a plain module-level def +# here would not be reachable from the rebound handler body. + + +@method("mcp.servers.list") +def _(rid, params: dict) -> dict: + """List a profile's configured MCP servers. + + Params: optional ``profile``. Result: ``{servers: [{name, transport, url, + command, args, env (key names only), auth, oauth_tokens_present, enabled, + tools}]}``. Reuses ``mcp_config._get_mcp_servers`` under the home override. + """ + token, err = _mcp_resolve_profile(rid, params) + if err: + return err + try: + from hermes_cli.mcp_config import _get_mcp_servers + + servers = _get_mcp_servers() + return _ok( + rid, + { + "servers": [ + _mcp_summarize_server(name, cfg) + for name, cfg in sorted(servers.items()) + ] + }, + ) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + _mcp_reset_profile(token) + + +@method("mcp.servers.add") +def _(rid, params: dict) -> dict: + """Add/save an MCP server to a profile's config.yaml. + + Params: optional ``profile``, ``name`` (required), and EITHER: + - ``preset`` (a catalog preset id) → applied via ``_apply_mcp_preset``, or + - ``config`` (an mcp_servers entry dict: url/command/args/env/headers/ + auth/tools) → saved via ``_save_mcp_server``. + If ``bearer_token`` is given (header auth), it is written to the profile's + .env via ``_save_bearer_auth_token`` and only the safe ``Authorization`` + header template is persisted in config.yaml. + + Result: ``{ok: true, name, server: }``. Duplicate names error. + """ + name = str(params.get("name") or "").strip() + if not name: + return _err(rid, 4063, "name required") + token, err = _mcp_resolve_profile(rid, params) + if err: + return err + try: + from hermes_cli.mcp_config import ( + _apply_mcp_preset, + _get_mcp_servers, + _save_bearer_auth_token, + _save_mcp_server, + ) + + if name in _get_mcp_servers(): + return _err(rid, 4090, f"server '{name}' already exists") + + preset = str(params.get("preset") or "").strip() + raw_cfg = params.get("config") + server_config: dict = dict(raw_cfg) if isinstance(raw_cfg, dict) else {} + + if preset: + # _apply_mcp_preset fills url/command/args from a known preset when + # transport details were omitted; it mutates server_config in place. + _apply_mcp_preset( + name, + preset_name=preset, + url=server_config.get("url"), + command=server_config.get("command"), + cmd_args=list(server_config.get("args") or []), + server_config=server_config, + ) + + if not server_config.get("url") and not server_config.get("command"): + return _err( + rid, + 4063, + "config must specify a 'url' (http) or 'command' (stdio), or a valid 'preset'", + ) + + bearer_token = params.get("bearer_token") + if bearer_token: + # Persist the secret in .env; store only the interpolation template. + server_config["headers"] = _save_bearer_auth_token(name, str(bearer_token)) + + if not _save_mcp_server(name, server_config): + return _err( + rid, + 4001, + f"server '{name}' rejected: suspicious command/args configuration", + ) + saved = _get_mcp_servers().get(name, server_config) + return _ok(rid, {"ok": True, "name": name, "server": _mcp_summarize_server(name, saved)}) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + _mcp_reset_profile(token) + + +@method("mcp.servers.set_api_key") +def _(rid, params: dict) -> dict: + """Store a required API key / credential for a server in a profile. + + Params: optional ``profile``, ``name`` (required), ``value`` (required, + the secret), and optional ``env_var`` (defaults to the server's canonical + ``MCP__API_KEY`` key). The secret is written to that profile's .env + via ``save_env_value``; the config.yaml entry is updated to reference it — + a header template ``Authorization: Bearer ${ENV}`` for http servers, or an + ``env: {VAR: "${ENV}"}`` reference for stdio servers — matching how + ``cmd_mcp_configure`` / ``_save_bearer_auth_token`` wire secrets. + + Result: ``{ok: true, name, env_var, server: }``. + """ + name = str(params.get("name") or "").strip() + if not name: + return _err(rid, 4063, "name required") + value = params.get("value") + if value is None or str(value) == "": + return _err(rid, 4063, "value required") + token, err = _mcp_resolve_profile(rid, params) + if err: + return err + try: + from hermes_cli.config import load_config, save_config, save_env_value + from hermes_cli.mcp_config import ( + _bearer_auth_headers, + _env_key_for_server, + _get_mcp_servers, + _strip_bearer_prefix, + ) + + servers = _get_mcp_servers() + if name not in servers: + return _err(rid, 4064, f"server '{name}' not found") + + env_var = str(params.get("env_var") or "").strip() or _env_key_for_server(name) + + entry = servers[name] + if not isinstance(entry, dict): + return _err(rid, 4001, "malformed server config") + + if entry.get("url"): + # http/sse server: store a bearer token + Authorization template. + normalized = _strip_bearer_prefix(str(value)) + if not normalized or normalized.lower() == "bearer": + return _err(rid, 4063, "value is not a valid credential") + save_env_value(env_var, normalized) + if env_var == _env_key_for_server(name): + headers = _bearer_auth_headers(name) + else: + headers = {"Authorization": f"Bearer ${{{env_var}}}"} + entry["headers"] = headers + else: + # stdio server: reference the secret from the process env block. + save_env_value(env_var, str(value)) + env_block = entry.get("env") + if not isinstance(env_block, dict): + env_block = {} + env_block[env_var] = f"${{{env_var}}}" + entry["env"] = env_block + + cfg = load_config() + cfg.setdefault("mcp_servers", {})[name] = entry + save_config(cfg) + return _ok( + rid, + { + "ok": True, + "name": name, + "env_var": env_var, + "server": _mcp_summarize_server(name, entry), + }, + ) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + _mcp_reset_profile(token) + + +@method("mcp.servers.test") +def _(rid, params: dict) -> dict: + """Probe a profile's MCP server: connect, list tools, disconnect. + + Params: optional ``profile``, ``name`` (required). Result on success: + ``{ok: true, tools: [{name, description}], prompts, resources, + oauth_tokens_present}``. On failure: ``{ok: false, error, tools: [], + oauth_needed}``. Reuses ``mcp_config._probe_single_server`` + + ``_oauth_tokens_present`` — same logic as the /test dashboard route. + + Runs on the RPC thread pool (see _LONG_HANDLERS): a cold stdio `npx` + spawn can block for many seconds. + """ + name = str(params.get("name") or "").strip() + if not name: + return _err(rid, 4063, "name required") + token, err = _mcp_resolve_profile(rid, params) + if err: + return err + try: + from hermes_cli.mcp_config import ( + _get_mcp_servers, + _oauth_tokens_present, + _probe_single_server, + ) + + servers = _get_mcp_servers() + if name not in servers: + return _err(rid, 4064, f"server '{name}' not found") + + cfg = servers[name] + # An `auth: oauth` server that serves tools/list anonymously would probe + # OK with no token — a false green. Require a token on disk for it. + needs_oauth_token = cfg.get("auth") == "oauth" + details: dict = {} + try: + tools = _probe_single_server(name, cfg, details=details) + token_present = _oauth_tokens_present(name) if needs_oauth_token else True + except Exception as exc: + return _ok( + rid, + { + "ok": False, + "error": str(exc), + "tools": [], + "oauth_needed": needs_oauth_token, + "oauth_tokens_present": _oauth_tokens_present(name) + if needs_oauth_token + else None, + }, + ) + if not token_present: + return _ok( + rid, + { + "ok": False, + "error": "OAuth authentication required — no token found.", + "tools": [], + "oauth_needed": True, + "oauth_tokens_present": False, + }, + ) + return _ok( + rid, + { + "ok": True, + "tools": [{"name": t, "description": d} for t, d in tools], + "prompts": details.get("prompts", 0), + "resources": details.get("resources", 0), + "oauth_needed": needs_oauth_token, + "oauth_tokens_present": True if needs_oauth_token else None, + }, + ) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + _mcp_reset_profile(token) + + +@method("mcp.servers.remove") +def _(rid, params: dict) -> dict: + """Remove a server from a profile's config.yaml. + + Params: optional ``profile``, ``name`` (required). Result: + ``{ok: true, removed: bool}``. Reuses ``mcp_config._remove_mcp_server``. + """ + name = str(params.get("name") or "").strip() + if not name: + return _err(rid, 4063, "name required") + token, err = _mcp_resolve_profile(rid, params) + if err: + return err + try: + from hermes_cli.mcp_config import _remove_mcp_server + + removed = _remove_mcp_server(name) + if not removed: + return _err(rid, 4064, f"server '{name}' not found") + return _ok(rid, {"ok": True, "removed": True}) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + _mcp_reset_profile(token) + + +@method("mcp.servers.oauth.start") +def _(rid, params: dict) -> dict: + """Begin a session-backed OAuth flow for an MCP server in a profile. + + Params: optional ``profile``, ``name`` (required). Result: + ``{ok: true, session_id, auth_url, flow: "pkce"}``. + + The client (desktop) opens ``auth_url`` in the native browser + (``window.hermesDesktop.openExternal``) and then polls + ``mcp.servers.oauth.poll`` with the returned ``session_id`` until + ``status == "approved"``. This mirrors the provider-OAuth start/poll model + (``/api/providers/oauth/{id}/start`` + ``/poll``): a background worker drives + the SAME interactive MCP OAuth machinery ``hermes mcp login`` uses + (``_probe_single_server`` under ``force_interactive_oauth``), and a loopback + listener captures the browser redirect — no FastAPI request object needed. + + Runs on the RPC thread pool (see _LONG_HANDLERS): start blocks briefly for + the authorization URL to be published. + """ + name = str(params.get("name") or "").strip() + if not name: + return _err(rid, 4063, "name required") + token, err = _mcp_resolve_profile(rid, params) + if err: + return err + try: + from hermes_cli.mcp_config import _get_mcp_servers + from hermes_constants import get_hermes_home + from tui_gateway import mcp_oauth_sessions + + servers = _get_mcp_servers() + if name not in servers: + return _err(rid, 4064, f"server '{name}' not found") + cfg = dict(servers[name]) + if not cfg.get("url"): + return _err( + rid, 4001, "stdio servers authenticate via env keys, not OAuth" + ) + if cfg.get("headers") and cfg.get("auth") != "oauth": + return _err( + rid, 4001, "this server uses header/API-key auth, not OAuth" + ) + cfg["auth"] = "oauth" + + hermes_home = str(get_hermes_home().expanduser().resolve(strict=False)) + result = mcp_oauth_sessions.start_flow(hermes_home, name, cfg) + return _ok( + rid, + { + "ok": True, + "session_id": result["session_id"], + "auth_url": result["auth_url"], + "flow": result["flow"], + }, + ) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + _mcp_reset_profile(token) + + +@method("mcp.servers.oauth.poll") +def _(rid, params: dict) -> dict: + """Poll a session-backed MCP OAuth flow. + + Params: optional ``profile``, ``name`` (required), ``session_id`` (required, + from ``mcp.servers.oauth.start``). Result: ``{ok: true, status: + "pending"|"approved"|"error", error_message?, auth_url?, tools?}``. + + On ``approved`` the OAuth tokens have been persisted for that server in that + profile (verified via ``_oauth_tokens_present`` inside the worker). The + profile scope is applied here too so a same-profile reconnect / token read + resolves correctly. + """ + name = str(params.get("name") or "").strip() + if not name: + return _err(rid, 4063, "name required") + session_id = str(params.get("session_id") or "").strip() + if not session_id: + return _err(rid, 4063, "session_id required") + token, err = _mcp_resolve_profile(rid, params) + if err: + return err + try: + from tui_gateway import mcp_oauth_sessions + + result = mcp_oauth_sessions.poll_flow(session_id, name) + return _ok(rid, {"ok": True, **result}) + except Exception as e: + return _err(rid, 5024, str(e)) + finally: + _mcp_reset_profile(token) @method("skills.reload") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 04b3c956e090f..3d0b8f7129c22 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -248,6 +248,12 @@ def _thread_panic_hook(args): # dead after a few skin switches. The handler serializes concurrent # reloads via _mcp_reload_lock. "reload.mcp", + # MCP server test/OAuth RPCs block on network: a probe spawns a stdio + # server (cold `npx` cold start = many seconds) or connects to a remote + # endpoint; oauth.start blocks up to ~30s waiting for the provider to + # publish an authorization URL. Keep them off the reader thread. + "mcp.servers.test", + "mcp.servers.oauth.start", "process.list", # profiles.list runs list_profiles() (recursive skill-tree walk per # profile) and opens each profile's state.db for the last-session @@ -362,6 +368,30 @@ def close(self) -> None: _detached_ws_transport = _DropTransport() +def _prepend_tool_paths(env: dict[str, str]) -> dict[str, str]: + """Prepend Hermes' managed bin, the venv bin dir, and the user-local + bin dir to PATH so slash_worker child processes can resolve + Hermes-managed CLIs (browser-use, uvx, uv) even when the parent + gateway was launched with a minimal PATH (e.g. by the + Desktop/Dashboard app). Managed bin leads, matching the managed-first + resolution policy for the Browser Use CLI.""" + managed_bin = "" + try: + from hermes_constants import get_hermes_home + + managed_bin = str(Path(get_hermes_home()) / "bin") + except Exception: + pass + venv_bin = str(Path(sys.executable).parent) # /bin (POSIX) or /Scripts (Windows) + user_bin = str(Path.home() / ".local" / "bin") + existing = env.get("PATH") or "" + env["PATH"] = os.pathsep.join( + [p for p in (managed_bin, venv_bin, user_bin) if p] + + ([existing] if existing else []) + ) + return env + + class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" @@ -398,6 +428,11 @@ def __init__(self, session_key: str, model: str, profile_home: str | None = None inherit_profile_home=False, # base already carries the HOME contract extra={"HERMES_HOME": str(profile_home)} if profile_home else None, ) + # Prepend the Hermes venv bin dir and the user-local bin dir to PATH so + # slash_worker child processes can resolve Hermes-managed CLIs + # (browser-use, uvx) even when the parent gateway was launched with a + # minimal PATH (e.g. by the Desktop/Dashboard app). See #83845. + env = _prepend_tool_paths(env) # start_new_session=True detaches the slash worker into its own # process group / session. Without this, the worker inherits the @@ -2389,11 +2424,42 @@ def _sess_nowait(params, rid): def _sess(params, rid): + s, err = _sess_building(params, rid) + if err: + return (None, err) + return (s, _wait_agent(s, rid)) + + +def _sess_building(params, rid): + """Resolve a session and warm its agent build WITHOUT waiting for it. + + For handlers that need the session record but not the agent. The attach + RPCs are the whole reason this exists: ``image.attach``, + ``image.attach_bytes``, ``file.attach``, ``pdf.attach``, + ``clipboard.paste`` and ``image.detach`` only read ``cwd`` / + ``profile_home`` and mutate ``attached_images`` — every one of those + fields is populated when the session record is created, so ``_sess``'s + ``_wait_agent`` was buying nothing and charging up to 30 seconds for it. + + That charge landed in the worst possible place. Attach runs BEFORE + ``prompt.submit``, none of these methods is in ``_LONG_HANDLERS``, and a + non-pooled handler runs inline on the socket reader thread — so pasting an + image into a session whose deferred build was still running (MCP + discovery, model metadata, skills scan: routinely tens of seconds on a + cold start) stalled the send AND every RPC queued behind it on the same + socket, with no spinner to explain it. Plain text was unaffected because + ``prompt.submit`` already resolves via ``_sess_nowait`` and waits later, + off the reader thread — which is exactly why the bug reads as "text is + instant, images hang." + + The build is still kicked off (it warms the agent the following + ``prompt.submit`` needs); we simply stop blocking on it here. + """ s, err = _sess_nowait(params, rid) if err: return (None, err) _start_agent_build(params.get("session_id") or "", s) - return (s, _wait_agent(s, rid)) + return (s, None) def _normalize_completion_path(path_part: str) -> str: @@ -3775,7 +3841,7 @@ def _resolve_startup_runtime() -> tuple[str, str | None]: # ``billing_provider="openrouter"``; dropping it forces resume to the current # global model (e.g. a custom endpoint), which is the wrong provider for the # stored model. See #57588. -_BARE_BILLING_PROVIDERS = {"auto", "custom"} +from hermes_state import _BARE_BILLING_PROVIDERS def _stored_session_runtime_overrides(row: dict | None) -> dict: @@ -3985,12 +4051,28 @@ def _persist_live_session_system_prompt(session: dict | None) -> None: if db is None or not hasattr(db, "update_system_prompt"): return + # Re-bind HERMES_HOME to the session's profile so load_soul_md() and + # build_skills_system_prompt() resolve to the correct profile. Without + # this, _start_agent_build's finally block has already reset the + # override and the rebuilt prompt silently uses the root profile's + # SOUL.md and skills. See issue #50233. + profile_home = session.get("profile_home") + home_token = ( + set_hermes_home_override(profile_home) if profile_home else None + ) try: prompt = agent._build_system_prompt(None) agent._cached_system_prompt = prompt db.update_system_prompt(getattr(agent, "session_id", None) or session_key, prompt) except Exception: - logger.debug("failed to persist live session system prompt", exc_info=True) + logger.warning( + "failed to persist live session system prompt for session %s", + session_key, + exc_info=True, + ) + finally: + if home_token is not None: + reset_hermes_home_override(home_token) # Stable leading text of the model-switch marker, shared by the builder and the @@ -9127,6 +9209,101 @@ def _notification_event_dedup_key(evt: dict) -> tuple: ) _KANBAN_SILENT_KINDS = frozenset({"archived", "unblocked"}) _KANBAN_POLL_SECONDS = 5.0 +_LOOP_POLL_SECONDS = 5.0 + + +def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None: + """Fire a due /loop wakeup for an idle TUI/Desktop/dashboard session. + + Called from the per-session notification poller thread on a coarse + cadence. Claims the session under history_lock (running=True) before + dispatching so a racing user prompt wins cleanly. The post-turn hook + in the turn dispatcher completes the tick. + """ + try: + from hermes_cli.loops import LoopManager, goal_blocks_loop_tick + except Exception: + return + + sid_key = session.get("session_key") or "" + if not sid_key: + return + mgr = LoopManager(session_id=sid_key) + if not mgr.is_due(): + return + if goal_blocks_loop_tick(sid_key): + return + + with session["history_lock"]: + if session.get("running"): + return # busy — stays due, next poll retries + session["running"] = True + + wakeup = mgr.fire_tick() + if not wakeup: + with session["history_lock"]: + session["running"] = False + return + + tick_no = mgr.state.ticks_fired if mgr.state else "?" + rid = f"__loop__{int(time.time() * 1000)}" + try: + _emit( + "status.update", + sid, + {"kind": "loop", "text": f"↻ /loop wakeup #{tick_no} firing…"}, + ) + if wakeup.lstrip().startswith("/"): + # Slash-command loop: route through the slash pipeline instead of + # the model. No model reply to evaluate — complete immediately. + with session["history_lock"]: + session["running"] = False + try: + parts = wakeup.lstrip()[1:].split(None, 1) + resp = _methods["command.dispatch"]( + rid, + { + "name": parts[0] if parts else "", + "arg": parts[1] if len(parts) > 1 else "", + "session_id": sid, + }, + ) + payload = (resp or {}).get("result") or {} + out = str(payload.get("output") or "").strip() + if out: + _emit("status.update", sid, {"kind": "loop", "text": out}) + if payload.get("type") == "send" and payload.get("message"): + # The command resolves to a prompt (skill command etc.) — + # run it as a normal turn; the post-turn hook completes + # the tick. + with session["history_lock"]: + if session.get("running"): + mgr.abandon_tick() + return + session["running"] = True + _emit("message.start", sid) + _run_prompt_submit(rid, sid, session, payload["message"]) + return + except Exception: + pass + decision = mgr.complete_tick("") + if decision.get("message"): + _emit("status.update", sid, {"kind": "loop", "text": decision["message"]}) + return + _emit("message.start", sid) + _run_prompt_submit(rid, sid, session, wakeup) + except Exception as exc: + print( + f"[tui_gateway] loop wakeup dispatch failed: " + f"{type(exc).__name__}: {exc}", + file=sys.stderr, + ) + with session["history_lock"]: + session["running"] = False + try: + mgr.abandon_tick() + except Exception: + pass def _format_kanban_event_text(sub: dict, task, ev, board_slug: str) -> Optional[str]: @@ -9306,8 +9483,23 @@ def _notification_poller_loop( _emitted = set() # dedup re-queued events so same completion isn't emitted 50 times while session is busy _last_kanban_poll = 0.0 + _last_loop_poll = 0.0 while not stop_event.is_set() and not session.get("_finalized"): _now = time.monotonic() + # ── /loop wakeup driver ────────────────────────────────────── + # Fire a due /loop tick for THIS session while it's idle. Same + # claim-under-lock pattern as the kanban dispatch below. Active + # non-parked /goal owns the idle boundary and defers the tick. + if _now - _last_loop_poll >= _LOOP_POLL_SECONDS: + _last_loop_poll = _now + try: + _maybe_fire_tui_loop_tick(sid, session) + except Exception as _loop_exc: + print( + f"[tui_gateway] loop wakeup poll failed: " + f"{type(_loop_exc).__name__}: {_loop_exc}", + file=sys.stderr, + ) if _now - _last_kanban_poll >= _KANBAN_POLL_SECONDS: _last_kanban_poll = _now try: @@ -9622,6 +9814,12 @@ def _wire_desktop_ui() -> None: _desktop_ui_wired = True +# (stop_event, thread) for every poller ever started in this process. +# Pruned of dead threads on each spawn; consumed by test teardowns to reap +# leaked pollers (see _start_notification_poller). +_notification_pollers: list = [] + + def _start_notification_poller(sid: str, session: dict) -> threading.Event: """Start the background notification poller for a TUI session.""" _wire_agent_terminal_output() @@ -9631,7 +9829,18 @@ def _start_notification_poller(sid: str, session: dict) -> threading.Event: target=_notification_poller_loop, args=(stop, sid, session), daemon=True, + # Stable, greppable name for debuggers and test teardowns. + name=f"tui-notif-poller-{sid}", ) + # Registry of (stop, thread) pairs so test teardowns can reap pollers + # leaked by session.init/create tests — an unjoined poller steals + # events off the process-global completion_queue mid-assertion in a + # LATER test (flaky test_run_prompt_submit_requeues_all_unstarted_...). + # Bounded: entries for dead threads are pruned on each spawn. + _notification_pollers[:] = [ + (s, th) for (s, th) in _notification_pollers if th.is_alive() + ] + _notification_pollers.append((stop, t)) t.start() return stop @@ -10366,6 +10575,36 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: file=sys.stderr, ) + # ── /loop tick completion ────────────────────────────────── + # If the turn that just finished was a /loop wakeup (fired by + # the notification poller), evaluate it: LOOP_COMPLETE marker, + # --until judge, --times / max_ticks caps, next-tick schedule. + if status == "complete": + try: + from hermes_cli.loops import LoopManager + + loop_sid_key = session.get("session_key") or "" + if loop_sid_key: + loop_mgr = LoopManager(session_id=loop_sid_key) + loop_state = loop_mgr.state + if loop_state is not None and loop_state.awaiting_response: + loop_decision = loop_mgr.complete_tick( + raw if isinstance(raw, str) else "" + ) + loop_msg = loop_decision.get("message") or "" + if loop_msg: + _emit( + "status.update", + sid, + {"kind": "loop", "text": loop_msg}, + ) + except Exception as _loop_exc: + print( + f"[tui_gateway] loop completion hook failed: " + f"{type(_loop_exc).__name__}: {_loop_exc}", + file=sys.stderr, + ) + # Apply pending_title now that the DB row exists — in the # session-owned profile store (not the launch profile). _pending = session.get("pending_title") @@ -12229,6 +12468,8 @@ def _finish_reload(rid, params: dict, *, coalesced: bool) -> dict: "steer", "plan", "goal", + "loop", + "proactive", "moa", "undo", "learn", @@ -14462,6 +14703,27 @@ def reap() -> None: +# Per-profile MCP lifecycle helpers (mcp.servers.* handlers). Defined on THIS +# namespace so the rebound handler bodies (register() below) can resolve them, +# same as _ok/_err — a plain def in methods_tools would be unreachable. +from .mcp_rpc_helpers import ( # noqa: E402 + reset_profile as _mcp_reset_profile, + summarize_server as _mcp_summarize_server_impl, +) + + +def _mcp_resolve_profile(rid, params): # noqa: E402 + # Bind this namespace's _err so the helper's error envelopes match every + # other handler's shape; handlers call this with just (rid, params). + from .mcp_rpc_helpers import resolve_profile as _rp + + return _rp(rid, params, _err) + + +def _mcp_summarize_server(name, cfg): # noqa: E402 + return _mcp_summarize_server_impl(name, cfg) + + # ── Split @method handler modules (see method_ctx.py) ──────────────── # Imported at the end of this module so every global the handlers close # over already exists; register() rebinds them onto this namespace. diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.focus.test.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.focus.test.tsx new file mode 100644 index 0000000000000..7357164303816 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.focus.test.tsx @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createSelectionState } from '../selection.js' +import { getTerminalFocusState, resetTerminalFocusState } from '../terminal-focus-state.js' +import { FOCUS_IN, FOCUS_OUT } from '../termio/csi.js' + +import App from './App.js' + +function makeApp(onTerminalFocusChange = vi.fn()) { + const stdin = { + isTTY: true, + readableLength: 0, + read: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), + setEncoding: vi.fn(), + setRawMode: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn() + } as unknown as NodeJS.ReadStream + + const stdout = { + isTTY: true, + columns: 80, + rows: 24, + write: vi.fn(), + on: vi.fn(), + off: vi.fn() + } as unknown as NodeJS.WriteStream + + return new App({ + children: null, + dispatchKeyboardEvent: vi.fn(), + exitOnCtrlC: false, + getHyperlinkAt: vi.fn(), + onClickAt: vi.fn(() => false), + onCursorDeclaration: vi.fn(), + onExit: vi.fn(), + onHoverAt: vi.fn(), + onMouseDownAt: vi.fn(() => undefined), + onMouseDragAt: vi.fn(), + onMouseUpAt: vi.fn(), + onMultiClick: vi.fn(), + onOpenHyperlink: vi.fn(), + onSelectionChange: vi.fn(), + onSelectionDrag: vi.fn(), + onTerminalFocusChange, + selection: createSelectionState(), + stderr: stdout, + stdin, + stdout, + terminalColumns: 80, + terminalRows: 24 + }) +} + +describe('App terminal focus events', () => { + beforeEach(() => { + resetTerminalFocusState() + }) + + it('notifies the renderer on DECSET 1004 focus transitions', () => { + const onTerminalFocusChange = vi.fn() + const app = makeApp(onTerminalFocusChange) + + app.processInput(FOCUS_OUT) + expect(getTerminalFocusState()).toBe('blurred') + expect(onTerminalFocusChange).toHaveBeenLastCalledWith(false) + + app.processInput(FOCUS_IN) + expect(getTerminalFocusState()).toBe('focused') + expect(onTerminalFocusChange).toHaveBeenLastCalledWith(true) + expect(onTerminalFocusChange).toHaveBeenCalledTimes(2) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 3d732b0fb87f1..10b3482f4c444 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -123,6 +123,11 @@ type Props = { // fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the // terminal side. Optional so testing.tsx doesn't need to stub it. readonly onStdinResume?: () => void + // Called for DECSET 1004 terminal focus transitions. The renderer uses + // focus-in as a strong signal that the emulator may have coalesced hidden + // tab writes or lost physical cursor state, so it can force one clean + // repaint instead of trusting incremental damage from before the blur. + readonly onTerminalFocusChange?: (isFocused: boolean) => void // Receives the declared native-cursor position from useDeclaredCursor // so ink.tsx can park the terminal cursor there after each frame. // Enables IME composition at the input caret and lets screen readers / @@ -630,6 +635,7 @@ export default class App extends PureComponent { // setTerminalFocused notifies subscribers: TerminalFocusProvider (context) // and Clock (interval speed) — no App setState needed. setTerminalFocused(isFocused) + this.props.onTerminalFocusChange?.(isFocused) } handleSuspend = (): void => { if (!this.isRawModeSupported()) { diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 609bcad54b74b..a3812a4cc9d62 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -600,6 +600,27 @@ export default class Ink { }, 160) } + private handleTerminalFocusChange(isFocused: boolean): void { + if (!isFocused || !this.options.stdout.isTTY) { + return + } + + // Focus-in means the terminal emulator has just made this tab/pane + // visible again. Some emulators throttle or coalesce hidden-tab output; + // if we continue with the pre-blur virtual cursor/backbuffer, only the + // next small dirty region may repaint and stale status/progress rows can + // remain visible. Defer one tick so TerminalFocusProvider subscribers + // observe the new focus state first, then do the same recovery as /redraw. + queueMicrotask(() => { + if (this.isUnmounted || this.isPaused || !this.options.stdout.isTTY || this.currentNode === null) { + return + } + + this.reassertTerminalModes(false) + this.forceRedraw() + }) + } + resolveExitPromise: () => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {} unsubscribeExit: () => void = () => {} @@ -1134,7 +1155,13 @@ export default class Ink { const { bytes: writeBytes, backpressure } = writeDiffToTerminal( this.terminal, optimized, - this.altScreenActive && !SYNC_OUTPUT_SUPPORTED, + // Never emit BSU/ESU (DEC 2026) on terminals that don't support it — + // main screen included. Multiplexers like Zellij re-parse and re-chunk + // the stream with their own timing, so the markers buy no atomicity and + // stale frames get pushed into main-screen scrollback as repeated + // chrome (#66490). Supported terminals keep today's behavior on both + // screens (skip=false → BSU/ESU wrapped). + !SYNC_OUTPUT_SUPPORTED, trackDrain ? () => { // Callback fires once Node has flushed the chunk to the OS. @@ -2398,6 +2425,7 @@ export default class Ink { onSelectionChange={this.notifySelectionChange} onSelectionDrag={this.handleSelectionDrag} onStdinResume={this.reassertTerminalModes} + onTerminalFocusChange={this.handleTerminalFocusChange} selection={this.selection} stderr={this.options.stderr} stdin={this.options.stdin} diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.test.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.test.ts index 6c4f117f92101..800f427840ba3 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { needsAltScreenResizeScrollbackClear } from './terminal.js' +import { isSynchronizedOutputSupported, needsAltScreenResizeScrollbackClear, writeDiffToTerminal } from './terminal.js' +import { BSU, ESU } from './termio/dec.js' describe('terminal resize quirks', () => { it('uses a deeper alt-screen resize clear for Apple Terminal', () => { @@ -13,3 +14,63 @@ describe('terminal resize quirks', () => { expect(needsAltScreenResizeScrollbackClear({ TERM_PROGRAM: 'iTerm.app' })).toBe(false) }) }) + +describe('synchronized output detection', () => { + it('does not trust an outer terminal DEC 2026 capability under Zellij', () => { + // Zellij (like tmux) proxies/chunks the stream, so the outer WezTerm's + // DEC 2026 support must not be trusted. Zellij sets ZELLIJ to the session + // index — "0" for the first session — so the guard keys on presence. + expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm', ZELLIJ: '0' })).toBe(false) + expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm', ZELLIJ: '1' })).toBe(false) + }) + + it('does not trust an outer terminal DEC 2026 capability under tmux', () => { + expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm', TMUX: '/tmp/tmux-1/default,1,0' })).toBe(false) + }) + + it('still reports support for a DEC 2026 terminal with no multiplexer', () => { + expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'WezTerm' })).toBe(true) + expect(isSynchronizedOutputSupported({ TERM_PROGRAM: 'iTerm.app' })).toBe(true) + }) + + it('reports no support for an unknown terminal', () => { + expect(isSynchronizedOutputSupported({ TERM: 'xterm-256color' })).toBe(false) + }) +}) + +describe('writeDiffToTerminal sync-marker gating (#66490 main-screen gap)', () => { + const makeTerminal = () => { + const writes: string[] = [] + + const stdout = { + write(chunk: string) { + writes.push(String(chunk)) + + return true + } + } + + return { terminal: { stderr: stdout, stdout } as never, writes } + } + + it('wraps the frame in BSU/ESU when sync markers are enabled', () => { + const { terminal, writes } = makeTerminal() + writeDiffToTerminal(terminal, [{ content: 'frame', type: 'stdout' }] as never, false) + const out = writes.join('') + expect(out).toContain(BSU) + expect(out).toContain(ESU) + }) + + it('emits no BSU/ESU when skipSyncMarkers is true (unsupported terminal, ANY screen)', () => { + // The renderer passes !SYNC_OUTPUT_SUPPORTED for every write path — main + // screen included. Under Zellij the multiplexer re-chunks the stream, so + // the markers buy no atomicity and stale frames leak into main-screen + // scrollback as repeated chrome. + const { terminal, writes } = makeTerminal() + writeDiffToTerminal(terminal, [{ content: 'frame', type: 'stdout' }] as never, true) + const out = writes.join('') + expect(out).not.toContain(BSU) + expect(out).not.toContain(ESU) + expect(out).toContain('frame') + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts index 5a3ae3906d05f..1f709c4e5b2b7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -67,16 +67,26 @@ export function isProgressReportingAvailable(): boolean { * Checks if the terminal supports DEC mode 2026 (synchronized output). * When supported, BSU/ESU sequences prevent visible flicker during redraws. */ -export function isSynchronizedOutputSupported(): boolean { +export function isSynchronizedOutputSupported(env: NodeJS.ProcessEnv = process.env): boolean { // tmux parses and proxies every byte but doesn't implement DEC 2026. // BSU/ESU pass through to the outer terminal but tmux has already // broken atomicity by chunking. Skip to save 16 bytes/frame + parser work. - if (process.env.TMUX) { + if (env.TMUX) { return false } - const termProgram = process.env.TERM_PROGRAM - const term = process.env.TERM + // Zellij is the same class of hazard as tmux: it sits between us and the + // outer terminal, parsing/proxying (and chunking) the stream, so we can't + // trust the outer terminal's DEC 2026 support advertised via TERM_PROGRAM + // (e.g. WezTerm). Trusting it wraps frames in BSU/ESU that Zellij has + // already broken atomicity on, repeating old frames into scrollback. + // Zellij sets ZELLIJ to the session index (e.g. "0"), so guard on presence. + if (env.ZELLIJ) { + return false + } + + const termProgram = env.TERM_PROGRAM + const term = env.TERM // Modern terminals with known DEC 2026 support if ( @@ -92,7 +102,7 @@ export function isSynchronizedOutputSupported(): boolean { } // kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID - if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) { + if (term?.includes('kitty') || env.KITTY_WINDOW_ID) { return true } @@ -112,17 +122,17 @@ export function isSynchronizedOutputSupported(): boolean { } // Zed uses the alacritty_terminal crate which supports DEC 2026 - if (process.env.ZED_TERM) { + if (env.ZED_TERM) { return true } // Windows Terminal - if (process.env.WT_SESSION) { + if (env.WT_SESSION) { return true } // VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68 - const vteVersion = process.env.VTE_VERSION + const vteVersion = env.VTE_VERSION if (vteVersion) { const version = parseInt(vteVersion, 10) diff --git a/ui-tui/src/__tests__/mergeUsageStable.test.ts b/ui-tui/src/__tests__/mergeUsageStable.test.ts new file mode 100644 index 0000000000000..ded9c0dc2c799 --- /dev/null +++ b/ui-tui/src/__tests__/mergeUsageStable.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { mergeUsageStable, usageChanged } from '../app/createGatewayEventHandler.js' +import type { Usage } from '../types.js' + +const baseUsage: Usage = { + calls: 3, + input: 1200, + output: 400, + total: 1600, + context_max: 200000, + context_percent: 12, + context_used: 24000 +} + +describe('mergeUsageStable (#41480 status-bar flicker)', () => { + it('returns the PRIOR reference when a patch changes nothing', () => { + // The load-bearing behavior: an unchanged-value patch must NOT mint a new + // object, or every $uiState subscriber re-renders per streaming delta. + const patch = { calls: 3, total: 1600 } + expect(mergeUsageStable(baseUsage, patch)).toBe(baseUsage) + }) + + it('returns the prior reference for an undefined patch', () => { + expect(mergeUsageStable(baseUsage, undefined)).toBe(baseUsage) + }) + + it('returns a new merged object when a value actually changes', () => { + const merged = mergeUsageStable(baseUsage, { total: 1700 }) + expect(merged).not.toBe(baseUsage) + expect(merged.total).toBe(1700) + expect(merged.calls).toBe(3) + }) + + it('detects an active_subagents-only update (field the original PR missed)', () => { + // usageChanged iterates the key union generically, so optional fields the + // status rule consumes (active_subagents drives the ⛓ segment and the + // resume hint) can never be silently dropped from the comparison. + const withSubagents = mergeUsageStable(baseUsage, { active_subagents: 2 }) + expect(withSubagents).not.toBe(baseUsage) + expect(withSubagents.active_subagents).toBe(2) + + // And clearing it back down is also a change. + const cleared = mergeUsageStable(withSubagents, { active_subagents: 0 }) + expect(cleared).not.toBe(withSubagents) + expect(cleared.active_subagents).toBe(0) + }) + + it('treats a key present on only one side as a change', () => { + expect(usageChanged(baseUsage, { ...baseUsage, cost_usd: 0.01 })).toBe(true) + expect(usageChanged({ ...baseUsage, cost_usd: 0.01 }, baseUsage)).toBe(true) + }) + + it('reports no change for deep-equal usages', () => { + expect(usageChanged(baseUsage, { ...baseUsage })).toBe(false) + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 0161de8e9ec13..9507cb08541fe 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -23,7 +23,7 @@ import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '.. import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme, themeToneHex } from '../theme.js' -import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' +import type { Msg, SubagentProgress, SubagentStatus, Usage } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' @@ -41,6 +41,35 @@ type VoiceSubmitMode = 'direct' | 'draft' const normalizeVoiceSubmitMode = (value: unknown): VoiceSubmitMode => typeof value === 'string' && value.trim().toLowerCase() === 'draft' ? 'draft' : 'direct' +// Shallow-compare Usage to avoid creating a new object reference when values +// haven't changed. A fresh reference on every streaming event forces every +// $uiState subscriber (including the status rule) to re-render, which showed +// up as per-delta status-bar flicker on iTerm2 (#41480). The comparator +// iterates the union of keys generically so a future Usage field (e.g. +// active_subagents, consumed by the status rule's subagent segment) can never +// be silently dropped from the comparison. +export const usageChanged = (prev: Usage, next: Usage): boolean => { + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) as Set + + for (const key of keys) { + if (prev[key] !== next[key]) { + return true + } + } + + return false +} + +export const mergeUsageStable = (prev: Usage, patch: Partial | undefined): Usage => { + if (!patch) { + return prev + } + + const merged: Usage = { ...prev, ...patch } + + return usageChanged(prev, merged) ? merged : prev +} + const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') // The last gateway skin, kept so the theme can be re-derived when the OSC-11 @@ -745,7 +774,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ...state, info, status: state.status === 'starting agent…' ? 'ready' : state.status, - usage: info.usage ? { ...state.usage, ...info.usage } : state.usage + usage: info.usage ? mergeUsageStable(state.usage, info.usage) : state.usage })) setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m))) @@ -1370,7 +1399,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: setStatus('ready') if (ev.payload?.usage) { - patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) + patchUiState(state => ({ ...state, usage: mergeUsageStable(state.usage, ev.payload!.usage) })) } // Billing wall (out of credits / payment required): open a proper diff --git a/ui-tui/src/app/sessionResumeView.test.ts b/ui-tui/src/app/sessionResumeView.test.ts new file mode 100644 index 0000000000000..f2674291b6f63 --- /dev/null +++ b/ui-tui/src/app/sessionResumeView.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { evictInkCachesMock, forceRedrawMock } = vi.hoisted(() => ({ + evictInkCachesMock: vi.fn(), + forceRedrawMock: vi.fn() +})) + +vi.mock('@hermes/ink', () => ({ + evictInkCaches: evictInkCachesMock, + forceRedraw: forceRedrawMock +})) + +import { refreshSessionView, scheduleResumeScrollToBottom } from './sessionResumeView.js' + +describe('refreshSessionView', () => { + afterEach(() => { + evictInkCachesMock.mockReset() + forceRedrawMock.mockReset() + }) + + it('evicts Ink caches and forces a full repaint', () => { + const stdout = {} as NodeJS.WriteStream + + refreshSessionView(stdout) + + expect(evictInkCachesMock).toHaveBeenCalledWith('all') + expect(forceRedrawMock).toHaveBeenCalledWith(stdout) + }) +}) + +describe('scheduleResumeScrollToBottom', () => { + afterEach(() => { + vi.useRealTimers() + evictInkCachesMock.mockReset() + forceRedrawMock.mockReset() + }) + + it('re-snaps while sticky and stops when the user scrolls away', () => { + vi.useFakeTimers() + let sticky = true + let lastManualScrollAt = 0 + const scrollToBottom = vi.fn() + + const cancel = scheduleResumeScrollToBottom( + { + current: { + getLastManualScrollAt: () => lastManualScrollAt, + isSticky: () => sticky, + scrollToBottom + } + } as any, + [0, 80, 240] + ) + + vi.advanceTimersByTime(0) + expect(scrollToBottom).toHaveBeenCalledTimes(1) + expect(evictInkCachesMock).toHaveBeenCalledWith('all') + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(80) + expect(scrollToBottom).toHaveBeenCalledTimes(2) + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + sticky = false + lastManualScrollAt = Date.now() + 1 + vi.advanceTimersByTime(160) + expect(scrollToBottom).toHaveBeenCalledTimes(2) + + cancel() + }) + + it('cancels pending resume snaps', () => { + vi.useFakeTimers() + const scrollToBottom = vi.fn() + + const cancel = scheduleResumeScrollToBottom( + { + current: { + getLastManualScrollAt: () => 0, + isSticky: () => true, + scrollToBottom + } + } as any, + [20] + ) + + cancel() + vi.advanceTimersByTime(20) + + expect(scrollToBottom).not.toHaveBeenCalled() + expect(forceRedrawMock).not.toHaveBeenCalled() + }) + + it('keeps the immediate resume snap even before sticky state settles', () => { + vi.useFakeTimers() + let sticky = false + const scrollToBottom = vi.fn() + + const cancel = scheduleResumeScrollToBottom( + { + current: { + getLastManualScrollAt: () => 0, + isSticky: () => sticky, + scrollToBottom + } + } as any, + [0, 80] + ) + + vi.advanceTimersByTime(0) + expect(scrollToBottom).toHaveBeenCalledTimes(1) + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(80) + expect(scrollToBottom).toHaveBeenCalledTimes(1) + expect(forceRedrawMock).toHaveBeenCalledTimes(1) + + sticky = true + cancel() + }) +}) diff --git a/ui-tui/src/app/sessionResumeView.ts b/ui-tui/src/app/sessionResumeView.ts new file mode 100644 index 0000000000000..131a44b5e907d --- /dev/null +++ b/ui-tui/src/app/sessionResumeView.ts @@ -0,0 +1,41 @@ +import type { ScrollBoxHandle } from '@hermes/ink' +import { evictInkCaches, forceRedraw } from '@hermes/ink' +import type { RefObject } from 'react' + +export const refreshSessionView = (stdout: NodeJS.WriteStream = process.stdout) => { + evictInkCaches('all') + forceRedraw(stdout) +} + +export const scheduleResumeScrollToBottom = ( + scrollRef: RefObject, + delays: readonly number[] = [0, 80, 240] +) => { + const startedAt = Date.now() + + const timers = delays.map((delay, index) => + setTimeout(() => { + const scroll = scrollRef.current + + if (!scroll) { + return + } + + const manuallyScrolledAfterResume = scroll.getLastManualScrollAt() > startedAt + + if (!manuallyScrolledAfterResume && (index === 0 || scroll.isSticky())) { + scroll.scrollToBottom() + + if (index === 0) { + refreshSessionView() + } + } + }, delay) + ) + + return () => { + for (const timer of timers) { + clearTimeout(timer) + } + } +} diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 5839ecdc69378..cc6d29d162ab9 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -22,10 +22,13 @@ import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js' import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' +import { scheduleResumeScrollToBottom } from './sessionResumeView.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' +export { refreshSessionView, scheduleResumeScrollToBottom } from './sessionResumeView.js' + const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) const statusFromLiveSession = (status?: string, running = false) => { @@ -82,35 +85,6 @@ export const signalFreshSessionBoundary = ( return true } -export const scheduleResumeScrollToBottom = ( - scrollRef: RefObject, - delays: readonly number[] = [0, 80, 240] -) => { - const startedAt = Date.now() - - const timers = delays.map((delay, index) => - setTimeout(() => { - const scroll = scrollRef.current - - if (!scroll) { - return - } - - const manuallyScrolledAfterResume = scroll.getLastManualScrollAt() > startedAt - - if (!manuallyScrolledAfterResume && (index === 0 || scroll.isSticky())) { - scroll.scrollToBottom() - } - }, delay) - ) - - return () => { - for (const timer of timers) { - clearTimeout(timer) - } - } -} - const trimTail = (items: Msg[]) => { const q = [...items] diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4ab91c2a127dd..bd72ff9bf9331 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1861,11 +1861,13 @@ export interface StatusResponse { * fail-closed state (the dashboard will refuse to bind). */ auth_providers?: string[]; /** Supported dashboard auth flows for the client to choose from. In gated - * mode always includes ``"cookie"``; includes ``"native_pkce"`` when a - * brokerable OAuth provider is registered, signalling that the desktop can - * use the RFC 8252 system-browser + loopback + PKCE flow (no embedded - * webview, no session cookies). Absent / missing ``"native_pkce"`` ⇒ an - * older gateway ⇒ the desktop falls back to the embedded-webview flow. */ + * mode always includes ``"cookie"``; includes ``"native_pkce"`` when any + * interactive session provider is registered (OAuth providers broker the + * IDP redirect; password providers complete at /login in the system + * browser), signalling that the desktop can use the RFC 8252 + * system-browser + loopback + PKCE flow (no embedded webview, no session + * cookies). Absent / missing ``"native_pkce"`` ⇒ an older gateway ⇒ the + * desktop falls back to the embedded-webview flow. */ auth_flows?: string[]; /** False when the dashboard is running in a hosted/managed layout where * updates are handled by the outer launcher instead of ``hermes update``. */ diff --git a/web/src/lib/pty-scroll.test.ts b/web/src/lib/pty-scroll.test.ts new file mode 100644 index 0000000000000..887efa3b70e1a --- /dev/null +++ b/web/src/lib/pty-scroll.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { isViewportPinnedToBottom, shouldFollowPtyOutput } from "./pty-scroll"; + +describe("isViewportPinnedToBottom", () => { + it("is pinned when the viewport sits on the bottom row", () => { + // xterm reports viewportY === baseY when the newest output is on screen. + expect(isViewportPinnedToBottom({ viewportY: 120, baseY: 120 })).toBe(true); + }); + + it("releases the pin once the user scrolls up into the backlog", () => { + // Scrolling up drops viewportY below baseY — the user is reading history, + // so the resume replay must not yank them back down (#59591 follow-up). + expect(isViewportPinnedToBottom({ viewportY: 40, baseY: 120 })).toBe(false); + }); + + it("stays pinned if viewportY overshoots baseY while rows are trimmed", () => { + // scrollback eviction can momentarily push viewportY past baseY. + expect(isViewportPinnedToBottom({ viewportY: 121, baseY: 120 })).toBe(true); + }); + + it("treats a fresh 0x0 buffer as pinned", () => { + expect(isViewportPinnedToBottom({ viewportY: 0, baseY: 0 })).toBe(true); + }); +}); + +describe("shouldFollowPtyOutput", () => { + it("follows replayed output while resuming and stuck to the bottom", () => { + // The core #59591 fix: scroll to bottom as each replay chunk commits. + expect(shouldFollowPtyOutput("sess-123", true)).toBe(true); + }); + + it("stops following once the user has scrolled up mid-replay", () => { + expect(shouldFollowPtyOutput("sess-123", false)).toBe(false); + }); + + it("does not follow a fresh (non-resume) session", () => { + // Fresh chats start empty; forcing scroll would fight normal cursor output. + expect(shouldFollowPtyOutput(null, true)).toBe(false); + }); + + it("does not follow a fresh session even when stickToBottom is true", () => { + expect(shouldFollowPtyOutput(null, false)).toBe(false); + }); + + it("treats an empty resume param as non-resume", () => { + expect(shouldFollowPtyOutput("", true)).toBe(false); + }); +}); diff --git a/web/src/lib/pty-scroll.ts b/web/src/lib/pty-scroll.ts new file mode 100644 index 0000000000000..6c0b15ceffb2b --- /dev/null +++ b/web/src/lib/pty-scroll.ts @@ -0,0 +1,50 @@ +/** + * Dashboard chat resume-scroll helpers. + * + * When a chat session is resumed (`/chat?resume=`) the PTY backend replays + * the entire scrollback over the WebSocket the instant it opens. xterm.js writes + * those bytes into its buffer but leaves the viewport wherever it was (e.g. the + * top of a fresh terminal), so the transcript looks truncated until something + * else forces a re-render. See #59591. + * + * The fix pins the viewport to the bottom *as each replayed chunk commits* (via + * xterm's `write` callback) instead of guessing with a fixed double-rAF delay, + * and releases that pin the moment the user scrolls up so their manual review of + * the backlog is never yanked back down. + * + * These two decisions are pulled out here as pure functions so they can be + * unit-tested without a live terminal; `ChatPage` wires them to the real xterm + * instance (see `term.onScroll` and `ws.onmessage`). + */ + +/** The subset of xterm's active `IBuffer` these helpers need. */ +export interface TerminalViewportPosition { + /** Row index of the top of the current viewport. */ + viewportY: number; + /** Row index of the viewport top when scrolled fully to the bottom. */ + baseY: number; +} + +/** + * True when the viewport is scrolled to (or past) the bottom, i.e. the latest + * output is on screen. xterm reports `viewportY === baseY` at the bottom; the + * `>=` also covers the transient overshoot while scrollback rows are trimmed. + */ +export function isViewportPinnedToBottom( + buffer: TerminalViewportPosition, +): boolean { + return buffer.viewportY >= buffer.baseY; +} + +/** + * Whether a freshly written PTY output chunk should scroll the terminal to the + * bottom afterwards. We only auto-follow while resuming a session (the replay + * case) and only while the user hasn't scrolled up to read the backlog. A fresh + * (non-resume) session returns `false` so normal cursor output is never fought. + */ +export function shouldFollowPtyOutput( + resumeParam: string | null, + stickToBottom: boolean, +): boolean { + return Boolean(resumeParam) && stickToBottom; +} diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx index 08e6f0245cead..907147048a776 100644 --- a/web/src/pages/ChatPage.test.tsx +++ b/web/src/pages/ChatPage.test.tsx @@ -57,6 +57,18 @@ class FakeTerminal { return { dispose() {} }; } + onScroll() { + return { dispose() {} }; + } + + get buffer() { + // Minimal active-buffer surface for the resume follow-scroll pin + // (isViewportPinnedToBottom reads viewportY/baseY). + return { active: { baseY: 0, viewportY: 0 } }; + } + + scrollToBottom() {} + open() {} paste() {} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 0e31c6589cfd4..ea6d9f36e5e16 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -59,6 +59,10 @@ import { normalizePtyMobileInput, shouldTreatInputAsMobileReplacement, } from "@/lib/pty-mobile-input"; +import { + isViewportPinnedToBottom, + shouldFollowPtyOutput, +} from "@/lib/pty-scroll"; import { imageFilesFromTransfer, transferMayContainImage, @@ -167,6 +171,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(null); + const stickToBottomRef = useRef(true); // Exposed to the main metrics-sync effect so it can refit the terminal // the moment `isActive` flips back to true (display:none → display:flex // collapses the host's box, so ResizeObserver never fires on return). @@ -903,6 +908,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { let unmounting = false; let onDataDisposable: { dispose(): void } | null = null; let onResizeDisposable: { dispose(): void } | null = null; + let onScrollDisposable: { dispose(): void } | null = null; let eraseSuppressionTimer: ReturnType | null = null; let resumeMaxTimer: ReturnType | null = null; const clearEraseSuppressionTimer = () => { @@ -1061,6 +1067,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // follow up with the authoritative measurement — at worst Ink // reflows once after the PTY boots, which is imperceptible. ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`); + // Resumed sessions replay scrollback over the socket. Start pinned to + // the bottom so the latest output is in view; released once the user + // scrolls up (#59591). + if (resumeParam) stickToBottomRef.current = true; // One-shot: a ?learn= param (set by the Skills page "Learn a // skill" panel) is typed into the composer as a /learn command once the // PTY is up. /learn resolves via command.dispatch → a normal agent turn, @@ -1107,7 +1117,17 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // resume frame into "" (pty-resume-sanitizer.ts); keying off raw `text` // would hide the wait notice while the terminal is still blank. const rendered = resumeParam ? sanitizer.next(text) : text; - term.write(rendered); + // Resume replay lands over many write chunks; pin the viewport to the + // bottom as each chunk COMMITS (xterm write callback) instead of + // guessing with a fixed delay, and release the pin the moment the user + // scrolls up to read the backlog (#59591). + const followScroll = shouldFollowPtyOutput( + resumeParam, + stickToBottomRef.current, + ) + ? () => termRef.current?.scrollToBottom() + : undefined; + term.write(rendered, followScroll); noteResumePtyChunk(rendered); }; @@ -1264,6 +1284,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { ws.send(`\x1b[RESIZE:${cols};${rows}]`); } }); + + // Release the stick-to-bottom pin the moment the user scrolls up, so + // we only auto-follow during the resume replay — not their manual + // review of the backlog (#59591). + onScrollDisposable = term.onScroll(() => { + stickToBottomRef.current = isViewportPinnedToBottom(term.buffer.active); + }); })(); term.focus(); @@ -1277,6 +1304,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { setResumeHydrating(false); onDataDisposable?.dispose(); onResizeDisposable?.dispose(); + onScrollDisposable?.dispose(); mobileInputCleanup?.(); host.removeEventListener("paste", handleBrowserPaste, true); host.removeEventListener("dragover", handleBrowserDragOver, true); diff --git a/website/docs/guides/desktop-native-signin.md b/website/docs/guides/desktop-native-signin.md index 96c59048c784c..186acec1540bb 100644 --- a/website/docs/guides/desktop-native-signin.md +++ b/website/docs/guides/desktop-native-signin.md @@ -97,13 +97,16 @@ tool blocks the loopback listener, or you close the browser tab — the app ## For gateway operators -Native sign-in is available automatically on any gated gateway that has a -brokerable OAuth provider registered (e.g. the bundled **Nous** provider). No -configuration is required — the `/auth/native/*` routes and the `auth_flows` -advertisement are part of the dashboard-auth subsystem. Password-only and -token-only providers do not advertise `native_pkce` (there is no upstream -redirect to broker), and those deployments continue to use their existing -login. +Native sign-in is available automatically on any gated gateway with an +interactive session provider registered. No configuration is required — the +`/auth/native/*` routes and the `auth_flows` advertisement are part of the +dashboard-auth subsystem. OAuth providers (e.g. the bundled **Nous** provider) +broker the upstream IDP redirect; password providers (e.g. the bundled +**basic-auth** plugin) land the system browser on the gateway's `/login` +credential form instead — which is what lets OS password managers (macOS +Passwords, etc.) autofill the form, something no embedded desktop webview can +offer. Token-only credentials (e.g. drain) are not interactive sign-ins and do +not advertise `native_pkce`. The relevant endpoints (all public, pre-auth bootstrap, same as the existing `/auth/*` OAuth routes): diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 423dfc7b73954..97e53ab083af3 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1749,6 +1749,7 @@ display: fields: ["model", "context_pct", "cwd"] file_mutation_verifier: true # Append an advisory footer when write_file/patch calls failed this turn credits_notices: true # Nous credits status-bar notices (usage bands, grant-spent, depleted). false = silence them; /usage still works + cli_rebuild_scrollback_on_redraw: false # Classic CLI: also wipe terminal scrollback (CSI 3J) on /redraw / Ctrl+L / width-change resize recovery. Enable when a terminal/tmux stack stamps stale prompt chrome into scrollback on maximize/restore. language: en # UI language for static messages (approval prompts, some gateway replies). en | zh | zh-hant | ja | de | es | fr | tr | uk | af | ko | it | ga | pt | ru | hu ``` diff --git a/website/docs/user-guide/features/loops.md b/website/docs/user-guide/features/loops.md new file mode 100644 index 0000000000000..7b066b44ba0a2 --- /dev/null +++ b/website/docs/user-guide/features/loops.md @@ -0,0 +1,122 @@ +--- +sidebar_position: 17 +title: "Recurring Loops" +description: "Re-run a prompt on a recurring interval inside your session — Hermes' take on Claude Code's /loop." +--- + +# Recurring Loops (`/loop`) + +`/loop` re-runs a prompt (or a slash command) on a recurring cadence **inside your current session**. Each wakeup is a real agent turn: Hermes reads the current state fresh — the latest CI result, the newest queue depth, the file as it is now — does the work, reports back, and goes quiet until the next tick. + +It's Hermes' take on **Claude Code's `/loop`** (and its `/proactive` alias, which works here too). Where [`/goal`](./goals.md) is judge-driven — "keep working until this objective is achieved" — `/loop` is timer-driven: "do this again every N minutes (or whenever it makes sense) until something says stop." + +## When to use it + +- **Polling external state.** "Watch the deploy / the CI run / the queue and tell me when it changes." The canonical use case. +- **Iterate-until-green.** "Run the tests, fix what fails, repeat until they pass." +- **Monitoring during a work session.** Keep an eye on error rates or a long job's progress while you do something else in the same conversation. +- **Periodic housekeeping.** Re-run a lint pass or a status summary every N minutes during a long session. + +When the work should run **unattended** — overnight, on a real schedule, surviving restarts of your terminal — use a [cron job](./cron.md) instead. `/loop` lives inside a session; cron lives outside all of them. And when the task is a single objective with a definition of done, [`/goal`](./goals.md) is usually the better fit. + +## Quick start + +``` +/loop 5m check the deploy status and tell me if it's live yet +``` + +What you'll see: + +1. **Loop accepted** — `↻ Loop set (every 5m): check the deploy status…` +2. **First wakeup in 5m** — while the session is idle, Hermes injects the wakeup and runs a normal turn against current state. +3. **Repeat** — every 5 minutes, until a stop condition fires or you stop it. + +Loop a slash command just as easily: + +``` +/loop 10m /recap +``` + +## The two cadence modes + +**Fixed interval — you set the clock.** Give an interval (`30s`, `5m`, `2h`, `1h30m`) and the loop fires on that schedule. Use it when the thing you're watching changes on its own timeline: + +``` +/loop 2m poll the build at ci.example.com/job/42 and ping me the moment it finishes +``` + +**Self-paced — Hermes sets the clock.** Omit the interval and the loop paces itself: it starts at the floor (1 minute by default), and while the agent's replies stop changing it backs off exponentially — 2m, 4m, 8m, up to the ceiling (15 minutes by default). The moment a reply differs from the last one, cadence snaps back to the floor. Change detection is a local digest comparison (timestamps are ignored), so idle waits cost nothing extra: + +``` +/loop keep an eye on the migration and summarize progress +``` + +The rule of thumb: **fixed interval when an external clock drives the work; self-paced when the work drives the rhythm.** + +## Stop conditions + +A loop ends when any of these fires: + +| Condition | How | +|---|---| +| The agent decides it's done | The wakeup prompt teaches the agent to end its reply with `LOOP_COMPLETE` on its own line when the task is finished or moot. | +| A run cap | `--times N` — stop after N wakeups. | +| An evidence-based condition | `--until ` — after each wakeup, the same auxiliary judge that powers `/goal` checks the reply against your condition (fail-open: a broken judge never wedges the loop). | +| You | `/loop stop` (or `/loop pause` to keep it around). | +| The backstop budget | `loops.max_ticks` (default 100) pauses the loop so an unattended session can't burn tokens forever. `0` = unlimited. | + +Examples: + +``` +/loop 2m poll CI --times 30 +/loop 5m watch the queue --until queue depth reaches zero +``` + +## Commands + +| Command | What it does | +|---|---| +| `/loop [interval] [--times N] [--until ]` | Start (or replace) the loop for this session. | +| `/loop` or `/loop status` | Show cadence, ticks fired, and time to the next wakeup. | +| `/loop pause` | Stop firing without losing the loop. | +| `/loop resume` | Pick it back up. | +| `/loop stop` | End the loop. | +| `/proactive …` | Alias for `/loop` (Claude Code parity). | + +Works on the CLI, the TUI (`hermes --tui`), the web dashboard chat, the desktop app, and every gateway platform (Telegram, Discord, Slack, WhatsApp, …). On messaging platforms the gateway fires wakeups even between your messages — the loop belongs to the chat's session, and its results arrive as ordinary replies. + +## Mixing with `/goal` + +Both features inject synthetic turns at idle boundaries, so they follow one rule: **an active goal owns the session.** While a `/goal` is actively driving (judge saying "continue"), loop wakeups defer. The loop resumes using idle time as soon as the goal finishes, pauses, or parks itself on a wait barrier (`/goal wait`, or the judge's automatic WAIT verdict). A parked goal plus a `/loop` is a natural combo: the goal waits on the big async thing while the loop keeps a heartbeat on something else. + +A real user message always wins over both — wakeups only fire while the session is idle and nothing of yours is queued. + +## Behavior details + +- **A wakeup is a normal user-role turn.** No system-prompt mutation, no toolset swap — prompt caching stays intact. +- **Survives `/resume` and compression.** Loop state persists per session and migrates across context-compression boundaries, same as `/goal`. +- **One loop per session.** Setting a new `/loop` replaces the old one. Run several loops by running several sessions (or use cron for a fleet of schedules). +- **Interrupting a wakeup turn (Ctrl+C) pauses the loop** — recoverable with `/loop resume`, so cancel actually means cancel. +- **Token cost scales with cadence.** Every tick is a full agent turn. Match the interval to how often the state actually changes; prefer self-pacing for idle waits. + +## Configuration + +```yaml +# ~/.hermes/config.yaml +loops: + min_interval_seconds: 30 # floor for fixed intervals + max_ticks: 100 # backstop budget (0 = unlimited) + self_paced_floor_seconds: 60 # self-paced starting cadence + self_paced_ceiling_seconds: 900 # self-paced max backoff +``` + +The `--until` judge routes through the `goal_judge` auxiliary task, so `auxiliary.goal_judge.*` overrides (provider, model, max_tokens) apply to loop conditions too. + +## `/loop` vs `/goal` vs cron + +| | `/loop` | `/goal` | cron | +|---|---|---|---| +| **Trigger** | Timer (or self-paced) | Judge verdict after each turn | Schedule, outside any session | +| **Lives in** | Your current session | Your current session | Its own session per run | +| **Ends when** | Stop condition / caps / you | Goal achieved / budget / you | You remove the job | +| **Best for** | Polling, monitoring, periodic re-runs | One objective, iterate until done | Unattended, long-horizon schedules | diff --git a/website/sidebars.ts b/website/sidebars.ts index a917148fd65e2..0839f3bbe6683 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -101,6 +101,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/kanban-worker-lanes', 'user-guide/features/goals', 'user-guide/features/heartbeat', + 'user-guide/features/loops', 'user-guide/features/code-execution', 'user-guide/features/hooks', 'user-guide/features/batch-processing', diff --git a/website/src/pages/skills/index.tsx b/website/src/pages/skills/index.tsx index 9892e465d1cb2..50dd3225c1b6f 100644 --- a/website/src/pages/skills/index.tsx +++ b/website/src/pages/skills/index.tsx @@ -285,6 +285,7 @@ function SkillCard({ onCategoryClick, onTagClick, style, + onPick, }: { skill: Skill; query: string; @@ -293,6 +294,8 @@ function SkillCard({ onCategoryClick: (cat: string) => void; onTagClick: (tag: string) => void; style?: React.CSSProperties; + /** Picker embed mode: render "+ Add to this Agent" and call this. */ + onPick?: (skill: Skill) => void; }) { const src = SOURCE_CONFIG[skill.source] || SOURCE_CONFIG["optional"]; const icon = CATEGORY_ICONS[skill.category] || "\u{1F4E6}"; @@ -420,6 +423,17 @@ function SkillCard({ text={skill.installCmd || `hermes skills install ${skill.name}`} />
+ {onPick ? ( + + ) : null}
{skill.docsPath ? (