diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ded0ff541..3441d7bb6db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,11 @@ jobs: needs: detect uses: ./.github/workflows/uv-lockfile-check.yml + infographic-check: + name: Check no committed infographics + needs: detect + uses: ./.github/workflows/infographic-check.yml + lockfile-diff: name: package-lock.json diff needs: detect diff --git a/.github/workflows/infographic-check.yml b/.github/workflows/infographic-check.yml new file mode 100644 index 00000000000..288f6f493a0 --- /dev/null +++ b/.github/workflows/infographic-check.yml @@ -0,0 +1,78 @@ +name: Infographic Check + +# Rejects PRs that commit PR-infographic images into the repo. +# +# PR infographics are rendered to an image-provider URL (fal.media) and +# embedded in the PR *description*. The PR body is the archive; the binary +# never belongs in git history. +# +# This has now leaked twice. PR #48261 removed the first batch, PR #54564 +# removed a second batch and added `infographic/` to `.gitignore` — but +# `.gitignore` only stops *accidental* `git add`. It does nothing against +# `git add -f`, and it does nothing for a path that does not literally match +# the ignore pattern. Nine more PNGs (~14MB) were committed in the four +# weeks AFTER that rule landed, plus PR #70552 caught an `infograficos/` +# spelling that sidestepped the pattern entirely. +# +# A passive ignore rule cannot enforce a policy. This check can. + +on: + workflow_call: + outputs: + review_status: + description: "JSON array of review_status objects for the synthesizer." + value: ${{ jobs.check-no-committed-infographics.outputs.review_status }} + +permissions: + contents: read + +jobs: + check-no-committed-infographics: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + review_status: ${{ steps.infographic-check.outputs.review_status }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - id: infographic-check + name: Reject committed PR-infographic images + run: | + # Match on the IMAGE, not on a directory name. Keying this to + # `infographic/` is what let `infograficos/` through in #70552 — + # any localized or typo'd directory would sidestep it again. + # Instead: find tracked raster images whose path contains an + # infographic-ish segment, in any spelling, at any depth. + # + # `docs/assets` and `website/` legitimately hold product imagery + # and are excluded; those are referenced from shipped docs pages. + OFFENDERS=$(git ls-files -z \ + | tr '\0' '\n' \ + | grep -iE '(^|/)(infograph|infograf)[^/]*/' \ + | grep -iE '\.(png|jpe?g|webp|gif)$' \ + || true) + + if [ -n "$OFFENDERS" ]; then + COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ') + STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached \n```\nThen put it in the PR description:\n```\n## Infographic\n\n![slug](https://)\n```\n"}]}]' + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" + echo "" + echo "::error::${COUNT} PR-infographic image(s) are tracked in git." + echo "" + printf '%s\n' "$OFFENDERS" | sed 's/^/ /' + echo "" + echo "PR infographics are rendered to an image-provider URL and" + echo "embedded in the PR DESCRIPTION. The PR body is the archive —" + echo "the binary never enters git history." + echo "" + echo "This rule has been re-established twice already (#48261," + echo "#54564) and leaked both times, because .gitignore cannot stop" + echo "'git add -f' or a differently-spelled directory (#70552)." + echo "" + echo "To fix:" + echo " git rm --cached # keeps your local copy" + echo " # then embed the provider URL in the PR description" + exit 1 + fi + echo "::notice::No committed PR-infographic images." + echo "review_status=[]" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 29489633104..cd05306af00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store /venv/ /venv.old/ +/venv.stale.runtime-*/ +/.hermes-runtime/ /_pycache/ *.pyc* __pycache__/ @@ -150,6 +152,11 @@ docs/superpowers/* .update-incomplete .update-incomplete.lock +# Checkout fingerprint the __pycache__ tree was last validated against +# (launch-time stale-bytecode sweep). Runtime state, never a code change. +.bytecode-fingerprint +.bytecode-fingerprint.tmp + # Installer-written method stamp in the managed checkout root (scripts/install.sh). # Runtime metadata only — never a code change. Ignore so `git status` stays clean # and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855). @@ -173,5 +180,13 @@ apps/desktop/demo/ # image-provider (fal.media) URL — they are NEVER committed to the repo. The # PR body is the archive. See the hermes-agent-dev skill's # pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1). +# +# Spelling variants are listed because a single `infographic/` pattern was +# sidestepped by an `infograficos/` directory (#70552). .gitignore is only +# the first line of defence and cannot stop `git add -f` at all — the +# infographic-check CI job is what actually enforces this. infographic/ +infographics/ +infograficos/ +infografico/ native/fts5_cjk/*.so diff --git a/AGENTS.md b/AGENTS.md index cb53e95eb0b..d623ba59bbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,7 +325,7 @@ class AIAgent: provider: str = None, api_mode: str = None, # "chat_completions" | "codex_responses" | ... model: str = "", # empty → resolved from config/provider later - max_iterations: int = 90, # tool-calling iterations (shared with subagents) + max_iterations: int = 500, # tool-calling iterations (shared with subagents) enabled_toolsets: list = None, disabled_toolsets: list = None, quiet_mode: bool = False, diff --git a/Dockerfile b/Dockerfile index 388056faacd..42870d73776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,45 @@ +# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset +# corruption bug. Build a pinned shared library for the runtime image instead +# of relying on a distro backport that trixie does not currently provide. +# See #70480 and https://sqlite.org/wal.html#walresetbug. +FROM debian:13.4 AS sqlite_build +ARG SQLITE_AUTOCONF_VERSION=3530400 +ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c +RUN apt-get -o Acquire::Retries=3 update && \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + build-essential ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* && \ + (curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \ + -o /tmp/sqlite.tar.gz \ + "https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \ + curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \ + -o /tmp/sqlite.tar.gz \ + "https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \ + printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \ + sha256sum -c /tmp/sqlite.sha256 && \ + tar -xzf /tmp/sqlite.tar.gz -C /tmp && \ + cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \ + CFLAGS="-O2 \ + -DSQLITE_ENABLE_FTS3 \ + -DSQLITE_ENABLE_FTS3_PARENTHESIS \ + -DSQLITE_ENABLE_FTS4 \ + -DSQLITE_ENABLE_FTS5 \ + -DSQLITE_ENABLE_RTREE \ + -DSQLITE_ENABLE_GEOPOLY \ + -DSQLITE_ENABLE_COLUMN_METADATA \ + -DSQLITE_ENABLE_UNLOCK_NOTIFY \ + -DSQLITE_ENABLE_DBSTAT_VTAB \ + -DSQLITE_ENABLE_DBPAGE_VTAB \ + -DSQLITE_ENABLE_MATH_FUNCTIONS \ + -DSQLITE_ENABLE_PREUPDATE_HOOK \ + -DSQLITE_ENABLE_SESSION \ + -DSQLITE_SECURE_DELETE \ + -DSQLITE_THREADSAFE=1 \ + -DSQLITE_MAX_VARIABLE_NUMBER=250000" \ + ./configure --prefix=/opt/sqlite-fixed --disable-static && \ + make -j"$(nproc)" && \ + make install + FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source # Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x # which reached EOL in April 2026 — we copy node + npm + corepack from the @@ -31,6 +73,23 @@ RUN apt-get -o Acquire::Retries=3 update && \ ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \ rm -rf /var/lib/apt/lists/* +# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the +# public library name stable so both the system interpreter and the uv-created +# venv resolve the replacement without changing Python import paths. +COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/ +RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \ + ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \ + printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \ + ldconfig && \ + python3 -c "import sqlite3, sys; \ +v = sqlite3.sqlite_version_info; \ +sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \ +db = sqlite3.connect(':memory:'); \ +db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \ +db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \ +sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \ +db.close()" + # ---------- s6-overlay install ---------- # s6-overlay provides supervision for the main hermes process, the dashboard, # and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT. diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 55773536122..fb9ed95450c 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -32,6 +32,7 @@ else: import argparse import asyncio import logging +import os import sys from pathlib import Path from hermes_constants import get_hermes_home @@ -251,11 +252,13 @@ def main(argv: list[str] | None = None) -> None: # MCP servers dynamically via asyncio.to_thread inside the event # loop; that path is unaffected.) Moved from model_tools.py module # scope to avoid freezing the gateway's loop on lazy import (#16856). - try: - from tools.mcp_tool import discover_mcp_tools - discover_mcp_tools() - except Exception: - logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) + # Metadata-only hosts can opt out of unrelated global MCP startup. + if os.environ.get("HERMES_ACP_SKIP_CONFIGURED_MCP", "").strip() != "1": + try: + from tools.mcp_tool import discover_mcp_tools + discover_mcp_tools() + except Exception: + logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) agent = HermesACPAgent() try: diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 3e79bdcd38a..7fee2d932f8 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -85,6 +85,110 @@ from tools.approval import ( logger = logging.getLogger(__name__) + +def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, str]]]]: + """Return ``(slug, label, [(model_id, description), ...])`` for named endpoints. + + Covers both the v12 ``providers:`` mapping and the legacy + ``custom_providers:`` list. These endpoints never appear in canonical + provider enumeration, so without this the ACP model selector hides every + named endpoint that the TUI ``/model`` picker already renders (#47039 + implemented named-endpoint rows for the TUI surface only). + + Model lists come from the entry's declared models (``default_model`` + + ``models``), refreshed from the endpoint's live ``/models`` listing when a + credential is available and ``discover_models`` is not disabled. Declared + models are kept even when live discovery fails — some OpenAI-compatible + endpoints (e.g. Bedrock Mantle Responses) expose no ``/models`` route at + all yet serve the declared models fine. + + Slugs use the ``custom:`` shape that ``parse_model_input`` and + ``resolve_runtime_provider`` already resolve, so encoded choice ids + (``custom::``) round-trip through ``set_session_model`` + unchanged. + """ + try: + from hermes_cli.config import ( + get_compatible_custom_providers, + is_provider_enabled, + load_config, + ) + from hermes_cli.models import fetch_api_models + except ImportError: + return [] + + try: + cfg = load_config() + entries = get_compatible_custom_providers(cfg) + except Exception: + logger.debug("Could not load named custom providers", exc_info=True) + return [] + + # ``get_compatible_custom_providers`` drops the ``enabled`` flag during + # normalization, so collect explicitly disabled provider keys from the + # raw config and skip their entries below. + disabled_keys: set[str] = set() + raw_providers = cfg.get("providers") if isinstance(cfg, dict) else None + if isinstance(raw_providers, dict): + for raw_key, raw_entry in raw_providers.items(): + if isinstance(raw_entry, dict) and not is_provider_enabled(raw_entry): + disabled_keys.add(str(raw_key).strip().lower()) + + catalogs: list[tuple[str, str, list[tuple[str, str]]]] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + provider_key = str(entry.get("provider_key", "") or "").strip() + if provider_key.lower() in disabled_keys: + continue + name = str(entry.get("name", "") or "").strip() + base_url = str(entry.get("base_url", "") or "").strip() + if not name or not base_url: + continue + slug_source = provider_key or name + slug = "custom:" + slug_source.strip().lower().replace(" ", "-") + + api_key = str(entry.get("api_key", "") or "").strip() + if not api_key: + key_env = str(entry.get("key_env", "") or "").strip() + api_key = os.environ.get(key_env, "").strip() if key_env else "" + + declared: list[str] = [] + default_model = str(entry.get("model", "") or "").strip() + if default_model: + declared.append(default_model) + models_cfg = entry.get("models") + if isinstance(models_cfg, dict): + for mid in models_cfg: + mid = str(mid or "").strip() + if mid and mid not in declared: + declared.append(mid) + + if not api_key and not declared: + # No credential to discover with and nothing declared: + # not addressable from the selector. + continue + + model_ids = list(declared) + discover = entry.get("discover_models", True) + if isinstance(discover, str): + discover = discover.lower() not in {"false", "no", "0"} + if discover and api_key: + try: + live = fetch_api_models( + api_key, base_url, api_mode=entry.get("api_mode") + ) + except Exception: + live = None + if live: + model_ids = declared + [m for m in live if m not in declared] + + if not model_ids: + continue + catalogs.append((slug, name, [(mid, "") for mid in model_ids])) + + return catalogs + try: from hermes_cli import __version__ as HERMES_VERSION except Exception: @@ -97,6 +201,13 @@ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent") # does not expose a client-side limit, so this is a fixed cap that clients # paginate against using `cursor` / `next_cursor`. _LIST_SESSIONS_PAGE_SIZE = 50 +# Per-provider cap for the ACP model selector. ACP clients (Zed, Buzz) render +# the whole `availableModels` array in one dropdown, so an unbounded +# cross-provider catalog degrades the picker. Mirrors the cap the MoA picker +# already uses (`hermes_cli/moa_cmd.py`). This bounds each provider's row, not +# the total; aggregator providers stay intentionally uncapped inside the shared +# inventory, and the current model is always kept via the fallback insert below. +ACP_MAX_MODELS_PER_PROVIDER = 200 _MAX_ACP_RESOURCE_BYTES = 512 * 1024 _TEXT_RESOURCE_MIME_PREFIXES = ("text/",) _TEXT_RESOURCE_MIME_TYPES = { @@ -585,46 +696,108 @@ class HermesACPAgent(acp.Agent): return f"{raw_provider}:{raw_model}" def _build_model_state(self, state: SessionState) -> SessionModelState | None: - """Return the ACP model selector payload for editors like Zed.""" + """Return authenticated providers and their models for ACP clients. + + The shared Hermes inventory is also used by ``hermes model``, the TUI, + and the dashboard. Keeping ACP on that substrate prevents its selector + from silently collapsing to the current provider's curated list. + """ model = str(state.model or getattr(state.agent, "model", "") or "").strip() provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter" try: - from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label + from hermes_cli.inventory import build_models_payload, load_picker_context + from hermes_cli.models import normalize_provider, provider_label normalized_provider = normalize_provider(provider) - provider_name = provider_label(normalized_provider) + context = load_picker_context().with_overrides( + current_provider=normalized_provider, + current_model=model, + current_base_url=str(getattr(state.agent, "base_url", "") or ""), + ) + payload = build_models_payload( + context, + explicit_only=True, + include_unconfigured=False, + picker_hints=False, + canonical_order=True, + pricing=False, + capabilities=False, + refresh=False, + probe_custom_providers=False, + probe_current_custom_provider=False, + max_models=ACP_MAX_MODELS_PER_PROVIDER, + ) + available_models: list[ModelInfo] = [] seen_ids: set[str] = set() - - for model_id, description in curated_models_for_provider(normalized_provider): - rendered_model = str(model_id or "").strip() - if not rendered_model: + for row in payload.get("providers") or []: + row_provider = normalize_provider(str(row.get("slug") or "").strip()) + if not row_provider: continue - choice_id = self._encode_model_choice(normalized_provider, rendered_model) - if choice_id in seen_ids: - continue - desc_parts = [f"Provider: {provider_name}"] - if description: - desc_parts.append(str(description).strip()) - if rendered_model == model: - desc_parts.append("current") - available_models.append( - ModelInfo( - model_id=choice_id, - name=rendered_model, - description=" • ".join(part for part in desc_parts if part), - ) + provider_name = str(row.get("name") or "").strip() or provider_label( + row_provider ) - seen_ids.add(choice_id) + for model_entry in row.get("models") or []: + if isinstance(model_entry, dict): + rendered_model = str( + model_entry.get("id") + or model_entry.get("model") + or model_entry.get("name") + or "" + ).strip() + else: + rendered_model = str(model_entry or "").strip() + if not rendered_model: + continue + choice_id = self._encode_model_choice(row_provider, rendered_model) + if choice_id in seen_ids: + continue + is_current = ( + row_provider == normalized_provider and rendered_model == model + ) + description = f"Provider: {provider_name}" + if is_current: + description += " • current" + available_models.append( + ModelInfo( + model_id=choice_id, + name=f"{provider_name} · {rendered_model}", + description=description, + ) + ) + seen_ids.add(choice_id) + + # Named user-defined endpoints (providers: / custom_providers:) + # are invisible to canonical provider enumeration — append them + # so editor clients can select them like the TUI /model picker. + for named_slug, named_label, named_catalog in _named_custom_provider_catalogs(): + for named_model, named_desc in named_catalog: + named_choice = self._encode_model_choice(named_slug, named_model) + if not named_choice or named_choice in seen_ids: + continue + named_parts = [f"Provider: {named_label}"] + if named_desc: + named_parts.append(str(named_desc).strip()) + if named_slug == normalized_provider and named_model == model: + named_parts.append("current") + available_models.append( + ModelInfo( + model_id=named_choice, + name=named_model, + description=" • ".join(part for part in named_parts if part), + ) + ) + seen_ids.add(named_choice) current_model_id = self._encode_model_choice(normalized_provider, model) if current_model_id and current_model_id not in seen_ids: + provider_name = provider_label(normalized_provider) available_models.insert( 0, ModelInfo( model_id=current_model_id, - name=model, + name=f"{provider_name} · {model}", description=f"Provider: {provider_name} • current", ), ) @@ -1588,7 +1761,16 @@ class HermesACPAgent(acp.Agent): clear_session_vars, set_session_vars, ) - session_tokens = set_session_vars(session_key=session_id) + # ``cwd`` pins the logical working directory for this context, + # which is what the system prompt's "Current working directory" + # line reports (agent/prompt_builder.py -> resolve_agent_cwd). + # Without it the prompt advertises the global Hermes workspace + # while the tools are rooted at the client's project, so the + # model emits absolute paths under ~/.hermes/workspace and the + # edit silently lands outside the editor's workspace. + session_tokens = set_session_vars( + session_key=session_id, cwd=state.cwd, + ) except Exception: session_tokens = None clear_session_vars = None # type: ignore[assignment] @@ -1875,8 +2057,26 @@ class HermesACPAgent(acp.Agent): if handler is None: return None # not a known command — let the LLM handle it - try: + # Slash handlers run on the event-loop thread, OUTSIDE the per-turn + # contextvars.copy_context() that pins the session cwd for the agent + # call. ``/compress`` and ``/model`` reach code that REBUILDS the + # system prompt (agent._build_system_prompt -> resolve_agent_cwd), so + # an unpinned handler bakes the Hermes install tree into the session's + # cached prompt — persisted, and therefore poisoning every later turn + # even though the turn itself is pinned. Pin inside a fresh context so + # the write can't leak into other concurrent ACP sessions and needs no + # teardown. + def _dispatch() -> str | None: + try: + from agent.runtime_cwd import set_session_cwd + + set_session_cwd(state.cwd) + except Exception: + logger.debug("Could not pin ACP session cwd for slash command", exc_info=True) return handler(args, state) + + try: + return contextvars.copy_context().run(_dispatch) except Exception as e: logger.error("Slash command /%s error: %s", cmd, e, exc_info=True) return f"Error executing /{cmd}: {e}" diff --git a/agent/agent_init.py b/agent/agent_init.py index da266684767..5c022f4f6fb 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -455,7 +455,7 @@ def init_agent( command: str = None, args: list[str] | None = None, model: str = "", - max_iterations: int = 90, # Default tool-calling iterations (shared with subagents) + max_iterations: int = 500, # Default tool-calling iterations (shared with subagents) tool_delay: float = 1.0, enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None, @@ -529,7 +529,7 @@ def init_agent( requested_provider (str): Original provider identity before runtime canonicalization api_mode (str): API mode override: "chat_completions" or "codex_responses" model (str): Model name to use (default: "anthropic/claude-opus-4.6") - max_iterations (int): Maximum number of tool calling iterations (default: 90) + max_iterations (int): Maximum number of tool calling iterations (default: 500) tool_delay (float): Delay between tool calls in seconds (default: 1.0) enabled_toolsets (List[str]): Only enable tools from these toolsets (optional) disabled_toolsets (List[str]): Disable tools from these toolsets (optional) @@ -645,6 +645,13 @@ def init_agent( # AWS Bedrock — auto-detect from provider name or base URL # (bedrock-runtime..amazonaws.com). agent.api_mode = "bedrock_converse" + elif agent.provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* → Messages, everything else → + # chat_completions. Callers that already pass api_mode win above; + # this covers direct AIAgent construction without a resolved runtime. + from hermes_cli.providers import nous_api_mode + + agent.api_mode = nous_api_mode(agent.model) else: agent.api_mode = "chat_completions" @@ -823,9 +830,10 @@ def init_agent( # Anthropic prompt caching: auto-enabled for Claude models on native # Anthropic, OpenRouter, and third-party gateways that speak the # Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces - # input costs by ~75% on multi-turn conversations. Uses system_and_3 - # strategy (4 breakpoints). See ``_anthropic_prompt_cache_policy`` - # for the layout-vs-transport decision. + # input costs by ~75% on multi-turn conversations. Uses four breakpoints: + # the static system prefix, full system prompt, and last two messages + # (falling back to system-and-3 when no static prefix is available). See + # ``_anthropic_prompt_cache_policy`` for the layout-vs-transport decision. agent._use_prompt_caching, agent._use_native_cache_layout = ( agent._anthropic_prompt_cache_policy() ) @@ -1341,6 +1349,13 @@ def init_agent( print("⚠️ Warning: API key appears invalid or missing") except Exception as e: raise RuntimeError(f"Failed to initialize OpenAI client: {e}") + + # Keep a stable identity for the pool entry that supplied this runtime. + # OAuth refreshes can replace the runtime token before a failed request is + # recovered, so the mutable API-key value alone cannot reliably attribute + # the failure to its source entry. + from agent.agent_runtime_helpers import sync_credential_pool_entry_id + sync_credential_pool_entry_id(agent) # Provider fallback chain — ordered list of backup providers tried # when the primary is exhausted (rate-limit, overload, connection @@ -1487,6 +1502,9 @@ def init_agent( # Cached system prompt -- built once per session, only rebuilt on compression agent._cached_system_prompt: Optional[str] = None + # Cross-session-stable prefix of the cached prompt. It remains separate + # from the persisted string and is used only to place an early cache marker. + agent._cached_system_prompt_static: Optional[str] = None # Filesystem checkpoint manager (transparent — not a tool) from tools.checkpoint_manager import CheckpointManager @@ -1941,8 +1959,12 @@ def init_agent( # parent_session_id chain, no `name #N` renumber). See #38763 and # agent/conversation_compression.py. Consumed by compress_context(), not the # compressor, so it rides on the agent. + # Default True must match DEFAULT_CONFIG["compression"]["in_place"] + # (#38763). default=False here previously flipped agents into rotation + # mode whenever the merged config omitted the key (partial configs, + # load_config failure → {}), re-arming the pre-lease drift abort. compression_in_place = is_truthy_value( - _compression_cfg.get("in_place"), default=False + _compression_cfg.get("in_place"), default=True ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index f9ff6b112ef..9fb894287a3 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -850,6 +850,25 @@ def strip_think_blocks(agent, content: str) -> str: +def sync_credential_pool_entry_id(agent) -> None: + """Rebind ``agent._credential_pool_entry_id`` from the current pool + key. + + OAuth refreshes can replace the runtime token before a failed request is + recovered, so the mutable API-key value alone cannot reliably attribute + the failure to its source entry. This resolves the stable pool-entry ID + for the agent's current ``api_key`` and clears it when no pool is bound. + """ + pool = getattr(agent, "_credential_pool", None) + try: + agent._credential_pool_entry_id = ( + pool.entry_id_for_api_key(getattr(agent, "api_key", None)) + if pool is not None + else None + ) + except Exception: + agent._credential_pool_entry_id = None + + def recover_with_credential_pool( agent, *, @@ -934,10 +953,30 @@ def recover_with_credential_pool( # failing entry exactly; fall back to current()'s key only when the agent # carries no key at all. _api_key_hint = getattr(agent, "api_key", None) or None + _raw_credential_id = getattr(agent, "_credential_pool_entry_id", None) + _credential_id = ( + _raw_credential_id + if isinstance(_raw_credential_id, str) and _raw_credential_id + else None + ) if not _api_key_hint: _cur = pool.current() if _cur: _api_key_hint = getattr(_cur, "runtime_api_key", None) + if not _credential_id: + _current_id = getattr(_cur, "id", None) + if isinstance(_current_id, str) and _current_id: + _credential_id = _current_id + + def _rotate_failed_credential(rotate_status: int): + kwargs = { + "status_code": rotate_status, + "error_context": error_context, + "api_key_hint": _api_key_hint, + } + if _credential_id: + kwargs["credential_id"] = _credential_id + return pool.mark_exhausted_and_rotate(**kwargs) effective_reason = classified_reason if effective_reason is None: @@ -972,11 +1011,7 @@ def recover_with_credential_pool( # Runtime credentials can be resolved by a separate pool instance, # leaving this recovery pool without ``current_id``. Match the key # that actually failed instead of quarantining a different account. - next_entry = pool.mark_exhausted_and_rotate( - status_code=rotate_status, - error_context=error_context, - api_key_hint=_api_key_hint, - ) + next_entry = _rotate_failed_credential(rotate_status) if next_entry is not None: _ra().logger.info( "Credential %s (billing) — rotated to pool entry %s", @@ -995,8 +1030,13 @@ def recover_with_credential_pool( # Prefer the entry matching the failing key over the shared current() # pointer, for the same attribution reason as above. current_entry = None - if _api_key_hint: + if _credential_id: current_entry = next( + (e for e in pool.entries() if e.id == _credential_id), + None, + ) + if _api_key_hint: + current_entry = current_entry or next( (e for e in pool.entries() if e.runtime_api_key == _api_key_hint), None, ) @@ -1009,11 +1049,7 @@ def recover_with_credential_pool( current_last_status, ) rotate_status = status_code if status_code is not None else 429 - next_entry = pool.mark_exhausted_and_rotate( - status_code=rotate_status, - error_context=error_context, - api_key_hint=_api_key_hint, - ) + next_entry = _rotate_failed_credential(rotate_status) if next_entry is not None: _ra().logger.info( "Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s", @@ -1037,11 +1073,7 @@ def recover_with_credential_pool( if not has_retried_429 and not usage_limit_reached: return False, True rotate_status = status_code if status_code is not None else 429 - next_entry = pool.mark_exhausted_and_rotate( - status_code=rotate_status, - error_context=error_context, - api_key_hint=_api_key_hint, - ) + next_entry = _rotate_failed_credential(rotate_status) if next_entry is not None: _ra().logger.info( "Credential %s (rate limit) — rotated to pool entry %s", @@ -1113,7 +1145,10 @@ def recover_with_credential_pool( # the shared pointer can reference a different, healthy entry, and # refreshing it would consume that entry's single-use refresh token # (or mark it exhausted on failure) for a failure it never had. - refreshed = pool.try_refresh_matching(api_key_hint=_api_key_hint) + refresh_kwargs = {"api_key_hint": _api_key_hint} + if _credential_id: + refresh_kwargs["credential_id"] = _credential_id + refreshed = pool.try_refresh_matching(**refresh_kwargs) if refreshed is not None: # ``try_refresh_matching()`` re-mints a fresh OAuth token and reports # success even when the upstream keeps rejecting it — a single-entry @@ -1145,11 +1180,7 @@ def recover_with_credential_pool( # Refresh failed — rotate to next credential instead of giving up. # The failed entry is already marked exhausted by the refresh attempt. rotate_status = status_code if status_code is not None else 401 - next_entry = pool.mark_exhausted_and_rotate( - status_code=rotate_status, - error_context=error_context, - api_key_hint=_api_key_hint, - ) + next_entry = _rotate_failed_credential(rotate_status) if next_entry is not None: _ra().logger.info( "Credential %s (auth refresh failed) — rotated to pool entry %s", @@ -1190,15 +1221,29 @@ def try_recover_primary_transport( if agent._is_openrouter_url(): return False provider_lower = (agent.provider or "").strip().lower() - if provider_lower in {"nous", "nous-research"}: + # Portal OpenAI-wire traffic still rides aggregator retry infra, so one + # more rebuilt OpenAI client won't help. Portal Claude on the native + # Messages route holds a local Anthropic SDK client whose connection + # pool *does* need the rebuild every other anthropic_messages provider + # already gets — don't blanket-skip the dual-wire path. + if ( + provider_lower in {"nous", "nous-portal", "nousresearch"} + and getattr(agent, "api_mode", None) != "anthropic_messages" + ): return False try: - # Close existing client to release stale connections + # Retire the existing client to release stale connections. #70773: + # never hard-close the shared client here — this runs on the + # conversation-loop thread while workers from stale-killed streaming + # attempts may still be unwinding their SSL BIOs on the old pool. + # ``_retire_shared_openai_client`` shuts the sockets down (FD-safe + # from any thread) and defers the FD release to GC, which cannot + # complete until every borrowing thread has unwound. if getattr(agent, "client", None) is not None: try: - agent._close_openai_client( - agent.client, reason="primary_recovery", shared=True, + agent._retire_shared_openai_client( + agent.client, reason="primary_recovery", ) except Exception: pass @@ -1460,6 +1505,7 @@ def restore_primary_runtime(agent) -> bool: pool_matches_primary = False if pool is not None and pool_provider and not pool_matches_primary: agent._credential_pool = None + agent._credential_pool_entry_id = None try: from agent.credential_pool import load_pool @@ -1479,6 +1525,7 @@ def restore_primary_runtime(agent) -> bool: # the pool for its current best entry and swap the live credential in. # When the pool is absent, empty, or the entry has no usable key, we # keep the snapshot key (the existing behavior). Fixes #25205. + agent._credential_pool_entry_id = None pool = getattr(agent, "_credential_pool", None) if pool is not None and pool.has_available(): entry = pool.select() @@ -1856,7 +1903,15 @@ def anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if (is_openrouter or is_nous_portal) and (is_claude or is_kimi): + # Envelope layout is an OpenAI-wire construct. Portal Claude on the native + # Messages route must fall through to the third-party anthropic_messages + # branch below, which emits inner-block cache_control breakpoints; the + # envelope form would be dropped and serve 0% cache hits. + if ( + (is_openrouter or is_nous_portal) + and (is_claude or is_kimi) + and not is_anthropic_wire + ): return True, False # Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout # cache_control path as Portal Claude. Portal proxies to OpenRouter @@ -2020,8 +2075,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo from hermes_cli.providers import determine_api_mode # ── Determine api_mode if not provided ── + # Pass model so dual-wire providers (Nous Portal anthropic/* → Messages) + # resolve correctly; without it determine_api_mode falls back to the + # openai_chat overlay default. if not api_mode: - api_mode = determine_api_mode(new_provider, base_url) + api_mode = determine_api_mode(new_provider, base_url, model=new_model) # Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing # /v1 into the anthropic_messages client, which would cause the SDK to @@ -2075,6 +2133,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # restore the original pool (issue #52727: pool reload is part of this # switch and must be reversible on rollback). _snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING) + _snapshot["_credential_pool_entry_id"] = getattr( + agent, "_credential_pool_entry_id", _MISSING + ) try: # Clear the per-config context_length override so the new model's @@ -2131,6 +2192,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # A pool bound to the old provider is worse than no pool: the # recovery guard rejects it and every later 401/429 skips rotation. agent._credential_pool = None + agent._credential_pool_entry_id = None try: from agent.credential_pool import load_pool agent._credential_pool = load_pool(new_provider) @@ -2140,7 +2202,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "continuing without pool rotation this turn", new_provider, _pool_exc, ) - # ── Build new client ── if (new_provider or "").strip().lower() == "moa": from agent.moa_loop import build_moa_facade @@ -2236,6 +2297,8 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo reason="switch_model", shared=True, ) + + sync_credential_pool_entry_id(agent) except Exception: # Rollback every mutated field to the pre-swap snapshot so the agent # is left consistent (old model + old provider + old client) and the @@ -2570,6 +2633,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i _clarify_tool( question=next_args.get("question", ""), choices=next_args.get("choices"), + multi_select=next_args.get("multi_select", False), callback=agent.clarify_callback, ), next_args, diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 38431a8c1f5..0d59d94c9c3 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -23,7 +23,7 @@ from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple -from utils import base_url_host_matches, normalize_proxy_env_vars +from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars # NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls # ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.) @@ -368,7 +368,7 @@ def _detect_claude_code_version() -> str: try: result = _sp.run( [cmd, "--version"], - capture_output=True, text=True, timeout=5, + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, ) if result.returncode == 0 and result.stdout.strip(): # Output is like "2.1.74 (Claude Code)" or just "2.1.74" @@ -546,15 +546,49 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: return "/anthropic" in normalized.rstrip("/").lower() +def _is_nous_portal_endpoint(base_url: str | None) -> bool: + """Return True for Nous Portal's Anthropic Messages route. + + Portal serves its ``anthropic/*`` catalog natively at + ``https://inference-api.nousresearch.com/v1/messages``. Portal-specific + behaviours key off this: Bearer JWT auth, verbatim catalog model ids, + and native thinking-signature replay. + + Trusted hosts only: + + 1. Prod hostname ``inference-api.nousresearch.com`` + 2. The operator-set ``NOUS_INFERENCE_BASE_URL`` hostname (staging/preview) + + Lookalikes such as ``inference-api.nousresearch.com.attacker.test`` are + rejected (hostname match, not substring). + """ + if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"): + return True + try: + from hermes_cli.auth import _nous_inference_env_override + + override = _nous_inference_env_override() + except Exception: + return False + if not override: + return False + # Exact host equality (not subdomain) so the env override can't broaden + # into sibling hosts the operator did not set. + override_host = base_url_hostname(override) + return bool(override_host) and base_url_hostname(base_url or "") == override_host + + def _requires_bearer_auth(base_url: str | None) -> bool: """Return True for Anthropic-compatible providers that require Bearer auth. Some third-party /anthropic endpoints implement Anthropic's Messages API but require Authorization: Bearer instead of Anthropic's native x-api-key header. MiniMax's global and China Anthropic-compatible endpoints, Azure AI - Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy - follow this pattern. + Foundry's Anthropic-style endpoint, Palantir Foundry's LLM proxy, and Nous + Portal's Messages route follow this pattern. """ + if _is_nous_portal_endpoint(base_url): + return True normalized = _normalize_base_url_text(base_url) if not normalized: return False @@ -721,7 +755,11 @@ def _build_anthropic_client_with_bearer_hook( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Same env-inference trap as build_anthropic_client: auth_token-only + # construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key. + client.api_key = None + return client def build_anthropic_client( @@ -850,7 +888,16 @@ def build_anthropic_client( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Bearer-only construction leaves ``api_key`` unset, so the SDK fills it + # from ``ANTHROPIC_API_KEY`` (Hermes loads that into the process env from + # ``~/.hermes/.env``). The result is dual auth — + # ``X-Api-Key: sk-ant-…`` *and* ``Authorization: Bearer `` — + # on every Portal / MiniMax / OAuth Messages request. Clear the env-filled + # key whenever we intentionally authenticated via auth_token alone. + if "auth_token" in kwargs and "api_key" not in kwargs: + client.api_key = None + return client def build_anthropic_bedrock_client(region: str): @@ -914,7 +961,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]: "-s", "Claude Code-credentials", "-w"], capture_output=True, - text=True, + text=True, encoding='utf-8', errors='replace', timeout=5, stdin=subprocess.DEVNULL, ) @@ -1920,10 +1967,18 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]: return None btype = b.get("type") if btype == "text": - # Coerce empty/whitespace-only text to a non-whitespace placeholder; - # the Messages input schema rejects blank text blocks (#69512), and a - # blank block stored in history replays on every turn → permanent 400. - out: Dict[str, Any] = {"type": "text", "text": _safe_text(b.get("text", ""))} + text_val = b.get("text", "") + # Bedrock and strict Anthropic-compatible endpoints reject text + # blocks where "text" is empty or whitespace-only (#69512). Drop the + # blank block (the caller relocates any cache_control it carried and + # falls back to a non-whitespace placeholder when nothing survives) + # rather than coercing in place — a coerced "(empty)" block would be + # model-visible noise next to surviving thinking/tool_use blocks. + # Type-safe: captured blocks can carry text=None from an invalid + # upstream payload, which a bare .strip() would crash on. + if not isinstance(text_val, str) or not text_val.strip(): + return None + out: Dict[str, Any] = {"type": "text", "text": text_val} # citations is input-valid ONLY when it's a non-empty list; the SDK # emits citations=None on responses, which the input schema rejects. cits = b.get("citations") @@ -2011,9 +2066,17 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: parsed_args = {} redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args replayed: List[Dict[str, Any]] = [] + _relocated_replay_cache_control = None + _dropped_blank_text = False for b in ordered_blocks: clean = _sanitize_replay_block(b) if clean is None: + if isinstance(b, dict) and b.get("type") == "text": + _dropped_blank_text = True + if isinstance(b, dict) and isinstance(b.get("cache_control"), dict): + # A dropped blank text block can still carry the cache + # breakpoint marker -- relocate it rather than losing it. + _relocated_replay_cache_control = b["cache_control"] continue if clean.get("type") == "tool_use": # Override raw (un-redacted) input with the redacted copy when @@ -2023,20 +2086,90 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: if redacted is not None: clean["input"] = redacted replayed.append(clean) + # When every text block was blank and nothing cacheable survived + # (e.g. signed thinking + a blank text block, or a SOLE blank + # cache-marked block), emit the non-whitespace placeholder so the + # replayed message stays schema-valid (#69512) and a relocated cache + # marker still has a carrier instead of being silently lost. + _has_cacheable_replay = any( + isinstance(b, dict) and b.get("type") in {"text", "tool_use"} + for b in replayed + ) + if not _has_cacheable_replay and ( + _dropped_blank_text or _relocated_replay_cache_control is not None + ): + replayed.append({"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}) if replayed: + if _relocated_replay_cache_control is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + replayed, _relocated_replay_cache_control + ) _apply_assistant_cache_control_to_last_cacheable_block( replayed, m.get("cache_control") ) + # apply_anthropic_cache_control marks an assistant turn with + # non-empty text by writing cache_control INTO ``content`` (see + # _apply_cache_marker's list branch), not at the top level. This + # branch rebuilds the message from ordered_blocks and never reads + # ``content``, so that marker would be dropped -- and because + # _can_carry_marker already counted this message as a carrier, the + # breakpoint is burned rather than relocated. #56195 covered the + # complementary shape (blank content -> top-level marker); this is + # the interleaved thinking + preamble-text + tool_use shape. + _inline_cc = None + _msg_content = m.get("content") + if isinstance(_msg_content, list): + for _blk in _msg_content: + if isinstance(_blk, dict) and isinstance( + _blk.get("cache_control"), dict + ): + _inline_cc = _blk["cache_control"] + break + if _inline_cc is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + replayed, _inline_cc + ) return {"role": "assistant", "content": replayed} blocks = _extract_preserved_thinking_blocks(m) + # Cache markers dropped along with a blank block are relocated onto the + # last surviving cacheable block below (via + # _apply_assistant_cache_control_to_last_cacheable_block), rather than + # lost -- prompt_caching.py's _apply_cache_marker() sets cache_control + # directly on content[-1] for list content, so if that last part happens + # to be blank text, dropping it silently would lose the breakpoint. + _relocated_cache_control = None if content: if isinstance(content, list): converted_content = _convert_content_to_anthropic(content) if isinstance(converted_content, list): - blocks.extend(converted_content) + # Bedrock and strict Anthropic-compatible endpoints reject + # text blocks where "text" is empty or whitespace-only. The + # ordered-replay path enforces the same invariant via + # _sanitize_replay_block(). Type-safe against ANY invalid + # "text" value from an upstream payload -- None, or a + # truthy non-string like an int -- not just None: checking + # isinstance() first (rather than `blk.get("text") or ""`) + # means a non-string value is treated as blank/invalid + # instead of reaching .strip() and raising AttributeError. + for blk in converted_content: + _blk_text = blk.get("text") if isinstance(blk, dict) else None + if ( + isinstance(blk, dict) + and blk.get("type") == "text" + and (not isinstance(_blk_text, str) or not _blk_text.strip()) + ): + if isinstance(blk.get("cache_control"), dict): + _relocated_cache_control = blk["cache_control"] + continue + blocks.append(blk) else: - blocks.append({"type": "text", "text": str(content)}) + # Scalar (non-list) content: a whitespace-only string is the + # same invalid-payload case as an empty list block -- drop it + # rather than emitting a blank text block. + text_str = str(content) + if text_str.strip(): + blocks.append({"type": "text", "text": text_str}) for tc in m.get("tool_calls", []): if not tc or not isinstance(tc, dict): continue @@ -2052,9 +2185,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: "name": fn.get("name", ""), "input": parsed_args, }) - _apply_assistant_cache_control_to_last_cacheable_block( - blocks, m.get("cache_control") - ) # Kimi's /coding endpoint (Anthropic protocol) requires assistant # tool-call messages to carry reasoning_content when thinking is # enabled server-side. Preserve it as a thinking block so Kimi @@ -2080,19 +2210,26 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: ) if isinstance(reasoning_content, str) and not _already_has_thinking: blocks.insert(0, {"type": "thinking", "thinking": reasoning_content}) - # Anthropic rejects empty assistant content - effective = blocks or content - if not effective or effective == "": - effective = [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] - elif isinstance(effective, list): - # The all-empty guard above misses a list that still contains a - # whitespace-only text block (e.g. from a content array of blank parts, - # or compression). Those also trip "text content blocks must contain - # non-whitespace text" (#69512). Coerce text blocks in place; other - # block types (thinking/tool_use/image) are left untouched. - for blk in effective: - if isinstance(blk, dict) and blk.get("type") == "text": - blk["text"] = _safe_text(blk.get("text", "")) + # Anthropic rejects empty assistant content. IMPORTANT: fall back only + # to the placeholder, never to the raw `content` variable -- `content` + # is the UNFILTERED original message content, and can itself be exactly + # the blank/whitespace-only payload the filtering above just removed + # (a sole blank text block, or scalar whitespace with no tool_calls). + # `blocks or content` there would silently restore the invalid provider + # payload this function exists to prevent (#69512). + effective = blocks if blocks else [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}] + # Applied here (after the empty-fallback resolution) rather than + # earlier against `blocks` directly, so a cache_control relocated from + # a dropped blank block that was the ONLY block still lands on the + # (empty) placeholder instead of being silently lost when blocks was + # empty at the point the marker would otherwise have been applied. + if _relocated_cache_control is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + effective, _relocated_cache_control + ) + _apply_assistant_cache_control_to_last_cacheable_block( + effective, m.get("cache_control") + ) return {"role": "assistant", "content": effective} @@ -2326,10 +2463,22 @@ def _manage_thinking_signatures( replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and hermes-agent#16748 (DeepSeek). + Nous Portal's ``/v1/messages`` route is the exception among third-party + hosts: it proxies Claude to Anthropic/Vertex/Bedrock and validates the + same signed thinking blocks. Sticky ``session_id`` keeps a conversation + on one upstream instance so those signatures stay warm — stripping them + here would 400 the first tool-loop turn ("thinking must be passed back"). + Portal therefore takes the native Anthropic replay path below. + Mutates ``result`` in place. """ _THINKING_TYPES = frozenset(("thinking", "redacted_thinking")) - _is_third_party = _is_third_party_anthropic_endpoint(base_url) + # Portal speaks Anthropic's thinking contract end-to-end; do not treat it + # as a signature-blind proxy even though the host is not anthropic.com. + _is_third_party = ( + _is_third_party_anthropic_endpoint(base_url) + and not _is_nous_portal_endpoint(base_url) + ) last_assistant_idx = None for i in range(len(result) - 1, -1, -1): @@ -2586,7 +2735,12 @@ def build_anthropic_kwargs( ) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] - model = normalize_model_name(model, preserve_dots=preserve_dots) + # Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``); + # normalizing to the bare Anthropic slug would make the model unresolvable + # there. Skipping the call preserves the prefix AND the dots, so + # ``preserve_dots`` stays irrelevant for Portal. + if not _is_nous_portal_endpoint(base_url): + model = normalize_model_name(model, preserve_dots=preserve_dots) # effective_max_tokens = output cap for this call (≠ total context window) # Use the resolver helper so non-positive values (negative ints, # fractional floats, NaN, non-numeric) fail locally with a clear error @@ -2825,6 +2979,8 @@ def create_anthropic_message( *, log_prefix: str = "", prefer_stream: bool = True, + on_stream_event=None, + on_response=None, ) -> Any: """Create an Anthropic message, aggregating via stream when available. @@ -2834,6 +2990,20 @@ def create_anthropic_message( crash on ``.content``. Prefer ``messages.stream().get_final_message()`` to match the main turn path, falling back to ``create()`` only for providers that explicitly do not support streaming, such as restricted Bedrock roles. + + ``on_stream_event``: optional callable invoked once per streamed event + (best-effort, exceptions swallowed). Lets callers report forward progress + to liveness watchdogs — e.g. the auxiliary compression path ticking its + progress hook so a slow-but-generating summary model isn't treated as + hung. Only fires on the streaming path; the ``create()`` fallback has no + events to report. + + ``on_response``: optional callable invoked once with the underlying httpx + response before the message is aggregated (best-effort, exceptions + swallowed). Response *headers* carry out-of-band provider state that the + parsed ``Message`` drops — Nous Portal's ``x-nous-credits-*`` balance family + in particular. Only fires on the streaming path, which is the one the main + turn loop takes. """ sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) @@ -2844,6 +3014,26 @@ def create_anthropic_message( stream_kwargs.pop("stream", None) try: with stream_fn(**stream_kwargs) as stream: + if callable(on_response): + try: + on_response(getattr(stream, "response", None)) + except Exception: + logger.debug( + "%son_response callback failed", + log_prefix, exc_info=True, + ) + if callable(on_stream_event): + # Consume the event stream manually so each event can + # tick the caller's progress callback; get_final_message + # then returns the accumulated snapshot. + for _event in stream: + try: + on_stream_event(_event) + except Exception: + logger.debug( + "%son_stream_event callback failed", + log_prefix, exc_info=True, + ) return stream.get_final_message() except Exception as exc: if not _is_stream_unavailable_error(exc): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index dc5c2648f7a..46b40b2a58a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -247,6 +247,50 @@ def aux_interrupt_protection(active: bool = True): _aux_interrupt_protection.active = prev +# ── Forward-progress hook for streamed auxiliary calls ─────────────────── +# Long auxiliary calls (context compression is the prime case) are watched by +# wall-clock deadlines in their hosts (gateway session hygiene). A fixed +# deadline punishes SLOW summary models exactly as hard as HUNG ones: a +# reasoning model happily streaming a large summary is killed mid-generation. +# This thread-local hook lets the host observe liveness instead: the wire +# consumers below tick it on every streamed token/SSE event, and the host +# extends its deadline while tokens are moving (see gateway/run.py session +# hygiene + CompressionCommitFence.touch_progress). Thread-local matches the +# call topology — the aux call and its stream consumption run synchronously +# on the thread that installed the hook. +_aux_progress = threading.local() + + +def _notify_aux_progress() -> None: + """Tick the installed forward-progress hook, if any. Never raises.""" + hook = getattr(_aux_progress, "hook", None) + if hook is None: + return + try: + hook() + except Exception: + logger.debug("aux progress hook failed", exc_info=True) + + +def _aux_progress_active() -> bool: + return getattr(_aux_progress, "hook", None) is not None + + +@contextlib.contextmanager +def aux_progress_hook(hook): + """Install *hook* as the current thread's aux forward-progress callback. + + ``hook=None`` is a no-op passthrough so callers can wire it + unconditionally. Re-entrant-safe: restores the previous hook on exit. + """ + prev = getattr(_aux_progress, "hook", None) + _aux_progress.hook = hook if callable(hook) else prev + try: + yield + finally: + _aux_progress.hook = prev + + def _safe_isinstance(obj: Any, maybe_type: Any) -> bool: """Return False instead of raising when a patched symbol is not a type.""" try: @@ -1058,16 +1102,29 @@ class _CodexCompletionsAdapter: # key in extra_body (not top-level) and GitHub/Copilot Responses opts # out of cache-key routing entirely — for those hosts, skip it here. try: - from agent.transports.codex import _content_cache_key + from agent.transports.codex import ( + _content_cache_key, + _default_prompt_cache_retention_for_request, + ) from utils import base_url_host_matches _host_src = str(getattr(self._client, "base_url", "") or "") _is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai") - _is_github = base_url_host_matches(_host_src, "githubcopilot.com") + _is_github = ( + base_url_host_matches(_host_src, "githubcopilot.com") + or base_url_host_matches(_host_src, "models.github.ai") + ) if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs: _cache_key = _content_cache_key(instructions, resp_kwargs.get("tools")) if _cache_key: resp_kwargs["prompt_cache_key"] = _cache_key + if "prompt_cache_retention" not in resp_kwargs: + _cache_retention = _default_prompt_cache_retention_for_request( + model, + _host_src, + ) + if _cache_retention: + resp_kwargs["prompt_cache_retention"] = _cache_retention except Exception: logger.debug( "Codex auxiliary: prompt_cache_key derivation skipped", exc_info=True @@ -1149,6 +1206,10 @@ class _CodexCompletionsAdapter: def _on_each_event(_event: Any) -> None: # Re-check timeout/cancellation per event, matching the # cadence the old in-line ``_check_cancelled()`` used. + # Each SSE event is also forward progress for hosts watching + # a progress hook (gateway session hygiene): a reasoning + # model streaming a long summary must not look hung. + _notify_aux_progress() _check_cancelled() event_stream = self._client.responses.create(**stream_kwargs) @@ -1301,10 +1362,32 @@ class AsyncCodexAuxiliaryClient: class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" - def __init__(self, real_client: Any, model: str, is_oauth: bool = False): + def __init__( + self, + real_client: Any, + model: str, + is_oauth: bool = False, + base_url: str | None = None, + ): self._client = real_client self._model = model self._is_oauth = is_oauth + # Prefer the caller-supplied URL (AnthropicAuxiliaryClient keeps the + # pre-strip Portal ``.../v1`` form). Only fall back to the SDK + # client's host for Nous Portal — a blanket fallback would flip + # MiniMax/Zhipu/etc. aux adapters from "unknown host = native + # Anthropic" to third-party (stripping thinking signatures). + self._base_url = base_url or None + if not self._base_url: + candidate = str(getattr(real_client, "base_url", "") or "") or None + if candidate: + try: + from agent.anthropic_adapter import _is_nous_portal_endpoint + + if _is_nous_portal_endpoint(candidate): + self._base_url = candidate + except Exception: + pass def create(self, **kwargs) -> Any: from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message @@ -1356,6 +1439,11 @@ class _AnthropicCompletionsAdapter: reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, + # Portal routes on ``anthropic/`` catalog ids and replays + # signed thinking like native Anthropic; both carve-outs key off + # base_url. Omitting it normalizes the id to a bare Anthropic + # slug and the Portal Messages route cannot resolve it. + base_url=self._base_url, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -1390,7 +1478,18 @@ class _AnthropicCompletionsAdapter: existing = {} anthropic_kwargs["extra_body"] = {**existing, **passthrough} - response = create_anthropic_message(self._client, anthropic_kwargs) + response = create_anthropic_message( + self._client, + anthropic_kwargs, + # Tick the aux forward-progress hook per streamed event so hosts + # watching liveness (gateway session hygiene) don't kill a + # slow-but-generating summary model. No-op when no hook is + # installed (None keeps the fast get_final_message path). + on_stream_event=( + (lambda _event: _notify_aux_progress()) + if _aux_progress_active() else None + ), + ) _transport = get_transport("anthropic_messages") _nr = _transport.normalize_response( response, strip_tool_prefix=self._is_oauth @@ -1438,7 +1537,9 @@ class AnthropicAuxiliaryClient: def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False): self._real_client = real_client - adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth) + adapter = _AnthropicCompletionsAdapter( + real_client, model, is_oauth=is_oauth, base_url=base_url, + ) self.chat = _AnthropicChatShim(adapter) self.api_key = api_key self.base_url = base_url @@ -1703,7 +1804,7 @@ def _read_nous_auth() -> Optional[dict]: try: if not _AUTH_JSON_PATH.is_file(): return None - data = json.loads(_AUTH_JSON_PATH.read_text()) + data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8")) if data.get("active_provider") != "nous": return None provider = data.get("providers", {}).get("nous", {}) @@ -4116,6 +4217,7 @@ def _try_main_agent_model_fallback( failed_provider: str, task: str = None, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Last-resort fallback to the user's main agent provider + model. @@ -4124,8 +4226,23 @@ def _try_main_agent_model_fallback( layer: if nothing the user asked for can serve the request, try the main chat model before giving up. - Skips when the failed provider already IS the main provider (no point - retrying the same backend that just failed). + ``failed_model`` narrows the same-provider skip to the exact + (provider, model) pair that just failed, mirroring + :func:`_try_configured_fallback_chain`. This matters for self-hosted / + custom endpoints serving several models behind one provider label: the + aux compression model timing out says nothing about the health of the + main agent model deployed on the same URL (real incident: aux + ``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the + identical endpoint was serving 448K-token turns fine — the + provider-label skip discarded the one fallback that would have worked). + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model``: skip the + main model only when it IS the exact model that failed. + - Provider-wide failures (auth 401, payment 402) and legacy callers + leave ``failed_model`` as None, keeping the whole-provider skip — + the shared credentials/account are broken, so the main model on the + same provider cannot help either. Returns: (client, model, provider_label) or (None, None, "") if no fallback. @@ -4142,9 +4259,23 @@ def _try_main_agent_model_fallback( if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: return None, None, "" - skip = (failed_provider or "").lower().strip() - if main_provider.lower() == skip: - # The thing that failed IS the main model — nothing to fall back to. + # Identity + scope semantics owned by agent.backend_identity (#72468): + # model-scoped failures skip only the exact deployment that failed; + # provider-wide failures (no failed_model) skip the credential surface. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + skip_model = (failed_model or "").strip().lower() or None + if should_skip_candidate( + BackendIdentity.build(provider=main_provider, model=main_model), + BackendIdentity.build(provider=failed_provider, model=skip_model), + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL, + ): + # The thing that failed IS the main model (or the failure was + # provider-wide) — nothing to fall back to. return None, None, "" if _is_provider_unhealthy(main_provider): _log_skip_unhealthy(main_provider, task) @@ -4254,6 +4385,7 @@ def _try_configured_fallback_chain( task: str, failed_provider: str, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Try user-configured fallback_chain for a specific auxiliary task. @@ -4261,6 +4393,25 @@ def _try_configured_fallback_chain( entry in order. Each entry must have at least ``provider``; ``model``, ``base_url``, and ``api_key`` are optional. + ``failed_model`` narrows the skip check to the exact (provider, model) + pair that just failed, rather than the whole provider. Without it every + entry sharing the failed provider is skipped (the original behaviour). + Callers pass it only when a sibling model on the same provider could + plausibly recover: + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model`` so a + chain that intentionally lists several models under the same provider + — e.g. two more NVIDIA NIM models after the primary NIM model times + out — is not skipped wholesale. Only the exact model that failed is + skipped; the siblings still run instead of jumping straight to the + main-agent-model safety net. + - Provider-wide failures (auth 401, payment 402) and "no client could + be built" callers leave ``failed_model`` as None, keeping the whole + provider skipped — the shared credentials/account behind every model + on that provider are broken, so a sibling can't help and the + main-agent-model safety net should be reached instead. + Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ @@ -4272,7 +4423,24 @@ def _try_configured_fallback_chain( if not chain or not isinstance(chain, list): return None, None, "" - skip = failed_provider.lower().strip() + skip_model = (failed_model or "").strip().lower() or None + # Identity + scope semantics owned by agent.backend_identity (#59561, + # #72468): a failed_model means the failure was model-scoped (timeout / + # connection / rate limit) — only the exact deployment is skipped; no + # failed_model means provider-wide (auth/payment) — the whole credential + # surface is skipped. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + failed_ident = BackendIdentity.build( + provider=failed_provider, model=skip_model, + ) + failure_scope = ( + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL + ) tried = [] min_ctx = _task_minimum_context_length(task) @@ -4280,9 +4448,20 @@ def _try_configured_fallback_chain( if not isinstance(entry, dict): continue fb_provider = str(entry.get("provider", "")).strip() - if not fb_provider or fb_provider.lower() == skip: + if not fb_provider: continue - fb_model = str(entry.get("model", "")).strip() or None + fb_model_raw = str(entry.get("model", "")).strip() + if should_skip_candidate( + BackendIdentity.build( + provider=fb_provider, + model=fb_model_raw, + base_url=str(entry.get("base_url") or ""), + ), + failed_ident, + failure_scope, + ): + continue + fb_model = fb_model_raw or None label = f"fallback_chain[{i}]({fb_provider})" @@ -4949,10 +5128,11 @@ def resolve_provider_client( # ── Nous Portal (OAuth) ────────────────────────────────────────── if provider == "nous": - # Detect vision tasks: either explicit model override from - # _PROVIDER_VISION_MODELS, or caller passed a known vision model. + # Detect vision tasks: caller flag (strict vision backend), explicit + # model override from _PROVIDER_VISION_MODELS, or a known vision id. _is_vision = ( - model in _PROVIDER_VISION_MODELS.values() + is_vision + or model in _PROVIDER_VISION_MODELS.values() or (model or "").strip().lower() == "mimo-v2-omni" ) client, default = _try_nous(vision=_is_vision) @@ -4961,6 +5141,17 @@ def resolve_provider_client( "but Nous Portal not configured (run: hermes auth)") return None, None final_model = _normalize_resolved_model(model or default, provider) + # Dual-wire: anthropic/* → /v1/messages, everything else stays on + # /chat/completions. Derive from the catalog id (not a stale + # api_mode=chat_completions) so aux matches the main agent. + from hermes_cli.providers import nous_api_mode + + portal_mode = nous_api_mode(final_model) + api_key_str = str(getattr(client, "api_key", "") or "") + base_url_str = str(getattr(client, "base_url", "") or "") + client = _maybe_wrap_anthropic( + client, final_model, api_key_str, base_url_str, portal_mode, + ) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -5617,7 +5808,10 @@ def _resolve_strict_vision_backend( if provider == "openrouter": return _try_openrouter(model=model) if provider == "nous": - return _try_nous(vision=True) + # Must go through resolve_provider_client so anthropic/* vision + # recommendations wrap onto /v1/messages — _try_nous alone returns + # a bare OpenAI client and the call 404s. + return resolve_provider_client("nous", model, is_vision=True) if provider == "openai-codex": # Route through resolve_provider_client so the caller's explicit # model is used. There is no safe default Codex model (shifting @@ -6878,8 +7072,14 @@ def _build_call_kwargs( _is_gemini_native = is_native_gemini_base_url(_effective_base) except Exception: pass + _nous_on_messages = False + if _provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( _is_anthropic_compat_endpoint(provider, _effective_base) + or _nous_on_messages or _is_nvidia_nim or _is_moa or _is_gemini_native @@ -6974,21 +7174,43 @@ def _build_call_kwargs( else: effort = reasoning_config.get("effort") or "medium" merged_extra["reasoning"] = {"enabled": True, "effort": effort} - if provider == "nous" and "tags" not in merged_extra: - merged_extra["tags"] = _nous_portal_tags() + # Portal product tags + sticky session_id. The provider profile usually + # supplies both; this fallback covers profile-load failures and alias + # spellings the profile lookup might miss. session_id keeps aux + # compression/title/vision calls on the same upstream instance as the + # main turn (cache warmth) — tags alone are not enough on /v1/messages. + _provider_for_portal = str(provider or "").strip().lower() + if _provider_for_portal in {"nous", "nous-portal", "nousresearch"}: + if "tags" not in merged_extra: + merged_extra["tags"] = _nous_portal_tags() + if "session_id" not in merged_extra: + try: + from agent.portal_tags import get_conversation_context + + sticky_key = get_conversation_context() + except Exception: + sticky_key = None + if sticky_key: + merged_extra["session_id"] = sticky_key if merged_extra: kwargs["extra_body"] = merged_extra - # Native Anthropic Messages adapters do not consume ``extra_body``. Carry - # the normalized Hermes reasoning config through a private kwarg so the - # adapter can pass it into build_anthropic_kwargs(), where provider-aware - # thinking/output_config projection lives. Do not expose this private kwarg - # to ordinary OpenAI-compatible SDK clients, which would reject it. + # Anthropic Messages adapters translate Hermes reasoning into native + # ``thinking`` via a private kwarg (and strip OpenAI-shaped + # ``extra_body.reasoning``). Do not expose this private kwarg to ordinary + # OpenAI-compatible SDK clients, which would reject it. Portal Claude is + # dual-wire — include it when the catalog id selects /v1/messages. if reasoning_config and isinstance(reasoning_config, dict): provider_norm = str(provider or "").strip().lower() effective_base = base_url or "" + _nous_on_messages = False + if provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( provider_norm == "anthropic" + or _nous_on_messages or _endpoint_speaks_anthropic_messages(effective_base) or _is_anthropic_compat_endpoint(provider_norm, effective_base) ): @@ -7104,6 +7326,346 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any: return value +# ── Streamed aggregation for progress-hooked auxiliary calls ───────────── +# When a forward-progress hook is installed (aux_progress_hook — today only +# by context compression), the primary chat.completions attempt is upgraded +# to a streamed request that is aggregated back into a complete response. +# Two effects, both deliberate: +# 1. The configured ``timeout`` becomes an INTER-CHUNK idle timeout instead +# of a total budget (httpx applies the read timeout per stream read), so +# a slow-but-generating summary model is never killed mid-generation +# while tokens are moving — only a genuinely silent connection dies. +# 2. Every arriving chunk ticks the progress hook, letting outer watchdogs +# (gateway session hygiene) extend their deadlines on liveness instead +# of guessing with a fixed wall clock. +# A total ceiling still bounds the pathological 1-token-per-idle-window +# stream; see _aux_stream_total_ceiling(). + +_AUX_STREAM_CEILING_FLOOR_SECONDS = 600.0 +_AUX_STREAM_CEILING_MULTIPLIER = 4.0 + + +def _aux_stream_total_ceiling(effective_timeout: Optional[float]) -> float: + """Absolute wall-clock bound for a progress-hooked streamed aux call. + + Generous by design — the idle timeout is the real guard; this only stops + a degenerate stream that trickles one token per idle window forever. + """ + try: + timeout = float(effective_timeout) if effective_timeout is not None else 0.0 + except (TypeError, ValueError): + timeout = 0.0 + return max(_AUX_STREAM_CEILING_FLOOR_SECONDS, + _AUX_STREAM_CEILING_MULTIPLIER * timeout) + + +def _client_streams_internally(client: Any) -> bool: + """Wire adapters that consume a stream inside .create() already tick the + progress hook themselves (Codex per SSE event, Anthropic per stream + event); Bedrock's Converse shim cannot stream at all. None of them + accept chat-completions ``stream=True`` semantics from us.""" + return isinstance(client, ( + CodexAuxiliaryClient, + AnthropicAuxiliaryClient, + BedrockAuxiliaryClient, + )) + + +def _is_streaming_rejected_error(exc: Exception) -> bool: + """Provider explicitly refused a streamed chat.completions request.""" + err = str(exc).lower() + if "stream_options" in err: + return True + return "stream" in err and ( + "not supported" in err + or "unsupported" in err + or "not allowed" in err + or "disabled" in err + ) + + +def _provider_requires_stream(provider: str, base_url: Optional[str]) -> bool: + """Detect providers that only accept streaming (non-stream = HTTP 400). + + Some OpenAI-compatible endpoints reject non-streaming chat requests + outright — e.g. Tencent Copilot returns + ``{"code": 11101, "msg": "Non-stream chat request is currently not + supported"}``. The main conversation loop already streams, so interactive + chat works; auxiliary tasks (title generation, compression, web extract) + used the non-streaming path and failed on every call. When this returns + True the auxiliary client sends ``stream=True`` and aggregates the chunks + itself (see :func:`_aggregate_chat_stream`). Credit @kudi88 (PR #60686). + + Beyond the known-host list, users can mark ANY custom endpoint as + stream-only via ``auxiliary.stream_only_base_urls`` in config.yaml + (list of substrings matched against the endpoint URL). + """ + _url = str(base_url or "").lower() + if not _url: + return False + # Tencent Copilot — "Non-stream chat request is currently not supported" + if base_url_host_matches(_url, "copilot.tencent.com"): + return True + try: + from hermes_cli.config import load_config + aux_cfg = (load_config() or {}).get("auxiliary", {}) + markers = aux_cfg.get("stream_only_base_urls") or [] + if isinstance(markers, (list, tuple)): + for marker in markers: + if isinstance(marker, str) and marker.strip() and marker.strip().lower() in _url: + return True + except Exception: + # Config read is best-effort; never break an aux call over it. + pass + return False + + +def _create_with_progress( + client: Any, + kwargs: Dict[str, Any], + task: Optional[str] = None, + *, + force_stream: bool = False, +) -> Any: + """chat.completions.create() that streams when a progress hook is active + or the provider only accepts streamed requests. + + Behavior is byte-for-byte identical to a plain ``create(**kwargs)`` when + neither trigger applies (every existing caller/task) or when the client's + wire adapter streams internally. With a hook + a chunk-capable client, + the request is sent with ``stream=True`` and aggregated, ticking the hook + per chunk — so the configured ``timeout`` acts per stream read (idle) + rather than as a total budget, and outer liveness watchdogs see tokens + moving. ``force_stream=True`` (stream-only providers such as Tencent + Copilot — credit @kudi88, PR #60686) takes the same streamed path even + without a hook. Providers that reject the streamed request fall back to + the plain non-streaming call — except under ``force_stream``, where a + stream-only provider rejects the plain call by definition, so the + original error is surfaced to the normal recovery chains instead. + """ + _notify_aux_progress() # request dispatched counts as progress + if (not _aux_progress_active() and not force_stream) or _client_streams_internally(client): + return client.chat.completions.create(**kwargs) + + total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) + stream_kwargs = dict(kwargs) + stream_kwargs["stream"] = True + stream_kwargs["stream_options"] = {"include_usage": True} + try: + chunks = client.chat.completions.create(**stream_kwargs) + except Exception as exc: + # Genuine provider failures (auth, credit, rate limit, network) are + # not streaming's fault — surface them unchanged so the existing + # recovery chains (credential refresh, pool rotation, provider + # fallback) see the same error they would on a plain call. + if ( + force_stream + or _is_transient_transport_error(exc) + or _is_auth_error(exc) + or _is_payment_error(exc) + or _is_rate_limit_error(exc) + ): + raise + # Anything else may be a streaming-specific rejection (explicit + # "stream not supported", stream_options 400, or an idiosyncratic + # 4xx). Retry non-streaming once; if the request itself is bad the + # plain call reproduces the real error for the normal except-chains. + logger.debug( + "Auxiliary %s: streamed request failed (%s); retrying " + "non-streaming", task or "call", exc, + ) + return client.chat.completions.create(**kwargs) + + # Some shims (MoA virtual provider under quiet mode, defensive adapters) + # return a complete response even when stream=True was requested. + if hasattr(chunks, "choices"): + _notify_aux_progress() + return chunks + return _aggregate_chat_stream( + chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, + ) + + +def _aggregate_chat_stream( + chunks: Any, + *, + model: str = "", + total_ceiling: Optional[float] = None, +) -> Any: + """Consume a chat.completions chunk stream into a complete response. + + Ticks the thread-local aux progress hook on every chunk. Raises + TimeoutError when *total_ceiling* seconds elapse before the stream + finishes — phrased with "timed out" so existing timeout classification + (``_is_timeout_error``) treats it exactly like a request timeout. + Accumulation is shared with the async mirror via + :class:`_ChatStreamAccumulator`. + """ + acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) + try: + for chunk in chunks: + acc.feed(chunk) + finally: + close_fn = getattr(chunks, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception: + pass + return acc.finish() + + +class _ChatStreamAccumulator: + """Shared per-chunk accumulation for sync and async stream aggregation. + + Mirrors :func:`_aggregate_chat_stream`'s chunk handling so the async + consumer below cannot drift from the sync one (same content/reasoning/ + tool-call delta reassembly, same "timed out" ceiling phrasing). + """ + + def __init__(self, model: str = "", total_ceiling: Optional[float] = None): + self._started = time.monotonic() + self._total_ceiling = total_ceiling + self.content_parts: List[str] = [] + self.reasoning_parts: List[str] = [] + self.tool_calls_acc: Dict[int, Dict[str, Any]] = {} + self.finish_reason = None + self.usage = None + self.resp_id = "" + self.resp_model = model or "" + + def feed(self, chunk: Any) -> None: + _notify_aux_progress() + if ( + self._total_ceiling is not None + and (time.monotonic() - self._started) >= self._total_ceiling + ): + raise TimeoutError( + f"Auxiliary streamed call timed out after {self._total_ceiling:.0f}s " + "total ceiling (stream still open but over budget)" + ) + self.resp_id = getattr(chunk, "id", None) or self.resp_id + self.resp_model = getattr(chunk, "model", None) or self.resp_model + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage: + self.usage = chunk_usage + choices = getattr(chunk, "choices", None) or [] + if not choices: + return + choice = choices[0] + self.finish_reason = getattr(choice, "finish_reason", None) or self.finish_reason + delta = getattr(choice, "delta", None) + if delta is None: + return + piece = getattr(delta, "content", None) + if piece: + self.content_parts.append(piece) + reasoning_piece = ( + getattr(delta, "reasoning", None) + or getattr(delta, "reasoning_content", None) + ) + if reasoning_piece and isinstance(reasoning_piece, str): + self.reasoning_parts.append(reasoning_piece) + for tc in (getattr(delta, "tool_calls", None) or []): + idx = getattr(tc, "index", 0) or 0 + acc = self.tool_calls_acc.setdefault( + idx, {"id": "", "name": "", "arguments": []} + ) + if getattr(tc, "id", None): + acc["id"] = tc.id + fn = getattr(tc, "function", None) + if fn is not None: + if getattr(fn, "name", None): + acc["name"] = fn.name + if getattr(fn, "arguments", None): + acc["arguments"].append(fn.arguments) + + def finish(self) -> Any: + tool_calls = None + if self.tool_calls_acc: + tool_calls = [ + SimpleNamespace( + id=acc["id"], + type="function", + function=SimpleNamespace( + name=acc["name"], + arguments="".join(acc["arguments"]), + ), + ) + for _idx, acc in sorted(self.tool_calls_acc.items()) + ] + message = SimpleNamespace( + role="assistant", + content="".join(self.content_parts), + tool_calls=tool_calls, + reasoning="".join(self.reasoning_parts) or None, + ) + choice = SimpleNamespace( + index=0, + message=message, + finish_reason=self.finish_reason or "stop", + ) + return SimpleNamespace( + id=self.resp_id, + model=self.resp_model, + object="chat.completion", + choices=[choice], + usage=self.usage, + ) + + +async def _aggregate_chat_stream_async( + chunks: Any, + *, + model: str = "", + total_ceiling: Optional[float] = None, +) -> Any: + """Async mirror of :func:`_aggregate_chat_stream` (``async for`` consumer). + + The AsyncOpenAI stream contract is an async iterator — consuming it with + the sync helper raises. Same accumulation and ceiling semantics via + :class:`_ChatStreamAccumulator`. + """ + acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) + try: + async for chunk in chunks: + acc.feed(chunk) + finally: + close_fn = getattr(chunks, "close", None) or getattr(chunks, "aclose", None) + if callable(close_fn): + try: + result = close_fn() + if inspect.isawaitable(result): + await result + except Exception: + pass + return acc.finish() + + +async def _acreate_with_stream( + client: Any, + kwargs: Dict[str, Any], + task: Optional[str] = None, +) -> Any: + """Async chat.completions.create() for stream-only providers. + + Sends ``stream=True`` and aggregates the async chunk stream into a + complete response (credit @kudi88, PR #60686 — async contract fixed to + ``async for`` and tool-call deltas preserved per sweeper review). + """ + total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) + stream_kwargs = dict(kwargs) + stream_kwargs["stream"] = True + stream_kwargs["stream_options"] = {"include_usage": True} + chunks = await client.chat.completions.create(**stream_kwargs) + # Defensive: shims may hand back a complete response despite stream=True. + if hasattr(chunks, "choices"): + return chunks + return await _aggregate_chat_stream_async( + chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, + ) + + def call_llm( task: str = None, *, @@ -7304,7 +7866,13 @@ def call_llm( # for the transient retry every auxiliary task shares. (PR #16587) try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task, + _create_with_progress( + client, kwargs, task, + force_stream=_provider_requires_stream( + resolved_provider, _base_info or resolved_base_url, + ), + ), + task, provider=resolved_provider, base_url=_base_info) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7337,7 +7905,13 @@ def call_llm( time.sleep(_backoff) try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task) + _create_with_progress( + client, kwargs, task, + force_stream=_provider_requires_stream( + resolved_provider, _base_info or resolved_base_url, + ), + ), + task) except Exception as retry_transient: if not _is_transient_transport_error(retry_transient): raise @@ -7654,6 +8228,15 @@ def call_llm( logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -7662,7 +8245,8 @@ def call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -7671,10 +8255,12 @@ def call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: fb_resp = _call_fallback_candidate_sync( @@ -7890,9 +8476,25 @@ async def async_call_llm( # Retry ONCE on the same provider for a transient transport blip # before the except-chain escalates to fallback — see call_llm() # for the rationale. (PR #16587) + _force_stream_async = ( + _provider_requires_stream( + resolved_provider, _client_base or resolved_base_url, + ) + and not isinstance(client, ( + AsyncCodexAuxiliaryClient, + AsyncAnthropicAuxiliaryClient, + AsyncBedrockAuxiliaryClient, + )) + ) + + async def _acreate(_kwargs: Dict[str, Any]) -> Any: + if _force_stream_async: + return await _acreate_with_stream(client, _kwargs, task) + return await client.chat.completions.create(**_kwargs) + try: return _validate_llm_response( - await client.chat.completions.create(**kwargs), task, + await _acreate(kwargs), task, provider=resolved_provider, base_url=_client_base) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7913,7 +8515,7 @@ async def async_call_llm( task or "call", transient_err, ) return _validate_llm_response( - await client.chat.completions.create(**kwargs), task) + await _acreate(kwargs), task) except Exception as first_err: if "temperature" in kwargs and _is_unsupported_temperature_error(first_err): retry_kwargs = dict(kwargs) @@ -8172,6 +8774,15 @@ async def async_call_llm( logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -8180,7 +8791,8 @@ async def async_call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8189,10 +8801,12 @@ async def async_call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: # Convert sync fallback client to async diff --git a/agent/backend_identity.py b/agent/backend_identity.py new file mode 100644 index 00000000000..7a7e9efb6bf --- /dev/null +++ b/agent/backend_identity.py @@ -0,0 +1,204 @@ +"""Single owner for backend identity and failure-scoped skip decisions. + +Every fallback / dedup / skip / quarantine decision in Hermes ultimately asks +one question: **"is this candidate the same backend as the one that failed, +along the axis that failure invalidated?"** Before this module, that +question was re-implemented inline at six call sites across four subsystems, +each comparing whatever string was locally convenient (provider label, +provider+model, base_url+model, ...). Each incident fixed one site while the +others kept the bug: #22548 (same-shim aliases), #70893 (xai-oauth vs xai — +same host, distinct credential), #59561 (aux chain skipped sibling models), +#72468 (aux main-model safety net, same bug three weeks later), #62984 / +#54250 / #57584 (dedup ignoring base_url strands multi-endpoint pools). + +The root insight: "provider" conflates three independent identity axes, and +each failure class invalidates a different one: + +* **credential surface** — auth 401 / payment 402 kill everything sharing the + credential (every model, every host reached with that key/token). +* **endpoint** — DNS failure / connection refused kill everything behind the + URL, regardless of model or credential. +* **model deployment** — timeout / overload / rate limit / model-incompatible + kill ONE model's deployment. A sibling model behind the same URL is an + independent deployment (real incident: aux ``glm-5.2`` hung and timed out + while main ``macaron-v1-venti`` on the identical endpoint was serving + 448K-token turns). + +Call sites should build :class:`BackendIdentity` values, classify the failure +with :func:`classify_failure_scope`, and ask :func:`should_skip_candidate`. +Do not re-implement any comparison inline — extend THIS module instead. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +logger = logging.getLogger(__name__) + + +class FailureScope(Enum): + """Which identity axis a failure invalidates.""" + + #: Timeout, overload/429, connection blip, model-incompatible, invalid + #: response: evidence against ONE model deployment only. + MODEL = "model" + #: Auth 401 / payment 402: evidence against the shared credential — + #: every model reached with it is equally dead. + CREDENTIAL = "credential" + #: DNS / connection-refused / unreachable host: evidence against the + #: endpoint — every model behind the URL is equally dead. + ENDPOINT = "endpoint" + + +#: Reason strings already used by auxiliary_client's except-chain, mapped to +#: scopes. Unknown reasons default to MODEL — the least-invalidating scope — +#: so an unrecognized failure never over-skips viable candidates. +_REASON_SCOPES = { + "auth error": FailureScope.CREDENTIAL, + "payment error": FailureScope.CREDENTIAL, + "rate limit": FailureScope.MODEL, + "model incompatible with route": FailureScope.MODEL, + "invalid provider response": FailureScope.MODEL, + "connection error": FailureScope.MODEL, + "timeout": FailureScope.MODEL, +} + + +def classify_failure_scope(reason: Optional[str]) -> FailureScope: + """Map a human-readable failure reason to the identity axis it kills.""" + return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL) + + +def _norm_provider(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_model(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_base_url(value: Optional[str]) -> str: + return (value or "").strip().rstrip("/").lower() + + +@dataclass(frozen=True) +class BackendIdentity: + """Normalized identity of one (provider, model, endpoint) deployment. + + Empty fields mean "unknown" — comparisons treat an unknown axis as + non-distinguishing (it can neither prove sameness nor difference on its + own; the remaining axes decide). + """ + + provider: str = "" + model: str = "" + base_url: str = "" + + @classmethod + def build( + cls, + provider: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> "BackendIdentity": + return cls( + provider=_norm_provider(provider), + model=_norm_model(model), + base_url=_norm_base_url(base_url), + ) + + +def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool: + """True when both providers are distinct registered first-class providers. + + Two different registry providers have distinct credential surfaces even + when they share an inference host (xai-oauth vs xai, openai-codex vs + openai-api) — #70893. Custom/shim aliases are NOT in the registry, so + two aliases pointing at one URL still count as the same backend (#22548). + """ + if not a.provider or not b.provider or a.provider == b.provider: + return False + try: + from hermes_cli.auth import PROVIDER_REGISTRY + + return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY + except Exception: + return False + + +def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities share the credential a 401/402 just invalidated? + + Conservative on purpose: an unprovable axis must answer "different" + (try the candidate — worst case one wasted RTT) rather than "same" + (skip — worst case stranded failover). Two distinct custom labels at + one URL may carry different per-entry api_keys, so a shared URL alone + never proves a shared credential; it is only used as a weak signal + when a provider label is missing entirely. + """ + if a.provider and b.provider: + # Same label = same configured credential. Different labels = + # different credential config (first-class registry providers + # explicitly so — #70893; custom entries can each carry their own + # api_key, so sameness is unprovable and we must not skip). + return a.provider == b.provider + # Provider unknown on a side: same explicit URL is the best signal left. + return bool(a.base_url and a.base_url == b.base_url) + + +def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities sit behind the endpoint that just went unreachable?""" + if a.base_url and b.base_url: + return a.base_url == b.base_url + # An unknown base_url inherits the provider default → same provider + # label implies the same default endpoint. + return bool(a.provider and a.provider == b.provider) + + +def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool: + """Are these the exact same model deployment (the thing a timeout kills)? + + Provider+model must match; the base_url axis distinguishes only when BOTH + sides carry an explicit URL (#62984: same provider+model on two different + explicit URLs is two deployments — a pool). A side with an unknown URL + inherits the provider default and cannot prove difference. + """ + if not (a.provider and b.provider and a.provider == b.provider): + # Same-host different-label shims: same URL + same model IS the same + # deployment even when the alias labels differ (#22548) — unless both + # labels are first-class registry providers (#70893). + if ( + a.base_url + and a.base_url == b.base_url + and a.model + and a.model == b.model + and not _both_first_class(a, b) + ): + return True + return False + if not (a.model and b.model and a.model == b.model): + return False + if a.base_url and b.base_url and a.base_url != b.base_url: + return False # distinct explicit endpoints — a pool, not a dup + return True + + +def should_skip_candidate( + candidate: BackendIdentity, + failed: BackendIdentity, + scope: FailureScope = FailureScope.MODEL, +) -> bool: + """THE skip predicate: would trying ``candidate`` just repeat the failure? + + True when the candidate is the same backend as ``failed`` along the axis + ``scope`` says the failure invalidated. Every fallback/dedup/skip site + must call this instead of comparing labels inline. + """ + if scope is FailureScope.CREDENTIAL: + return same_credential_surface(candidate, failed) + if scope is FailureScope.ENDPOINT: + return same_endpoint(candidate, failed) + return same_deployment(candidate, failed) diff --git a/agent/background_review.py b/agent/background_review.py index c2ea87bd94e..a0dbd4a99e2 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -209,7 +209,10 @@ _SKILL_REVIEW_PROMPT = ( "conversation for skills the user loaded via /skill-name or you " "read via skill_view. If any of them covers the territory of the " "new learning, PATCH that one first. It is the skill that was in " - "play, so it's the right one to extend.\n" + "play, so it's the right one to extend — but only if it is " + "curator-managed. Bundled, hub, pinned, and user-owned skills are " + "off-limits to you no matter how relevant (see Protected skills " + "below); for those, fall through to the next option.\n" " 2. UPDATE AN EXISTING UMBRELLA (via skills_list + skill_view). " "If no loaded skill fits but an existing class-level skill does, " "patch it. Add a subsection, a pitfall, or broaden a trigger.\n" @@ -251,10 +254,18 @@ _SKILL_REVIEW_PROMPT = ( "Protected skills (DO NOT edit these):\n" " • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" " • Hub-installed skills (installed via 'hermes skills install').\n" - "Pinned skills (marked via 'hermes curator pin') CAN be improved — " - "pin only blocks deletion/archive/consolidation by the curator, not " - "content updates. Patch them when a pitfall or missing step turns up, " - "same as any other agent-created skill.\n" + " • Skills in skills.external_dirs (externally owned).\n" + " • PINNED skills (marked via 'hermes curator pin'). You are an " + "autonomous no-user-present actor, so pin blocks your writes too — " + "content updates included. Only the user, in a foreground session, " + "can change a pinned skill.\n" + " • USER-OWNED skills — anything not curator-managed. A skill the " + "user hand-wrote, installed by URL, or asked a foreground agent to " + "create is theirs, not yours; your writes to it WILL be refused. " + "This includes skills that were loaded or consulted this session: " + "being in play does not make one yours to edit. If such a skill is " + "wrong or outdated, say so in your reply and recommend " + "'hermes curator adopt ' — do not try to patch it.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" "Do NOT capture (these become persistent self-imposed constraints " @@ -309,7 +320,9 @@ _COMBINED_REVIEW_PROMPT = ( " 1. UPDATE A CURRENTLY-LOADED SKILL. Check what skills were " "loaded via /skill-name or skill_view in the conversation. If one " "of them covers the learning, PATCH it first. It was in play; " - "it's the right place.\n" + "it's the right place — provided it is curator-managed. Protected " + "and user-owned skills are off-limits however relevant; fall " + "through when one of those is the best fit.\n" " 2. UPDATE AN EXISTING UMBRELLA (skills_list + skill_view to " "find the right one). Patch it.\n" " 3. ADD A SUPPORT FILE under an existing umbrella via " @@ -337,10 +350,15 @@ _COMBINED_REVIEW_PROMPT = ( "Protected skills (DO NOT edit these):\n" " • Bundled skills (shipped with Hermes, e.g. 'hermes-agent').\n" " • Hub-installed skills (installed via 'hermes skills install').\n" - "Pinned skills (marked via 'hermes curator pin') CAN be improved — " - "pin only blocks deletion/archive/consolidation by the curator, not " - "content updates. Patch them when a pitfall or missing step turns up, " - "same as any other agent-created skill.\n" + " • Skills in skills.external_dirs (externally owned).\n" + " • PINNED skills (marked via 'hermes curator pin'). Pin blocks " + "autonomous writes entirely — content updates included — because no " + "user is present to consent. Only a foreground session can change one.\n" + " • USER-OWNED skills — anything not curator-managed (hand-written, " + "URL-installed, or created by a foreground agent at the user's " + "request). Your writes to these WILL be refused, including to skills " + "loaded or consulted this session. If one is wrong, say so in your " + "reply and recommend 'hermes curator adopt ' instead.\n" "If the only skills that need updating are protected, say\n" "'Nothing to save.' and stop.\n\n" "Do NOT capture as skills (these become persistent self-imposed " diff --git a/agent/billing_view.py b/agent/billing_view.py index a535aee1f14..29e8068c227 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -107,6 +107,22 @@ class CardInfo: return f"{self.masked} — {label}" if label else self.masked +@dataclass(frozen=True) +class PaymentMethodInfo: + """The payment method on file. `kind` is "card", "link", or "unknown" + — anything else is normalised to "unknown" at parse time, so consumers + only ever see fields that belong to the kind they are looking at.""" + + kind: str + brand: Optional[str] = None + last4: Optional[str] = None + wallet: Optional[str] = None + email: Optional[str] = None + resolved_via: Optional[str] = None + #: What the server called it, when we did not recognise the kind. + raw_kind: Optional[str] = None + + @dataclass(frozen=True) class MonthlyCap: limit_usd: Optional[Decimal] = None @@ -150,6 +166,7 @@ class BillingState: min_usd: Optional[Decimal] = None max_usd: Optional[Decimal] = None card: Optional[CardInfo] = None + payment_method: Optional[PaymentMethodInfo] = None monthly_cap: Optional[MonthlyCap] = None auto_reload: Optional[AutoReload] = None portal_url: Optional[str] = None @@ -201,6 +218,41 @@ def _parse_card(raw: Any) -> Optional[CardInfo]: return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via) +def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]: + if not isinstance(raw, dict): + return None + kind = raw.get("kind") + if not isinstance(kind, str): + return None + + def _optional_string(key: str) -> Optional[str]: + value = raw.get(key) + return value if isinstance(value, str) else None + + resolved_via = _optional_string("resolvedVia") + brand = _optional_string("brand") + last4 = _optional_string("last4") + # Settle the kind here, the way _parse_card settles a card, so nothing + # downstream has to re-check which fields this kind is allowed to have. + if kind == "card" and brand and last4: + return PaymentMethodInfo( + kind="card", + brand=brand, + last4=last4, + wallet=_optional_string("wallet"), + resolved_via=resolved_via, + ) + if kind == "link": + return PaymentMethodInfo( + kind="link", + email=_optional_string("email"), + resolved_via=resolved_via, + ) + return PaymentMethodInfo( + kind="unknown", raw_kind=kind, resolved_via=resolved_via + ) + + def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]: if not isinstance(raw, dict): return None @@ -274,6 +326,7 @@ def billing_state_from_payload( min_usd=parse_money(bounds.get("minUsd")), max_usd=parse_money(bounds.get("maxUsd")), card=_parse_card(payload.get("card")), + payment_method=_parse_payment_method(payload.get("paymentMethod")), monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")), auto_reload=_parse_auto_reload(payload.get("autoReload")), portal_url=portal_url, diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 37113b61acf..b0c3faaf6f7 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -188,6 +188,31 @@ def _provider_preferences_for_agent(agent) -> Dict[str, Any]: return preferences +def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dict: + """Merge Portal ``tags`` / ``session_id`` onto an Anthropic Messages kwargs dict. + + The Nous provider profile is only consulted by the OpenAI-wire transport; + anthropic_messages callers must merge it themselves. Passes ``session_id`` + only — not ``provider_preferences`` (those become a top-level ``provider`` + routing object on the OpenAI wire). Never blocks a turn on tagging. + """ + if getattr(agent, "provider", None) not in {"nous", "nous-portal", "nousresearch"}: + return anthropic_kwargs + try: + from providers import get_provider_profile + + nous_profile = get_provider_profile("nous") + if nous_profile is not None: + anthropic_kwargs.setdefault("extra_body", {}).update( + nous_profile.build_extra_body( + session_id=getattr(agent, "session_id", None) + ) + ) + except Exception as exc: # noqa: BLE001 — never block a turn on tagging + logger.debug("Nous Portal extra_body merge failed: %s", exc) + return anthropic_kwargs + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -433,26 +458,56 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client): def should_use_direct_api_call(agent) -> bool: - """Whether a cron OpenAI-wire request should skip the interrupt worker. + """Whether an OpenAI-wire request should skip the interrupt worker. - Issue #62151 is specific to OpenRouter's chat-completions path inside the - gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their - established workers: their cancellation and client ownership differ, and - the report provides no evidence that those paths share the pre-HTTP wedge. + Two nested-pool contexts wedge before the socket opens when the request + is pushed onto yet another daemon worker thread: + + - Gateway cron turns (#62151): gateway asyncio loop → cron thread → + interrupt worker. Fixed by running inline. + - Delegated children (#60203): gateway loop → async-delegation executor + (module-lifetime daemon pool) → per-child timeout executor → interrupt + worker. Same fingerprint after multi-day gateway uptime — children hang + at their FIRST API call with zero stale-detector output (the worker + never reaches dispatch), all providers, restart cures it. The cron fix + originally excluded delegation "for lack of evidence"; #60203 is that + evidence. + + Running inline drops the deepest thread layer (whose only job is + interactive-interrupt responsiveness). Interrupts still work: the inline + path registers ``agent._active_request_abort``, which ``interrupt()`` + invokes cross-thread to shut the active sockets — the same mechanism the + async-delegation stall monitor (#72227) relies on. + + Keep native/Codex/Bedrock/MoA transports on their established workers: + their cancellation and client ownership differ. """ - return ( - getattr(agent, "platform", None) == "cron" - and getattr(agent, "api_mode", None) == "chat_completions" - and getattr(agent, "provider", None) != "moa" - ) + if getattr(agent, "api_mode", None) != "chat_completions": + return False + if getattr(agent, "provider", None) == "moa": + return False + if getattr(agent, "platform", None) == "cron": + return True + # Delegated child (delegate_task sync or background) — detected via the + # execution ContextVar set by _run_single_child, with the agent's own + # platform stamp as a fallback for callers that bypass the runner. + try: + from agent.delegation_context import is_delegated_child_context + + if is_delegated_child_context(): + return True + except Exception: + pass + return getattr(agent, "platform", None) == "subagent" def direct_api_call(agent, api_kwargs: dict): """Run a non-streaming LLM call inline on the conversation thread. - Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker - (whose only job is interactive-interrupt responsiveness, which this context - does not have) so the nested-pool deadlock (#62151) cannot occur. Because the + Used when ``should_use_direct_api_call`` is True (cron turns and + delegated children). Skips the interrupt worker (whose only job is + interactive-interrupt responsiveness, which these contexts do not have) + so the nested-pool deadlock (#62151, #60203) cannot occur. Because the request runs in-flight normally, the per-request OpenAI client's own httpx timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds a genuinely hung provider — the same bound interactive calls already rely on. @@ -463,7 +518,7 @@ def direct_api_call(agent, api_kwargs: dict): request_client_lock = threading.Lock() def _abort_active_request(reason: str) -> None: - """Abort the inline request from cron's watchdog/interrupt thread.""" + """Abort the inline request from a watchdog/interrupt thread.""" with request_client_lock: request_client = request_client_holder["client"] if request_client is not None: @@ -993,7 +1048,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None) if ephemeral_out is not None: agent._ephemeral_max_output_tokens = None # consume immediately - return _transport.build_kwargs( + anthropic_kwargs = _transport.build_kwargs( model=agent.model, messages=anthropic_messages, tools=tools_for_api, @@ -1006,6 +1061,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict: fast_mode=(agent.request_overrides or {}).get("speed") == "fast", drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)), ) + # Nous Portal reads ``tags`` and ``session_id`` as top-level body fields + # on its Messages route the same way it does on /chat/completions, but + # the profile hook that produces them is only consulted by the + # OpenAI-wire transport. Merge them here so Messages traffic keeps + # product attribution and sticky routing. + return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs) # AWS Bedrock native Converse API — bypasses the OpenAI client entirely. # The adapter handles message/tool conversion and boto3 calls directly. @@ -1076,6 +1137,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: tools=tools_for_api, reasoning_config=agent.reasoning_config, session_id=getattr(agent, "session_id", None), + base_url=agent.base_url, max_tokens=agent.max_tokens, timeout=agent._resolved_api_call_timeout(), request_overrides=agent.request_overrides, @@ -1598,29 +1660,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool ) return agent._try_activate_fallback(reason) - # Skip entries that resolve to the current (provider, model) — falling - # back to the same backend that just failed loops the failure. Compare - # base_url too so two distinct custom_providers entries pointing at the - # same shim/proxy URL also dedup. See issue #22548. - current_provider = (getattr(agent, "provider", "") or "").strip().lower() - current_model = (getattr(agent, "model", "") or "").strip() - current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower() - fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower() - if fb_provider == current_provider and fb_model == current_model: + # Skip entries that resolve to the same backend that just failed — + # falling back to it loops the failure. Identity semantics (which axes + # distinguish two backends, shim aliases, first-class credential + # surfaces, multi-endpoint pools) are owned by agent.backend_identity — + # see #22548, #70893, #62984. Do not re-implement comparisons here. + from agent.backend_identity import BackendIdentity, should_skip_candidate + + current_ident = BackendIdentity.build( + provider=getattr(agent, "provider", ""), + model=getattr(agent, "model", ""), + base_url=str(getattr(agent, "base_url", "") or ""), + ) + fb_ident = BackendIdentity.build( + provider=fb_provider, + model=fb_model, + base_url=(fb.get("base_url") or ""), + ) + if should_skip_candidate(fb_ident, current_ident): logger.warning( - "Fallback skip: chain entry %s/%s matches current provider/model", - fb_provider, fb_model, - ) - return agent._try_activate_fallback(reason) - if ( - fb_base_url_for_dedup - and current_base_url - and fb_base_url_for_dedup == current_base_url - and fb_model == current_model - ): - logger.warning( - "Fallback skip: chain entry base_url %s matches current backend", - fb_base_url_for_dedup, + "Fallback skip: chain entry %s/%s resolves to the same backend " + "as the current one (%s)", + fb_provider, fb_model, current_ident.base_url or current_ident.provider, ) return agent._try_activate_fallback(reason) @@ -1671,6 +1732,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool _fb_is_azure = agent._is_azure_openai_url(fb_base_url) if fb_provider == "openai-codex": fb_api_mode = "codex_responses" + elif fb_provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* must land on /v1/messages. + # resolve_provider_client still returns an OpenAI client for + # Nous; the anthropic_messages branch below rebuilds the native + # client from that credential + base_url. + from hermes_cli.providers import nous_api_mode + + fb_api_mode = nous_api_mode(fb_model) elif ( fb_provider == "anthropic" or fb_base_url.rstrip("/").lower().endswith("/anthropic") @@ -1739,6 +1808,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool fb_provider, fb_model, _pool_provider, ) agent._credential_pool = None + agent._credential_pool_entry_id = None if getattr(agent, "_credential_pool", None) is None: try: from agent.credential_pool import load_pool @@ -1801,6 +1871,9 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool # not only after a later credential-rotation rebuild. agent._replace_primary_openai_client(reason="fallback_timeout_apply") + from agent.agent_runtime_helpers import sync_credential_pool_entry_id + sync_credential_pool_entry_id(agent) + # Re-evaluate prompt caching for the new provider/model agent._use_prompt_caching, agent._use_native_cache_layout = ( agent._anthropic_prompt_cache_policy( @@ -2093,7 +2166,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, is_oauth=agent._is_anthropic_oauth, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw = _merge_nous_portal_messages_extra_body(agent, _ant_kw) summary_response = agent._anthropic_messages_create(_ant_kw) _summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_summary_result.content or "").strip() @@ -2123,7 +2198,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None, is_oauth=agent._is_anthropic_oauth, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw2 = _merge_nous_portal_messages_extra_body(agent, _ant_kw2) retry_response = agent._anthropic_messages_create(_ant_kw2) _retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_retry_result.content or "").strip() @@ -3444,13 +3521,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # already worker-owned-closed by _close_request_client_once # above; the next attempt builds a fresh one. The shared # _anthropic_client is never closed from inside a request. - if agent.api_mode != "anthropic_messages": - try: - agent._replace_primary_openai_client( - reason="stream_mid_tool_retry_pool_cleanup" - ) - except Exception: - pass + # #70773: same FD-recycle corruption vector for OpenAI. + # The shared client will be replaced lazily by + # _ensure_primary_openai_client on the next attempt. continue # SSE error events from proxies (e.g. OpenRouter sends @@ -3509,13 +3582,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # above; next attempt builds fresh), so the shared # _anthropic_client is never closed from inside a # request — only the OpenAI-wire primary is refreshed. - if agent.api_mode != "anthropic_messages": - try: - agent._replace_primary_openai_client( - reason="stream_retry_pool_cleanup" - ) - except Exception: - pass + # #70773: same FD-recycle corruption vector for OpenAI. + # The shared client will be replaced lazily by + # _ensure_primary_openai_client on the next attempt. continue # Retries exhausted. Log the final failure with # full diagnostic detail (chain, headers, @@ -3758,10 +3827,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # FD-recycle corruption vector. Nothing further is needed. pass else: - try: - agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup") - except Exception: - pass + # #70773: same FD-recycle corruption vector as #67142. + # The shared OpenAI client's connection pool must NOT be + # closed from this watchdog/poll thread — worker threads + # from previous stale-killed attempts may still be + # unwinding their SSL BIOs. The request-local client is + # already closed above via _close_request_client_once. + # The shared client will be replaced lazily by + # _ensure_primary_openai_client on the next request. + pass # Reset the timer so we don't kill repeatedly while # the inner thread processes the closure. last_chunk_time["t"] = time.time() diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index bce372ebb5d..ee75f4190e6 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -912,7 +912,8 @@ def _preflight_codex_api_kwargs( allowed_keys = { "model", "instructions", "input", "tools", "store", "reasoning", "include", "max_output_tokens", "temperature", - "tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier", + "tool_choice", "parallel_tool_calls", "prompt_cache_key", + "prompt_cache_retention", "service_tier", "extra_headers", "extra_body", "timeout", } normalized: Dict[str, Any] = { @@ -950,8 +951,13 @@ def _preflight_codex_api_kwargs( if isinstance(temperature, (int, float)): normalized["temperature"] = float(temperature) - # Pass through tool_choice, parallel_tool_calls, prompt_cache_key - for passthrough_key in ("tool_choice", "parallel_tool_calls", "prompt_cache_key"): + # Pass through cache routing/retention and tool-dispatch hints. + for passthrough_key in ( + "tool_choice", + "parallel_tool_calls", + "prompt_cache_key", + "prompt_cache_retention", + ): val = api_kwargs.get(passthrough_key) if val is not None: normalized[passthrough_key] = val diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 59f9bac25a2..da3bc4f9569 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -778,12 +778,27 @@ def run_codex_app_server_turn( # the already-flushed user turn). See gateway/run.py agent_persisted. if getattr(agent, "_session_db", None) is not None: try: - agent._flush_messages_to_session_db(messages) + _codex_flush_ok = agent._flush_messages_to_session_db(messages) except Exception: - logger.debug( + _codex_flush_ok = False + logger.warning( "codex app-server projected-message flush failed", exc_info=True, ) + if _codex_flush_ok is False: + # Unlike the chat-completions loop (which fails closed BEFORE + # projection — see conversation_loop session_persistence_failed), + # codex output has already streamed to the user by the time this + # flush runs, so there is nothing left to withhold. We cannot + # flip agent_persisted=False either: the gateway fallback write + # would re-INSERT the already-flushed user turn (#860/#42039). + # Surface the durability gap loudly instead of a silent debug. + logger.warning( + "codex app-server turn was delivered but could NOT be " + "persisted to the session DB (session=%s) — this turn " + "will be missing after restart/resume", + getattr(agent, "session_id", None), + ) # Counter ticks for the agent-improvement loop. diff --git a/agent/coding_context.py b/agent/coding_context.py index 4a0cb841030..fabbdb48e07 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -520,30 +520,46 @@ class RuntimeMode: return None return [self.profile.toolset, *_enabled_mcp_servers(config)] - def system_blocks(self) -> list[str]: - """Stable system-prompt blocks for this posture (brief + workspace). + def system_prompt_parts(self) -> tuple[list[str], list[str], list[str]]: + """Return prefix, workspace, and trailing posture blocks separately. The operating brief carries a model-family edit-format nudge appended to it (one cached string, not a separate block) so the model is steered toward the `patch` mode it handles best — see ``_edit_format_line``. + + The three lists preserve the historical flat prompt order: the brief, + the live workspace snapshot, then configured operator instructions. + Prompt assembly can therefore put a cache boundary before the snapshot + without changing the persisted system-prompt bytes. """ if not self.is_coding: - return [] - blocks: list[str] = [] + return [], [], [] + prefix: list[str] = [] + workspace_parts: list[str] = [] + trailing: list[str] = [] if self.profile.guidance: brief = self.profile.guidance edit_line = _edit_format_line(self.model) if edit_line: brief = f"{brief}\n{edit_line}" - blocks.append(brief) + prefix.append(brief) workspace = build_coding_workspace_block(self.cwd) if workspace: - blocks.append(workspace) + workspace_parts.append(workspace) # Operator instructions ride their own block so the brief (block 0) stays # byte-stable and cache-keyed independently of user config. if self.instructions: - blocks.append(f"Operator instructions (from config):\n{self.instructions}") - return blocks + trailing.append(f"Operator instructions (from config):\n{self.instructions}") + return prefix, workspace_parts, trailing + + def system_blocks(self) -> list[str]: + """Return posture blocks in their historical display order. + + ``system_prompt_parts`` is the cache-aware API. This compatibility + helper retains the public flat list for callers outside prompt assembly. + """ + prefix, workspace, trailing = self.system_prompt_parts() + return [*prefix, *workspace, *trailing] def compact_skill_categories(self) -> frozenset[str]: """Skill categories to demote to names-only in the prompt's skill index. @@ -644,6 +660,19 @@ def coding_system_blocks( ).system_blocks() +def coding_system_prompt_parts( + *, + platform: Optional[str] = None, + cwd: Optional[str | Path] = None, + config: Optional[dict[str, Any]] = None, + model: Optional[str] = None, +) -> tuple[list[str], list[str], list[str]]: + """Return coding prefix, workspace snapshot, and trailing guidance.""" + return resolve_runtime_mode( + platform=platform, cwd=cwd, config=config, model=model + ).system_prompt_parts() + + def coding_compact_skill_categories( *, platform: Optional[str] = None, diff --git a/agent/context_breakdown.py b/agent/context_breakdown.py index 0e2eb772f2f..4527c5dca22 100644 --- a/agent/context_breakdown.py +++ b/agent/context_breakdown.py @@ -154,3 +154,207 @@ def compute_session_context_breakdown( "estimated_total": estimated_total, "model": getattr(agent, "model", "") or "", } + + +# ── /context rendering (CLI + gateway) ────────────────────────────────────── +# +# Pure text renderers over the payload above. The CLI shows a glyph block-grid +# plus a category table; the gateway uses the same table without the grid +# (proportional monospace is not guaranteed on messaging platforms). + +_CATEGORY_GLYPHS = { + "system_prompt": "■", + "tool_definitions": "▣", + "rules": "▩", + "skills": "▤", + "mcp": "▥", + "subagent_definitions": "▦", + "memory": "▧", + "conversation": "▨", +} +_FREE_GLYPH = "·" +_GRID_COLUMNS = 20 +_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window + +# Human-readable tables cap the expanded listings; nothing is dropped from +# the underlying data. +_DETAILS_TABLE_LIMIT = 15 + + +def _bytes_to_tokens(size: Optional[int]) -> Optional[int]: + if size is None: + return None + return (int(size) + 3) // 4 + + +def compute_context_details(agent: Any) -> Dict[str, Any]: + """Expanded per-skill / per-toolset cost listing for ``/context all``. + + Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656): + per-skill index-line bytes parsed from the live ```` + block, and per-toolset schema bytes attributed via the tool registry's + canonical tool→toolset map. Byte figures are converted to the same + chars/4 token heuristic the categories above use. + """ + from hermes_cli.prompt_size import ( + _compute_skills_breakdown, + _compute_toolsets_breakdown, + ) + from agent.system_prompt import build_system_prompt_parts + + parts = build_system_prompt_parts(agent) + stable = parts.get("stable", "") or "" + skills_match = _SKILLS_BLOCK_RE.search(stable) + skills_block = skills_match.group(0) if skills_match else "" + + skills: List[Dict[str, Any]] = [] + if skills_block: + for entry in _compute_skills_breakdown(skills_block): + skills.append({ + "name": entry.get("name", ""), + "index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0, + "skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")), + }) + + toolsets: List[Dict[str, Any]] = [] + tools = list(getattr(agent, "tools", None) or []) + if tools: + for group in _compute_toolsets_breakdown(tools): + toolsets.append({ + "toolset": group.get("toolset", ""), + "tool_count": int(group.get("tool_count", 0) or 0), + "schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0, + }) + + return {"skills": skills, "toolsets": toolsets} + + +def render_context_grid(payload: Dict[str, Any]) -> List[str]: + """Render the payload as a Claude Code-style glyph block grid. + + 100 cells (5×20), each one percent of the model context window. Categories + fill in declaration order; the remainder renders as free space. + """ + context_max = int(payload.get("context_max") or 0) + categories = payload.get("categories") or [] + total_cells = _GRID_COLUMNS * _GRID_ROWS + + cells: List[str] = [] + if context_max > 0: + for cat in categories: + tokens = int(cat.get("tokens") or 0) + n = round(tokens / context_max * total_cells) + if tokens > 0 and n == 0: + n = 1 # never render a nonzero category as invisible + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + cells.extend([glyph] * n) + cells = cells[:total_cells] + cells.extend([_FREE_GLYPH] * (total_cells - len(cells))) + + return [ + " ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS]) + for row in range(_GRID_ROWS) + ] + + +def render_context_category_lines(payload: Dict[str, Any]) -> List[str]: + """Render the 'Estimated usage by category' table as plain-text lines.""" + categories = payload.get("categories") or [] + context_max = int(payload.get("context_max") or 0) + estimated_total = int(payload.get("estimated_total") or 0) + denom = context_max or estimated_total + + lines = ["Estimated usage by category"] + if not categories: + lines.append(" (no data yet — send a message first)") + return lines + + width = max(len(str(cat.get("label") or "")) for cat in categories) + width = max(width, len("Free space")) + for cat in categories: + tokens = int(cat.get("tokens") or 0) + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + pct = tokens / denom * 100 if denom else 0.0 + label = str(cat.get("label") or cat.get("id") or "") + lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%") + if context_max > 0: + free = max(0, context_max - estimated_total) + pct = free / context_max * 100 + lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%") + return lines + + +def render_context_details_lines(details: Dict[str, Any]) -> List[str]: + """Render the expanded ``/context all`` per-skill / per-toolset tables.""" + lines: List[str] = [] + + toolsets = details.get("toolsets") or [] + if toolsets: + lines.append("Toolsets by schema cost (largest first)") + for group in toolsets[:_DETAILS_TABLE_LIMIT]: + lines.append( + f" {group['toolset']:<24} {group['tool_count']:>3} tools" + f" {group['schema_tokens']:>8,} tokens" + ) + remaining = len(toolsets) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + skills = details.get("skills") or [] + if skills: + if lines: + lines.append("") + lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)") + for entry in skills[:_DETAILS_TABLE_LIMIT]: + name = str(entry.get("name") or "") + if len(name) > 28: + name = name[:27] + "…" + md = entry.get("skill_md_tokens") + md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}" + lines.append( + f" {name:<28} index {entry['index_tokens']:>6,}" + f" SKILL.md {md_str} tokens" + ) + remaining = len(skills) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + return lines + + +def render_context_breakdown_lines( + payload: Dict[str, Any], + *, + details: Optional[Dict[str, Any]] = None, + grid: bool = True, +) -> List[str]: + """Render the full /context view as plain-text lines. + + ``grid=True`` (CLI) prepends the glyph block grid; the gateway passes + ``grid=False`` and keeps its own gauge. ``details`` (from + :func:`compute_context_details`) appends the expanded listings. + """ + lines: List[str] = [] + if grid: + lines.extend(render_context_grid(payload)) + lines.append("") + lines.extend(render_context_category_lines(payload)) + + context_max = int(payload.get("context_max") or 0) + context_used = int(payload.get("context_used") or 0) + if context_max > 0: + pct = int(payload.get("context_percent") or 0) + lines.append("") + lines.append( + f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)" + ) + + if details is not None: + detail_lines = render_context_details_lines(details) + if detail_lines: + lines.append("") + lines.extend(detail_lines) + else: + lines.append("") + lines.append("Use /context all for per-skill and per-toolset costs.") + return lines diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 65684ced454..73fa1e36f21 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -90,9 +90,6 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool: HISTORICAL_TASK_HEADING = "## Historical Task Snapshot" -HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State" -HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks" -HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work" SUMMARY_PREFIX = ( @@ -107,9 +104,7 @@ SUMMARY_PREFIX = ( "Topic overlap with the summary does NOT mean you should resume its " "task: even on similar topics, the latest user message WINS. Treat ONLY " "the latest message as the active task and discard stale items from " - f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / " - f"'{HISTORICAL_PENDING_ASKS_HEADING}' / " - f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or " + f"'{HISTORICAL_TASK_HEADING}' entirely — do not 'wrap up' or " "'finish' work described there unless the latest message explicitly " "asks for it. " "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " @@ -219,8 +214,45 @@ _MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW] # stale directive it carried (e.g. "resume exactly from Active Task") survives # embedded in the body and keeps hijacking replies. Keep newest-first; entries # are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes. +# NEVER mutate or reorder an existing entry — each one is the exact wire text a +# shipped build persisted, so editing it silently un-normalizes every summary +# written by that build generation; prepend only. tests/agent/ +# test_summary_prefix_semantics.py byte-pins every entry to enforce this. _HISTORICAL_SUMMARY_PREFIXES = ( - # Jul 2026 (#65848 class): identical to the current prefix except it + # Pre-#69619: identical to the current prefix except the stale-item + # discard clause named all four historical headings (the three + # section headers removed by #69619 were still in the template). + # Summaries persisted by builds immediately before #69619 carry this + # exact text and must remain detectable/strippable on resume. + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted " + "into the summary below. This is a handoff from a previous context " + "window — treat it as background reference, NOT as active instructions. " + "Do NOT answer questions or fulfill requests mentioned in this summary; " + "they were already addressed. " + "Respond ONLY to the latest user message that appears AFTER this " + "summary — that message is the single source of truth for what to do " + "right now. " + "Topic overlap with the summary does NOT mean you should resume its " + "task: even on similar topics, the latest user message WINS. Treat ONLY " + "the latest message as the active task and discard stale items from " + "'## Historical Task Snapshot' / '## Historical In-Progress State' / " + "'## Historical Pending User Asks' / " + "'## Historical Remaining Work' entirely — do not 'wrap up' or " + "'finish' work described there unless the latest message explicitly " + "asks for it. " + "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " + "back', 'just verify', 'don't do that anymore', 'never mind', a new " + "topic) must immediately end any in-flight work described in the " + "summary; do not re-surface it in later turns. " + "IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system " + "prompt is ALWAYS authoritative and active — never ignore or deprioritize " + "memory content due to this compaction note. " + "None of the above restricts HOW you work: your tools remain fully " + "active — keep calling them normally for the active task (edit files, " + "run commands, search) instead of merely narrating what you would do. " + "The current session state (files, config, etc.) may reflect work " + "described here — avoid repeating it:", + # Jul 2026 (#65848 class): identical to the pre-#69619 prefix except it # lacked the explicit "tools remain fully active" clause — the strong # REFERENCE ONLY framing bled into general tool-use suppression # (observed: 7 consecutive narration-only turns immediately after a @@ -236,9 +268,9 @@ _HISTORICAL_SUMMARY_PREFIXES = ( "Topic overlap with the summary does NOT mean you should resume its " "task: even on similar topics, the latest user message WINS. Treat ONLY " "the latest message as the active task and discard stale items from " - f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / " - f"'{HISTORICAL_PENDING_ASKS_HEADING}' / " - f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or " + "'## Historical Task Snapshot' / '## Historical In-Progress State' / " + "'## Historical Pending User Asks' / " + "'## Historical Remaining Work' entirely — do not 'wrap up' or " "'finish' work described there unless the latest message explicitly " "asks for it. " "Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll " @@ -1213,6 +1245,7 @@ class ContextCompressor(ContextEngine): self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._anti_thrash_recovery_deadline = 0.0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False self._last_compression_made_progress = False @@ -1350,6 +1383,7 @@ class ContextCompressor(ContextEngine): self._last_aux_model_failure_model = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._anti_thrash_recovery_deadline = 0.0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False self._last_compression_made_progress = False @@ -1376,6 +1410,7 @@ class ContextCompressor(ContextEngine): self._consecutive_timeout_failures = 0 self._fallback_compression_streak = 0 self._ineffective_compression_count = 0 + self._anti_thrash_recovery_deadline = 0.0 self.get_active_compression_failure_cooldown() self._load_fallback_compression_streak() self._load_ineffective_compression_count() @@ -1747,6 +1782,15 @@ class ContextCompressor(ContextEngine): # rationale as the gpt-5.5/Codex 85% autoraise. _MIN_CTX_TRIGGER_RATIO = 0.85 + # Anti-thrash recovery window (#14694): once the ineffective/fallback + # breaker trips, automatic compaction stays blocked for this long, then + # ONE probe attempt is allowed (counters drop to 1 strike, so another + # ineffective pass re-trips immediately). Long enough that a genuinely + # incompressible session isn't compacting in a loop; short enough that a + # session which has since grown real compressible material recovers well + # before it rides into the provider's hard context limit. + _ANTI_THRASH_RECOVERY_SECONDS = 300.0 + @staticmethod def _coerce_max_tokens(value: Any) -> int | None: """Normalize a max_tokens value to a positive int or None. @@ -2016,6 +2060,12 @@ class ContextCompressor(ContextEngine): # Anti-thrashing: track whether last compression was effective self._last_compression_savings_pct: float = 100.0 self._ineffective_compression_count: int = 0 + # Monotonic deadline after which a tripped anti-thrash guard grants + # one probation probe (#14694). 0.0 = clock not armed. Armed lazily on + # the first blocked evaluation; deliberately NOT durable, so a process + # restart with a persisted tripped counter (#69872) waits a full fresh + # window before probing (#54923: restart must never disarm a guard). + self._anti_thrash_recovery_deadline: float = 0.0 # Consecutive completed deterministic-fallback boundaries. Unlike the # real-usage effectiveness counter, ordinary fitting responses must not # reset this breaker; only a healthy completed summary does. @@ -2306,21 +2356,66 @@ class ContextCompressor(ContextEngine): _cooldown_remaining, ) return True - # Anti-thrashing: back off if recent compressions were ineffective + # Anti-thrashing: back off if recent compressions were ineffective. + # The back-off must not be permanent (#14694): the tripped state was + # judged against the transcript as it existed THEN (e.g. a middle + # region too small to matter), but the conversation keeps growing and + # can accumulate plenty of compressible material later. Without a + # recovery path the session never auto-compacts again and rides into + # the provider's hard context limit. Recovery is a probation probe: + # after _ANTI_THRASH_RECOVERY_SECONDS of continuous block, allow ONE + # attempt by dropping the tripped counter(s) to 1 strike (persisted, + # so sibling agents on the same session row unblock too). If the probe + # is ineffective again the very next verdict re-trips the guard, so + # the worst case in the truly-incompressible state is one compaction + # attempt per recovery window — bounded, not thrash. + # + # The clock is armed lazily on the first BLOCKED evaluation rather + # than persisted at trip time: a fresh process that loads a durable + # tripped counter (#69872) therefore starts a full window blocked, + # preserving the restart-must-not-disarm contract (#54923). if ( self._ineffective_compression_count >= 2 or self._fallback_compression_streak >= 2 ): + _now = time.monotonic() + if self._anti_thrash_recovery_deadline <= 0.0: + self._anti_thrash_recovery_deadline = ( + _now + self._ANTI_THRASH_RECOVERY_SECONDS + ) + elif _now >= self._anti_thrash_recovery_deadline: + self._anti_thrash_recovery_deadline = 0.0 + if self._ineffective_compression_count >= 2: + self._record_ineffective_compression_verdict(1) + if self._fallback_compression_streak >= 2: + self._fallback_compression_streak = 1 + self._persist_fallback_compression_streak() + if not self.quiet_mode: + logger.info( + "Anti-thrashing recovery: %.0fs elapsed since the " + "guard tripped — allowing one compaction probe " + "(ineffective=%d fallback=%d).", + self._ANTI_THRASH_RECOVERY_SECONDS, + self._ineffective_compression_count, + self._fallback_compression_streak, + ) + return False if not self.quiet_mode: logger.warning( "Compression skipped — repeated compaction attempts did not " "restore healthy context. ineffective=%d fallback=%d. " - "Consider /new to start fresh, or /compress for " - "focused compression.", + "Auto-compaction will retry once in %.0fs. Consider /new " + "to start fresh, or /compress for focused " + "compression.", self._ineffective_compression_count, self._fallback_compression_streak, + max(0.0, self._anti_thrash_recovery_deadline - _now), ) return True + # Guard not tripped (counters were cleared by an effective compaction + # or a fitting real-usage reading) — disarm any pending recovery clock + # so a LATER trip starts its own full window. + self._anti_thrash_recovery_deadline = 0.0 return False # ------------------------------------------------------------------ @@ -2968,12 +3063,6 @@ Recovered from a deterministic fallback because the LLM context summarizer was u ## Active State Unknown from deterministic fallback. Inspect current repository/session state if needed. -{HISTORICAL_IN_PROGRESS_HEADING} -Unknown from deterministic fallback — the latest user ask is recorded once under -"{HISTORICAL_TASK_HEADING}" above as historical context only. Do NOT treat it as an -unfulfilled instruction to re-answer; verify current state and continue from the -protected recent messages after this summary. - ## Blocked {_bullets(blockers, limit=5)} @@ -2983,17 +3072,9 @@ None recoverable from deterministic fallback. ## Resolved Questions None recoverable from deterministic fallback. -{HISTORICAL_PENDING_ASKS_HEADING} -None recoverable from deterministic fallback. (The latest user ask is preserved once -under "{HISTORICAL_TASK_HEADING}" as historical context — it is NOT necessarily -outstanding.) - ## Relevant Files {_bullets(relevant_files, limit=12)} -{HISTORICAL_REMAINING_WORK_HEADING} -Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims. - ## Last Dropped Turns {_bullets(last_dropped_turns, limit=8)} @@ -3304,9 +3385,6 @@ Be specific with file paths, commands, line numbers, and results.] - Any running processes or servers - Environment details that matter] -{HISTORICAL_IN_PROGRESS_HEADING} -[Work currently underway — what was being done when compaction fired] - ## Blocked [Any blockers, errors, or issues not yet resolved. Include exact error messages.] @@ -3316,15 +3394,9 @@ Be specific with file paths, commands, line numbers, and results.] ## Resolved Questions {_resolved_questions_instructions} -{HISTORICAL_PENDING_ASKS_HEADING} -{_pending_asks_instructions} - ## Relevant Files [Files read, modified, or created — with brief note on each] -{HISTORICAL_REMAINING_WORK_HEADING} -[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.] - ## Critical Context [Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.] diff --git a/agent/context_references.py b/agent/context_references.py index eea16ae52b4..8981aa472f5 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -19,6 +19,7 @@ REFERENCE_PATTERN = re.compile( rf"(?diff|staged)\b|(?Pfile|folder|git|url):(?P{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+))" ) TRAILING_PUNCTUATION = ",.;!?" +_NEEDS_QUOTING = re.compile(r"""[\s()\[\]{}<>"'`]""") _SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh") _SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",) _SENSITIVE_HOME_FILES = ( @@ -60,6 +61,21 @@ class ContextReferenceResult: blocked: bool = False +def format_reference_value(value: str) -> str: + """Quote a reference value so ``REFERENCE_PATTERN`` reads it back whole. + + The unquoted alternative in the pattern is ``\\S+``, so a path containing a + space parses as a truncated ref with the tail left behind as loose text. + Mirrors ``formatRefValue`` in the desktop's directive-text.tsx. + """ + if not _NEEDS_QUOTING.search(value): + return value + for quote in ("`", '"', "'"): + if quote not in value: + return f"{quote}{value}{quote}" + return value + + def parse_context_references(message: str) -> list[ContextReference]: refs: list[ContextReference] = [] if not message: @@ -308,7 +324,7 @@ def _expand_git_reference( ["git", *args], cwd=cwd, capture_output=True, - text=True, + text=True, encoding='utf-8', errors='replace', timeout=30, stdin=subprocess.DEVNULL, **_popen_kwargs, @@ -534,7 +550,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None: ["rg", "--files", str(path.relative_to(cwd))], cwd=cwd, capture_output=True, - text=True, + text=True, encoding='utf-8', errors='replace', timeout=10, stdin=subprocess.DEVNULL, **_popen_kwargs, diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 88a5f4d7ab8..506181f2d39 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -40,7 +40,7 @@ import uuid import threading from datetime import datetime from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from agent.context_engine import ( automatic_compaction_status_message, @@ -221,6 +221,25 @@ class CompressionCommitFence: self._lock = threading.Lock() self._cancelled = False self._commit_started = False + # Forward-progress telemetry: the compression worker touches this + # whenever the streamed summary call produces a token (see + # ContextCompressor._call_summary_llm). Waiters use it to distinguish + # a SLOW-but-alive summary model from a HUNG one, so slow models are + # not killed by a fixed wall-clock deadline while tokens are moving. + self._last_progress = time.monotonic() + + def touch_progress(self) -> None: + """Record forward progress (e.g. a streamed summary token arriving). + + Called from the compression worker thread; read by async waiters via + :meth:`seconds_since_progress`. A bare float store is atomic in + CPython, so no lock is needed. + """ + self._last_progress = time.monotonic() + + def seconds_since_progress(self) -> float: + """Seconds since the worker last reported forward progress.""" + return max(0.0, time.monotonic() - self._last_progress) def cancel_before_commit(self) -> bool: """Cancel a pending commit, or wait for an active commit to finish. @@ -373,6 +392,125 @@ def compression_skipped_due_to_lock(agent: Any) -> bool: return _sig is True or isinstance(_sig, str) +def _adopt_live_compression_child( + agent: Any, + session_db: Any, + parent_session_id: str, +) -> Optional[List[Dict[str, Any]]]: + """Move a stale compression contender onto the unique durable child. + + Resolve and load first, then mutate the live agent. This ordering keeps the + stale contender fail-closed when lineage is ambiguous or the compacted + handoff cannot be read. + """ + finder = getattr(type(session_db), "find_live_compression_child", None) + loader = getattr(type(session_db), "get_messages_as_conversation", None) + if not callable(finder) or not callable(loader): + return None + child = finder(session_db, parent_session_id) + if not child or not child.get("id"): + return None + child_session_id = str(child["id"]) + recovered = loader(session_db, child_session_id) + if not isinstance(recovered, list) or not recovered: + return None + # Revalidate after loading: the child may have rotated or a competing + # continuation may have appeared between the two DB reads. + confirmed = finder(session_db, parent_session_id) + if not confirmed or str(confirmed.get("id") or "") != child_session_id: + return None + + agent.session_id = child_session_id + try: + from gateway.session_context import set_current_session_id + + set_current_session_id(child_session_id) + except Exception: + os.environ["HERMES_SESSION_ID"] = child_session_id + try: + from hermes_logging import set_session_context + + set_session_context(child_session_id) + except Exception: + pass + + agent._session_db_created = True + if child.get("system_prompt"): + agent._cached_system_prompt = child["system_prompt"] + agent._last_flushed_db_idx = len(recovered) + agent._flushed_db_message_session_id = child_session_id + agent._flushed_db_message_ids = { + id(message) for message in recovered if isinstance(message, dict) + } + + on_session_start = getattr(agent.context_compressor, "on_session_start", None) + if callable(on_session_start): + try: + on_session_start( + child_session_id, + boundary_reason="compression", + old_session_id=parent_session_id, + session_db=session_db, + platform=getattr(agent, "platform", None) or "cli", + conversation_id=getattr(agent, "_gateway_session_key", None), + ) + except Exception as exc: + logger.debug("context engine compression-child adoption failed: %s", exc) + else: + bind_state = getattr(agent.context_compressor, "bind_session_state", None) + if callable(bind_state): + try: + bind_state(session_db=session_db, session_id=child_session_id) + except Exception: + pass + try: + if agent._memory_manager: + agent._memory_manager.on_session_switch( + child_session_id, + parent_session_id=parent_session_id, + reset=False, + reason="compression", + ) + except Exception as exc: + logger.debug("memory manager compression-child adoption failed: %s", exc) + + return recovered + + +def recover_rotated_compression_session( + agent: Any, +) -> Optional[List[Dict[str, Any]]]: + """Recover a stale live agent before a new turn writes to its old parent.""" + session_db = getattr(agent, "_session_db", None) + session_id = getattr(agent, "session_id", None) or "" + if session_db is None or not session_id: + return None + try: + if not _session_was_rotated_by_compression(session_db, session_id): + return None + # Rotation publication holds the parent compression lease until the + # child handoff is durable. A concurrent turn waits briefly rather than + # observing the intentional parent-ended/child-empty intermediate state. + holder_getter = getattr(session_db, "get_compression_lock_holder", None) + for attempt in range(21): + recovered = _adopt_live_compression_child(agent, session_db, session_id) + if recovered is not None: + return recovered + holder = holder_getter(session_id) if callable(holder_getter) else None + if not holder or attempt == 20: + return None + time.sleep(0.05) + return None + except Exception as exc: + logger.warning( + "compression session recovery failed for session=%s (%s: %s)", + session_id, + type(exc).__name__, + exc, + ) + return None + + def _compression_lock_holder(agent: Any) -> str: """Build a unique holder id for the lock: pid:tid:agent-instance:uuid. @@ -533,7 +671,17 @@ class _CompressionLockLeaseRefresher: # by the TTL the acquirer set — the lock can never be held past its TTL # by a stuck refresher. consecutive_failures = 0 - while not self._stop.wait(self._refresh_interval_seconds): + # First refresh happens immediately, not one interval late. Everything + # between try_acquire() and start() (the rotation-ownership lookup, the + # durable-breaker re-read, thread startup) is charged against the very + # first lease, so on a short TTL under load the lock could already be + # expired — and reclaimable by a competing path — before tick #1. + first = True + while first or not self._stop.wait(self._refresh_interval_seconds): + if first: + first = False + if self._stop.is_set(): + break try: refreshed = self._db.refresh_compression_lock( self._session_id, @@ -892,6 +1040,7 @@ _SYNTHETIC_USER_FLAGS = ( "_empty_recovery_synthetic", "_verification_stop_synthetic", "_pre_verify_synthetic", + "_dropped_toolcall_nudge", ) @@ -1252,8 +1401,11 @@ def compress_context( # parent_session_id child, no # `name #N` renumber, no contextvar/env/logging re-sync, no memory/context- # engine session-switch. The conversation keeps one durable id for life, - # eliminating the session-rotation bug cluster. Default False during rollout. - in_place = bool(getattr(agent, "compression_in_place", False)) + # eliminating the session-rotation bug cluster. Default True (2107b86024). + # Default True matches DEFAULT_CONFIG / #38763. A missing attribute must + # NOT fall back to rotation mode — that re-enables the pre-lease drift + # path and can wedge busy sessions that never set the flag. + in_place = bool(getattr(agent, "compression_in_place", True)) # Set True once the in-place DB write actually completes (the DB block can # raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place. compacted_in_place = False @@ -1460,6 +1612,8 @@ def compress_context( if _lock_released: return _lock_released = True + if getattr(agent, "_active_compression_lock_holder", None) == _lock_holder: + agent._active_compression_lock_holder = None if _lock_refresher is not None: try: _lock_refresher.stop() @@ -1471,6 +1625,9 @@ def compress_context( except Exception as _rel_err: logger.debug("compression lock release failed: %s", _rel_err) + if _lock_holder is not None: + agent._active_compression_lock_holder = _lock_holder + # A delayed contender can acquire the parent lock after the winning path # has released it and completed rotation. The lock serializes work but does # not by itself prove that this stale agent still owns a live parent. @@ -1493,15 +1650,25 @@ def compress_context( _existing_sp = agent._build_system_prompt(system_message) return messages, _existing_sp if _parent_already_rotated: - logger.info( - "compression skipped: session=%s was already rotated by " - "another compression path", - _lock_sid, + recovered_messages = _adopt_live_compression_child( + agent, _lock_db, _lock_sid ) _release_lock() _existing_sp = getattr(agent, "_cached_system_prompt", None) if not _existing_sp: _existing_sp = agent._build_system_prompt(system_message) + if recovered_messages is not None: + logger.warning( + "compression recovery: stale session=%s adopted live child=%s", + _lock_sid, + agent.session_id, + ) + return recovered_messages, _existing_sp + logger.warning( + "compression skipped: session=%s was already rotated by " + "another compression path, but no unique live child could be adopted", + _lock_sid, + ) return messages, _existing_sp # The agent may have been constructed before another path completed an @@ -1535,6 +1702,47 @@ def compress_context( ) _lock_refresher.start() + # The caller's history snapshot predates lease acquisition. Reload the + # durable parent after the lease is live; MORE durable rows than the + # snapshot carries means a frontend/background writer committed a turn + # in that window, so publishing from this snapshot would omit it. + # Deliberately a LENGTH check, not content equality: in-memory + # mutation of past turns is legal (multimodal compression, retry + # history replacement, think-tag stripping), and a content-equality + # abort would permanently wedge compression on such sessions — the + # #14694 failure shape. + # Rotation-only: in-place compaction (archive_and_compact) is + # non-destructive — pre-compaction rows are soft-archived (active=0, + # compacted=1), stay searchable and recoverable, so snapshot/durable + # drift cannot lose data there and must not abort compaction. + # + # When durable DID grow, ADOPT it and continue rather than aborting. + # Aborting returned the stale snapshot unchanged, so busy sessions + # (memory review / shared session_id writers) stayed permanently + # behind the DB: every /compress and auto-compress saw + # "changed before lease acquisition", surfaced as the misleading + # "No changes from compression", and never reclaimed tokens. + if not in_place and _lock_db is not None and _lock_sid: + durable_loader = getattr( + type(_lock_db), "get_messages_as_conversation", None + ) + if callable(durable_loader): + durable_parent = durable_loader(_lock_db, _lock_sid) + if isinstance(durable_parent, list) and len(durable_parent) > len(messages): + logger.info( + "compression: session=%s grew before lease " + "(%d → %d msgs); adopting durable snapshot", + _lock_sid, + len(messages), + len(durable_parent), + ) + messages = durable_parent + _pre_msg_count = len(messages) + # Token estimate was for the stale snapshot; clear it so + # the compressor re-derives from the adopted transcript + # instead of under-counting the newly visible rows. + approx_tokens = 0 + # Notify external memory provider before compression discards context. # The provider's on_pre_compress() may return a string of insights it # wants surfaced inside the compression summary; capture and forward it @@ -1575,7 +1783,29 @@ def compress_context( messages_before_compression = copy.deepcopy(messages) _activity_heartbeat = _CompressionActivityHeartbeat(agent).start() - compressed = compress_fn(messages, **compress_kwargs) + # Publish forward progress to the commit fence while the summary LLM + # call streams. Async hosts (gateway session hygiene) poll + # ``commit_fence.seconds_since_progress()`` to extend their deadline + # while tokens are moving — so a SLOW summary model is only killed + # when it is actually silent, not merely thorough. The hook is + # thread-local and the compress call is synchronous on this thread, + # so it cannot leak into unrelated auxiliary calls. + # + # Fenceless callers (CLI /compress, in-loop auto-compress) install a + # no-op hook: nobody polls their progress, but an ACTIVE hook is what + # switches the summary call onto the streamed path — giving every + # compression path the same two guarantees: the configured timeout + # acts on inactivity (slow models finish), and a byte-trickling + # provider that keeps the connection alive forever is cut off at the + # streamed total ceiling (see _aux_stream_total_ceiling) instead of + # outliving the SDK's inactivity timeout indefinitely. + from agent.auxiliary_client import aux_progress_hook + _progress_hook = ( + commit_fence.touch_progress if commit_fence is not None + else (lambda: None) + ) + with aux_progress_hook(_progress_hook): + compressed = compress_fn(messages, **compress_kwargs) except BaseException as _compress_exc: # ANY exception after lock acquisition — memory hook, capability # inspection, engine lookup, or compress() — must release the lock so @@ -1803,6 +2033,20 @@ def compress_context( ): new_system_prompt = cached_system_prompt agent._cached_system_prompt = cached_system_prompt + # _invalidate_system_prompt() above also cleared the + # cross-session-stable prefix marker boundary. The kept prompt + # is byte-identical, so reconstruct the stable tier and reuse + # it ONLY when the kept prompt still literally starts with it + # (same startswith gate as the restore path); otherwise the + # request layer falls back to the legacy single-breakpoint + # layout with the prompt bytes untouched. + from agent.system_prompt import reconstruct_static_prefix + + reconstruct_static_prefix( + agent, + system_message=system_message, + log_label="compression keep-prompt", + ) else: new_system_prompt = agent._build_system_prompt(system_message) agent._cached_system_prompt = new_system_prompt @@ -1879,40 +2123,13 @@ def compress_context( ) except Exception: pass # best-effort — don't block compression on a flush error - # Propagate title to the new session with auto-numbering - old_title = agent._session_db.get_session_title(agent.session_id) - agent._session_db.end_session(agent.session_id, "compression") - old_session_id = agent.session_id - agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - # Ordering contract: the agent thread updates the contextvar here; - # the gateway propagates to SessionEntry after run_in_executor returns. - try: - from gateway.session_context import set_current_session_id - - set_current_session_id(agent.session_id) - except Exception: - os.environ["HERMES_SESSION_ID"] = agent.session_id - # The gateway/tools session context (ContextVar + env) and the - # logging session context are SEPARATE mechanisms. The call above - # moves the former; the ``[session_id]`` tag on log lines comes - # from ``hermes_logging._session_context`` (set once per turn in - # conversation_loop.py). Without this, post-rotation log lines in - # the same turn keep the STALE old id while the message/DB/gateway - # state carry the new one — breaking log correlation exactly at the - # compaction boundary (see #34089). Guarded separately so a logging - # failure can never regress the routing update above. - try: - from hermes_logging import set_session_context - - set_session_context(agent.session_id) - except Exception: - pass - agent._session_db_created = False + # Publish parent closure + child row + compacted handoff in + # one transaction. No reader can observe a missing/empty child. # The rotation child must stay on the parent's profile — # mirror _ensure_db_session's stamp ("default" persists as - # NULL). _insert_session_row's parent backfill additionally - # COALESCEs from the parent row, covering app-global remote - # sessions whose thread lacks the HERMES_HOME context. + # NULL). publish_compression_child additionally COALESCEs + # from the parent row, covering app-global remote sessions + # whose thread lacks the HERMES_HOME context. try: from hermes_cli.profiles import get_active_profile_name @@ -1921,53 +2138,39 @@ def compress_context( _profile_for_child = None except Exception: _profile_for_child = None + old_title = agent._session_db.get_session_title(agent.session_id) + old_session_id = agent.session_id + new_session_id = ( + f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_" + f"{uuid.uuid4().hex[:6]}" + ) + agent._session_db.publish_compression_child( + parent_session_id=old_session_id, + child_session_id=new_session_id, + source=agent.platform + or os.environ.get("HERMES_SESSION_SOURCE", "cli"), + model=agent.model, + model_config=agent._session_init_model_config, + system_prompt=new_system_prompt, + messages=compressed, + cwd=getattr(agent, "working_directory", None), + profile_name=_profile_for_child, + compression_lock_holder=_lock_holder, + require_compression_lease=_lock_holder is not None, + ) + agent.session_id = new_session_id try: - agent._session_db.create_session( - session_id=agent.session_id, - source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), - model=agent.model, - model_config=agent._session_init_model_config, - parent_session_id=old_session_id, - profile_name=_profile_for_child, - ) - except Exception as _cs_err: - # The child row could not be created (e.g. FK constraint, - # contended write). Previously the outer handler simply - # warned and let the agent continue on the NEW id — which - # has no row in state.db, producing an orphan: the parent - # is ended, the child is never indexed, and every - # subsequent message is attributed to a session that - # doesn't exist (#33906/#33907). Roll the live id back to - # the parent so the conversation stays attached to a real, - # indexed session instead of a phantom. - logger.warning( - "Compression child session create failed (%s) — " - "rolling back to parent session %s to avoid an orphan.", - _cs_err, old_session_id, - ) - agent.session_id = old_session_id - try: - from gateway.session_context import set_current_session_id - set_current_session_id(agent.session_id) - except Exception: - os.environ["HERMES_SESSION_ID"] = agent.session_id - try: - from hermes_logging import set_session_context - set_session_context(agent.session_id) - except Exception: - pass - # Re-open the parent: it was ended above, but we're - # continuing on it, so it must not stay closed. - try: - agent._session_db.reopen_session(old_session_id) - except Exception: - pass - old_session_id = None # no rotation happened - # The parent row already exists in state.db, so mark the - # session as created — _ensure_db_session would otherwise - # retry a (harmless INSERT OR IGNORE) create next turn. - agent._session_db_created = True - raise + from gateway.session_context import set_current_session_id + + set_current_session_id(agent.session_id) + except Exception: + os.environ["HERMES_SESSION_ID"] = agent.session_id + try: + from hermes_logging import set_session_context + + set_session_context(agent.session_id) + except Exception: + pass agent._session_db_created = True split_status = "rotated_committed" # Carry a persistent /goal onto the continuation session. @@ -1987,18 +2190,14 @@ def compress_context( except (ValueError, Exception) as e: logger.debug("Could not propagate title on compression: %s", e) - # Shared post-write steps (both modes target agent.session_id, which - # in-place keeps and rotation has already reassigned to the new id): - # refresh the stored system prompt and reset the flush cursor so the - # next turn re-bases its append diff. - agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) + # In-place mode still updates/replaces the current row here. + # Rotation already published prompt + compacted handoff atomically. if in_place: + agent._session_db.update_system_prompt( + agent.session_id, new_system_prompt + ) agent._last_flushed_db_idx = 0 else: - # A headless turn can be killed before its finalizer. Persist - # the rotated child's compacted handoff at the boundary so - # the new session is immediately resumable. - agent._session_db.replace_messages(agent.session_id, compressed) agent._last_flushed_db_idx = len(compressed) agent._flushed_db_message_session_id = agent.session_id agent._flushed_db_message_ids = { @@ -2008,7 +2207,22 @@ def compress_context( } _session_commit_succeeded = True except Exception as e: - split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed" + if ( + not in_place + and locals().get("old_session_id") + and agent.session_id == old_session_id + ): + # Atomic publication failed (including lease loss): keep the + # parent live and discard the stale compacted snapshot. + old_session_id = None + messages[:] = copy.deepcopy(messages_before_compression) + compressed = messages + _compression_made_progress = False + split_status = ( + "aborted" + if locals().get("old_session_id") is None and not in_place + else "failed_not_indexed" + ) # If the rotation rolled back to the parent (orphan-avoidance # above), agent.session_id is the still-indexed parent and # old_session_id was cleared — so this is recovery, not an diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a234213e32c..6b5d4d6ad97 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -48,6 +48,7 @@ from agent.turn_context import ( reanchor_current_turn_user_idx, ) from agent.turn_retry_state import TurnRetryState +from agent.runtime_cwd import resolve_agent_cwd from agent.message_sanitization import ( close_interrupted_tool_sequence, _repair_tool_call_arguments, @@ -71,7 +72,10 @@ from agent.model_metadata import ( save_context_length, ) from agent.process_bootstrap import _install_safe_stdio -from agent.prompt_caching import apply_anthropic_cache_control +from agent.prompt_caching import ( + apply_anthropic_cache_control, + strip_anthropic_cache_control, +) from agent.retry_utils import ( adaptive_rate_limit_backoff, is_zai_coding_overload_error, @@ -436,6 +440,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) # Continuing session — reuse the exact system prompt from the # previous turn so the Anthropic cache prefix matches. agent._cached_system_prompt = stored_prompt + # Reconstruct the cross-session-stable prefix for the early cache + # breakpoint. The static prefix is not persisted (only the full + # prompt is), so gateway surfaces that build a fresh AIAgent per + # turn would otherwise lose the two-block system layout after the + # first turn — flip-flopping the wire shape mid-conversation and + # silently degrading to the legacy single-breakpoint layout. + # + # ``reconstruct_static_prefix`` gates on ``_use_prompt_caching`` (so + # non-Anthropic routes skip the rebuild), applies the startswith + # safety gate (stored prompt bytes are never rewritten), and + # fails open to the legacy cache layout. + from agent.system_prompt import reconstruct_static_prefix + + reconstruct_static_prefix(agent, system_message=system_message) return if stored_prompt: stored_state = "stale_runtime" @@ -508,9 +526,17 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: - """Return False when the persisted Model/Provider lines are stale.""" + """Return False when the persisted runtime-identity lines are stale.""" def line_value(label: str) -> str: + """Last matching line wins. + + Safe ONLY for fields emitted in the volatile tier at the very END of + the prompt (Model / Provider / Platform). User-supplied project + context (AGENTS.md / CLAUDE.md / .cursorrules) is embedded in the + middle context tier, so a last-match scan lets project prose shadow + any field emitted EARLIER — see ``host_info_value``. + """ prefix = f"{label}:" value = "" for line in prompt.splitlines(): @@ -518,6 +544,32 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: value = line[len(prefix):].strip() return value + def host_info_value(label: str) -> str: + """Read a field from the prompt's own host-info block. + + The host-info block (``build_environment_hints``) sits in the STABLE + tier, ahead of the embedded project context files. A bare scan of the + whole prompt would therefore match a user's ``AGENTS.md`` that merely + contains a line starting with the same label, comparing runtime state + against project prose. That mismatch never clears, so the check would + reject the stored prompt on EVERY turn — rebuilding the system prompt + each message and destroying the prefix cache for the whole session, + which is far worse than the staleness this function guards against. + + Anchor on the ``User home directory:`` line that immediately precedes + the working-directory line in that block, and take the FIRST such + occurrence, so only Hermes' own emitted block can satisfy the read. + """ + prefix = f"{label}:" + lines = prompt.splitlines() + for idx, line in enumerate(lines): + if not line.startswith("User home directory:"): + continue + for candidate in lines[idx + 1: idx + 4]: + if candidate.startswith(prefix): + return candidate[len(prefix):].strip() + return "" + stored_model = line_value("Model") current_model = str(getattr(agent, "model", "") or "").strip() if stored_model and current_model and stored_model != current_model: @@ -528,6 +580,24 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: if stored_provider and current_provider and stored_provider != current_provider: return False + # Detect cwd drift: if the stored prompt was built in a different working + # directory, reuse would silently inject a stale path into the prefix cache. + # Compare against resolve_agent_cwd() — the SAME resolver used to build the + # prompt — so gateway/TUI sessions that set TERMINAL_CWD are not falsely + # rejected (they would always differ from the launch dir's os.getcwd()). + stored_cwd = host_info_value("Current working directory") + if stored_cwd: + if stored_cwd != str(resolve_agent_cwd()): + return False + + # Detect runtime-surface drift: the stored prompt records which platform it + # was built for (e.g. "desktop" vs "cli"). Reusing a desktop-built prompt on + # a terminal session (or vice versa) would inject the wrong runtime hints. + stored_platform = line_value("Platform") + current_platform = str(getattr(agent, "platform", "") or "").strip() + if stored_platform and current_platform and stored_platform != current_platform: + return False + return True @@ -690,6 +760,41 @@ def _compression_deferred_result( } +def _rewrite_system_content_blocks(system_message: dict, effective: str) -> bool: + """Rewrite a cache-decorated system message in place, keeping its blocks. + + ``apply_anthropic_cache_control`` runs once per call block, *before* the + retry loop, and splits the system prompt into ``[static prefix, volatile + tail]`` text blocks carrying the cache_control breakpoints. Assigning a bare + string over that list drops both breakpoints, so the failover retry ships + the whole system prompt uncached and re-bills it in full. + + ``rewrite_prompt_model_identity`` only touches the LAST ``Model:`` / + ``Provider:`` lines, and those live in the volatile tail — so the static + prefix stays byte-identical and its cache entry keeps matching. Returns + False when the shape is not one we can safely patch, so the caller falls + back to the plain-string assignment. + """ + content = system_message.get("content") + if not isinstance(content, list) or not content: + return False + if not all( + isinstance(part, dict) and part.get("type") == "text" for part in content + ): + return False + if len(content) == 1: + content[0]["text"] = effective + return True + if len(content) == 2: + head = content[0].get("text") or "" + if head and effective.startswith(head): + tail = effective[len(head):] + if tail: + content[1]["text"] = tail + return True + return False + + def _sync_failover_system_message(agent, api_messages, active_system_prompt): """Refresh the in-flight system message after a provider failover. @@ -712,10 +817,109 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt): effective = sp if agent.ephemeral_system_prompt: effective = (effective + "\n\n" + agent.ephemeral_system_prompt).strip() - api_messages[0]["content"] = effective + if not _rewrite_system_content_blocks(api_messages[0], effective): + api_messages[0]["content"] = effective return sp +def _ensure_cached_system_prompt_static(agent, system_message=None) -> None: + """Rebuild ``_cached_system_prompt_static`` when caching becomes active. + + Sessions restored under a cache-off primary skip the static-prefix rebuild + (gated on ``_use_prompt_caching`` at restore time). A later failover to a + cache-on provider would otherwise redecorate with ``static_system_prefix= + None`` and silently fall back to the legacy system-plus-3 layout (#72626). + + Thin wrapper over :func:`agent.system_prompt.reconstruct_static_prefix`, + which memoizes failed rebuilds so this stays cheap on the retry-loop hot + path (it runs at the top of every attempt). + """ + from agent.system_prompt import reconstruct_static_prefix + + reconstruct_static_prefix( + agent, system_message=system_message, log_label="failover redecoration" + ) + + +def _peel_moa_guidance( + messages: List[Dict[str, Any]], + guidance: Any, +) -> List[Dict[str, Any]]: + """Remove MoA reference guidance previously attached by ``_attach_reference_guidance``. + + Thin wrapper over :func:`agent.moa_loop.peel_reference_guidance` (kept + adjacent to the attach so the forward/inverse shapes evolve together). + Lazy import mirrors the module's other moa_loop touchpoints. + """ + from agent.moa_loop import peel_reference_guidance + + return peel_reference_guidance(messages, guidance) + + +def _redecorate_prompt_cache_for_provider( + agent, + api_messages: List[Dict[str, Any]], + *, + system_message=None, + moa_prepared: Optional[Dict[str, Any]] = None, +) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Strip and re-apply cache_control for the *current* provider policy. + + Decoration runs once per call block before the retry loop for the primary + provider. ``try_activate_fallback`` refreshes ``_use_prompt_caching`` / + ``_use_native_cache_layout`` but the nine failover ``continue`` paths reused + the old ``api_messages`` (#72626). Mirror ``_reapply_reasoning_echo_for_provider`` + by reshaping at the top of each retry attempt. + + The source list is the mutated in-flight request (image shrink / ASCII / + reasoning_details recoveries already applied) — never a pristine + pre-decoration snapshot. MoA guidance is peeled, the base is redecorated, + then ``rebase_prepared_request`` re-attaches guidance outside the cached + span. + """ + messages: List[Dict[str, Any]] = [ + dict(m) if isinstance(m, dict) else m for m in (api_messages or []) + ] + prepared = moa_prepared + guidance = prepared.get("guidance") if isinstance(prepared, dict) else None + if guidance: + messages = _peel_moa_guidance(messages, guidance) + + strip_anthropic_cache_control(messages) + + # Direct attribute access matches the call-block decoration site — the + # flags are unconditionally initialized on AIAgent, and a getattr + # default here would mask a real init bug as silent cache-off. + if agent._use_prompt_caching: + _ensure_cached_system_prompt_static(agent, system_message=system_message) + static = getattr(agent, "_cached_system_prompt_static", None) + messages = apply_anthropic_cache_control( + messages, + cache_ttl=agent._cache_ttl, + native_anthropic=agent._use_native_cache_layout, + static_system_prefix=static if isinstance(static, str) else None, + ) + + if ( + prepared is not None + and getattr(agent, "provider", None) == "moa" + ): + # No `and guidance` here: guidance=None is a real prepared shape + # (all-references-failed / silent degraded policy builds the + # prepared request without attaching guidance), and the MoA facade + # sends prepared["messages"] — not api_kwargs["messages"] — so the + # rebase must refresh the prepared object even when there is no + # guidance to re-attach. rebase_prepared_request handles falsy + # guidance by copying the messages and skipping the attach. + completions = getattr(getattr(agent.client, "chat", None), "completions", None) + rebase = getattr(completions, "rebase_prepared_request", None) + if callable(rebase): + prepared = rebase(prepared, messages) + messages = prepared["messages"] + + return messages, prepared + + def _apply_context_engine_selection( agent: Any, api_messages: List[Dict[str, Any]], @@ -941,6 +1145,9 @@ def run_conversation( # Commentary deduplication spans all provider continuations and tool calls # within one user turn, but must not suppress the same phrase next turn. agent._delivered_interim_texts = set() + # A configured SessionDB append failure halts only the affected turn. A + # cached gateway agent must recover on the next message if storage did. + agent._incremental_persistence_failed = False # Main conversation loop counters (pure locals consumed by the loop below). api_call_count = 0 @@ -1278,9 +1485,9 @@ def run_conversation( # # Hermes invariant: the system prompt is built ONCE per session # (cached on ``_cached_system_prompt``) and replayed verbatim on - # every turn. We send it as a single content string so the - # bytes are byte-stable across turns and upstream prompt caches - # stay warm. + # every turn. ``apply_anthropic_cache_control`` may split its stable + # prefix into content blocks on the wire, but the stored string and + # its byte-stability remain unchanged. effective_system = active_system_prompt or "" if agent.ephemeral_system_prompt: effective_system = (effective_system + "\n\n" + agent.ephemeral_system_prompt).strip() @@ -1365,19 +1572,6 @@ def run_conversation( logger=request_logger, ) - # Apply Anthropic prompt caching for Claude models on native - # Anthropic, OpenRouter, and third-party Anthropic-compatible - # gateways. Auto-detected: if ``_use_prompt_caching`` is set, - # inject cache_control breakpoints (system + last 3 messages) - # to reduce input token costs by ~75% on multi-turn - # conversations. - if agent._use_prompt_caching: - api_messages = apply_anthropic_cache_control( - api_messages, - cache_ttl=agent._cache_ttl, - native_anthropic=agent._use_native_cache_layout, - ) - # Safety net: strip orphaned tool results / add stubs for missing # results before sending to the API. Runs unconditionally — not # gated on context_compressor — so orphans from session loading or @@ -1436,6 +1630,39 @@ def run_conversation( # the OpenAI SDK. Sanitizing here prevents the 3-retry cycle. _sanitize_messages_surrogates(api_messages) + # Apply Anthropic prompt caching for Claude models on native + # Anthropic, OpenRouter, and third-party Anthropic-compatible + # gateways. Auto-detected: if ``_use_prompt_caching`` is set, inject + # cache_control breakpoints for the static system prefix, full system + # prompt, and last two messages (or the legacy system-and-3 layout + # when no static prefix is available). + # + # Runs LAST, after every message mutation above. Marking earlier + # defeats the prefix stability the mutations exist to create: + # ``_apply_cache_marker`` rewrites ``content`` from a plain string + # into a ``[{"type": "text", ...}]`` block, so the marked messages + # no longer match the ``isinstance(content, str)`` test in the + # whitespace-normalization pass and silently keep their raw + # leading/trailing whitespace. A tool result ending in "\n" is + # therefore sent unstripped while it sits in the last-3 window and + # stripped once it rolls out of it — the same message, different + # bytes on consecutive turns, which breaks the prefix match at + # exactly the point the breakpoints were meant to protect. Marking + # last also keeps breakpoints off messages that the orphan sweep or + # the thinking-only drop is about to remove or merge away. + if agent._use_prompt_caching: + _static_system_prefix = getattr(agent, "_cached_system_prompt_static", None) + api_messages = apply_anthropic_cache_control( + api_messages, + cache_ttl=agent._cache_ttl, + native_anthropic=agent._use_native_cache_layout, + static_system_prefix=( + _static_system_prefix + if isinstance(_static_system_prefix, str) + else None + ), + ) + # Build a persistent-MoA request before measuring compression pressure. # MoA reference output is injected into the aggregator prompt, but it # is deliberately ephemeral and therefore absent from ``messages``. @@ -1766,6 +1993,18 @@ def run_conversation( # unless the active provider needs it) so the fallback request # isn't sent with stale, primary-shaped reasoning fields. agent._reapply_reasoning_echo_for_provider(api_messages) + # Same story for prompt-cache decoration (#72626): try_activate_ + # fallback refreshes the policy flags, but the decorated list + # still carries the primary's breakpoints (or none). Strip and + # re-render for the current provider before building kwargs. + api_messages, _moa_prepared_request = ( + _redecorate_prompt_cache_for_provider( + agent, + api_messages, + system_message=system_message, + moa_prepared=_moa_prepared_request, + ) + ) api_kwargs = agent._build_api_kwargs(api_messages) if agent._force_ascii_payload: _sanitize_structure_non_ascii(api_kwargs) @@ -3455,7 +3694,7 @@ def run_conversation( agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...") continue if ( - agent.api_mode == "chat_completions" + agent.api_mode in ("chat_completions", "anthropic_messages") and agent.provider == "nous" and status_code == 401 and not _retry.nous_auth_retry_attempted @@ -5329,6 +5568,14 @@ def run_conversation( args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200] logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview) + # Uniquify duplicate tool-call ids BEFORE any downstream + # consumer (validation error paths, dispatch, history build, + # Responses item-id derivation). Models that reuse one id for + # different calls in a batch otherwise lose the later call's + # result: the pre-API sanitizer keeps only the first + # call/result pair per id. See _uniquify_tool_call_ids. + agent._uniquify_tool_call_ids(assistant_message.tool_calls) + # Validate tool call names - detect model hallucinations # Repair mismatched tool names before validating for tc in assistant_message.tool_calls: @@ -5617,6 +5864,10 @@ def run_conversation( # flag so it can fire again if the model goes empty on # a LATER tool round. agent._post_tool_empty_retried = False + # A landed tool call means any earlier dropped-tool-call stall + # was recovered — refresh that budget too so it guards each + # stall independently rather than capping the whole run. + agent._dropped_toolcall_retries = 0 previous_msg = messages[-1] if messages else None current_interim_visible = agent._interim_assistant_visible_text(assistant_msg) @@ -5633,8 +5884,6 @@ def run_conversation( and previous_interim_visible == current_interim_visible ) messages.append(assistant_msg) - if not duplicate_previous_interim: - agent._emit_interim_assistant_message(assistant_msg) # Mixed batch: error-result the invalid calls and strip them # from the execution set. The assistant message above keeps @@ -5656,13 +5905,17 @@ def run_conversation( if tc.function.name in agent.valid_tool_names ] + _tool_turn_persisted = None try: # Persist the assistant tool-call turn before any tool # side effects run. If a destructive tool restarts or # terminates Hermes mid-turn, resume logic still sees the # exact tool-call block that already executed. - agent._flush_messages_to_session_db(messages, conversation_history) + _tool_turn_persisted = agent._flush_messages_to_session_db( + messages, conversation_history + ) except Exception as exc: + _tool_turn_persisted = False logger.warning( "Incremental tool-call persistence failed before execution " "(session=%s): %s", @@ -5670,6 +5923,22 @@ def run_conversation( exc, ) + if _tool_turn_persisted is False: + # The canonical append failed. Do not project the row or + # run side-effecting tools from state that exists only in + # this process. Breaking also avoids retrying the same + # unpersisted turn until the iteration budget is exhausted. + _turn_exit_reason = "session_persistence_failed" + final_response = "" + failed = True + break + + # A UI must never observe an assistant/tool-call row that is + # still only an ephemeral in-memory projection. Emit interim + # commentary only after the canonical SessionDB append above. + if not duplicate_previous_interim: + agent._emit_interim_assistant_message(assistant_msg) + # Close any open streaming display (response box, reasoning # box) before tool execution begins. Intermediate turns may # have streamed early content that opened the response box; @@ -5684,6 +5953,15 @@ def run_conversation( agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count) + if getattr(agent, "_incremental_persistence_failed", False): + # A tool result could not be made canonical. Do not send + # the in-memory result back to the model or project any + # later events from this turn. + _turn_exit_reason = "session_persistence_failed" + final_response = "" + failed = True + break + if agent._tool_guardrail_halt_decision is not None: decision = agent._tool_guardrail_halt_decision _turn_exit_reason = "guardrail_halt" @@ -5865,11 +6143,26 @@ def run_conversation( # Save session log incrementally (so progress is visible even if interrupted) agent._session_messages = messages + # Touch activity before continuing so the gateway's + # inactivity monitor never sees a stale timestamp + # between tool completion and the start of the next + # API call. Without this, a tool-call result (which + # takes ~0s to process) followed by slow post-tool + # processing (compression, persist) and a slow + # follow-up API call can exceed the gateway inactivity + # timeout (HERMES_AGENT_TIMEOUT, default 1800s) and the + # gateway kills the session before the next activity + # touch fires (#69559, #69131). + agent._touch_activity(f"tool results posted, continuing iteration #{api_call_count}") # Continue loop for next response continue else: - # No tool calls - this is the final response + # No tool calls - this is the final response. + # (Dropped tool-call recovery — finish_reason=="tool_calls" with + # an empty tool_calls array — is handled at the finalization + # chokepoint below, after final_msg is built, so it catches + # every path that reaches turn finalization, not just this one.) final_response = assistant_message.content or "" # Fix: unmute output when entering the no-tool-call branch @@ -6141,7 +6434,28 @@ def run_conversation( ". No fallback providers configured.") ) - final_response = "(empty)" + # Deliver a labeled reasoning excerpt instead of a bare + # "(empty)" when the model DID think but never produced + # visible text. This is delivery-only: the persisted + # assistant message above keeps the "(empty)" sentinel + # (its replay semantics prevent empty-response loops), + # and raw chain-of-thought is never promoted to a normal + # answer earlier in the ladder — prefill continuation, + # empty-content retries, and provider fallback all run + # first. Only at this terminal, where the alternative is + # returning nothing, is showing the model's own reasoning + # (clearly labeled as such) strictly more useful. + # Idea credit: PR #48795 (@ligl0325). + if reasoning_text: + final_response = ( + "⚠️ The model produced only internal reasoning and " + "no final answer, despite retries" + + (" and fallback" if agent._fallback_chain else "") + + ". Its last reasoning, which may contain the " + "answer:\n\n" + reasoning_preview + ) + else: + final_response = "(empty)" break # Reset retry counter/signature on successful content @@ -6201,6 +6515,64 @@ def run_conversation( final_msg = agent._build_assistant_message(assistant_message, finish_reason) + # ── Dropped tool-call recovery (copilot/Claude) ──────── + # Some providers (observed: claude-opus-4.8 / claude-sonnet-4.5 + # on GitHub Copilot, ~2026-07) return finish_reason="tool_calls" + # while the parsed tool_calls array is empty — the model + # signalled it wanted to act but the payload shipped no call. + # Reaching finalization with that mismatch means the turn is + # about to end with the task unstarted (the narration, which may + # be in content or only in the reasoning field, gets treated as + # the final answer). Re-prompt (bounded to 3 CONSECUTIVE stalls; + # the budget resets after any successful tool round) to make the + # model emit the call instead of exiting. finish_reason="stop" + # text finishes never enter this guard. + if ( + finish_reason == "tool_calls" + and not assistant_message.tool_calls + and getattr(agent, "_dropped_toolcall_retries", 0) < 3 + ): + agent._dropped_toolcall_retries = getattr(agent, "_dropped_toolcall_retries", 0) + 1 + logger.warning( + "finish_reason=tool_calls with empty tool_calls array " + "(narration only) — re-prompting to emit the call " + "(retry %d/3, model=%s provider=%s)", + agent._dropped_toolcall_retries, agent.model, agent.provider, + ) + agent._emit_status( + "↻ Model signaled a tool call but sent none — " + f"re-prompting ({agent._dropped_toolcall_retries}/3)" + ) + # Both halves of the re-prompt pair are ephemeral recovery + # scaffolding (mirrors the empty-response nudge pattern): + # the interim narration-only assistant turn exists solely to + # keep role alternation valid for the nudge, and the nudge + # exists solely to drive the retry. Flag both so the + # persistence layer never writes them to the durable + # transcript and the finalization pop below can strip an + # unanswered tail pair. A recovered (answered) pair stays + # buried mid-list in live memory but is skipped by the + # flush regardless of position. + final_msg["_dropped_toolcall_nudge"] = True + messages.append(final_msg) + messages.append({ + "role": "user", + "content": ( + "Your previous turn indicated a tool call but none was " + "included. Do not narrate a plan or restate intent — issue " + "the actual tool call now to continue the task." + ), + "_dropped_toolcall_nudge": True, + }) + agent._session_messages = messages + final_response = None + continue + + # Reached finalization without the dropped-tool-call mismatch — + # a genuine turn end. Clear the consecutive-stall budget so the + # next turn starts fresh. + agent._dropped_toolcall_retries = 0 + # Pop thinking-only prefill and empty-response retry # scaffolding before appending either a final response or a # verification-stop follow-up. These internal turns are only @@ -6213,6 +6585,7 @@ def run_conversation( messages[-1].get("_thinking_prefill") or messages[-1].get("_empty_recovery_synthetic") or messages[-1].get("_empty_terminal_sentinel") + or messages[-1].get("_dropped_toolcall_nudge") ) ): messages.pop() diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 662facc2dfe..9cbdcd34944 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -512,7 +512,7 @@ class CopilotACPClient: stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, + text=True, encoding='utf-8', errors='replace', bufsize=1, cwd=self._acp_cwd, env=_build_subprocess_env(), @@ -708,7 +708,7 @@ class CopilotACPClient: if block_error: raise PermissionError(block_error) try: - content = path.read_text() + content = path.read_text(encoding="utf-8") except FileNotFoundError: content = "" line = params.get("line") @@ -736,7 +736,7 @@ class CopilotACPClient: if denied: raise PermissionError(denied) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(str(params.get("content") or "")) + path.write_text(str(params.get("content") or ""), encoding="utf-8") response = { "jsonrpc": "2.0", "id": message_id, diff --git a/agent/credential_pool.py b/agent/credential_pool.py index d5d652ad741..08b0c0ea6b9 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -594,6 +594,14 @@ class CredentialPool: # Re-armed to None on every successful selection so a recover→re-exhaust # transition logs promptly instead of being swallowed by a stale window. self._last_no_entries_log_at: Optional[float] = None + # #70401: consecutive mark_exhausted_and_rotate() calls whose supplied + # credential identity matched no pool entry (OAuth wrappers whose + # runtime key rotates, entries pruned by another process, ...). These + # rotations mark nothing exhausted, so without a cap the pool can + # never converge to "no available entries" and the caller's 401 retry + # loop runs unbounded and non-interruptible. Reset whenever a real + # entry is identified or an escape path returns None. + self._unmatched_rotation_streak: int = 0 def has_credentials(self) -> bool: with self._lock: @@ -622,6 +630,28 @@ class CredentialPool: with self._lock: return self._current_unlocked() + def entry_id_for_api_key(self, api_key_hint: Any = None) -> Optional[str]: + """Return the stable id for the runtime credential in use. + + Prefer the current selection when it still supplies ``api_key_hint``. + If the cursor was cleared, fall back to an unambiguous key match. + """ + with self._lock: + current = self._current_unlocked() + if current is not None and ( + api_key_hint is None + or current.runtime_api_key == api_key_hint + ): + return current.id + if api_key_hint is None: + return None + matches = [ + entry + for entry in self._entries + if entry.runtime_api_key == api_key_hint + ] + return matches[0].id if len(matches) == 1 else None + def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None: """Swap an entry in-place by id, preserving sort order.""" for idx, entry in enumerate(self._entries): @@ -1562,7 +1592,13 @@ class CredentialPool: def select(self) -> Optional[PooledCredential]: with self._lock: - return self._select_unlocked() + entry = self._select_unlocked() + if entry is not None: + # A normal (non-recovery) selection starts a fresh episode — + # don't let a leftover unmatched-rotation streak from an old + # failure trip the #70401 bound early next time. + self._unmatched_rotation_streak = 0 + return entry def _available_entries(self, *, clear_expired: bool = False, refresh: bool = False) -> List[PooledCredential]: """Return entries not currently in exhaustion cooldown. @@ -1763,10 +1799,17 @@ class CredentialPool: status_code: Optional[int], error_context: Optional[Dict[str, Any]] = None, api_key_hint: Optional[str] = None, + credential_id: Optional[str] = None, ) -> Optional[PooledCredential]: with self._lock: entry = None - if api_key_hint: + identity_supplied = bool(credential_id or api_key_hint) + if credential_id: + entry = next( + (e for e in self._entries if e.id == credential_id), + None, + ) + if entry is None and api_key_hint: # Prefer the specific entry whose API key matches the one that # actually failed. When this pool was freshly loaded from disk # (another process already rotated), current() is None and @@ -1775,20 +1818,59 @@ class CredentialPool: (e for e in self._entries if e.runtime_api_key == api_key_hint), None, ) - if entry is None: - # The failed key is identifiable but matches no entry - # (rotated away, or a wrapper whose runtime key differs). - # Falling through to current()/_select_unlocked() would - # mark an INNOCENT healthy key exhausted for the full - # cooldown TTL. Don't guess — just hand back a fresh - # selection so the caller can retry. - logger.info( - "credential pool: failed key hint matched no %s entry; " - "rotating without marking any credential exhausted", + if entry is None and identity_supplied: + # The failed credential is identifiable but matches no entry + # (rotated away, or a wrapper whose runtime key differs). + # Falling through to current()/_select_unlocked() would mark an + # innocent healthy key exhausted for the full cooldown TTL. + # + # #70401: this branch must still be BOUNDED. With OAuth-token + # auth the upstream 401's key hint never matches any entry's + # ``runtime_api_key``, so every retry lands here, nothing is + # ever marked exhausted, and the pool can never reach the + # "no available entries" state — the caller retries the same + # dead token forever (~6/sec, starving the event loop so chat + # interrupts are never processed). The single-entry case + # below already escapes; multi-entry pools could still + # ping-pong A→B→A indefinitely without marking anything. + # Cap consecutive no-mark rotations at one full lap of the + # available entries: past that, every candidate has been + # handed back at least once without recovery, so stop + # guessing and surface the error (no cooldown is written for + # anybody — healthy keys stay available for the next turn). + self._unmatched_rotation_streak += 1 + available_count = len(self._available_entries()) + if self._unmatched_rotation_streak > max(available_count, 1): + logger.warning( + "credential pool: failed credential identity matched no " + "%s entry for %d consecutive rotations (pool size %d) — " + "surfacing the error instead of rotating again", self.provider, + self._unmatched_rotation_streak, + available_count, ) + self._unmatched_rotation_streak = 0 self._current_id = None - return self._select_unlocked() + return None + logger.info( + "credential pool: failed credential identity matched no %s " + "entry; rotating without marking any credential exhausted", + self.provider, + ) + self._current_id = None + next_entry = self._select_unlocked() + if next_entry is not None and len(self._available_entries()) == 1: + # A single-entry pool cannot rotate. Returning its only + # entry reports a successful recovery without changing + # the credential, so the caller retries the same 401 + # indefinitely. Let fallback/error propagation proceed. + self._unmatched_rotation_streak = 0 + self._current_id = None + return None + return next_entry + # A real entry was identified — any prior unmatched-rotation + # streak is stale (this mark WILL advance pool state). + self._unmatched_rotation_streak = 0 if entry is None: entry = self._current_unlocked() or self._select_unlocked() if entry is None: @@ -1806,12 +1888,13 @@ class CredentialPool: # disconnects (a ~2.5min hang with no error surfaced to the user). # Mark every entry sharing the failed key so the pool can reach the # "no available entries" state and let the error propagate. - if api_key_hint: + failed_runtime_key = getattr(entry, "runtime_api_key", None) + if identity_supplied and failed_runtime_key: siblings_marked = False for sibling in self._entries: if sibling.id == entry.id: continue - if sibling.runtime_api_key == api_key_hint: + if sibling.runtime_api_key == failed_runtime_key: self._mark_exhausted( sibling, status_code, error_context, persist=False ) @@ -1885,9 +1968,11 @@ class CredentialPool: return self._try_refresh_current_unlocked() def try_refresh_matching( - self, api_key_hint: Optional[str] = None + self, + api_key_hint: Optional[str] = None, + credential_id: Optional[str] = None, ) -> Optional[PooledCredential]: - """Force-refresh the entry that supplied ``api_key_hint``. + """Force-refresh the entry that supplied the failed request. Direct provider integrations may reload the pool after a request has already failed, so they cannot rely on ``current_id`` identifying the @@ -1897,17 +1982,29 @@ class CredentialPool: """ with self._lock: entry = None - if api_key_hint: + if credential_id: entry = next( ( candidate for candidate in self._entries - if candidate.runtime_api_key == api_key_hint + if candidate.id == credential_id ), None, ) - else: - entry = self._current_unlocked() or self._select_unlocked(refresh=False) + if entry is None: + if api_key_hint: + entry = next( + ( + candidate + for candidate in self._entries + if candidate.runtime_api_key == api_key_hint + ), + None, + ) + else: + entry = self._current_unlocked() or self._select_unlocked( + refresh=False + ) if entry is None: return None self._current_id = entry.id diff --git a/agent/credential_sources.py b/agent/credential_sources.py index 18f0823ba84..32cd5e01a80 100644 --- a/agent/credential_sources.py +++ b/agent/credential_sources.py @@ -164,7 +164,7 @@ def _remove_env_source(provider: str, removed) -> RemovalResult: if env_path.exists(): env_in_dotenv = any( line.strip().startswith(f"{env_var}=") - for line in env_path.read_text(errors="replace").splitlines() + for line in env_path.read_text(errors="replace", encoding="utf-8").splitlines() ) except OSError: pass diff --git a/agent/curator.py b/agent/curator.py index dc908fc3593..975ed102de0 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -325,7 +325,7 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0} - for row in _u.agent_created_report(): + for row in _u.curated_report(): counts["checked"] += 1 name = row["name"] if row.get("pinned"): @@ -1472,15 +1472,16 @@ def _render_report_markdown(p: Dict[str, Any]) -> str: # --------------------------------------------------------------------------- def _render_candidate_list() -> str: - """Human/agent-readable list of agent-created skills with usage stats.""" - rows = skill_usage.agent_created_report() + """Human/agent-readable list of curator-managed skills with usage stats.""" + rows = skill_usage.curated_report() if not rows: - return "No agent-created skills to review." + return "No curator-managed skills to review." cron_referenced = _cron_referenced_skills() - lines = [f"Agent-created skills ({len(rows)}):\n"] + lines = [f"Curator-managed skills ({len(rows)}):\n"] for r in rows: lines.append( f"- {r['name']} " + f"provenance={r.get('provenance', 'agent')} " f"state={r['state']} " f"pinned={'yes' if r.get('pinned') else 'no'} " f"cron={'yes' if r['name'] in cron_referenced else 'no'} " @@ -1533,7 +1534,7 @@ def run_curator_review( if dry_run: # Count candidates without mutating state. try: - report = skill_usage.agent_created_report() + report = skill_usage.curated_report() counts = { "checked": len(report), "marked_stale": 0, @@ -1586,7 +1587,7 @@ def run_curator_review( nonlocal auto_summary # Snapshot skill state BEFORE the LLM pass so the report can diff. try: - before_report = skill_usage.agent_created_report() + before_report = skill_usage.curated_report() except Exception: before_report = [] before_names = {r.get("name") for r in before_report if isinstance(r, dict)} @@ -1612,7 +1613,7 @@ def run_curator_review( state2["last_run_duration_seconds"] = elapsed state2["last_run_summary"] = final_summary try: - after_report = skill_usage.agent_created_report() + after_report = skill_usage.curated_report() except Exception: after_report = [] try: @@ -1699,7 +1700,7 @@ def run_curator_review( try: rename_lines = _build_rename_summary( before_names=before_names, - after_report=skill_usage.agent_created_report(), + after_report=skill_usage.curated_report(), tool_calls=llm_meta.get("tool_calls", []) or [], model_final=llm_meta.get("final", "") or "", ) @@ -1717,7 +1718,7 @@ def run_curator_review( # reporting bug never breaks the curator itself. Report path is # recorded in state so `hermes curator status` can point at it. try: - after_report = skill_usage.agent_created_report() + after_report = skill_usage.curated_report() except Exception: after_report = [] try: diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 33c2f545856..e629e7b7af9 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [ "throttlingexception", "too many concurrent requests", "servicequotaexceededexception", + # Generic throttle prefix — Bedrock (and some proxies) surface throttling + # as "Throttling error: Too many tokens, please wait before trying + # again." Without this entry the message falls through to the + # context-overflow list (which contains "too many tokens") and the retry + # loop compresses a healthy session instead of backing off. Matched + # BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the + # throttle wins. (port of anomalyco/opencode#37848's exclusion guard) + "throttling", ] # Patterns that indicate provider-side overload, NOT a per-credential rate @@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [ "request entity too large", "payload too large", "error code: 413", + # Anthropic's structured 413 error type. Normally arrives with an HTTP + # 413 status (handled by the status path), but aggregators/proxies can + # re-wrap it into a plain message with no status attribute — route it to + # the same compression recovery. (port of anomalyco/opencode#37848) + "request_too_large", + "request exceeds the maximum size", ] # Image-size patterns. Matched against 400 bodies (not 413) because most @@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [ "max input token", "input token", "exceeds the maximum number of input tokens", + # Together/Fireworks-style: "Input length 131393 exceeds the maximum + # allowed input length of 131040 tokens." No other pattern in this list + # matches that wording. (port of anomalyco/opencode#37848) + "maximum allowed input length", ] # Model not found patterns diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b4f6e6386e7..bb53e32b2ce 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = ( ) +def is_standard_key_auth_error( + status: int, error_message: str, reason: str = "" +) -> bool: + """Return True when a Gemini 401 indicates Google rejected the key TYPE. + + Google began rejecting unrestricted legacy "Standard" Google Cloud API + keys on the Gemini API on June 19, 2026, and ALL Standard keys stop + working in September 2026. The rejection surfaces as a misleading 401 + telling the user to supply an OAuth 2 access token ("Request had invalid + authentication credentials. Expected OAuth 2 access token, login cookie + or other valid authentication credential."), optionally carrying + ``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``. + + Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``, + "API key not valid") keeps its existing message. + """ + if status != 401: + return False + if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED": + return True + return "expected oauth 2 access token" in (error_message or "").lower() + + +_STANDARD_KEY_GUIDANCE = ( + "\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. " + "Google began rejecting legacy 'Standard' Google Cloud keys for the " + "Gemini API on June 19, 2026, and all Standard keys stop working in " + "September 2026. Open https://aistudio.google.com/api-keys, check the " + "key's type and status, and create a replacement Gemini API key (or, as " + "a temporary bridge, restrict the Standard key to " + "generativelanguage.googleapis.com). Then update GEMINI_API_KEY / " + "GOOGLE_API_KEY in ~/.hermes/.env and restart your session. " + "Details: https://ai.google.dev/gemini-api/docs/api-key" +) + + class GeminiAPIError(Exception): """Error shape compatible with Hermes retry/error classification.""" @@ -824,6 +860,12 @@ def gemini_http_error( if status == 429 and is_free_tier_quota_error(err_message or body_text): message = message + _FREE_TIER_GUIDANCE + # Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) -> + # Google's raw 401 misleadingly tells the user to use OAuth. Append the + # actual fix (mint a new Gemini API key in AI Studio). + if is_standard_key_auth_error(status, err_message or body_text, reason): + message = message + _STANDARD_KEY_GUIDANCE + return GeminiAPIError( message, code=code, diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index b0985422fbb..665fd79a37e 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from typing import Any, Dict # Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema`` @@ -76,15 +77,31 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: # Gemini's Schema validator requires every ``enum`` entry to be a string, # even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``. - # OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's - # ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``), - # so we only drop the ``enum`` when it would collide with Gemini's rule. - # Keeping ``type: integer`` plus the human-readable description gives the - # model enough guidance; the tool handler still validates the value. + # Preserve those constraints by stringifying scalar values while keeping + # the declared type intact; Gemini uses the strings as schema metadata and + # still emits typed tool arguments at runtime. enum_val = cleaned.get("enum") type_val = cleaned.get("type") if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}: - if any(not isinstance(item, str) for item in enum_val): + stringified = [] + for item in enum_val: + if isinstance(item, str): + value = item + elif isinstance(item, bool): + value = "true" if item else "false" + elif ( + isinstance(item, (int, float)) + and not isinstance(item, bool) + and math.isfinite(item) + ): + value = str(item) + else: + continue + if value not in stringified: + stringified.append(value) + if stringified: + cleaned["enum"] = stringified + else: cleaned.pop("enum", None) # Gemini validates ``required`` strictly against the same node's diff --git a/agent/i18n.py b/agent/i18n.py index b55b8128c92..24f8ab0a023 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -25,7 +25,8 @@ Language resolution order: 3. ``display.language`` from config.yaml 4. ``"en"`` (baseline) -Supported languages: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en. +Supported languages: en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, +pt, ru, hu, ar. Unknown values fall back to en. """ from __future__ import annotations @@ -41,7 +42,7 @@ logger = logging.getLogger(__name__) SUPPORTED_LANGUAGES: tuple[str, ...] = ( "en", "zh", "zh-hant", "ja", "de", "es", "fr", "tr", "uk", - "af", "ko", "it", "ga", "pt", "ru", "hu", + "af", "ko", "it", "ga", "pt", "ru", "hu", "ar", ) DEFAULT_LANGUAGE = "en" @@ -78,6 +79,9 @@ _LANGUAGE_ALIASES: dict[str, str] = { "russian": "ru", "русский": "ru", "ru-ru": "ru", # Hungarian "hungarian": "hu", "magyar": "hu", "hu-hu": "hu", + # Arabic — bare "arabic"/endonym plus the common regional BCP-47 tags. + "arabic": "ar", "العربية": "ar", + "ar-sa": "ar", "ar-eg": "ar", "ar-ae": "ar", "ar-ma": "ar", "ar-dz": "ar", } _catalog_cache: dict[str, dict[str, str]] = {} diff --git a/agent/iteration_budget.py b/agent/iteration_budget.py index 213b97c0226..7d50026c170 100644 --- a/agent/iteration_budget.py +++ b/agent/iteration_budget.py @@ -2,7 +2,7 @@ Extracted from ``run_agent.py``. Each ``AIAgent`` instance (parent or subagent) holds an :class:`IterationBudget`; the parent's cap comes from -``max_iterations`` (default 90), each subagent's cap comes from +``max_iterations`` (default 500), each subagent's cap comes from ``delegation.max_iterations`` (default 50). ``run_agent`` re-exports ``IterationBudget`` so existing @@ -18,7 +18,7 @@ class IterationBudget: """Thread-safe iteration counter for an agent. Each agent (parent or subagent) gets its own ``IterationBudget``. - The parent's budget is capped at ``max_iterations`` (default 90). + The parent's budget is capped at ``max_iterations`` (default 500). Each subagent gets an independent budget capped at ``delegation.max_iterations`` (default 50) — this means total iterations across parent + subagents can exceed the parent's cap. diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 68a9751e7fa..da8f1fd7788 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -267,7 +267,7 @@ def _install_npm( [npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(), @@ -316,7 +316,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]: [go, "install", pkg], check=False, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=600, env=env, stdin=subprocess.DEVNULL, diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 21fae396d9d..173816c8cbd 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -388,10 +388,10 @@ def _maybe_apply_moa_cache_control( Reuses the SAME policy function as the main agent loop (``anthropic_prompt_cache_policy``) resolved against the slot's own - provider/base_url/api_mode/model, and the SAME breakpoint layout - (``apply_anthropic_cache_control``, system_and_3). This keeps advisor and - aggregator calls decorated exactly like an acting agent on that provider - would be — no MoA-specific caching logic to drift. + provider/base_url/api_mode/model and shared marker helper + (``apply_anthropic_cache_control``). MoA has no per-session static prefix, + so it uses the helper's legacy system-and-3 fallback without carrying a + separate caching strategy. Returns the messages unchanged on any resolution error or when the policy says the route doesn't honor markers. @@ -480,10 +480,11 @@ def _run_reference( reserve_output_tokens=max_tokens, context_length_cache=context_length_cache, ) - # Apply the same Anthropic-style prompt-caching decoration the main - # agent loop applies (system_and_3 breakpoints). The advisory view is - # append-only across iterations (new turns append before the trailing - # synthetic marker), so on cache-honoring routes (Claude via + # Apply the Anthropic-style prompt-caching decoration used by the main + # agent loop. This fixed reference prompt has no session-specific + # prefix split, so the helper uses its legacy system-and-3 fallback. + # The advisory view is append-only across iterations (new turns append + # before the trailing synthetic marker), so on cache-honoring routes (Claude via # OpenRouter/native, MiniMax, Qwen/DashScope) iteration N+1's prefix # replays iteration N's cached prefix. Without this, Claude advisors # served ZERO cache reads across an entire benchmark run (measured: @@ -1336,6 +1337,63 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str agg_messages.append({"role": "user", "content": guidance}) +def peel_reference_guidance( + messages: list[dict[str, Any]], + guidance: Any, +) -> list[dict[str, Any]]: + """Remove reference guidance previously attached by ``_attach_reference_guidance``. + + Exact inverse of the three attach shapes above (string merge, trailing + text part, appended user message) — kept adjacent so the two evolve + together; a drifting separator or shape would make the peel silently + no-op and let a cache breakpoint land on the turn-varying guidance + block (the bug class #72626 fixes). + + Used by the failover redecoration chokepoint: redecoration must run on + the base transcript so the last cache breakpoint does not land on the + guidance; callers then rebase via ``rebase_prepared_request``. + + Returns a new list (input list and its messages are not mutated). + """ + if not guidance or not messages: + return messages + guidance_text = str(guidance) + last = messages[-1] + if not isinstance(last, dict) or last.get("role") != "user": + return messages + content = last.get("content") + if content == guidance_text: + # Attach shape (c): guidance was appended as its own user message. + return list(messages[:-1]) + suffix = "\n\n" + guidance_text + if isinstance(content, str) and content.endswith(suffix): + # Attach shape (a): merged into a trailing string user turn. + peeled = dict(last) + peeled["content"] = content[: -len(suffix)] + return [*messages[:-1], peeled] + if isinstance(content, list) and content: + last_part = content[-1] + if isinstance(last_part, dict) and last_part.get("type", "text") == "text": + text = last_part.get("text") or "" + if text == suffix or text == guidance_text: + # Attach shape (b): guidance rode as its own trailing part. + peeled = dict(last) + peeled["content"] = list(content[:-1]) + if not peeled["content"]: + # The guidance part was the only content — mirror the + # string shape (c) and drop the whole message rather + # than leaving an empty-content user turn behind. + return list(messages[:-1]) + return [*messages[:-1], peeled] + if text.endswith(suffix): + new_part = dict(last_part) + new_part["text"] = text[: -len(suffix)] + peeled = dict(last) + peeled["content"] = [*content[:-1], new_part] + return [*messages[:-1], peeled] + return messages + + class MoAChatCompletions: """OpenAI-chat-compatible facade where the aggregator is the acting model.""" diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 288083628e0..296fe0aedca 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -215,6 +215,7 @@ DEFAULT_CONTEXT_LENGTHS = { # OpenRouter-prefixed models resolve via OpenRouter live API or models.dev. "claude-fable-5": 1000000, "claude-fable": 1000000, + "claude-opus-5": 1000000, "claude-sonnet-5": 1000000, "claude-opus-4-8": 1000000, "claude-opus-4.8": 1000000, diff --git a/agent/nous_rate_guard.py b/agent/nous_rate_guard.py index 415d367ca17..0234eef2ea2 100644 --- a/agent/nous_rate_guard.py +++ b/agent/nous_rate_guard.py @@ -117,7 +117,7 @@ def record_nous_rate_limit( # Atomic write: write to temp file + rename fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp") try: - with os.fdopen(fd, "w") as f: + with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(state, f) atomic_replace(tmp_path, path) except Exception: diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index 9a2fdf4ccce..2e606cd377c 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -1,9 +1,11 @@ """Anthropic prompt caching strategy. -Single layout: ``system_and_3``. 4 cache_control breakpoints — system -prompt + last 3 non-system messages, all at the same TTL (5m or 1h). -Reduces input token costs by ~75% on multi-turn conversations within a -single session. +The default layout uses 4 cache_control breakpoints: the static system +prefix, the end of the system prompt, and the last 2 non-system messages. +When a static system prefix is unavailable, it falls back to one system +breakpoint plus the last 3 messages. All markers use the same TTL (5m or 1h). +This preserves intra-session caching while allowing new sessions to reuse the +stable system-prompt prefix. Pure functions -- no class state, no AIAgent dependency. """ @@ -81,15 +83,108 @@ def _build_marker(ttl: str) -> Dict[str, str]: return marker +def _apply_system_cache_markers( + message: dict, + cache_marker: dict, + static_system_prefix: str | None, + *, + native_anthropic: bool, +) -> int: + """Mark the static system prefix and full prompt when they can be split. + + The system prompt remains one stored string. Splitting it only in the + outgoing request keeps session persistence and non-Anthropic transports + unchanged while making the stable prefix independently cacheable. + """ + content = message.get("content") + if ( + isinstance(static_system_prefix, str) + and static_system_prefix + and isinstance(content, str) + and content.startswith(static_system_prefix) + ): + suffix = content[len(static_system_prefix):] + if suffix: + message["content"] = [ + { + "type": "text", + "text": static_system_prefix, + "cache_control": cache_marker, + }, + {"type": "text", "text": suffix, "cache_control": cache_marker}, + ] + return 2 + + _apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic) + return 1 + + +def strip_anthropic_cache_control( + api_messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Remove ``cache_control`` markers and undo decoration-produced list shapes. + + Used before re-applying decoration after a mid-turn provider failover so + the mutated, undecorated shape (image shrink / ASCII cleanup / etc.) is + preserved while markers match the *new* provider's cache policy (#72626). + + Flattening back to a plain string is restricted to the exact shapes + :func:`apply_anthropic_cache_control` produces from string content — + a single ``{"type": "text"}`` part, or the two-part ``[static, volatile]`` + system split — so the ``""``-join is provably byte-exact. Organic + multi-part text (merged user turns, imported transcripts) and parts + carrying extra keys (``citations`` etc.) keep their structure; only + per-part markers are removed. Marker removal is copy-on-write on the + part dicts: content parts may alias the persistent conversation history + (the per-call copy is shallow), and stripping must never rewrite the + stored transcript. + + Mutates the top-level message dicts of ``api_messages`` in place and + returns the same list. + """ + for msg in api_messages: + if not isinstance(msg, dict): + continue + msg.pop("cache_control", None) + content = msg.get("content") + if not isinstance(content, list): + continue + if any(isinstance(part, dict) and "cache_control" in part for part in content): + content = [ + {k: v for k, v in part.items() if k != "cache_control"} + if isinstance(part, dict) and "cache_control" in part + else part + for part in content + ] + msg["content"] = content + decoration_shape = content and all( + isinstance(part, dict) + and part.get("type", "text") == "text" + and isinstance(part.get("text"), str) + and set(part.keys()) <= {"type", "text"} + for part in content + ) and ( + len(content) == 1 + or (msg.get("role") == "system" and len(content) == 2) + ) + if decoration_shape: + msg["content"] = "".join(part["text"] for part in content) + return api_messages + + def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", native_anthropic: bool = False, + static_system_prefix: str | None = None, ) -> List[Dict[str, Any]]: - """Apply system_and_3 caching strategy to messages for Anthropic models. + """Apply Anthropic cache-control markers to API messages. - Places up to 4 cache_control breakpoints: system prompt + last 3 non-system - messages, all at the same TTL. + When ``static_system_prefix`` exactly matches the beginning of a string + system prompt, it receives an early marker and the full system prompt gets + a trailing marker. The remaining two markers target the latest cacheable + non-system messages. Without that prefix, the legacy system-and-3 layout + is retained. Returns: Deep copy of messages with cache_control breakpoints injected. @@ -103,8 +198,12 @@ def apply_anthropic_cache_control( breakpoints_used = 0 if messages[0].get("role") == "system": - _apply_cache_marker(messages[0], marker, native_anthropic=native_anthropic) - breakpoints_used += 1 + breakpoints_used = _apply_system_cache_markers( + messages[0], + marker, + static_system_prefix, + native_anthropic=native_anthropic, + ) remaining = 4 - breakpoints_used non_sys = [ diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py index dae910248d5..277cd018654 100644 --- a/agent/proxy_sources/iron_proxy.py +++ b/agent/proxy_sources/iron_proxy.py @@ -706,7 +706,7 @@ def iron_proxy_version(binary: Path) -> str: res = subprocess.run( # noqa: S603 [str(binary), "--version"], capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=_RUN_TIMEOUT, env=minimal_env, ) @@ -1052,7 +1052,7 @@ def _detect_docker_bridge_ip() -> Optional[str]: try: res = subprocess.run( # noqa: S603 — ip is a system binary ["ip", "-4", "-o", "addr", "show", "docker0"], - capture_output=True, text=True, timeout=2, + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=2, ) if res.returncode == 0: for line in res.stdout.splitlines(): @@ -1743,7 +1743,7 @@ def _pid_alive(pid: int) -> bool: try: res = subprocess.run( # noqa: S603 ["ps", "-p", str(pid), "-o", "comm="], - capture_output=True, text=True, timeout=2, + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=2, ) if res.returncode == 0: comm = (res.stdout or "").strip() diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 13df836b110..9c5fc202015 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -102,9 +102,18 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( # ``claude-opus-4`` so non-thinking Claude 3.x or future # non-reasoning Claude variants don't match. ("claude-opus-4", 240), + ("claude-opus-5", 240), ("claude-sonnet-5", 180), ("claude-sonnet-4.5", 180), ("claude-sonnet-4.6", 180), + # Anthropic Mythos-class named reasoning models (claude-fable-5, …). + # 1M context + 128K output — heavier thinking phase than the + # numbered Claude line, so the floor is in the deep-reasoning tier + # alongside o1 / deepseek-r1 / nemotron-3-ultra. Without this + # entry the stale-stream detector kills fable-5's thinking phase + # at the default 180s (300s with context scaling), tripping the + # cross-turn circuit breaker after 5 consecutive stale kills. + ("claude-fable", 600), # xAI Grok reasoning variants. Explicit reasoning-only keys # plus one for the ``non-reasoning`` variant so users picking # the fast variant don't get the 300s floor. Bare ``grok-3``, @@ -206,6 +215,8 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: 300.0 >>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6") 240.0 + >>> get_reasoning_stale_timeout_floor("anthropic/claude-fable-5") + 600.0 >>> get_reasoning_stale_timeout_floor("gpt-4o") is None True >>> get_reasoning_stale_timeout_floor("olmo-1") is None diff --git a/agent/redact.py b/agent/redact.py index ebca1ae75f1..bff23934da6 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile( re.IGNORECASE | re.MULTILINE, ) +# Word-boundary validation for the mixed/lowercase key patterns above +# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE). +# +# Those key classes allow arbitrary alphanumeric affixes around the secret +# keyword so real key names like ``client_secret``, ``clientSecret``, and +# ``s3.secret-key`` match. The side effect: ordinary prose/document words that +# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret), +# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling +# legitimate content on the surfaces that run these passes (browser snapshots, +# log lines, kanban summaries, CLI-echoed command output). Ported from +# nearai/ironclaw#6129, where the same substring false positive ("Secretary of +# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool +# results from the replayed transcript and sent the model into a re-fetch +# loop. +# +# A keyword occurrence only counts when it sits at a word boundary within the +# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a +# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A +# trailing plural ``s`` is treated as part of the keyword (``secrets:``, +# ``tokens:``). Common concatenated compounds keep matching via explicit +# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey`` +# minio, ``apikey``). Embedded occurrences inside a larger word +# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer +# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an +# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE. +_KEY_KEYWORD_RE = re.compile( + r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)" + r"|token|secret|passwd|password|credential|auth", + re.IGNORECASE, +) + + +def _is_word_start(s: str, i: int) -> bool: + """True if position ``i`` in ``s`` begins a word (not mid-word).""" + if i == 0: + return True + prev, cur = s[i - 1], s[i] + if not prev.isalpha(): + return True + if cur.isupper() and prev.islower(): + return True # camelCase: clientSecret + # Acronym run ending: APIToken — the 'T' begins a new word when it is + # followed by lowercase while the preceding run is uppercase. + if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower(): + return True + return False + + +def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool: + """True if position ``j`` (exclusive end) in ``s`` ends a word.""" + if j >= len(s): + return True + cur = s[j] + if not cur.isalpha(): + return True + if cur.isupper() and s[j - 1].islower(): + return True # camelCase continuation: secretKey + if allow_plural and cur in "sS": + return _is_word_end(s, j + 1, allow_plural=False) + return False + + +def _key_has_secret_keyword(key: str) -> bool: + """True if ``key`` contains a secret keyword at a word boundary. + + Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE / + _YAML_ASSIGN_RE hits — rejects prose words that merely embed a keyword + (``secretary``, ``tokenizer``, ``authored``). Safe to call with the + _ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy + embedded-match behavior. + """ + letters = [c for c in key if c.isalpha()] + if letters and all(c.isupper() for c in letters): + return True # legacy all-caps behavior (MYTOKEN=…) + for m in _KEY_KEYWORD_RE.finditer(key): + if _is_word_start(key, m.start()) and _is_word_end(key, m.end()): + return True + return False + # JSON field patterns: "apiKey": "value", "token": "value", etc. _JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)" _JSON_FIELD_RE = re.compile( @@ -614,6 +693,13 @@ def redact_sensitive_text( # prose/log contexts (issue #2852): ``KEY=os.getenv('X')``. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``author=Smith`` / ``press.secretary=…`` are prose, not + # credentials (ported from nearai/ironclaw#6129). All-caps + # keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy + # embedded matching inside the helper. + if not _key_has_secret_keyword(name): + return m.group(0) return f"{name}={quote}{_mask_token(value)}{quote}" text = _ENV_ASSIGN_RE.sub(_redact_env, text) # Lowercase/dotted config keys (issue #16413). Skip URLs entirely — @@ -647,6 +733,11 @@ def redact_sensitive_text( # not a leaked secret value. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are + # document text, not credentials (nearai/ironclaw#6129). + if not _key_has_secret_keyword(key): + return m.group(0) return f"{key}{sep}{_mask_token(value)}" text = _YAML_ASSIGN_RE.sub(_redact_yaml, text) diff --git a/agent/secret_sources/base.py b/agent/secret_sources/base.py index d4ead7d3f26..070051e046b 100644 --- a/agent/secret_sources/base.py +++ b/agent/secret_sources/base.py @@ -295,7 +295,7 @@ def run_secret_cli( list(argv), env=env, capture_output=True, - text=True, + text=True, encoding="utf-8", errors="replace", timeout=timeout, stdin=subprocess.DEVNULL, ) diff --git a/agent/secret_sources/bitwarden.py b/agent/secret_sources/bitwarden.py index 8b047880be1..2d993f396c2 100644 --- a/agent/secret_sources/bitwarden.py +++ b/agent/secret_sources/bitwarden.py @@ -200,7 +200,7 @@ def _platform_asset_name() -> str: res = subprocess.run( ["ldd", "--version"], capture_output=True, - text=True, + text=True, encoding='utf-8', errors='replace', timeout=2, stdin=subprocess.DEVNULL, ) @@ -684,7 +684,7 @@ def _run_bws_list( cmd, env=env, capture_output=True, - text=True, + text=True, encoding='utf-8', errors='replace', timeout=_BWS_RUN_TIMEOUT, stdin=subprocess.DEVNULL, ) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index c1cec81df33..5699e4d9eb6 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -100,6 +100,7 @@ emitted by each built-in hook site. child_role – role string of the child agent child_summary – summary of the child's work child_status – exit status string (e.g. "success", "error") + tool_call_history – redacted tool name/input summary/byte counts/status list duration_ms – wall-clock time of the child run in milliseconds """ @@ -464,7 +465,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: input=stdin_json, capture_output=True, timeout=spec.timeout, - text=True, + text=True, encoding='utf-8', errors='replace', shell=False, **_popen_kwargs, ) @@ -632,7 +633,7 @@ def allowlist_path() -> Path: def load_allowlist() -> Dict[str, Any]: """Return the parsed allowlist, or an empty skeleton if absent.""" try: - raw = json.loads(allowlist_path().read_text()) + raw = json.loads(allowlist_path().read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError, OSError): return {"approvals": []} if not isinstance(raw, dict): diff --git a/agent/skill_commands.py b/agent/skill_commands.py index fa1b4044a7a..294ca2b1754 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -54,6 +54,21 @@ _BUNDLE_MARKER = " skill bundle," _BUNDLE_USER_INSTRUCTION = "\nUser instruction: " _BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the " +# The skill name sits in the first quoted span of the activation note, for both +# the single-skill and the bundle header ("work" / "/clean /work"). +_SKILL_NAME_RE = re.compile(re.escape(_SKILL_INVOCATION_PREFIX) + r'"([^"]*)"') + +# SQL LIKE pattern matching a skill-expanded turn, for listing queries that +# have to recognize scaffolding before the row reaches Python. The prefix +# contains no LIKE wildcards (`%`, `_`), so it needs no ESCAPE clause. +SKILL_SCAFFOLD_SQL_LIKE = _SKILL_INVOCATION_PREFIX + "%" + +# Marks where a preview query joined the head and tail of a long scaffolded +# message. ``describe_skill_invocation`` may hand back a span that runs across +# the joint (a bundle instruction cut off by the head window); callers cut the +# description there rather than show the skill body on the far side. +SKILL_EXCERPT_JOINT = "\x1e" + def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: """Recover the user's instruction from a slash-skill-expanded turn. @@ -82,6 +97,41 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: return None +def describe_skill_invocation(content: Any) -> Optional[str]: + """Render a slash-skill-expanded turn the way the user typed it. + + The expanded message embeds the whole skill body, so any surface that + summarizes a user turn from its raw content — session titles, sidebar + previews, the ``/rewind`` picker — otherwise shows the skill's own prose + as if the user had written it. That is how a skill's opening line ends up + as a session title. + + Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare + invocation, or ``None`` when *content* is not skill scaffolding (the + caller should then summarize it as an ordinary message). + """ + if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX): + return None + + match = _SKILL_NAME_RE.match(content) + name = (match.group(1) if match else "").strip() + # Bundle headers already carry their typed "/a /b" keys; a single skill is + # a bare name. + label = name if name.startswith("/") else f"/{name}" + + instruction = extract_user_instruction_from_skill_message(content) + if instruction and instruction is not content: + # An excerpted message (head + tail, joined by SKILL_EXCERPT_JOINT) can + # put the joint inside the matched span — keep only the side the + # instruction marker was found on. + instruction = instruction.split(SKILL_EXCERPT_JOINT)[0] + instruction = " ".join(instruction.split()) + if instruction: + return f"{label} — {instruction}" if name else instruction + + return label if name else None + + def _extract_single_skill_user_instruction(message: str) -> Optional[str]: # Single-skill format appends the user instruction after the skill body, so # the last occurrence is the user-provided one; the body may quote this text. diff --git a/agent/skill_preprocessing.py b/agent/skill_preprocessing.py index bd0386d5805..19c6eeb80fb 100644 --- a/agent/skill_preprocessing.py +++ b/agent/skill_preprocessing.py @@ -74,7 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str: ["bash", "-c", command], cwd=str(cwd) if cwd else None, capture_output=True, - text=True, + text=True, encoding='utf-8', errors='replace', timeout=max(1, int(timeout)), check=False, stdin=subprocess.DEVNULL, diff --git a/agent/skill_utils.py b/agent/skill_utils.py index df0f933317f..eea78d6a07c 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -50,7 +50,7 @@ EXCLUDED_SKILL_DIRS = frozenset( SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) -def is_excluded_skill_path(path) -> bool: +def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* should be skipped by active skill scanners. Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to @@ -66,11 +66,11 @@ def is_excluded_skill_path(path) -> bool: from pathlib import PurePath parts = PurePath(str(path)).parts return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path( - path + path, root=root ) -def is_skill_support_path(path) -> bool: +def is_skill_support_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* is under a support dir of an actual skill root. ``references/``, ``templates/``, ``assets/``, and ``scripts/`` are @@ -92,6 +92,8 @@ def is_skill_support_path(path) -> bool: if part not in SKILL_SUPPORT_DIRS or idx == 0: continue skill_root = Path(*parts[:idx]) + if root is not None and not path_obj.is_absolute(): + skill_root = root / skill_root if (skill_root / "SKILL.md").exists(): return True return False diff --git a/agent/ssl_guard.py b/agent/ssl_guard.py index 557f8566c32..ac1b7841b2d 100644 --- a/agent/ssl_guard.py +++ b/agent/ssl_guard.py @@ -31,7 +31,8 @@ def _skip_ssl_guard_enabled() -> bool: def _repair_hint() -> str: return ( - "Repair: python -m pip install --force-reinstall certifi openai httpx\n" + "Repair: run `hermes doctor --fix` (auto-reinstalls certifi), or " + "manually: python -m pip install --force-reinstall certifi openai httpx\n" "If you configured a custom corporate CA bundle, fix or unset the " "broken CA bundle environment variable." ) diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py new file mode 100644 index 00000000000..b85d84d9e19 --- /dev/null +++ b/agent/subagent_lifecycle.py @@ -0,0 +1,533 @@ +"""Public, plugin-safe lifecycle API for delegated Hermes subagents. + +This module deliberately exposes immutable contracts, not ``AIAgent`` objects. +It is the supported boundary for plugins that need to supervise fresh child +sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``. +""" + +from __future__ import annotations + +import contextvars +import dataclasses +import enum +import hashlib +import hmac +import json +import math +import secrets +import threading +import time +from contextlib import contextmanager +from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError +from typing import Any, Callable, Mapping, Optional + + +PUBLIC_CONTRACT_VERSION = 1 +_MAX_GOAL_CHARS = 16_000 +_MAX_CONTEXT_CHARS = 32_000 +_MAX_METADATA_BYTES = 8_192 +_MAX_RESULT_CHARS = 32_000 +_TERMINAL_RETENTION_SECONDS = 3_600 + + +class SubagentLifecycleError(ValueError): + """A request cannot be safely accepted by the public lifecycle API.""" + + +class SubagentState(str, enum.Enum): + PENDING = "PENDING" + STARTING = "STARTING" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + INTERRUPTED = "INTERRUPTED" + CANCEL_REQUESTED = "CANCEL_REQUESTED" + CANCELLED = "CANCELLED" + UNKNOWN = "UNKNOWN" + + +@dataclasses.dataclass(frozen=True) +class SubagentLaunchRequest: + goal: str + context: Optional[str] = None + role: str = "leaf" + model: Optional[str] = None + allowed_toolsets: Optional[tuple[str, ...]] = None + blocked_tools: tuple[str, ...] = () + working_directory: Optional[str] = None + parent_session_id: Optional[str] = None + correlation_id: Optional[str] = None + metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + timeout_seconds: Optional[float] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentHandle: + contract_version: int + subagent_id: str + parent_session_id: Optional[str] + correlation_id: Optional[str] + created_at: float + provider: Optional[str] + model: Optional[str] + role: str + depth: int + capability: str + + def to_dict(self) -> dict[str, Any]: + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle": + try: + return cls(**dict(value)) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("Malformed subagent handle.") from exc + + +@dataclasses.dataclass(frozen=True) +class SubagentStatus: + handle: SubagentHandle + state: SubagentState + updated_at: float + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentTerminalState: + handle: SubagentHandle + state: SubagentState + completed: bool + timed_out: bool = False + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentCancelResult: + accepted: bool + already_terminal: bool = False + unknown_handle: bool = False + unsupported: bool = False + state: SubagentState = SubagentState.UNKNOWN + + +@dataclasses.dataclass(frozen=True) +class SubagentResult: + handle: SubagentHandle + terminal_state: SubagentState + ready: bool + summary: Optional[str] = None + structured_payload: Optional[Mapping[str, Any]] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + error_classification: Optional[str] = None + error_message: Optional[str] = None + usage_metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + tool_execution_summary: Mapping[str, Any] = dataclasses.field(default_factory=dict) + result_hash: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentReconnectResult: + connected: bool + state: SubagentState + diagnostic: Optional[str] = None + + +@dataclasses.dataclass +class _Record: + handle: SubagentHandle + state: SubagentState + updated_at: float + agent: Any = None + future: Optional[Future] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + result: Optional[SubagentResult] = None + + +class _Registry: + """Thread-safe terminal-retention registry; never returns live records.""" + + def __init__(self) -> None: + self.lock = threading.RLock() + self.records: dict[str, _Record] = {} + self.correlations: dict[tuple[Optional[str], str], str] = {} + + +_REGISTRY = _Registry() +# Daemon worker pool: a wedged/abandoned child must never block interpreter +# exit at atexit-join time (same rationale as _run_single_child's timeout +# executor and the async-delegation registry pool). +from tools.daemon_pool import DaemonThreadPoolExecutor as _DaemonExecutor + +_EXECUTOR = _DaemonExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle") +_SECRET = secrets.token_bytes(32) +_ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar( + "hermes_subagent_lifecycle_parent", default=None +) + + +@contextmanager +def bind_subagent_parent(parent_agent: Any): + """Bind the host-owned parent for the current agent turn.""" + token = _ACTIVE_PARENT_AGENT.set(parent_agent) + try: + yield + finally: + _ACTIVE_PARENT_AGENT.reset(token) + + +def get_active_subagent_parent() -> Any: + """Return the parent bound to this execution context, if any.""" + return _ACTIVE_PARENT_AGENT.get() + + +class SubagentLifecycleService: + """Stable public service returned by :attr:`PluginContext.subagent_lifecycle`. + + Running children are in-process only. Completed results remain available + until process exit; ``reconnect`` accurately reports that a serialized + handle cannot reconnect after a restart instead of launching work again. + """ + + def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None: + self._parent_agent_resolver = parent_agent_resolver + + def launch(self, request: SubagentLaunchRequest) -> SubagentHandle: + parent = self._parent_agent_resolver() + if parent is None: + raise SubagentLifecycleError( + "No active Hermes parent session is available." + ) + self._validate_request(request, parent) + parent_session_id = str(getattr(parent, "session_id", "") or "") or None + if request.parent_session_id and request.parent_session_id != parent_session_id: + raise SubagentLifecycleError( + "parent_session_id does not match the active session." + ) + correlation_key = (parent_session_id, request.correlation_id or "") + with _REGISTRY.lock: + self._cleanup_locked() + if request.correlation_id and correlation_key in _REGISTRY.correlations: + raise SubagentLifecycleError( + "Duplicate correlation_id for this parent session." + ) + + # Delegate construction remains internal so plugin code never imports + # private delegation helpers or manipulates the active-child registry. + from tools.delegate_tool import ( + _build_child_preserving_parent_tools, + DEFAULT_MAX_ITERATIONS, + ) + + child = _build_child_preserving_parent_tools( + task_index=0, + goal=request.goal, + context=request.context, + toolsets=list(request.allowed_toolsets) + if request.allowed_toolsets + else None, + model=request.model, + max_iterations=DEFAULT_MAX_ITERATIONS, + task_count=1, + parent_agent=parent, + role=request.role, + ) + subagent_id = str(getattr(child, "_subagent_id", "") or "") + if not subagent_id: + raise SubagentLifecycleError("Hermes failed to assign a child identity.") + created = time.time() + handle = SubagentHandle( + PUBLIC_CONTRACT_VERSION, + subagent_id, + parent_session_id, + request.correlation_id, + created, + getattr(child, "provider", None), + getattr(child, "model", None), + getattr(child, "_delegate_role", request.role), + int(getattr(child, "_delegate_depth", 1) or 1), + self._capability(subagent_id, parent_session_id, created), + ) + record = _Record(handle, SubagentState.PENDING, created, agent=child) + with _REGISTRY.lock: + _REGISTRY.records[subagent_id] = record + if request.correlation_id: + _REGISTRY.correlations[correlation_key] = subagent_id + record.future = _EXECUTOR.submit(self._run, record, request.goal, parent) + return handle + + def status(self, handle: SubagentHandle) -> SubagentStatus: + record = self._record(handle) + if record is None: + return SubagentStatus( + handle, SubagentState.UNKNOWN, time.time(), "UNKNOWN_HANDLE" + ) + with _REGISTRY.lock: + return SubagentStatus(record.handle, record.state, record.updated_at) + + def wait( + self, handle: SubagentHandle, *, timeout_seconds: Optional[float] = None + ) -> SubagentTerminalState: + record = self._record(handle) + if record is None: + return SubagentTerminalState( + handle, SubagentState.UNKNOWN, True, diagnostic="UNKNOWN_HANDLE" + ) + future = record.future + if future is not None: + try: + future.result(timeout=timeout_seconds) + except TimeoutError: + return SubagentTerminalState(record.handle, record.state, False, True) + except Exception: + pass + with _REGISTRY.lock: + return SubagentTerminalState( + record.handle, record.state, record.result is not None + ) + + def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult: + record = self._record(handle) + if record is None: + return SubagentCancelResult(False, unknown_handle=True) + with _REGISTRY.lock: + if record.result is not None: + return SubagentCancelResult( + False, already_terminal=True, state=record.state + ) + agent = record.agent + record.state = SubagentState.CANCEL_REQUESTED + record.updated_at = time.time() + if agent is None or not hasattr(agent, "interrupt"): + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + try: + agent.interrupt(f"Lifecycle cancellation requested: {reason[:500]}") + except Exception: + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + return SubagentCancelResult(True, state=SubagentState.CANCEL_REQUESTED) + + def result(self, handle: SubagentHandle) -> SubagentResult: + record = self._record(handle) + if record is None: + return SubagentResult( + handle, + SubagentState.UNKNOWN, + False, + error_classification="UNKNOWN_HANDLE", + ) + with _REGISTRY.lock: + if record.result is not None: + return record.result + return SubagentResult( + record.handle, record.state, False, error_classification="NOT_READY" + ) + + def reconnect(self, handle: SubagentHandle) -> SubagentReconnectResult: + record = self._record(handle) + if record is None: + return SubagentReconnectResult( + False, SubagentState.UNKNOWN, "RECONNECT_UNAVAILABLE" + ) + with _REGISTRY.lock: + return SubagentReconnectResult(True, record.state) + + def _record(self, handle: SubagentHandle) -> Optional[_Record]: + if ( + not isinstance(handle, SubagentHandle) + or type(handle.contract_version) is not int + or handle.contract_version != PUBLIC_CONTRACT_VERSION + ): + return None + if ( + not isinstance(handle.subagent_id, str) + or not handle.subagent_id + or ( + handle.parent_session_id is not None + and not isinstance(handle.parent_session_id, str) + ) + or ( + handle.correlation_id is not None + and not isinstance(handle.correlation_id, str) + ) + or isinstance(handle.created_at, bool) + or not isinstance(handle.created_at, (int, float)) + or not math.isfinite(handle.created_at) + or (handle.provider is not None and not isinstance(handle.provider, str)) + or (handle.model is not None and not isinstance(handle.model, str)) + or not isinstance(handle.role, str) + or type(handle.depth) is not int + or not isinstance(handle.capability, str) + ): + return None + if not hmac.compare_digest( + handle.capability, + self._capability( + handle.subagent_id, handle.parent_session_id, handle.created_at + ), + ): + return None + parent = self._parent_agent_resolver() + active_parent_id = str(getattr(parent, "session_id", "") or "") or None + if active_parent_id != handle.parent_session_id: + return None + with _REGISTRY.lock: + return _REGISTRY.records.get(handle.subagent_id) + + @staticmethod + def _cleanup_locked() -> None: + """Retain terminal snapshots for a bounded period, never live work.""" + cutoff = time.time() - _TERMINAL_RETENTION_SECONDS + expired = [ + subagent_id + for subagent_id, record in _REGISTRY.records.items() + if record.result is not None + and record.completed_at is not None + and record.completed_at < cutoff + ] + for subagent_id in expired: + record = _REGISTRY.records.pop(subagent_id) + if record.handle.correlation_id: + _REGISTRY.correlations.pop( + (record.handle.parent_session_id, record.handle.correlation_id), + None, + ) + + def _run(self, record: _Record, goal: str, parent: Any) -> None: + with _REGISTRY.lock: + if record.state is not SubagentState.CANCEL_REQUESTED: + record.state = SubagentState.RUNNING + record.started_at = time.time() + record.updated_at = record.started_at + try: + from tools.delegate_tool import _run_child_lifecycle + + raw = _run_child_lifecycle(0, goal, record.agent, parent) + status = ( + str(raw.get("status", "error")) if isinstance(raw, dict) else "error" + ) + if status == "completed": + state = SubagentState.SUCCEEDED + elif status == "interrupted": + state = ( + SubagentState.CANCELLED + if record.state == SubagentState.CANCEL_REQUESTED + else SubagentState.INTERRUPTED + ) + else: + state = SubagentState.FAILED + summary = raw.get("summary") if isinstance(raw, dict) else None + summary = str(summary)[:_MAX_RESULT_CHARS] if summary is not None else None + error = raw.get("error") if isinstance(raw, dict) else None + result = SubagentResult( + record.handle, + state, + True, + summary=summary, + completed_at=time.time(), + started_at=record.started_at, + error_classification=None + if state == SubagentState.SUCCEEDED + else status.upper(), + error_message=str(error)[:_MAX_RESULT_CHARS] if error else None, + usage_metadata={"api_calls": raw.get("api_calls", 0)} + if isinstance(raw, dict) + else {}, + tool_execution_summary={ + "duration_seconds": raw.get("duration_seconds", 0) + } + if isinstance(raw, dict) + else {}, + ) + except Exception as exc: + result = SubagentResult( + record.handle, + SubagentState.FAILED, + True, + started_at=record.started_at, + completed_at=time.time(), + error_classification=type(exc).__name__, + error_message=str(exc)[:_MAX_RESULT_CHARS], + ) + payload = dataclasses.asdict(result) + payload.pop("result_hash", None) + result = dataclasses.replace( + result, + result_hash=hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode() + ).hexdigest(), + ) + with _REGISTRY.lock: + record.agent = None + record.result = result + record.state = result.terminal_state + record.completed_at = result.completed_at + record.updated_at = result.completed_at or time.time() + + @staticmethod + def _capability( + subagent_id: str, parent_session_id: Optional[str], created_at: float + ) -> str: + value = f"{subagent_id}|{parent_session_id or ''}|{created_at:.6f}".encode() + return hmac.new(_SECRET, value, hashlib.sha256).hexdigest() + + @staticmethod + def _validate_request(request: SubagentLaunchRequest, parent: Any) -> None: + if ( + not isinstance(request, SubagentLaunchRequest) + or not isinstance(request.goal, str) + or not request.goal.strip() + or len(request.goal) > _MAX_GOAL_CHARS + ): + raise SubagentLifecycleError( + "goal must be a non-empty string of at most 16000 characters." + ) + if request.context is not None and ( + not isinstance(request.context, str) + or len(request.context) > _MAX_CONTEXT_CHARS + ): + raise SubagentLifecycleError( + "context must be a string of at most 32000 characters." + ) + if request.role not in {"leaf", "orchestrator"}: + raise SubagentLifecycleError("role must be 'leaf' or 'orchestrator'.") + if request.timeout_seconds is not None: + raise SubagentLifecycleError( + "Per-launch timeout is not supported; configure delegation timeout explicitly." + ) + if request.working_directory is not None: + raise SubagentLifecycleError( + "working_directory is not supported because Hermes delegates use isolated task environments." + ) + if request.blocked_tools: + raise SubagentLifecycleError( + "Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools." + ) + try: + metadata_bytes = len( + json.dumps(dict(request.metadata), sort_keys=True).encode() + ) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("metadata must be JSON-serializable.") from exc + if metadata_bytes > _MAX_METADATA_BYTES: + raise SubagentLifecycleError("metadata exceeds 8192 bytes.") + if request.allowed_toolsets: + from toolsets import TOOLSETS + + unknown = set(request.allowed_toolsets) - set(TOOLSETS) + if unknown: + raise SubagentLifecycleError( + f"Unknown toolsets: {', '.join(sorted(unknown))}." + ) + enabled = getattr(parent, "enabled_toolsets", None) + if enabled is not None and not set(request.allowed_toolsets).issubset( + set(enabled) + ): + raise SubagentLifecycleError( + "Requested toolsets would broaden parent permissions." + ) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index aab34cb799e..8b8832ca280 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -12,9 +12,11 @@ Three tiers are joined with ``\\n\\n``: * ``stable`` — identity (SOUL.md or DEFAULT_AGENT_IDENTITY), tool guidance, computer-use guidance, nous subscription block, tool-use enforcement guidance + per-model operational guidance, skills prompt, - alibaba model-name workaround, environment hints, platform hints. + alibaba model-name workaround, environment hints, coding guidance, + platform hints. * ``context`` — caller-supplied ``system_message`` plus context files - (AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``. + (AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``, + plus the session's coding-workspace snapshot. * ``volatile`` — memory snapshot, USER.md profile, external memory provider block, timestamp/session/model/provider line. @@ -24,6 +26,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders. from __future__ import annotations import json +import logging import os from typing import Any, Dict, List, Optional @@ -49,6 +52,8 @@ from agent.runtime_cwd import resolve_context_cwd from hermes_constants import get_hermes_home from utils import is_truthy_value +logger = logging.getLogger(__name__) + def _ra(): """Lazy reference to the ``run_agent`` module. @@ -145,14 +150,14 @@ def _tui_embedded_pane_clarifier(hint: str) -> str: def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]: - """Assemble the system prompt as three ordered parts. + """Assemble the system prompt as three ordered cache tiers. Returns a dict with three keys: - * ``stable`` — identity, tool guidance, skills prompt, - environment hints, platform hints, model-family operational - guidance. - * ``context`` — context files (AGENTS.md, .cursorrules, etc.) - and caller-supplied system_message. + * ``stable`` — the cross-session-stable prefix, through the coding + operating brief when a workspace snapshot follows. + * ``context`` — the workspace snapshot followed by the remaining + session-stable guidance, context files, and caller-supplied + system_message. * ``volatile`` — memory snapshot, user profile, external memory provider block, timestamp line. @@ -345,25 +350,35 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) stable_parts.append(_env_hints) # Coding posture (base Hermes, any interactive coding surface in a code - # workspace — see agent/coding_context.py). The operating brief + the live - # git/workspace snapshot are built once here and cached for the session; - # the snapshot is never re-probed per turn (that would break the prompt - # cache), so the brief tells the model to re-check git before relying on it. + # workspace — see agent/coding_context.py). Keep the operating brief in + # the cross-session-stable prefix, while placing the live git/workspace + # snapshot behind its own cache boundary. The post-snapshot blocks must + # stay in their historical position after the workspace snapshot. + coding_workspace_parts: List[str] = [] + coding_trailing_parts: List[str] = [] if agent.valid_tool_names: try: - from agent.coding_context import coding_system_blocks + from agent.coding_context import coding_system_prompt_parts - stable_parts.extend( - coding_system_blocks( - platform=agent.platform, - cwd=resolve_context_cwd(), - model=agent.model, - ) + coding_prefix_parts, coding_workspace_parts, coding_trailing_parts = coding_system_prompt_parts( + platform=agent.platform, + cwd=resolve_context_cwd(), + model=agent.model, ) + stable_parts.extend(coding_prefix_parts) except Exception: # Coding-context probing must never block prompt build. pass + # Guidance assembled after the coding posture historically followed the + # workspace snapshot. With no snapshot, the coding tail instead remains + # directly after the coding prefix in the cacheable prefix. + if coding_workspace_parts: + post_workspace_parts: List[str] = [] + else: + stable_parts.extend(coding_trailing_parts) + post_workspace_parts = stable_parts + # Local Python toolchain probe — names python/pip/uv/PEP-668 state when # something is non-default so the model can pick the right install # strategy without discovering by failure. Emits a single line; emits @@ -376,7 +391,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) from tools.env_probe import get_environment_probe_line _probe_line = get_environment_probe_line() if _probe_line: - stable_parts.append(_probe_line) + post_workspace_parts.append(_probe_line) except Exception: # Probe failure must never block prompt build. pass @@ -394,7 +409,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) except Exception: active_profile = "default" if active_profile == "default": - stable_parts.append( + post_workspace_parts.append( "Active Hermes profile: default. Other profiles (if any) live " "under " + str(get_hermes_home()) + "/profiles//. Each profile has its own " "skills/, plugins/, cron/, and memories/ that affect a different " @@ -403,7 +418,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) "you to." ) else: - stable_parts.append( + 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/, " @@ -449,11 +464,16 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if platform_key == "tui" and _effective_hint: _effective_hint = _tui_embedded_pane_clarifier(_effective_hint) if _effective_hint: - stable_parts.append(_effective_hint) + post_workspace_parts.append(_effective_hint) # ── Context tier (cwd-dependent, may change between sessions) ─ context_parts: List[str] = [] + if coding_workspace_parts: + context_parts.extend(coding_workspace_parts) + context_parts.extend(coding_trailing_parts) + context_parts.extend(post_workspace_parts) + # Note: ephemeral_system_prompt is NOT included here. It's injected at # API-call time only so it stays out of the cached/stored system prompt. if system_message is not None: @@ -515,6 +535,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) timestamp_line += f"\nModel: {agent.model}" if agent.provider: timestamp_line += f"\nProvider: {agent.provider}" + if agent.platform: + timestamp_line += f"\nPlatform: {agent.platform}" volatile_parts.append(timestamp_line) return { @@ -541,6 +563,7 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str """ parts = build_system_prompt_parts(agent, system_message=system_message) joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) + agent._cached_system_prompt_static = parts["stable"] # Surface context-file truncation warnings through the normal agent status # channel so gateway/CLI users see them in chat instead of only in logs. @@ -557,10 +580,65 @@ def invalidate_system_prompt(agent: Any) -> None: so the rebuilt prompt captures any writes from this session. """ agent._cached_system_prompt = None + agent._cached_system_prompt_static = None if agent._memory_store: agent._memory_store.load_from_disk() +def reconstruct_static_prefix( + agent: Any, + system_message: Optional[str] = None, + *, + log_label: str = "restore", +) -> None: + """Reconstruct ``_cached_system_prompt_static`` for a stored prompt. + + The static prefix is not persisted (only the full prompt is), so any + path that adopts a stored/kept ``_cached_system_prompt`` — session + restore, the compression keep-prompt path, or a failover to a cache-on + provider mid-turn (#72626) — must rebuild the stable tier to regain the + two-block ``[static, volatile]`` system layout. + + Safety: the rebuilt stable tier is used ONLY when the stored prompt + literally starts with it (checked here AND re-checked by + ``_apply_system_cache_markers``'s ``startswith`` gate). If any + stable-tier input changed since the prompt was persisted (skills + edited, identity changed), the prefix mismatches, the static stays + None, and requests fall back to the legacy layout with the stored + prompt bytes untouched — never a rewritten prompt. + + A failed reconstruction is memoized per stored prompt + (``_static_rebuild_failed_for``): ``build_system_prompt_parts`` does + real file I/O (SOUL.md, context files, memory), and callers on the + retry-loop hot path must not re-run it every attempt when the inputs + haven't changed. A legitimately changed stored prompt retries once. + """ + if not getattr(agent, "_use_prompt_caching", False): + return + stored = getattr(agent, "_cached_system_prompt", None) + if not isinstance(stored, str) or not stored: + return + existing = getattr(agent, "_cached_system_prompt_static", None) + if isinstance(existing, str) and existing and stored.startswith(existing): + return + if getattr(agent, "_static_rebuild_failed_for", None) == stored: + return + try: + static = build_system_prompt_parts(agent, system_message=system_message)["stable"] + if static and stored.startswith(static): + agent._cached_system_prompt_static = static + agent._static_rebuild_failed_for = None + return + except Exception: + logger.debug( + "static system-prefix reconstruction failed on %s", + log_label, + exc_info=True, + ) + agent._cached_system_prompt_static = None + agent._static_rebuild_failed_for = stored + + def format_tools_for_system_message(agent: Any) -> str: """Format tool definitions for the system message in the trajectory format. diff --git a/agent/title_generator.py b/agent/title_generator.py index 7469a665bfc..bb44da276ec 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -71,6 +71,27 @@ def _auto_title_enabled() -> bool: return True +def _summarize_user_message(user_message: str) -> str: + """Collapse a slash-skill-expanded turn back to what the user typed. + + A ``/skill`` invocation expands into a message that embeds the whole skill + body, so feeding it to the titler verbatim titles the session after the + *skill's* prose — "Kick off a task in a fresh isolated git worktree" — not + after the user's request. Reuse the canonical scaffolding parser so the + model sees ``/work — fix the title leak`` instead. + """ + if not user_message: + return "" + try: + from agent.skill_commands import describe_skill_invocation + + described = describe_skill_invocation(user_message) + except Exception: + logger.debug("Skill-scaffolding summary failed; titling raw", exc_info=True) + return user_message + return described if described is not None else user_message + + def generate_title( user_message: str, assistant_response: str, @@ -110,7 +131,7 @@ def generate_title( logger.debug("Title runtime validator raised; proceeding", exc_info=True) # Truncate long messages to keep the request small - user_snippet = user_message[:500] if user_message else "" + user_snippet = _summarize_user_message(user_message)[:500] assistant_snippet = assistant_response[:500] if assistant_response else "" language = _title_language() @@ -143,6 +164,11 @@ def generate_title( title = title.strip('"\'') if title.lower().startswith("title:"): title = title[6:].strip() + # A title is one line. A model that ignores "return ONLY the title" and + # answers the prompt instead (a shell transcript, a bulleted plan) would + # otherwise be stored verbatim and truncated mid-command. Keep the first + # non-empty line — the closest thing to a title in that response. + title = next((line.strip() for line in title.splitlines() if line.strip()), "") # Enforce reasonable length if len(title) > 80: title = title[:77] + "..." diff --git a/agent/tool_executor.py b/agent/tool_executor.py index d235de36c03..d32fe99c0c5 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -140,8 +140,8 @@ def _flush_session_db_after_tool_progress( messages: list, *, stage: str, -) -> None: - """Best-effort incremental SessionDB flush for tool-call progress. +) -> bool: + """Flush tool-call progress before projecting it to any UI surface. Tool execution can perform side effects that terminate or restart the current Hermes process before the normal turn-end persistence path runs. @@ -149,9 +149,14 @@ def _flush_session_db_after_tool_progress( transcript survives destructive-but-valid tool calls. """ try: - agent._flush_messages_to_session_db(messages) + persisted = agent._flush_messages_to_session_db(messages) is not False + if not persisted: + agent._incremental_persistence_failed = True + return persisted except Exception as exc: + agent._incremental_persistence_failed = True logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc) + return False def _ra(): @@ -431,8 +436,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = json.dumps({ "error": ( @@ -854,6 +866,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls): r = results[i] blocked = False + is_error = True + progress_function_name = name # A worker can finish and write results[i] in the window between the # deadline snapshot (timed_out_indices, taken from not_done) and this # loop. Prefer that real result over a fabricated timeout message — the @@ -909,6 +923,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tool_duration = 0.0 else: function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r + progress_function_name = function_name if blocked: effect_disposition = "none" @@ -936,43 +951,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe except Exception as _ver_err: logging.debug("file-mutation verifier record failed: %s", _ver_err) - if not blocked and agent.tool_progress_callback: - try: - agent.tool_progress_callback( - "tool.completed", function_name, None, None, - duration=tool_duration, is_error=is_error, - result=function_result, - ) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - if agent.verbose_logging: logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s") logging.debug(f"Tool result ({len(function_result)} chars): {function_result}") - # Print cute message per tool - if agent._should_emit_quiet_tool_messages(): - cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result) - agent._safe_print(f" {cute_msg}") - elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": - _preview_str = _multimodal_text_summary(function_result) - if agent.verbose_logging: - print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") - print(agent._wrap_verbose("Result: ", _preview_str)) - else: - response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str - print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}") - agent._current_tool = None - agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s)") - - if not blocked and agent.tool_complete_callback: - try: - display_args = _redact_tool_args_for_display(name, args) or args - agent.tool_complete_callback(tc.id, name, display_args, function_result) - except Exception as cb_err: - logging.debug(f"Tool complete callback error: {cb_err}") + _status_suffix = " (error)" if is_error else "" + agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s){_status_suffix}") + display_function_result = function_result function_result = maybe_persist_tool_result( content=function_result, tool_name=name, @@ -1007,6 +994,50 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) messages.append(tool_message) risk_metadata = tool_message.get("_tool_output_risk") + if not _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {name}", + ): + return + + # Every completion surface is downstream of the canonical append. If + # the UI bridge or process dies while projecting one of these events, + # resume can reconstruct the tool result that was already visible. + if not blocked and agent.tool_progress_callback: + try: + agent.tool_progress_callback( + "tool.completed", progress_function_name, None, None, + duration=tool_duration, is_error=is_error, + result=display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool progress callback error: {cb_err}") + + # Print cute message per tool + if agent._should_emit_quiet_tool_messages(): + cute_msg = _get_cute_tool_message_impl( + name, args, tool_duration, result=display_function_result, + ) + agent._safe_print(f" {cute_msg}") + elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": + _preview_str = _multimodal_text_summary(display_function_result) + if agent.verbose_logging: + print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") + print(agent._wrap_verbose("Result: ", _preview_str)) + else: + response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str + print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}") + + if not blocked and agent.tool_complete_callback: + try: + display_args = _redact_tool_args_for_display(name, args) or args + agent.tool_complete_callback( + tc.id, name, display_args, display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool complete callback error: {cb_err}") + if ( risk_metadata is not None and risk_metadata.get("risk") != "low" @@ -1023,11 +1054,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - _flush_session_db_after_tool_progress( - agent, - messages, - stage=f"tool result {name}", - ) # ── Per-tool /steer drain ─────────────────────────────────── # Same as the sequential path: drain between each collected @@ -1059,6 +1085,8 @@ 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) for i, tool_call in enumerate(assistant_message.tool_calls, 1): + if getattr(agent, "_incremental_persistence_failed", False): + return # SAFETY: check interrupt BEFORE starting each tool. # If the user sent "stop" during a previous tool's execution, # do NOT start any more tools -- skip them all immediately. @@ -1074,11 +1102,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_tc.id, effect_disposition="none", )) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"cancelled tool result {skipped_name}", - ) + ): + return break function_name = tool_call.function.name @@ -1094,11 +1123,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_call.id, ) ) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"invalid tool arguments {function_name}", - ) + ): + return agent._apply_pending_steer_to_tool_results(messages, 1) continue @@ -1112,8 +1142,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + # This path wraps _block_msg in {"error": ...} — + # flatten the probe payload to one plain string. + try: + _probe = json.loads(_probe_err) + _ts_scope_block = ( + f"{_probe.get('error', '')} Parameters schema: " + f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. " + f"{_probe.get('hint', '')}" + ).strip() + except Exception: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = ( f"'{_underlying}' is not available in this session. " @@ -1359,6 +1406,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe return _clarify_tool( question=next_args.get("question", ""), choices=next_args.get("choices"), + multi_select=next_args.get("multi_select", False), callback=agent.clarify_callback, ) function_result, function_args = _run_agent_tool_execution_middleware( @@ -1644,31 +1692,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe except Exception as _ver_err: logging.debug("file-mutation verifier record failed: %s", _ver_err) - if not _execution_blocked and agent.tool_progress_callback: - try: - agent.tool_progress_callback( - "tool.completed", function_name, None, None, - duration=tool_duration, is_error=_is_error_result, - result=function_result, - ) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - agent._current_tool = None - agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s)") + _status_suffix = " (error)" if _is_error_result else "" + agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s){_status_suffix}") if agent.verbose_logging: logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s") _log_result = _multimodal_text_summary(function_result) logging.debug(f"Tool result ({len(_log_result)} chars): {_log_result}") - if not _execution_blocked and agent.tool_complete_callback: - try: - display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result) - except Exception as cb_err: - logging.debug(f"Tool complete callback error: {cb_err}") - + display_function_result = function_result function_result = maybe_persist_tool_result( content=function_result, tool_name=function_name, @@ -1691,6 +1724,40 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id) messages.append(tool_message) risk_metadata = tool_message.get("_tool_output_risk") + if not _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {function_name}", + ): + return + + # UI completion/progress events are projections of the canonical tool + # row, never a competing in-memory authority. + if not _execution_blocked and agent.tool_progress_callback: + try: + agent.tool_progress_callback( + "tool.completed", function_name, None, None, + duration=tool_duration, is_error=_is_error_result, + result=display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool progress callback error: {cb_err}") + + if not _execution_blocked and agent.tool_complete_callback: + try: + display_args = ( + _redact_tool_args_for_display(function_name, function_args) + or function_args + ) + agent.tool_complete_callback( + tool_call.id, + function_name, + display_args, + display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool complete callback error: {cb_err}") + if ( risk_metadata is not None and risk_metadata.get("risk") != "low" @@ -1707,11 +1774,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe ) except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - _flush_session_db_after_tool_progress( - agent, - messages, - stage=f"tool result {function_name}", - ) # ── Per-tool /steer drain ─────────────────────────────────── # Drain pending steer BETWEEN individual tool calls so the @@ -1739,11 +1801,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_tc.id, effect_disposition="none", )) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"skipped tool result {skipped_name}", - ) + ): + return break if agent.tool_delay > 0 and i < len(assistant_message.tool_calls): @@ -1794,6 +1857,8 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec segments = _plan_tool_batch_segments(assistant_message.tool_calls, execution_cwd=_exec_cwd) for kind, calls in segments: + if getattr(agent, "_incremental_persistence_failed", False): + return segment_message = SimpleNamespace(tool_calls=list(calls)) if kind == "parallel": execute_tool_calls_concurrent( @@ -1806,6 +1871,9 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec finalize=False, ) + if getattr(agent, "_incremental_persistence_failed", False): + return + # ── Whole-turn finalize (budget + /steer) ───────────────────────── total_tools = len(assistant_message.tool_calls) if total_tools > 0: diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index f08f1b60478..444ce373959 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -79,6 +79,7 @@ class ToolCallGuardrailConfig: no_progress_block_after: int = 5 idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) + loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig()) @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": @@ -121,6 +122,54 @@ class ToolCallGuardrailConfig: hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), defaults.no_progress_block_after, ), + loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")), + ) + + +# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop +# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at +# the start of every agent loop (reset_for_turn), so the limit is "within a +# single turn" rather than cumulative over the whole session. A single loop +# issuing dozens of web searches or spawning dozens of subagents is already +# pathological, so the defaults are deliberately low. +_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50 +_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50 + + +@dataclass(frozen=True) +class LoopCapConfig: + """Per-turn caps on runaway-prone tool calls. + + Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on + WebSearch calls and subagent spawns to stop runaway search / delegation + loops. Here the caps count *within a single agent loop* (one turn): the + counters reset in ``reset_for_turn`` at the start of every + ``run_conversation``, so a legitimate multi-turn session is never starved, + but a single turn that spirals into an unbounded search / delegation loop + is stopped. + + Semantics differ from the per-turn loop *detector* above (which keys on + repeated identical/failing calls): these caps are a hard ceiling on the + total count of a tool within the turn and fire regardless of + ``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited). + """ + + max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN + max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN + + @classmethod + def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig": + """Build config from the ``tool_loop_guardrails.loop_caps`` section.""" + if not isinstance(data, Mapping): + return cls() + defaults = cls() + return cls( + max_web_searches=_non_negative_int( + data.get("max_web_searches"), defaults.max_web_searches + ), + max_subagents=_non_negative_int( + data.get("max_subagents"), defaults.max_subagents + ), ) @@ -233,6 +282,11 @@ class ToolCallGuardrailController: self._same_tool_failure_counts: dict[str, int] = {} self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} self._halt_decision: ToolGuardrailDecision | None = None + # Per-turn runaway-loop cap counters. Reset every turn (this method + # runs at the start of each run_conversation), so the caps bound a + # single agent loop rather than accumulating across the session. + self._turn_web_search_count = 0 + self._turn_subagent_count = 0 @property def halt_decision(self) -> ToolGuardrailDecision | None: @@ -240,6 +294,17 @@ class ToolCallGuardrailController: def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + + # ── Per-turn runaway-loop caps ────────────────────────────────── + # These are hard ceilings on how many times a runaway-prone tool may + # be called within a single agent loop (turn). They apply regardless + # of hard_stop_enabled (which only governs the per-turn loop detector). + # We block BEFORE the call runs once the count is already at the cap, + # then increment for an allowed call so the (cap+1)-th is refused. + cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature) + if cap_block is not None: + return cap_block + if not self.config.hard_stop_enabled: return ToolGuardrailDecision(tool_name=tool_name, signature=signature) @@ -379,6 +444,68 @@ class ToolCallGuardrailController: return False return tool_name in self.config.idempotent_tools + def _check_loop_cap( + self, + tool_name: str, + args: Mapping[str, Any], + signature: ToolCallSignature, + ) -> ToolGuardrailDecision | None: + """Enforce and advance the per-turn runaway-loop counters. + + Returns a ``block`` decision when the cap is already reached, otherwise + increments the relevant counter for the allowed call and returns + ``None``. A cap of 0 disables that limit entirely. Counters reset each + turn via ``reset_for_turn``. + """ + caps = self.config.loop_caps + + if tool_name == "web_search": + cap = caps.max_web_searches + if cap and self._turn_web_search_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="loop_web_search_cap", + message=( + f"Blocked web_search: this turn has already made {cap} " + "web searches, the per-turn limit. This looks like a " + "runaway search loop. Work with the results you already " + "have and give the user your answer." + ), + tool_name=tool_name, + count=self._turn_web_search_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._turn_web_search_count += 1 + return None + + if tool_name == "delegate_task": + cap = caps.max_subagents + if not cap: + return None + spawn_count = _subagent_spawn_count(args) + if self._turn_subagent_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="loop_subagent_cap", + message=( + f"Blocked delegate_task: this turn has already spawned " + f"{self._turn_subagent_count} subagents (limit {cap}). " + "This looks like a runaway delegation loop. Finish the " + "work with the results you have and answer the user." + ), + tool_name=tool_name, + count=self._turn_subagent_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._turn_subagent_count += spawn_count + return None + + return None + def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str: """Build a synthetic role=tool content string for a blocked tool call.""" @@ -471,6 +598,32 @@ def _positive_int(value: Any, default: int) -> int: return parsed if parsed >= 1 else default +def _non_negative_int(value: Any, default: int) -> int: + """Parse a session-cap value. 0 is a valid (disable) value; negatives and + junk fall back to the default.""" + if value is None: + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _subagent_spawn_count(args: Mapping[str, Any]) -> int: + """How many subagents a single delegate_task call spawns. + + delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty + list, one child per item) or a single task (``goal``). Count the batch size + when present, otherwise 1, so the session subagent cap reflects real spawns + rather than delegate_task invocations. + """ + tasks = args.get("tasks") if isinstance(args, Mapping) else None + if isinstance(tasks, list) and tasks: + return len(tasks) + return 1 + + def _sha256(value: str) -> str: # surrogatepass: tool results scraped from the web can carry unpaired # UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict diff --git a/agent/trace_upload.py b/agent/trace_upload.py index f65547440c7..404d9be70b1 100644 --- a/agent/trace_upload.py +++ b/agent/trace_upload.py @@ -162,7 +162,7 @@ def build_trace_jsonl( if cwd: r = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True, timeout=3, cwd=cwd, + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=cwd, ) if r.returncode == 0: git_branch = r.stdout.strip() diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 5855fcfe9c5..15dd3409e3f 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -7,6 +7,7 @@ streaming, or the _run_codex_stream() call path. import hashlib import json +import re from typing import Any, Dict, List, Optional from agent.transports.base import ProviderTransport @@ -27,6 +28,49 @@ def _bounded_prompt_cache_key(value: Any) -> Optional[str]: return f"pck_{digest}" +_EXTENDED_PROMPT_CACHE_MODELS = ( + "gpt-5.5-pro", + "gpt-5.5", + "gpt-5.4", + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.1-chat-latest", + "gpt-5.1-codex", + "gpt-5.1", + "gpt-5-codex", + "gpt-5", + "gpt-4.1", +) +_EXTENDED_PROMPT_CACHE_MODEL_RE = re.compile( + rf"(?:^|[./:])(?:{'|'.join(re.escape(name) for name in _EXTENDED_PROMPT_CACHE_MODELS)})" + r"(?:-\d{4}-\d{2}-\d{2})?$" +) + + +def _default_prompt_cache_retention_for_request( + model: str, + base_url: Any, +) -> Optional[str]: + """Return ``24h`` for supported models on Amazon Bedrock Mantle.""" + from utils import base_url_hostname + + hostname_parts = base_url_hostname(str(base_url or "")).split(".") + is_bedrock_mantle = ( + len(hostname_parts) == 4 + and hostname_parts[0] == "bedrock-mantle" + and bool(hostname_parts[1]) + and hostname_parts[2:] == ["api", "aws"] + ) + if not is_bedrock_mantle: + return None + + normalized = str(model or "").strip().lower().replace("_", "-") + if _EXTENDED_PROMPT_CACHE_MODEL_RE.search(normalized): + return "24h" + return None + + def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]: """Content-address the prompt cache key from the static request prefix. @@ -284,6 +328,13 @@ class ResponsesApiTransport(ProviderTransport): if not is_github_responses and not is_xai_responses and cache_key: kwargs["prompt_cache_key"] = cache_key + cache_retention = _default_prompt_cache_retention_for_request( + model, + params.get("base_url"), + ) + if cache_retention: + kwargs.setdefault("prompt_cache_retention", cache_retention) + if reasoning_enabled and is_xai_responses: from agent.model_metadata import grok_supports_reasoning_effort diff --git a/agent/transports/codex_app_server.py b/agent/transports/codex_app_server.py index 7f5831f2a3e..c23ff836ed8 100644 --- a/agent/transports/codex_app_server.py +++ b/agent/transports/codex_app_server.py @@ -394,7 +394,7 @@ def check_codex_binary( proc = subprocess.run( [codex_bin, "--version"], capture_output=True, - text=True, + text=True, encoding='utf-8', errors='replace', timeout=10, stdin=subprocess.DEVNULL, ) diff --git a/agent/turn_context.py b/agent/turn_context.py index 59914befe0c..ac9555d012e 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -36,6 +36,7 @@ from agent.conversation_compression import ( PREFLIGHT_COMPRESSION_STATUS_TEMPLATE, compression_skipped_due_to_lock, conversation_history_after_compression, + recover_rotated_compression_session, ) from agent.context_engine import automatic_compaction_status_message from agent.iteration_budget import IterationBudget @@ -353,6 +354,13 @@ def build_turn_context( # Guard stdio against OSError from broken pipes (systemd/headless/daemon). install_safe_stdio() + # Recover a session rotated by another path before binding log/turn ids or + # copying client-supplied history. Everything in this turn must consistently + # belong to the canonical child, including observability metadata. + recovered_history = recover_rotated_compression_session(agent) + if recovered_history is not None: + conversation_history = recovered_history + # NOTE: the DB session row is created later, AFTER the system prompt is # restored/built (see _ensure_db_session() below the system-prompt block). # Creating it here — before _cached_system_prompt is populated — inserts a diff --git a/agent/turn_summary.py b/agent/turn_summary.py new file mode 100644 index 00000000000..f4440afb50a --- /dev/null +++ b/agent/turn_summary.py @@ -0,0 +1,310 @@ +"""Per-turn accounting for the interactive CLI. + +Two display-only pieces live here: + +* :class:`TurnSummaryCollector` — a tiny observer that rides the existing + ``tool_progress_callback`` feed (``tool.completed`` events already carry + the tool name and its raw result) and tallies what a turn actually did. + It holds **no** agent-loop state: the display layer already sees every + tool call, so nothing new is threaded through the conversation loop. +* :func:`format_turn_summary` — a pure formatter that turns a tally plus a + wall-clock duration into one dim line, e.g.:: + + ⋯ 12.4s · edited 2 files +18 -3 · read 4 files · ran 3 commands + + Ported from Claude Code's post-turn accounting line + ("Edited 1 file +6 -2, read 1 file … Worked for 10s"). + +:func:`format_token_flow` is the spinner-side counterpart: a cumulative +token readout appended to the live elapsed timer (``↓ 1.2k tok``). + +Everything in this module is pure/side-effect free apart from the +collector's own counters, which makes it directly unit-testable without a +terminal, an agent, or a network call. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +__all__ = [ + "TurnSummaryCollector", + "TurnTally", + "format_turn_summary", + "format_token_flow", + "format_elapsed", +] + + +# Leading glyph for the summary line. Deliberately not an emoji — the line is +# meant to read as terminal chrome, not as agent speech. +SUMMARY_PREFIX = "⋯" + +# A turn that called no tools and finished this fast has nothing worth +# reporting (plain chat reply). Below the threshold the formatter returns "". +_MIN_TOOLLESS_SECONDS = 2.0 + +# Max number of "verb + count" segments rendered before collapsing the rest +# into a "+N more" tail, so a 12-tool turn cannot blow past one line. +_MAX_SEGMENTS = 4 + + +# Tool name -> (verb, singular noun, plural noun). +# +# Verbs are past tense because the line is printed *after* the turn. Tools not +# listed here fall into a generic "called N tools" bucket rather than inventing +# phrasing for plugin/MCP tools whose semantics we don't know. +_VERB_GROUPS: dict[str, tuple[str, str, str]] = { + "write_file": ("edited", "file", "files"), + "patch": ("edited", "file", "files"), + "read_file": ("read", "file", "files"), + "web_extract": ("read", "page", "pages"), + "terminal": ("ran", "command", "commands"), + "execute_code": ("ran", "script", "scripts"), + "search_files": ("searched", "path", "paths"), + "web_search": ("searched the web", "time", "times"), + "session_search": ("searched sessions", "time", "times"), + "browser_navigate": ("browsed", "page", "pages"), + "skill_view": ("read", "skill", "skills"), + "skill_manage": ("updated", "skill", "skills"), + "skills_list": ("listed skills", "time", "times"), + "todo": ("updated", "task list", "task lists"), + "delegate_task": ("delegated", "task", "tasks"), + "memory": ("updated", "memory", "memories"), +} + +# Verb groups that carry file-edit line deltas (+X -Y) when known. +_EDIT_VERB = "edited" + +# Render order: edits first (the thing users most want confirmed), then reads, +# then commands. Anything else follows in first-seen order. +_VERB_PRIORITY: tuple[str, ...] = ("edited", "read", "ran") + +# Tools whose results may report a unified diff we can count lines from. +_DIFF_RESULT_TOOLS = frozenset({"patch"}) + + +@dataclass +class TurnTally: + """What a single turn did, as observed from the tool-progress feed.""" + + # verb -> {noun_plural: count}; keeps insertion order for stable rendering. + verbs: dict[str, dict[str, int]] = field(default_factory=dict) + # Tools with no curated verb, counted together. + other_tools: int = 0 + # Aggregated unified-diff line deltas across edit tools, when reported. + lines_added: int = 0 + lines_removed: int = 0 + # True once at least one edit tool reported a countable diff, so the + # formatter knows the difference between "+0 -0" and "unknown". + has_line_deltas: bool = False + + @property + def total_tools(self) -> int: + counted = sum(sum(nouns.values()) for nouns in self.verbs.values()) + return counted + self.other_tools + + +def _count_diff_lines(diff: str) -> tuple[int, int]: + """Count added/removed lines in unified-diff text. + + File headers (``+++``/``---``) are excluded so a one-line edit does not + read as three additions. + """ + added = removed = 0 + for line in diff.splitlines(): + if line.startswith("+++") or line.startswith("---"): + continue + if line.startswith("+"): + added += 1 + elif line.startswith("-"): + removed += 1 + return added, removed + + +def _extract_line_deltas(tool_name: str, result: Any) -> tuple[int, int] | None: + """Pull (added, removed) from a tool result, or None when unavailable. + + Only tools that already report a diff in their result payload are + inspected — we never shell out to git and never re-read files to + synthesise a delta. + """ + if tool_name not in _DIFF_RESULT_TOOLS: + return None + payload: Any = result + if isinstance(payload, str): + text = payload.strip() + if not text.startswith("{"): + return None + try: + import json + + # strict=False tolerates literal control characters inside strings + # (raw newlines in an embedded diff), which some tool serialisers + # emit. A tally line is never worth failing over formatting. + payload = json.loads(text, strict=False) + except Exception: + return None + if not isinstance(payload, dict): + return None + diff = payload.get("diff") + if not isinstance(diff, str) or not diff.strip(): + return None + added, removed = _count_diff_lines(diff) + # A diff that carries no +/- content lines (e.g. a bare hunk header) tells + # us nothing — report it as unknown rather than rendering a misleading + # "+0 -0" next to a real edit. + if added == 0 and removed == 0: + return None + return added, removed + + +class TurnSummaryCollector: + """Accumulate per-turn tool tallies from the tool-progress feed. + + Wired into the CLI's existing ``_on_tool_progress`` handler: the display + layer already receives every ``tool.completed`` event with the tool name + and raw result, so no agent-loop bookkeeping is added. + """ + + def __init__(self) -> None: + self._tally = TurnTally() + + def begin(self) -> None: + """Start a fresh turn (drops any prior tally).""" + self._tally = TurnTally() + + def record_tool( + self, + tool_name: str | None, + *, + result: Any = None, + is_error: bool = False, + ) -> None: + """Record one completed tool call. + + Failed calls are skipped: a summary claiming "edited 2 files" when one + write was denied would be exactly the over-claim the file-mutation + verifier exists to catch. + """ + if not tool_name or is_error: + return + # Internal/pseudo tools (``_thinking``) are not user-visible work. + if tool_name.startswith("_"): + return + + group = _VERB_GROUPS.get(tool_name) + if group is None: + self._tally.other_tools += 1 + return + + verb, _singular, plural = group + nouns = self._tally.verbs.setdefault(verb, {}) + nouns[plural] = nouns.get(plural, 0) + 1 + + if verb == _EDIT_VERB: + deltas = _extract_line_deltas(tool_name, result) + if deltas is not None: + added, removed = deltas + self._tally.lines_added += added + self._tally.lines_removed += removed + self._tally.has_line_deltas = True + + @property + def tally(self) -> TurnTally: + return self._tally + + def render(self, elapsed_seconds: float) -> str: + """Render this turn's summary line (see :func:`format_turn_summary`).""" + return format_turn_summary(elapsed_seconds, self._tally) + + +def format_elapsed(seconds: float) -> str: + """Format a wall-clock duration compactly (``12.4s`` / ``2m05s``).""" + if seconds < 0: + seconds = 0.0 + if seconds < 60: + return f"{seconds:.1f}s" + minutes, rest = divmod(int(round(seconds)), 60) + return f"{minutes}m{rest:02d}s" + + +def _pluralize(count: int, plural_noun: str) -> str: + """Return ``"1 file"`` / ``"3 files"`` from a plural noun form.""" + if count == 1: + singular = plural_noun + if plural_noun.endswith("ies"): + singular = plural_noun[:-3] + "y" + elif plural_noun.endswith("ses"): + singular = plural_noun[:-2] + elif plural_noun.endswith("s"): + singular = plural_noun[:-1] + return f"1 {singular}" + return f"{count} {plural_noun}" + + +def _ordered_verbs(tally: TurnTally) -> list[str]: + """Verbs in render order: priority verbs first, then first-seen order.""" + seen = list(tally.verbs.keys()) + ranked = [v for v in _VERB_PRIORITY if v in tally.verbs] + ranked += [v for v in seen if v not in _VERB_PRIORITY] + return ranked + + +def format_turn_summary( + elapsed_seconds: float, + tally: TurnTally | None, + *, + max_segments: int = _MAX_SEGMENTS, +) -> str: + """Render the per-turn accounting line, or ``""`` when there's nothing to say. + + Pure function — no config lookups, no terminal access, no I/O. Gating + (``display.turn_summary``, quiet mode, CLI-only) is the caller's job. + """ + if tally is None: + tally = TurnTally() + + segments: list[str] = [] + for verb in _ordered_verbs(tally): + nouns = tally.verbs[verb] + parts = [_pluralize(count, plural) for plural, count in nouns.items() if count] + if not parts: + continue + segment = f"{verb} {', '.join(parts)}" + if verb == _EDIT_VERB and tally.has_line_deltas: + segment += f" +{tally.lines_added} -{tally.lines_removed}" + segments.append(segment) + + if tally.other_tools: + segments.append(f"called {_pluralize(tally.other_tools, 'tools')}") + + if not segments and tally.total_tools == 0 and elapsed_seconds < _MIN_TOOLLESS_SECONDS: + return "" + + if max_segments > 0 and len(segments) > max_segments: + hidden = len(segments) - max_segments + segments = segments[:max_segments] + [f"+{hidden} more"] + + pieces = [format_elapsed(elapsed_seconds)] + segments + return f"{SUMMARY_PREFIX} " + " · ".join(pieces) + + +def format_token_flow(output_tokens: Any, *, arrow: str = "↓") -> str: + """Render cumulative turn tokens for the live spinner (``↓ 1.2k tok``). + + Returns ``""`` for a non-positive count so the spinner shows nothing + rather than a misleading ``↓ 0 tok`` before the first API response lands. + """ + try: + count = int(output_tokens) + except (TypeError, ValueError): + return "" + if count <= 0: + return "" + if count < 1000: + return f"{arrow} {count} tok" + if count < 1_000_000: + return f"{arrow} {count / 1000:.1f}k tok" + return f"{arrow} {count / 1_000_000:.1f}M tok" diff --git a/agent/verification_evidence.py b/agent/verification_evidence.py index c3154378f5e..2c8d1f85efa 100644 --- a/agent/verification_evidence.py +++ b/agent/verification_evidence.py @@ -13,10 +13,11 @@ import shlex import sqlite3 import tempfile import threading +from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Optional +from typing import Any, Iterator, Optional from hermes_constants import get_hermes_home @@ -65,13 +66,38 @@ def _connect() -> sqlite3.Connection: path = _db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) - apply_wal_with_fallback(conn, db_label="verification_evidence.db") - conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row - _ensure_schema(conn) + try: + apply_wal_with_fallback(conn, db_label="verification_evidence.db") + conn.execute("PRAGMA busy_timeout=5000") + _ensure_schema(conn) + except Exception: + # A PRAGMA/DDL failure after a successful connect() must not leak the + # just-opened connection back to the caller. + conn.close() + raise return conn +@contextmanager +def _transaction() -> Iterator[sqlite3.Connection]: + """Open a connection, commit/rollback on exit, and ALWAYS close it. + + ``sqlite3.Connection.__enter__``/``__exit__`` only commit or roll back the + transaction; they do not close the connection. Using ``with _connect()`` + alone therefore leaks a connection — and its WAL/SHM file descriptors — on + every call, deferring the close to the garbage collector, which over a + long-running process can exhaust ``RLIMIT_NOFILE`` (the cron-ledger sibling + of this bug was #69567 / PR #69594). + """ + conn = _connect() + try: + with conn: + yield conn + finally: + conn.close() + + def _ensure_schema(conn: sqlite3.Connection) -> None: conn.execute( """ @@ -454,7 +480,7 @@ def record_terminal_result( created_at = _utc_now() with _DB_LOCK: - with _connect() as conn: + with _transaction() as conn: cur = conn.execute( """ INSERT INTO verification_events( @@ -520,7 +546,7 @@ def mark_workspace_edited( edited_at = _utc_now() with _DB_LOCK: - with _connect() as conn: + with _transaction() as conn: row = conn.execute( """ SELECT changed_paths_json FROM verification_state @@ -570,7 +596,7 @@ def verification_status( sid = str(session_id or "default") root = str(facts.get("root") or Path(cwd or ".").resolve()) with _DB_LOCK: - with _connect() as conn: + with _transaction() as conn: state = conn.execute( """ SELECT last_event_id, last_edit_at, changed_paths_json diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index 1d70ec59a61..f78b26134e4 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -12,11 +12,11 @@ //! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`. //! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::{mpsc, Mutex}; @@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool { && resolve_hermes_desktop_exe(install_root).is_some() } +fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option { + if let Some(commit) = pin + .commit + .as_ref() + .filter(|commit| !commit.trim().is_empty()) + { + return Some(commit.clone()); + } + + let output = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(install_root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if commit.is_empty() { + None + } else { + Some(commit) + } +} + +fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result { + use std::io::Write; + + let marker_path = crate::paths::likely_bootstrap_marker(install_root); + if let Some(parent) = marker_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "could not create bootstrap marker directory {}", + parent.display() + ) + })?; + } + + let completed_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let marker = serde_json::json!({ + "schemaVersion": 1, + "pinnedCommit": resolve_marker_commit(install_root, pin), + "pinnedBranch": pin.branch.clone(), + "completedAtUnix": completed_at_unix, + }); + let mut body = serde_json::to_vec_pretty(&marker)?; + body.push(b'\n'); + + // Atomic publish (temp sibling + flush + rename), matching Electron's + // writeFileAtomic(). hermes_is_installed() only checks existence, so a + // partial direct write would incorrectly enable the launcher fast path. + let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp"); + { + let mut file = std::fs::File::create(&tmp_path).with_context(|| { + format!( + "could not create temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.write_all(&body).with_context(|| { + format!( + "could not write temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.sync_all().with_context(|| { + format!( + "could not flush temp bootstrap marker {}", + tmp_path.display() + ) + })?; + } + // Windows rename fails if the destination already exists; drop any prior + // marker first so a re-run can still publish a fresh payload. + if marker_path.exists() { + std::fs::remove_file(&marker_path).with_context(|| { + format!( + "could not replace existing bootstrap marker {}", + marker_path.display() + ) + })?; + } + if let Err(err) = std::fs::rename(&tmp_path, &marker_path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err).with_context(|| { + format!( + "could not publish bootstrap marker {} → {}", + tmp_path.display(), + marker_path.display() + ) + }); + } + + tracing::info!(path = %marker_path.display(), "bootstrap marker written"); + Ok(marker) +} + /// Spawn the already-built desktop app, detached. Returns Err if no built app /// exists or the spawn fails, so the caller can fall back to showing the /// installer UI. @@ -644,6 +745,23 @@ async fn run_bootstrap( .unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned()); let install_root = PathBuf::from(&hermes_home).join("hermes-agent"); + // Marker publish is terminal for this run: a write failure must emit Failed + // so the UI leaves the progress state (it does not poll get_bootstrap_status). + let marker = match write_bootstrap_complete_marker(&install_root, &pin) { + Ok(marker) => marker, + Err(err) => { + let msg = format!("write bootstrap marker failed: {err:#}"); + emit_event( + &app, + BootstrapEvent::Failed { + stage: None, + error: msg.clone(), + }, + ); + return Err(anyhow!(msg)); + } + }; + // Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can // re-invoke us with `--update` and shortcuts have a stable target. This is // a one-shot install concern; an `--update` re-invocation no-ops because @@ -660,10 +778,7 @@ async fn run_bootstrap( &app, BootstrapEvent::Complete { install_root: install_root.to_string_lossy().into_owned(), - marker: Some(serde_json::json!({ - "pinnedCommit": pin.commit, - "pinnedBranch": pin.branch, - })), + marker: Some(marker), }, ); @@ -903,4 +1018,103 @@ mod tests { ); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn bootstrap_complete_marker_uses_desktop_compatible_schema() { + let root = unique_tmp_dir("marker-schema"); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let marker = + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + let marker_path = root.join(".hermes-bootstrap-complete"); + let from_disk: serde_json::Value = + serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap(); + + assert_eq!(marker, from_disk); + assert_eq!(from_disk["schemaVersion"], 1); + assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890"); + assert_eq!(from_disk["pinnedBranch"], "main"); + assert!( + from_disk["completedAtUnix"].as_u64().is_some(), + "marker must carry a completion timestamp" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_complete_marker_is_published_atomically() { + let root = unique_tmp_dir("marker-atomic"); + make_release_tree(&root); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + + let marker_path = root.join(".hermes-bootstrap-complete"); + let tmp_path = root.join(".hermes-bootstrap-complete.tmp"); + assert!( + marker_path.is_file(), + "final marker must exist after atomic publish" + ); + assert!( + !tmp_path.exists(), + "temp sibling must not remain after atomic publish" + ); + assert!( + hermes_is_installed(&root), + "atomically published marker must enable the installer fast path" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn hermes_is_installed_treats_marker_existence_as_sufficient() { + // Documents why write_bootstrap_complete_marker must publish atomically: + // the launcher predicate only checks existence, so a partial/corrupt + // final marker would still enable the fast path. + let root = unique_tmp_dir("marker-existence-only"); + make_release_tree(&root); + std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap(); + + assert!( + hermes_is_installed(&root), + "empty/partial marker content still counts as installed" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn marker_write_failure_leaves_no_final_marker() { + // install_root is a regular file → create_dir_all on its path fails + // before any marker bytes are published under the final name. + let base = unique_tmp_dir("marker-fail"); + let not_a_dir = base.join("not-a-dir"); + std::fs::write(¬_a_dir, b"not a directory").unwrap(); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let err = write_bootstrap_complete_marker(¬_a_dir, &pin) + .expect_err("marker write against a non-directory root must fail"); + let msg = format!("{err:#}"); + assert!( + msg.contains("bootstrap marker"), + "error should mention the marker path: {msg}" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete").exists(), + "failed write must not leave a final marker that enables the fast path" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(), + "failed write must not leave a temp marker sibling either" + ); + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 7c64c91cf6e..0eec8ccd319 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) { #[cfg(not(target_os = "macos"))] fn repair_macos_installer_helper(_path: &Path) {} -/// Where install.ps1 writes the bootstrap-complete marker (existence-only file -/// the Electron app also checks). Per main.ts: +/// Where the bootstrap-complete marker lives (existence-only for the Rust +/// installer fast path; JSON schema-checked by the Electron app). Per main.ts: /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// this is a probe helper, not a definitive path. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a1da176726d..706611acb1a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -30,7 +30,7 @@ Already have the Hermes CLI? Just run: hermes desktop ``` -It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure. +It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model. ### Prebuilt installers @@ -134,6 +134,19 @@ Desktop supports a managed local backend, explicit remote gateways, and Hermes Cloud connections. Remote and cloud modes use the same remote-capability path; authentication and discovery differ, not the renderer feature model. +When no usable local runtime or saved remote connection exists, the first-run +screen offers **Connect to existing Hermes** before starting the local installer. +Desktop probes the gateway to discover token or OAuth authentication, requires a +successful HTTP and WebSocket connection test, and saves the connection using +the same encrypted Desktop configuration used by Settings. A saved remote +connection bypasses this choice on later launches. The regular Desktop build +still includes the local-install option; this is a remote operating mode, not a +separate client-only application. + +In remote mode the gateway host is the execution boundary: agent tools, +terminal commands, and file operations run against the remote Hermes host, not +the computer displaying the Desktop UI. + Projects are the workspace abstraction. A project may own multiple folders, repositories, worktrees, and sessions; a bare new chat remains detached unless the user enters a project or configures a default project directory. Use the diff --git a/apps/desktop/e2e/correction-session-switch.spec.ts b/apps/desktop/e2e/correction-session-switch.spec.ts index dd32caa61e7..dc435b1d538 100644 --- a/apps/desktop/e2e/correction-session-switch.spec.ts +++ b/apps/desktop/e2e/correction-session-switch.spec.ts @@ -21,8 +21,16 @@ const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER' const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.` const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.` +// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the +// renderer's keep-alive visibility policy instead of relying on DOM order. +const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])' + +function activeSurface(page: Page) { + return page.locator(SURFACE).last() +} + async function send(page: Page, text: string): Promise { - const composer = page.locator('[contenteditable="true"]').first() + const composer = activeSurface(page).locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 15_000 }) await composer.click() await composer.type(text, { delay: 5 }) @@ -30,8 +38,9 @@ async function send(page: Page, text: string): Promise { } async function steer(page: Page, text: string): Promise { - const composer = page.locator('[contenteditable="true"]').first() - const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + const surface = activeSurface(page) + const composer = surface.locator('[contenteditable="true"]').first() + const primary = surface.locator('[data-slot="composer-root"] button[type="submit"]') await composer.waitFor({ state: 'visible', timeout: 15_000 }) await composer.click() @@ -42,55 +51,80 @@ async function steer(page: Page, text: string): Promise { async function waitForTranscriptText(page: Page, text: string): Promise { await page.waitForFunction( - (expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), - text, + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + + return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) + }, + [text, SURFACE] as [string, string], { timeout: 30_000 }, ) } async function textNodeOccurrences(page: Page, text: string): Promise { - return page.evaluate((expected: string) => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - if (!viewport) return 0 + return page.evaluate( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return 0 - const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) - let count = 0 - while (walker.nextNode()) { - if (walker.currentNode.textContent?.includes(expected)) { - count += 1 + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) + let count = 0 + while (walker.nextNode()) { + if (walker.currentNode.textContent?.includes(expected)) { + count += 1 + } } - } - return count - }, text) + return count + }, + [text, SURFACE] as [string, string], + ) } async function transcriptTextOrder(page: Page): Promise { - return page.evaluate(() => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') if (!viewport) return [] return Array.from(viewport.querySelectorAll('[data-role="message"], [data-message-id]')) .map(message => message.textContent?.trim() ?? '') .filter(Boolean) - }) + }, SURFACE) } async function transcriptMessageOrder(page: Page): Promise { - return page.evaluate(() => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') if (!viewport) return [] - return Array.from(viewport.querySelectorAll('[data-role="user"], [data-role="assistant"]')) + return Array.from( + viewport.querySelectorAll('[data-role="user"], [data-role="assistant"], [data-role="system"]'), + ) .map(message => message.textContent?.trim() ?? '') .filter(Boolean) - }) + }, SURFACE) } +/** + * The sidebar "+" opens a NEW TAB beside the current chat rather than + * replacing it, so the prior session stays mounted in its own surface. Wait + * for the newly-mounted surface to show an empty transcript instead of waiting + * for the old text to disappear from the page (it never will). + */ async function openFreshDraft(page: Page, priorSessionText: string): Promise { await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() await page.waitForFunction( - (priorText: string) => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(priorText), - priorSessionText, + ([priorText, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + const transcript = active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return surfaces.length > 0 && !transcript.includes(priorText) + }, + [priorSessionText, SURFACE] as [string, string], { timeout: 15_000 }, ) } @@ -116,7 +150,12 @@ async function reopenInferenceSession(page: Page): Promise { } function relevantOrder(messages: string[]): string[] { - return messages.filter(message => message.includes(ORIGINAL_PROMPT) || message.includes(CORRECTION)) + return messages.flatMap(message => { + if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT] + if (message.includes(CORRECTION)) return [CORRECTION] + + return [] + }) } function steerTurnOrder(messages: string[]): string[] { diff --git a/apps/desktop/e2e/image-attachment-resume.spec.ts b/apps/desktop/e2e/image-attachment-resume.spec.ts new file mode 100644 index 00000000000..a4f8da68e39 --- /dev/null +++ b/apps/desktop/e2e/image-attachment-resume.spec.ts @@ -0,0 +1,197 @@ +/** + * Regression coverage for an attached image in a durable session. The gateway + * persists the turn, the builder exits, and desktop renders it from SessionDB + * for the first time — the "quit and relaunch" case, where the transcript used + * to come back as vision-enrichment prose instead of a thumbnail. + * + * The fixture pins `image_input_mode: native` because that is the majority + * routing path (any vision-capable model) and the one where a text-only + * persist override is silently dropped. The image also sits behind directory + * and file names containing spaces, mirroring the macOS composer's + * `~/Library/Application Support/...` staging path. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type Sandbox, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { type MockServer, startMockServer } from './mock-server' +import { RealSessionBuilder } from './real-session-builder' +import { type ElectronApplication, expect, type Page, test } from './test' + +// A seeded session has no generated title, so every label falls back to the +// session preview — the first 60 characters of the first user message. +const SESSION_TITLE = 'E2E attached image session' +const CAPTION = 'E2E attached image must survive a relaunch' +const IMAGE_DIR = 'Application Support/e2e shots' +const IMAGE_NAME = 'e2e capture.png' +const NATIVE_IMAGE_CONFIG = 'agent:\n image_input_mode: native' + +/** A 160x100 framed magenta block — small, but visible in the screenshots. */ +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABkCAIAAACO1KzYAAAA30lEQVR42u3dwQ2AIBAAQTAWAx1iBXYI7diCuWhEMvP2dZsj+CL30hLr2oxAYARGYARGYARGYIERGIH53n7nozpOk5rQqIcNdkQjMAIjMNPeomP3N54V+5exwY5oBEZgBEZgBEZggREYgREYgREYgQVGYARGYARGYARGYIERGIERGIERGIEFRmAERmAERmAEFhiBERiBERiBERiBBUZgBEZgBEZgBBYYgREYgREYgRFYYCMQGIERmPSj94Njb9ligxEYgRFYYJaQe2mmYIMRGIERGIERGIEFRmAERmDedAFtjAtAGWDnoAAAAABJRU5ErkJggg==' + +interface SeededFixture { + app: ElectronApplication + mock: MockServer + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +function writeImage(sandbox: Sandbox): string { + const dir = path.join(sandbox.root, IMAGE_DIR) + fs.mkdirSync(dir, { recursive: true }) + + const imagePath = path.join(dir, IMAGE_NAME) + fs.writeFileSync(imagePath, Buffer.from(PNG_BASE64, 'base64')) + + return imagePath +} + +async function setupSeededDesktop(): Promise { + const mock = await startMockServer() + const sandbox = createSandbox('image-attachment') + writeMockProviderConfig(sandbox.hermesHome, mock.url, undefined, NATIVE_IMAGE_CONFIG) + writeEnvFile(sandbox.hermesHome) + + const builder = await RealSessionBuilder.start(sandbox.hermesHome) + + try { + await builder.createSession({ + title: SESSION_TITLE, + turns: [{ images: [writeImage(sandbox)], text: CAPTION }], + }) + } finally { + await builder.close() + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + return { + app, + mock, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +function sessionRow(page: Page) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: CAPTION }).first() +} + +// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the +// renderer's keep-alive visibility policy instead of relying on DOM order. +const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])' + +function activeViewportText(surfaceSelector: string): string { + const surfaces = document.querySelectorAll(surfaceSelector) + + return surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' +} + +async function openSeededSession(page: Page): Promise { + const row = sessionRow(page) + await row.waitFor({ state: 'visible', timeout: 60_000 }) + await row.click() + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return text.includes(expected) + }, + [CAPTION, SURFACE] as [string, string], + { timeout: 30_000 }, + ) +} + +/** + * The sidebar "+" opens a NEW TAB beside the current chat instead of replacing + * it, so the seeded session stays mounted in its own surface. Assert the new + * surface is empty rather than waiting for the old caption to leave the page. + */ +async function openNewSession(page: Page): Promise { + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return surfaces.length > 0 && !text.includes(expected) + }, + [CAPTION, SURFACE] as [string, string], + { timeout: 15_000 }, + ) +} + +async function transcriptText(page: Page): Promise { + return page.evaluate(activeViewportText, SURFACE) +} + +async function assertRendersThumbnail(page: Page, label: string): Promise { + const thumbnail = page.locator('[data-slot="aui_directive-image"] img') + await expect(thumbnail, `${label}: the attachment should render as an image`).toHaveCount(1) + await expect(thumbnail, `${label}: the thumbnail should resolve off disk`).toHaveAttribute('src', /^data:image\//) + + const text = await transcriptText(page) + expect(text, `${label}: the caption should survive alongside the image`).toContain(CAPTION) + // A broken ref falls back to a chip whose label leaks the path, and a + // flattened multimodal turn leaves the agent's placeholder behind. + expect(text, `${label}: the raw image path should not leak into the transcript`).not.toContain(IMAGE_NAME) + expect(text, `${label}: the image directive should not render literally`).not.toContain('@image:') + expect(text, `${label}: the flattening placeholder should not render`).not.toContain('[screenshot]') +} + +test.describe('attached image resume', () => { + let fixture: SeededFixture | null = null + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('renders a persisted attachment as a thumbnail on first open and after a cold reload', async ({}, testInfo) => { + // Seeding through the real gateway plus two full app boots does not fit the + // default per-test budget on a cold runner. + test.slow() + + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + // The sidebar labels a session by its preview, so the caption has to lead + // the persisted turn — a leading directive reads as a truncated file path. + const row = sessionRow(fixture.page) + await row.waitFor({ state: 'visible', timeout: 60_000 }) + + const label = (await row.textContent())?.trim() ?? '' + expect(label.startsWith(CAPTION), `sidebar label should open with the caption: ${label}`).toBe(true) + + await openSeededSession(fixture.page) + await assertRendersThumbnail(fixture.page, 'first open') + await fixture.page.screenshot({ path: testInfo.outputPath('attachment-first-open.png') }) + + // A reload drops every cached attachment ref, so the transcript has to come + // back from the persisted turn alone. + await fixture.page.reload() + await waitForAppReady(fixture, 120_000) + await openNewSession(fixture.page) + + await openSeededSession(fixture.page) + await assertRendersThumbnail(fixture.page, 'cold reload') + await fixture.page.screenshot({ path: testInfo.outputPath('attachment-cold-reload.png') }) + }) +}) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index b39ab3f1a4d..02c5f16937d 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -19,6 +19,12 @@ import { RealSessionBuilder } from './real-session-builder' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') const SESSION_TITLE = 'E2E large persisted session' const EXPECTED_TEXT = 'E2E persisted user message 52' +// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only +// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a +// baseline count taken before that backfill sees a clipped transcript and +// falsely reports duplicates once the full list mounts. Waiting for this +// oldest row means the baseline reflects the fully-mounted transcript. +const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix' const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume' const HISTORY_TURNS = Array.from( { length: 27 }, @@ -210,6 +216,16 @@ test.describe('large session resume', () => { await waitForAppReady(fixture, 120_000) await openSeededSession(fixture.page) + // The transcript first paints only the newest turns (FIRST_PAINT_BUDGET) + // and backfills older turns in a rAF. Wait for the oldest seeded row to + // mount before taking the baseline so it reflects the full transcript — + // otherwise a clipped baseline makes the backfilled rows look like + // duplicates of the completed reply. + await fixture.page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + OLDEST_SEEDED_TEXT, + { timeout: 30_000 }, + ) const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY) await submitPrompt(fixture.page, BACKGROUND_PROMPT) await fixture.mock.waitForHeldStream() diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index a198ff8989c..ce4665d1775 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -14,8 +14,11 @@ * prove the full boot → gateway → inference → renderer chain works. */ +import fs from 'node:fs' import http from 'node:http' import type { ServerResponse } from 'node:http' +import os from 'node:os' +import nodePath from 'node:path' /** A canned assistant reply used for every chat completion request. */ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.' @@ -27,6 +30,14 @@ export interface MockServerOptions { holdFirstCompletionContaining?: string /** Absolute sandbox path written by the verify-on-stop scripted tool call. */ verificationWritePath?: string +/** + * Sentinel path that ends the E2E_SIDEBAR_CROSS background process. + * + * Without it that process is a bare `sleep 5`, which races the agent turn and + * the 4s auto-dismiss linger — see `createBackgroundReleaseHandle`. Pass a + * handle's `path` to let the test decide when the process exits. + */ +backgroundReleasePath?: string } export interface MockServer { @@ -167,37 +178,68 @@ const SIDEBAR_SCRIPT: ScriptedTurn[] = [ // ─── Sidebar cross-session script ────────────────────────────────────── // -// E2E_SIDEBAR_CROSS trigger uses a longer background process (sleep 5) so -// the "background running" dot is visible long enough for the test to: +// E2E_SIDEBAR_CROSS starts a long background process plus a subagent so the +// tests can: // 1. See the background dot while the subagent runs. // 2. Open a different session and see session A's dot transition to // "finished unread" when the background process completes. +// +// The background process must outlive the agent turn — the whole point is a +// dot that is still "running" after the final answer lands. A fixed `sleep` +// cannot guarantee that: on a loaded CI runner the turn (two model round +// trips + a real subagent delegation) can take longer than the sleep, the +// process exits early, the 4s success linger elapses, and the dot is gone +// before the test looks. That is a wall-clock race between three independent +// timers, and it made this the flakiest spec in the suite. +// +// When `backgroundReleasePath` is set the process instead blocks until the +// test creates that sentinel file, so the test — not the clock — decides when +// the dot clears. `sleep 5` remains the fallback for callers that don't pass +// a handle. +function sidebarCrossBgCommand(releasePath?: string): string { + if (!releasePath) { + return 'echo "long bg output" && sleep 5 && echo "finished"' + } + // Bounded wait (60s): if a test forgets to release (or crashes mid-way), + // the process still exits instead of hanging the worker until the suite + // times out. + const quoted = JSON.stringify(releasePath) + return [ + 'echo "long bg output"', + `for _ in $(seq 1 600); do [ -e ${quoted} ] && break; sleep 0.1; done`, + 'echo "finished"', + ].join(' && ') +} -const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = [ - { - text: 'Starting a long background task and delegating work.', - toolCalls: [ - { - name: 'terminal', - args: { - command: 'echo "long bg output" && sleep 5 && echo "finished"', - background: true, - notify_on_complete: true, +function sidebarCrossScript(releasePath?: string): ScriptedTurn[] { + return [ + { + text: 'Starting a long background task and delegating work.', + toolCalls: [ + { + name: 'terminal', + args: { + command: sidebarCrossBgCommand(releasePath), + background: true, + notify_on_complete: true, + }, }, - }, - { - name: 'delegate_task', - args: { - goal: 'Analyze cross-session state', - context: 'Testing that the background dot updates across sessions.', + { + name: 'delegate_task', + args: { + goal: 'Analyze cross-session state', + context: 'Testing that the background dot updates across sessions.', + }, }, - }, - ], - }, - { - text: 'Both tasks are running in the background now.', - }, -] + ], + }, + { + text: 'Both tasks are running in the background now.', + }, + ] +} + +const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = sidebarCrossScript() const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [ { @@ -423,7 +465,8 @@ export function startMockServer(options: MockServerOptions = {}): Promise void + /** Remove the sentinel if it still exists. Safe to call twice. */ + cleanup: () => void +} + +/** + * Create a sentinel that keeps the E2E_SIDEBAR_CROSS background process alive + * until the test explicitly releases it. + * + * The cross-session sidebar tests need a background process that is still + * RUNNING after the agent turn finishes — that is the state under test (a + * session whose turn is done but whose background work is not). With a fixed + * `sleep`, three independent clocks race: the sleep, the agent turn (two model + * round trips plus a real subagent delegation), and the 4s success linger + * before a finished task auto-dismisses. When a loaded CI runner makes the + * turn slower than the sleep, the process is already gone and the assertion + * samples an empty sidebar. Observed on CI 2026-07-26 across two unrelated + * PRs: the "should appear" poll needed 7.5s to see the dot, by which point + * `sleep 5` had exited. + * + * With a sentinel there is one clock and the test owns it: + * + * ```ts + * const release = createBackgroundReleaseHandle() + * const mock = await startMockServer({ backgroundReleasePath: release.path }) + * // ... assert the dot is visible; it cannot vanish on its own ... + * release.release() // now, and only now, the process exits + * ``` + */ +export function createBackgroundReleaseHandle(): BackgroundReleaseHandle { + const path = nodePath.join( + os.tmpdir(), + `hermes-e2e-bg-release-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ) + return { + path, + release: () => { + try { + fs.writeFileSync(path, 'release') + } catch { + // The process also has a bounded fallback wait; a failed write must + // not crash the test before its real assertions run. + } + }, + cleanup: () => { + try { + fs.rmSync(path, { force: true }) + } catch { + // Best-effort — the sentinel lives in the OS temp dir. + } + }, + } +} + /** * The interim script's text constants, exported for test assertions. * Each entry is the visible text of one turn. Turns with empty text @@ -756,8 +858,12 @@ export const SIDEBAR_CROSS_TEXTS = { interimText: SIDEBAR_CROSS_SCRIPT[0].text, /** The final answer text. */ finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text, - /** The longer background process command (sleep 5). */ - bgCommand: 'echo "long bg output" && sleep 5 && echo "finished"', + /** + * The default (unheld) background process command. Tests that pass a + * `backgroundReleasePath` get a sentinel-waiting command instead — see + * `createBackgroundReleaseHandle`. + */ + bgCommand: sidebarCrossBgCommand(), /** The subagent's goal. */ subagentGoal: 'Analyze cross-session state', } as const diff --git a/apps/desktop/e2e/real-session-builder.ts b/apps/desktop/e2e/real-session-builder.ts index 488291695b5..0f670e9698b 100644 --- a/apps/desktop/e2e/real-session-builder.ts +++ b/apps/desktop/e2e/real-session-builder.ts @@ -28,11 +28,18 @@ interface CreatedSession { stored_session_id: string } +export interface RealSessionTurn { + /** Local image paths attached before the prompt, as the composer would. */ + images?: readonly string[] + text: string +} + export interface RealSessionSpec { - /** Human-visible sidebar title, persisted by the first completed turn. */ + /** Session label. The durable row stores no title, so clients fall back to + * the preview (the first 60 characters of the first user message). */ title: string /** Each item becomes one real user prompt followed by the mock provider's reply. */ - turns: readonly string[] + turns: readonly (RealSessionTurn | string)[] } export interface RealSession { @@ -107,7 +114,13 @@ export class RealSessionBuilder { const runtimeId = requireString(created, 'session_id') const sessionId = requireString(created, 'stored_session_id') - for (const text of spec.turns) { + for (const turn of spec.turns) { + const { images = [], text } = typeof turn === 'string' ? { text: turn } : turn + + for (const image of images) { + await this.request('image.attach', { session_id: runtimeId, path: image }) + } + const completion = this.waitForEvent( frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId, ) diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index 911f15c7840..e48c52cda2b 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -4,11 +4,7 @@ import { expect, test, type Page } from '@playwright/test' -import { - type MockBackendFixture, - setupMockBackend, - waitForAppReady, -} from './fixtures' +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server' async function send(page: Page, text: string, delay = 15): Promise { @@ -25,12 +21,11 @@ async function pasteAndSend(page: Page, text: string): Promise { await page.keyboard.press('Enter') } - async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise { await page.waitForFunction( expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false, text, - { timeout }, + { timeout } ) } @@ -62,23 +57,16 @@ test.describe('session compression', () => { await send(page, 'E2E_COMPRESSION_THIRD') await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1) - // Commit the command before typing its argument. This waits for the async - // completion request on cold CI workers, then uses the composer's own - // keyboard accept path to replace the `/compress` trigger with a command - // chip. Clicking a later completion after typing the argument can insert a - // second command token (for example `//compress ...`) as plain text. + // This test covers compression and continuation, not slash completion. + // Insert the complete command atomically and click Send so an async + // completion response cannot consume Enter as a picker acceptance. const composer = page.locator('[contenteditable="true"]').first() await composer.click() - await composer.type('/compress', { delay: 15 }) - await page.getByText('/compress').first().waitFor({ state: 'visible' }) - await page.keyboard.press('Enter') - await composer.type(' preserve the three test turns', { delay: 15 }) - await page.keyboard.press('Enter') + await page.keyboard.insertText('/compress preserve the three test turns') + await expect.poll(() => composer.textContent()).toContain('preserve the three test turns') + await page.getByRole('button', { name: 'Send', exact: true }).click() await expect - .poll( - () => page.locator('[data-slot="aui_thread-viewport"]').textContent(), - { timeout: 90_000 }, - ) + .poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 }) .toMatch(/Compressed|No changes from compression/) // Compression rotates the agent's live session id. A post-compression @@ -105,7 +93,7 @@ auxiliary: provider: custom model: mock-model`, mockServer: { - holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.', + holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.' } }) await waitForAppReady(fixture, 120_000) diff --git a/apps/desktop/e2e/sidebar-states.spec.ts b/apps/desktop/e2e/sidebar-states.spec.ts index 051efda3543..6d8c0c2c9c9 100644 --- a/apps/desktop/e2e/sidebar-states.spec.ts +++ b/apps/desktop/e2e/sidebar-states.spec.ts @@ -16,7 +16,12 @@ import { setupMockBackend, waitForAppReady, } from './fixtures' -import { SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, restartMockServer } from './mock-server' +import { + createBackgroundReleaseHandle, + restartMockServer, + SIDEBAR_CROSS_TEXTS, + SIDEBAR_TEXTS, +} from './mock-server' /** Background-running dot aria-label (from i18n en.ts). */ const BG_DOT_LABEL = 'Background task running' @@ -176,21 +181,30 @@ test.describe('sidebar states — cross-session dot transition', () => { test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture + // Keeps the background process alive until this test releases it, so the + // "still running after the turn finished" state can't expire on its own. + const bgRelease = createBackgroundReleaseHandle() test.beforeAll(async () => { restartMockServer() - fixture = await setupMockBackend() + fixture = await setupMockBackend({ + mockServer: { backgroundReleasePath: bgRelease.path }, + }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { + // Release first so the process exits even if the test failed early, + // then drop the sentinel file. + bgRelease.release() await fixture?.cleanup() + bgRelease.cleanup() }) test('background dot transitions to finished when viewing another session', async () => { const page = fixture.page - // Start a turn with a long background process (sleep 5). + // Start a turn whose background process runs until we release it. const composer = page.locator('[contenteditable="true"]').first() await composer.waitFor({ state: 'visible', timeout: 10_000 }) await composer.click() @@ -212,8 +226,9 @@ test.describe('sidebar states — cross-session dot transition', () => { { timeout: 90_000 }, ) - // The background dot should still be visible (sleep 5 hasn't finished yet, - // or auto-dismiss hasn't fired). + // The background dot must still be visible: the turn is done but the + // process is held open by the sentinel, so this is a stable state rather + // than a window we have to catch in time. const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0) @@ -225,8 +240,9 @@ test.describe('sidebar states — cross-session dot transition', () => { await page.locator('button:has-text("New session")').first().click() await page.waitForTimeout(2000) - // Now wait for the background process to finish (sleep 5 + auto-dismiss). - // The session A dot should transition away from "background running". + // Now let the background process finish. The session A dot should + // transition away from "background running". + bgRelease.release() await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), diff --git a/apps/desktop/e2e/tile-unread-bug.spec.ts b/apps/desktop/e2e/tile-unread-bug.spec.ts index cd87a2eb681..fa614a21d2f 100644 --- a/apps/desktop/e2e/tile-unread-bug.spec.ts +++ b/apps/desktop/e2e/tile-unread-bug.spec.ts @@ -22,7 +22,12 @@ import { setupMockBackend, waitForAppReady, } from './fixtures' -import { SIDEBAR_CROSS_TEXTS, restartMockServer } from './mock-server' +import { + type BackgroundReleaseHandle, + createBackgroundReleaseHandle, + restartMockServer, + SIDEBAR_CROSS_TEXTS, +} from './mock-server' /** Finished-unread dot aria-label. */ const UNREAD_DOT_LABEL = 'Finished — unread' @@ -34,7 +39,7 @@ function sessionRow(page: import('@playwright/test').Page, text: string) { return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first() } -/** Common setup: start a turn with a sleep 5 bg process + subagent, wait for +/** Common setup: start a turn with a held bg process + subagent, wait for * the turn to complete, then switch to a new session so the first session is * no longer $selectedStoredSessionId (required before opening a tile). */ async function startTurnAndSwitchAway(page: import('@playwright/test').Page) { @@ -67,7 +72,9 @@ async function startTurnAndSwitchAway(page: import('@playwright/test').Page) { { timeout: 90_000 }, ) - // The background dot should still be visible (sleep 5 hasn't finished). + // The background dot must still be visible: the turn is done but the + // process is held open by the sentinel, so this is a stable state rather + // than a window we have to catch in time. const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0) @@ -77,8 +84,12 @@ async function startTurnAndSwitchAway(page: import('@playwright/test').Page) { await page.waitForTimeout(2000) } -/** Wait for the background process to finish (sleep 5 + auto-dismiss). */ -async function waitForBgProcessToFinish(page: import('@playwright/test').Page) { +/** Release the held background process, then wait for its dot to clear. */ +async function waitForBgProcessToFinish( + page: import('@playwright/test').Page, + release?: BackgroundReleaseHandle, +) { + release?.release() await expect .poll( () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), @@ -95,15 +106,20 @@ test.describe('sidebar states — tab (hidden) unread is correct', () => { test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture + const bgRelease = createBackgroundReleaseHandle() test.beforeAll(async () => { restartMockServer() - fixture = await setupMockBackend() + fixture = await setupMockBackend({ + mockServer: { backgroundReleasePath: bgRelease.path }, + }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { + bgRelease.release() await fixture?.cleanup() + bgRelease.cleanup() }) test('session opened as a tab (not visible) correctly gets unread dot', async () => { @@ -123,12 +139,21 @@ test.describe('sidebar states — tab (hidden) unread is correct', () => { // Evidence: the tab is open but the session is not visible on screen. await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' }) - await waitForBgProcessToFinish(page) + await waitForBgProcessToFinish(page, bgRelease) // A tab that's not the active tab IS hidden — the unread dot is correct. // The user is NOT looking at it, so marking it "unread" is right. - const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count() - expect(unreadCount, 'hidden tab should be marked unread').toBeGreaterThan(0) + // + // Poll rather than sampling once: "finished-unread" is an event-driven + // transition that lands slightly after the running dot clears, and with a + // released (rather than slowly-expiring) process there is no incidental + // slack between the two. Same reasoning as the cross-session spec. + await expect + .poll( + () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'hidden tab should be marked unread' }, + ) + .toBeGreaterThan(0) await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' }) }) @@ -142,15 +167,20 @@ test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => test.describe.configure({ mode: 'serial' }) let fixture: MockBackendFixture + const bgRelease = createBackgroundReleaseHandle() test.beforeAll(async () => { restartMockServer() - fixture = await setupMockBackend() + fixture = await setupMockBackend({ + mockServer: { backgroundReleasePath: bgRelease.path }, + }) await waitForAppReady(fixture, 120_000) }) test.afterAll(async () => { + bgRelease.release() await fixture?.cleanup() + bgRelease.cleanup() }) test('session visible in a split tile does NOT get unread dot when it finishes', async () => { @@ -196,7 +226,7 @@ test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => // Evidence: the split tile is now open side-by-side — both sessions visible. await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' }) - await waitForBgProcessToFinish(page) + await waitForBgProcessToFinish(page, bgRelease) // THE BUG: the session visible in the split tile should NOT have the green // "finished unread" dot — the user is looking right at it. This assertion diff --git a/apps/desktop/e2e/warm-resume-jitter.spec.ts b/apps/desktop/e2e/warm-resume-jitter.spec.ts index 72e71087d69..dbc0fe5dd11 100644 --- a/apps/desktop/e2e/warm-resume-jitter.spec.ts +++ b/apps/desktop/e2e/warm-resume-jitter.spec.ts @@ -24,6 +24,8 @@ * MutationObserver burst), but `$messages` was still set twice. * * The test passes when bursts === 1 AND reconciles === 0. + * The sidebar "+" keeps the session warm in another tab. Its reactivation + * follows the same contract: one additive paint and zero reconciles. * * Prerequisite: `npm run build` must have been run so dist/ exists. */ @@ -43,6 +45,11 @@ import { startMockServer } from './mock-server' import { RealSessionBuilder } from './real-session-builder' const SESSION_TITLE = 'E2E Warm Resume Jitter Test' + +// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the +// renderer's keep-alive visibility policy instead of relying on DOM order. +const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])' +const ALL_SURFACES = '[data-composer-target]' /** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */ const MESSAGE_COUNT = 32 /** Seeded PRNG so the generated content is deterministic across runs. */ @@ -154,15 +161,29 @@ test.afterAll(async () => { * after the initial paint, catching key-based reconciles that don't * add/remove nodes. */ -async function installRenderCounter(page: import('@playwright/test').Page): Promise { - await page.evaluate(() => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') +async function installRenderCounter( + page: import('@playwright/test').Page, + transcriptText?: string, +): Promise { + await page.evaluate(([visibleSelector, allSelector, expected]: [string, string, string | undefined]) => { + const surfaces = [...document.querySelectorAll(expected ? allSelector : visibleSelector)] + const surface = expected + ? surfaces.find(candidate => + (candidate.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + ) + : surfaces.at(-1) + const viewport = surface?.querySelector('[data-slot="aui_thread-viewport"]') if (!viewport) { throw new Error('Thread viewport not found before warm resume') } const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 } - ;(window as unknown as { __RENDER_COUNT__: typeof state }).__RENDER_COUNT__ = state + const debugWindow = window as unknown as { + __RENDER_COUNT__: typeof state + __RENDER_VIEWPORT__: Element + } + debugWindow.__RENDER_COUNT__ = state + debugWindow.__RENDER_VIEWPORT__ = viewport let currentBatch = 0 let flushTimer: ReturnType | null = null @@ -224,7 +245,53 @@ async function installRenderCounter(page: import('@playwright/test').Page): Prom hasMessages = true } }, 2) - }) + }, [SURFACE, ALL_SURFACES, transcriptText] as [string, string, string | undefined]) +} + +/** Wait until the ACTIVE chat surface's transcript contains `text`. */ +async function waitForActiveTranscriptText( + page: import('@playwright/test').Page, + text: string, + timeout = 30_000, +): Promise { + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + + return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) + }, + [text, SURFACE] as [string, string], + { timeout }, + ) +} + +async function waitForActiveTranscriptWithoutText( + page: import('@playwright/test').Page, + text: string, +): Promise { + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + + return !(active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) + }, + [text, SURFACE] as [string, string], + { timeout: 15_000 }, + ) +} + +/** Replace the primary surface with a draft while retaining its warm cache. */ +async function openFreshDraft(page: import('@playwright/test').Page, priorText: string): Promise { + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N') + await waitForActiveTranscriptWithoutText(page, priorText) +} + +/** Stack an empty tab while leaving the current transcript mounted and warm. */ +async function openNewSessionTab(page: import('@playwright/test').Page, priorText: string): Promise { + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await waitForActiveTranscriptWithoutText(page, priorText) } /** Stop the render counter and return the recorded burst/reconcile counts. */ @@ -245,6 +312,30 @@ async function readRenderCount(page: import('@playwright/test').Page): Promise<{ }) } +async function observedViewportIsActive(page: import('@playwright/test').Page): Promise { + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const activeViewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') + const observedViewport = (window as unknown as { __RENDER_VIEWPORT__?: Element }).__RENDER_VIEWPORT__ + + return activeViewport === observedViewport + }, SURFACE) +} + +/** A kept-alive tab must become visible without rebuilding its transcript. */ +function assertNoRepaint(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void { + expect(result, 'MutationObserver should have recorded render data').toBeTruthy() + expect( + result!.bursts, + `Expected no additive render bursts for a kept-alive tab, but got ${result!.bursts}. ` + + `Mutation timeline: ${JSON.stringify(result!.timeline)}.`, + ).toBe(0) + expect( + result!.reconciles, + `Expected no transcript reconciles for a kept-alive tab, but got ${result!.reconciles}.`, + ).toBe(0) +} + /** Assert the render counter shows exactly one paint with no re-renders. */ function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void { expect(result, 'MutationObserver should have recorded render data').toBeTruthy() @@ -261,7 +352,7 @@ function assertNoJitter(result: { bursts: number; mutations: number; timeline: n ).toBe(0) } -test('warm-route resume paints transcript exactly once (no jitter)', async ({}, testInfo) => { +test('tab reactivation preserves the mounted transcript without repainting', async ({}, testInfo) => { const page = fixture!.page // Wait for the sidebar to populate with our seeded session. @@ -277,63 +368,29 @@ test('warm-route resume paints transcript exactly once (no jitter)', async ({}, // Wait for the transcript to appear — the first user message text confirms // the cold-path prefetch painted. - await page.waitForFunction( - (text: string) => - document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? - false, - FIRST_USER_MSG, - { timeout: 30_000 }, - ) + await waitForActiveTranscriptText(page, FIRST_USER_MSG) // Wait for the session to fully settle (cold-path RPC + reconciliation). await page.waitForTimeout(2_000) - // Step 2: Navigate away to a new chat — this does NOT evict the warm cache. - const newSessionButton = page - .locator('[data-slot="sidebar"] button[aria-label="New session"]') - .first() - await newSessionButton.click() - - // Wait for the new-chat empty state. - await page.waitForFunction( - (firstMsg: string) => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - if (!viewport) return false - const text = viewport.textContent ?? '' - return !text.includes(firstMsg) - }, - FIRST_USER_MSG, - { timeout: 15_000 }, - ) - + // Stack a new tab, then observe the seeded transcript while it is hidden. + // Installing after the switch isolates reactivation from mutations caused + // while the new tab was being created. + await openNewSessionTab(page, FIRST_USER_MSG) await page.waitForTimeout(500) + await installRenderCounter(page, FIRST_USER_MSG) - // Step 3: Install render counter, click back (warm resume), wait, assert. - await installRenderCounter(page) + // Step 3: Click back and verify the same kept-alive viewport becomes active + // without rebuilding or reconciling its transcript. await sessionRow.click() - await page.waitForFunction( - (text: string) => - document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? - false, - FIRST_USER_MSG, - { timeout: 30_000 }, - ) - - // Wait for at least 1 burst, then settle. - await page.waitForFunction( - () => { - const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } } - return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0) - }, - undefined, - { timeout: 10_000 }, - ) + await waitForActiveTranscriptText(page, FIRST_USER_MSG) await page.waitForTimeout(2_000) + expect(await observedViewportIsActive(page), 'Reactivation should reveal the observed kept-alive viewport').toBe(true) const result = await readRenderCount(page) await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') }) - assertNoJitter(result) + assertNoRepaint(result) }) test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => { @@ -354,13 +411,7 @@ test('warm-route resume after background inference completes (no jitter)', async // Step 1: Cold resume — populate the warm cache. await sessionRow.click() - await page.waitForFunction( - (text: string) => - document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? - false, - FIRST_USER_MSG, - { timeout: 30_000 }, - ) + await waitForActiveTranscriptText(page, FIRST_USER_MSG) await page.waitForTimeout(2_000) // Step 2: Send a message — triggers inference via the mock server. @@ -373,34 +424,15 @@ test('warm-route resume after background inference completes (no jitter)', async // Wait for the mock response to appear in the transcript, confirming // the turn completed and message.complete fired (which updates the warm // cache via updateSessionState). - await page.waitForFunction( - () => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - return viewport?.textContent?.includes('mock inference server') ?? false - }, - undefined, - { timeout: 60_000 }, - ) + await waitForActiveTranscriptText(page, 'mock inference server', 60_000) // Extra settle for message.complete → updateSessionState → cache write. await page.waitForTimeout(2_000) // Verify the prompt was received by the mock server. expect(mock.receivedPrompts).toContain(PROMPT) - // Step 3: Navigate away — the warm cache retains the updated messages. - const newSessionButton = page - .locator('[data-slot="sidebar"] button[aria-label="New session"]') - .first() - await newSessionButton.click() - await page.waitForFunction( - (prompt: string) => { - const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') - if (!viewport) return false - return !(viewport.textContent ?? '').includes(prompt) - }, - PROMPT, - { timeout: 15_000 }, - ) + // Step 3: Replace the primary chat; the warm cache retains the updated messages. + await openFreshDraft(page, PROMPT) await page.waitForTimeout(500) // Step 4: Install render counter, click back (warm resume), wait, assert. @@ -409,13 +441,7 @@ test('warm-route resume after background inference completes (no jitter)', async // Wait for the transcript to reappear — the warm cache should already // have the completed turn (updated by message.complete events). - await page.waitForFunction( - (text: string) => - document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? - false, - FIRST_USER_MSG, - { timeout: 30_000 }, - ) + await waitForActiveTranscriptText(page, FIRST_USER_MSG) // Wait for at least 1 burst, then settle. await page.waitForFunction( diff --git a/apps/desktop/electron/active-runtime-state.test.ts b/apps/desktop/electron/active-runtime-state.test.ts new file mode 100644 index 00000000000..afc3e74b9d4 --- /dev/null +++ b/apps/desktop/electron/active-runtime-state.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state' + +const VALID_MARKER = { + pinnedCommit: '1234567890abcdef1234567890abcdef12345678', + schemaVersion: 1 +} + +test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => { + assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true) +}) + +test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => { + assert.equal(hasValidBootstrapMarker(null, 1), false) + assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false) + assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false) +}) + +test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => { + assert.deepEqual(classifyActiveRuntime(null, 1, true), { + hasValidMarker: false, + shouldUseActiveRuntime: true, + usabilityReason: 'usable' + }) +}) + +test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => { + assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), { + hasValidMarker: false, + shouldUseActiveRuntime: true, + usabilityReason: 'usable' + }) +}) + +test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => { + assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), { + hasValidMarker: true, + shouldUseActiveRuntime: false, + usabilityReason: 'unusable' + }) +}) + +test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => { + // The reported symptom (#60721): install.sh / install.ps1 produced a healthy + // repo+venv, no desktop-managed marker was ever written, and every launch + // dropped the user back into the first-run installer. + const state = classifyActiveRuntime(null, 1, true) + + assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch') + assert.equal(state.hasValidMarker, false, 'marker provenance stays honest') +}) + +test('a repair that deleted the marker does not strand a healthy install', () => { + // #72166: the repair handler clears the marker unconditionally. Runtime + // usability, not marker presence, must decide the next boot. + assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true) +}) diff --git a/apps/desktop/electron/active-runtime-state.ts b/apps/desktop/electron/active-runtime-state.ts new file mode 100644 index 00000000000..6d922a46277 --- /dev/null +++ b/apps/desktop/electron/active-runtime-state.ts @@ -0,0 +1,58 @@ +export interface BootstrapMarkerLike { + pinnedCommit?: unknown + schemaVersion?: unknown +} + +export interface ActiveRuntimeState { + hasValidMarker: boolean + shouldUseActiveRuntime: boolean + usabilityReason: 'usable' | 'unusable' +} + +export function hasValidBootstrapMarker( + marker: BootstrapMarkerLike | null | undefined, + schemaVersion: number +): boolean { + if (!marker || typeof marker !== 'object') { + return false + } + + if (marker.schemaVersion !== schemaVersion) { + return false + } + + if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { + return false + } + + return true +} + +// The active install at ~/.hermes/hermes-agent can be real and runnable even if +// Desktop never wrote its first-run bootstrap marker (for example when Hermes +// was installed by the CLI first, or when a past desktop build forgot the +// marker). Runtime usability is authoritative for "can we launch local Hermes +// right now?"; the marker is only provenance about how that install was +// created. A missing/stale marker must never force a healthy local install into +// the first-run bootstrap UI. +export function classifyActiveRuntime( + marker: BootstrapMarkerLike | null | undefined, + schemaVersion: number, + runtimeUsable: boolean +): ActiveRuntimeState { + const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion) + + if (!runtimeUsable) { + return { + hasValidMarker, + shouldUseActiveRuntime: false, + usabilityReason: 'unusable' + } + } + + return { + hasValidMarker, + shouldUseActiveRuntime: true, + usabilityReason: 'usable' + } +} diff --git a/apps/desktop/electron/backend-env.test.ts b/apps/desktop/electron/backend-env.test.ts index e24a66ee396..a92ce6e062e 100644 --- a/apps/desktop/electron/backend-env.test.ts +++ b/apps/desktop/electron/backend-env.test.ts @@ -68,6 +68,26 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () = assert.ok(env.PATH.includes('/opt/homebrew/bin')) }) +test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly', () => { + const defaulted = buildDesktopBackendEnv({ + hermesHome: '/Users/test/.hermes', + currentEnv: { PATH: '/usr/bin' }, + platform: 'darwin', + pathModule: path.posix + }) + + assert.equal(defaulted.PYTHONUTF8, '1') + + const optedOut = buildDesktopBackendEnv({ + hermesHome: '/Users/test/.hermes', + currentEnv: { PATH: '/usr/bin', PYTHONUTF8: '0' }, + platform: 'darwin', + pathModule: path.posix + }) + + assert.equal(optedOut.PYTHONUTF8, '0') +}) + test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => { assert.equal( normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }), diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index a225e238177..3db4a19d034 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -104,6 +104,13 @@ function buildDesktopBackendEnv({ return { PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }), + // Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and + // subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK, + // cp1252, ...). hermes_bootstrap sets this inside the child too, but only + // after import — anything emitted earlier (interpreter startup errors, + // pre-bootstrap tracebacks) still decodes with the locale default without + // this. User's explicit setting wins. Re-port of PR #56499 (echoriver89). + PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1', [key]: buildDesktopBackendPath({ hermesHome, venvRoot, diff --git a/apps/desktop/electron/backend-health.test.ts b/apps/desktop/electron/backend-health.test.ts new file mode 100644 index 00000000000..8d29ea1988d --- /dev/null +++ b/apps/desktop/electron/backend-health.test.ts @@ -0,0 +1,340 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + DEFAULT_HEALTH_PROBE_TIMEOUT_MS, + isAuthRejectionError, + isGatedMissingHealthError, + isMissingHealthEndpointError, + isReauthRequiredError, + waitForHermesReady +} from './backend-health' + +const GATE_401 = '401: {"error":"unauthenticated","detail":"Unauthorized","reason":"no_cookie","login_url":"/login"}' + +test('uses lightweight /api/health for current backends', async () => { + const calls: string[][] = [] + + await waitForHermesReady('http://127.0.0.1:9000/', { + token: 'secret-token', + fetchPublicJson: async url => { + calls.push(['public', url]) + + return { ok: true } + }, + fetchJson: async url => { + calls.push(['token', url]) + throw new Error('status should not be called') + }, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']]) +}) + +test('falls back to /api/status only for old backends without /api/health', async () => { + const calls: string[][] = [] + + await waitForHermesReady('http://127.0.0.1:9000', { + token: 'secret-token', + fetchPublicJson: async url => { + calls.push(['public', url]) + + throw new Error('404: {"detail":"Not Found"}') + }, + fetchJson: async (url, token) => { + calls.push(['token', url, token ?? '']) + + return { version: 'old' } + }, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(calls, [ + ['public', 'http://127.0.0.1:9000/api/health'], + ['token', 'http://127.0.0.1:9000/api/status', 'secret-token'] + ]) +}) + +test('does not fall back to heavyweight /api/status for transient health failures', async () => { + const calls: string[][] = [] + let currentTime = 0 + + await assert.rejects( + waitForHermesReady('http://127.0.0.1:9000', { + fetchPublicJson: async url => { + calls.push(['public', url]) + throw new Error('Timed out connecting to Hermes backend after 15000ms') + }, + fetchJson: async url => { + calls.push(['token', url]) + }, + sleep: async () => {}, + now: () => { + currentTime += 20 + + return currentTime + }, + timeoutMs: 50, + pollMs: 1 + }), + /Timed out connecting/ + ) + + assert.ok(calls.length > 0) + assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health'))) +}) + +test('probes health on a short timeout but leaves the legacy fallback its own', async () => { + const timeouts: (number | undefined)[] = [] + + await waitForHermesReady('http://127.0.0.1:9000', { + fetchPublicJson: async (_url, options) => { + timeouts.push(options?.timeoutMs) + + throw new Error('404: {"detail":"Not Found"}') + }, + fetchJson: async (_url, _token, options) => { + timeouts.push(options?.timeoutMs) + + return { version: 'old' } + }, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined]) +}) + +test('aborts as superseded when the bootstrap signal fires', async () => { + const controller = new AbortController() + controller.abort() + + await assert.rejects( + waitForHermesReady('http://127.0.0.1:9000', { + signal: controller.signal, + fetchPublicJson: async () => { + throw new Error('should not probe after abort') + }, + fetchJson: async () => { + throw new Error('should not probe after abort') + }, + timeoutMs: 100, + pollMs: 1 + }), + (error: any) => error.kind === 'superseded' + ) +}) + +test('recognizes missing-route shapes only', () => { + assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true) + assert.equal( + isMissingHealthEndpointError( + new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.') + ), + true + ) + assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false) + assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false) +}) + +// --- Gated backends that predate /api/health (release 0.19.0 and earlier) --- +// +// The dashboard auth gate runs ahead of the SPA catch-all, so on a backend +// without the route an ANONYMOUS probe is rejected as unauthenticated rather +// than 404 — verified against a simulated 0.19.0 backend: +// credential-free: /api/health -> 401 no_cookie, /api/status -> 200 +// credentialed: /api/health -> 404, /api/sessions -> 200 + +test('anonymous gate-shaped 401 falls back to /api/status (backend predates /api/health)', async () => { + const calls: string[][] = [] + + await waitForHermesReady('http://192.168.1.132:9119', { + token: null, + fetchPublicJson: async url => { + calls.push(['public', url]) + throw new Error(GATE_401) + }, + fetchJson: async (url, token) => { + calls.push(['token', url, token == null ? 'null' : token]) + + return { version: '0.19.0', auth_required: true } + }, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(calls, [ + ['public', 'http://192.168.1.132:9119/api/health'], + ['token', 'http://192.168.1.132:9119/api/status', 'null'] + ]) +}) + +test('a credentialed 401 fails fast for reauth instead of reporting a dead session ready', async () => { + // The regression a blanket 401->fallback introduces: /api/status is public, + // so an expired session would answer 200 and boot would report "ready", + // deferring the no_cookie to the first real API call. + const calls: string[][] = [] + + await assert.rejects( + waitForHermesReady('https://gateway.example', { + token: 'session-token', + fetchPublicJson: async () => { + throw new Error('public probe must not be used when credentialed') + }, + fetchJson: async url => { + calls.push(['status', url]) + + return { version: '0.19.0' } + }, + probeHealth: async url => { + calls.push(['probe', url]) + throw new Error(GATE_401) + }, + probeIsCredentialed: true, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }), + (error: any) => { + assert.equal(isReauthRequiredError(error), true) + assert.equal(error.needsOauthLogin, true) + assert.match(error.message, /remote gateway session has expired/i) + + return true + } + ) + + // Fail fast: never reached the public /api/status leg. + assert.deepEqual(calls, [['probe', 'https://gateway.example/api/health']]) +}) + +test('a credentialed 403 is also a terminal reauth failure', async () => { + await assert.rejects( + waitForHermesReady('https://gateway.example', { + fetchPublicJson: async () => ({}), + fetchJson: async () => ({}), + probeHealth: async () => { + throw new Error('403: {"detail":"Forbidden"}') + }, + probeIsCredentialed: true, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }), + (error: any) => isReauthRequiredError(error) + ) +}) + +test('a credentialed probe still uses the 404 fallback for a genuinely missing route', async () => { + // With credentials the gate lets the request through to the SPA catch-all, + // so an old backend answers a real 404 — that must still fall back, not be + // mistaken for a rejected session. + const calls: string[][] = [] + + await waitForHermesReady('https://gateway.example', { + token: 'session-token', + fetchPublicJson: async () => { + throw new Error('public probe must not be used when credentialed') + }, + fetchJson: async url => { + calls.push(['status', url]) + + return { version: '0.19.0' } + }, + probeHealth: async url => { + calls.push(['probe', url]) + throw new Error('404: {"detail":"Not Found"}') + }, + probeIsCredentialed: true, + sleep: async () => {}, + timeoutMs: 100, + pollMs: 1 + }) + + assert.deepEqual(calls, [ + ['probe', 'https://gateway.example/api/health'], + ['status', 'https://gateway.example/api/status'] + ]) +}) + +test('a non-gate 401 keeps polling rather than skipping a misconfigured health route', async () => { + const calls: string[][] = [] + let currentTime = 0 + + await assert.rejects( + waitForHermesReady('http://127.0.0.1:9000', { + fetchPublicJson: async url => { + calls.push(['public', url]) + throw new Error('401: {"detail":"Unauthorized"}') + }, + fetchJson: async url => { + calls.push(['token', url]) + }, + sleep: async () => {}, + now: () => { + currentTime += 20 + + return currentTime + }, + timeoutMs: 50, + pollMs: 1 + }), + /401: \{"detail":"Unauthorized"\}/ + ) + + assert.ok(calls.length > 0) + assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health'))) +}) + +test('credentialed 5xx and 429 keep polling — only 401/403 are terminal', async () => { + for (const transient of ['500: boom', '429: {"detail":"Too Many Requests"}']) { + let attempts = 0 + let currentTime = 0 + + await assert.rejects( + waitForHermesReady('https://gateway.example', { + fetchPublicJson: async () => ({}), + fetchJson: async () => ({}), + probeHealth: async () => { + attempts += 1 + throw new Error(transient) + }, + probeIsCredentialed: true, + sleep: async () => {}, + now: () => { + currentTime += 20 + + return currentTime + }, + timeoutMs: 100, + pollMs: 1 + }), + (error: any) => isReauthRequiredError(error) === false + ) + + assert.ok(attempts > 1, `${transient} should have retried, got ${attempts} attempt(s)`) + } +}) + +test('error-shape predicates', () => { + assert.equal(isGatedMissingHealthError(new Error(GATE_401)), true) + assert.equal(isGatedMissingHealthError(new Error('401: {"detail":"Unauthorized"}')), false) + assert.equal(isGatedMissingHealthError(new Error('404: {"detail":"Not Found"}')), false) + + assert.equal(isAuthRejectionError(new Error(GATE_401)), true) + assert.equal(isAuthRejectionError(new Error('403: {"detail":"Forbidden"}')), true) + assert.equal(isAuthRejectionError(new Error('404: {"detail":"Not Found"}')), false) + assert.equal(isAuthRejectionError(new Error('429: slow down')), false) + assert.equal(isAuthRejectionError(new Error('500: boom')), false) + + // A gated 401 must NOT be conflated with a missing route by the 404 predicate. + assert.equal(isMissingHealthEndpointError(new Error(GATE_401)), false) +}) diff --git a/apps/desktop/electron/backend-health.ts b/apps/desktop/electron/backend-health.ts new file mode 100644 index 00000000000..3da6c7a80fa --- /dev/null +++ b/apps/desktop/electron/backend-health.ts @@ -0,0 +1,169 @@ +export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000 +export const DEFAULT_BACKEND_READY_POLL_MS = 500 +// A cold backend can stall its event loop for tens of seconds while Windows +// scans and byte-compiles the gateway import tree. At the default 15s socket +// timeout only three probes fit in the budget; a short one keeps retrying +// across the stall. Health only — the legacy /api/status fallback is genuinely +// slow to answer and keeps the caller's default timeout. +export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000 + +type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise +type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise + +export interface HermesReadyOptions { + fetchPublicJson: FetchPublicJson + fetchJson: FetchJson + token?: string | null + signal?: AbortSignal + timeoutMs?: number + pollMs?: number + healthProbeTimeoutMs?: number + sleep?: (ms: number) => Promise + now?: () => number + /** + * Credentialed health probe. When supplied, readiness is probed with the + * connection's own credentials instead of anonymously — which is what lets + * a gated backend answer 404 for a genuinely missing /api/health, and what + * makes a 401 from this probe mean "session rejected" rather than "route + * behind a gate". Defaults to the credential-free `fetchPublicJson`. + */ + probeHealth?: (url: string, options?: { timeoutMs?: number }) => Promise + /** + * Whether `probeHealth` actually presents credentials. Distinguishes the + * two very different meanings of a 401 (see `waitForHermesReady`). + */ + probeIsCredentialed?: boolean +} + +export const REMOTE_SESSION_EXPIRED_MESSAGE = + 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.' + +export function isMissingHealthEndpointError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? '') + + return /^404:/.test(message) || message.includes('endpoint is likely missing') +} + +/** + * True for a hard auth rejection (401/403) as opposed to a transient failure. + * Deliberately shape-based: 429 is a throttle and 5xx is a server fault, and + * both must keep polling. + */ +export function isAuthRejectionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? '') + + return /^40[13]:/.test(message) +} + +/** + * True for an auth rejection carrying the dashboard gate's "no session at all" + * shape. On a backend that predates `/api/health`, the gate runs ahead of the + * SPA catch-all, so an unknown `/api/*` path is rejected as unauthenticated + * instead of 404 — this is the signal that an ANONYMOUS probe cannot reach the + * route, and the reason a credential-free 401 must fall back to `/api/status` + * rather than be reported as a boot failure. + */ +export function isGatedMissingHealthError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? '') + + return isAuthRejectionError(error) && message.includes('no_cookie') +} + +/** Tag a terminal reauth failure the main process latches and the overlay keys on. */ +export function makeReauthRequiredError(detail?: string): Error { + const error = new Error(REMOTE_SESSION_EXPIRED_MESSAGE) as any + error.needsOauthLogin = true + error.isReauthRequired = true + + if (detail) { + error.detail = detail + } + + return error +} + +export function isReauthRequiredError(error: unknown): boolean { + return Boolean((error as any)?.isReauthRequired) +} + +function supersededError() { + const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') + error.kind = 'superseded' + + return error +} + +export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS + const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS + const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS + const now = options.now ?? Date.now + const signal = options.signal + + const sleep = + options.sleep ?? + (ms => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms) + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer) + reject(supersededError()) + }, + { once: true } + ) + })) + + const base = baseUrl.replace(/\/+$/, '') + const deadline = now() + timeoutMs + const probeHealth = options.probeHealth ?? options.fetchPublicJson + const probeIsCredentialed = Boolean(options.probeIsCredentialed) + let lastError: unknown = null + let useStatusFallback = false + + while (now() < deadline) { + if (signal?.aborted) { + throw supersededError() + } + + try { + if (useStatusFallback) { + await options.fetchJson(`${base}/api/status`, options.token) + } else { + await probeHealth(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs }) + } + + return + } catch (error) { + lastError = error + + // A confirmed 401/403 from a CREDENTIALED probe means the session was + // rejected, not that the route is missing. Fail fast into a reauth + // state: falling back to the public /api/status would answer 200 and + // report a dead session as "ready", deferring the failure to the first + // real API call. Applies to the /api/status leg too — it is routed + // through the same credentials. + if (probeIsCredentialed && isAuthRejectionError(error)) { + throw makeReauthRequiredError(error instanceof Error ? error.message : String(error)) + } + + // An explicitly missing route means the backend predates /api/health. + // So does a gate-shaped 401 on an ANONYMOUS probe: the dashboard auth + // gate runs ahead of the SPA catch-all, so a pre-/api/health backend + // rejects the unknown path as unauthenticated instead of 404 and a + // credential-free probe can never observe the 404. Timeouts, 5xx, 429, + // and non-gate 401s keep polling health. + if (!useStatusFallback && (isMissingHealthEndpointError(error) || isGatedMissingHealthError(error))) { + useStatusFallback = true + + continue + } + + await sleep(pollMs) + } + } + + const detail = lastError instanceof Error ? lastError.message : 'timeout' + throw new Error(`Hermes backend did not become ready: ${detail}`) +} diff --git a/apps/desktop/electron/backend-start-failure.test.ts b/apps/desktop/electron/backend-start-failure.test.ts index 0888d65fbc4..36d352d66cc 100644 --- a/apps/desktop/electron/backend-start-failure.test.ts +++ b/apps/desktop/electron/backend-start-failure.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { shouldLatchBackendStartFailure } from './backend-start-failure' +import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' test('latches a LOCAL backend failure so the install-retry loop is broken', () => { assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: false }), true) @@ -21,3 +21,32 @@ test('the two branches are mutually exclusive (a failure either latches or stays assert.equal(latched, !attemptedRemote) } }) + +test('latches a CONFIRMED remote reauth failure so the overlay stays clickable', () => { + // Without this the non-latching remote path re-runs startHermes on every + // getConnection/api call, re-emits running:true, and the overlay hides + // itself — the "Sign in" button flickers away before it can be clicked. + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: true }), true) +}) + +test('does not latch a transient remote failure as reauth', () => { + // A mint timeout or a host unreachable across sleep must still self-heal. + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: false }), false) +}) + +test('never latches a LOCAL failure as reauth (that is backendStartFailure job)', () => { + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: true }), false) + assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: false }), false) +}) + +test('the two latches never fire for the same failure', () => { + // They are complementary, not overlapping: local failures latch via + // backendStartFailure, confirmed remote reauth latches via its own flag. + for (const attemptedRemote of [true, false]) { + for (const isReauth of [true, false]) { + const start = shouldLatchBackendStartFailure({ attemptedRemote }) + const reauth = shouldLatchRemoteReauthFailure({ attemptedRemote, isReauth }) + assert.ok(!(start && reauth), `both latched for remote=${attemptedRemote} reauth=${isReauth}`) + } + } +}) diff --git a/apps/desktop/electron/backend-start-failure.ts b/apps/desktop/electron/backend-start-failure.ts index 4998b0164a7..3c5ffdbcad9 100644 --- a/apps/desktop/electron/backend-start-failure.ts +++ b/apps/desktop/electron/backend-start-failure.ts @@ -39,3 +39,34 @@ export interface BackendStartFailureContext { export function shouldLatchBackendStartFailure(context: BackendStartFailureContext): boolean { return !context.attemptedRemote } + +export interface RemoteReauthFailureContext { + /** True when the boot that just failed was dialing a REMOTE (or cloud) backend. */ + attemptedRemote: boolean + /** + * True when the failure was a CONFIRMED auth rejection (a credentialed + * probe got 401/403), not a transient connectivity fault. + */ + isReauth: boolean +} + +/** + * Whether a failed remote boot should latch as a reauth failure. + * + * This is the deliberate counterpart to `shouldLatchBackendStartFailure`, + * which never latches a remote failure because remote faults are usually + * transient and must stay retryable. A *confirmed* reauth rejection is the + * exception: it cannot self-heal, because nothing will change until the user + * signs in again. + * + * Without a latch, the non-latching remote path actively prevents recovery. + * Every subsequent `getConnection`/`api` call re-runs `startHermes`, re-emits + * `running: true`, and the boot-failure overlay (`visible = Boolean(boot.error) + * && !boot.running`) hides itself — so the "Sign in" button flickers out from + * under the user before they can click it. Latching holds the overlay still + * and clickable. Cleared on every recovery path (reset, repair, apply-config, + * and a confirmed sign-in) so a fresh session boots normally. + */ +export function shouldLatchRemoteReauthFailure(context: RemoteReauthFailureContext): boolean { + return context.attemptedRemote && context.isReauth +} diff --git a/apps/desktop/electron/connection-apply.ts b/apps/desktop/electron/connection-apply.ts index 5764b9091e7..579af1a79e9 100644 --- a/apps/desktop/electron/connection-apply.ts +++ b/apps/desktop/electron/connection-apply.ts @@ -1,6 +1,7 @@ async function applyConnectionChange({ cancelAndWait, isPrimary, + rehomePrimary = null, scope, sendApplied, stopPool, @@ -16,6 +17,12 @@ async function applyConnectionChange({ return } + if (rehomePrimary) { + await rehomePrimary() + + return + } + await teardownPrimary() sendApplied() } diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 4361678cb8f..5bae2e8e708 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -36,6 +36,7 @@ import { profileRemoteOverride, profileSshOverride, resolveAuthMode, + resolveProfileBackendRoute, resolveTestWsUrl, RT_COOKIE_VARIANTS, savedProfileSsh, @@ -187,6 +188,65 @@ test('saved SSH drafts are inactive and explicit overrides take precedence', () assert.equal(profileHasRemoteConnection(config, 'coder'), true) }) +// --- resolveProfileBackendRoute --- + +const ROUTES = [ + { + name: 'the primary profile owns the window backend', + profile: 'default', + opts: { primaryProfile: 'default' }, + expected: { backend: 'primary', descriptorProfile: null, scopePath: false } + }, + { + name: 'a renamed primary profile still owns the window backend', + profile: ' coder ', + opts: { primaryProfile: 'coder', globalRemote: true }, + expected: { backend: 'primary', descriptorProfile: null, scopePath: false } + }, + { + name: 'an unset profile resolves to the primary', + profile: '', + opts: { primaryProfile: 'default', globalRemote: true }, + expected: { backend: 'primary', descriptorProfile: null, scopePath: false } + }, + { + name: 'a profile inheriting the app-global remote shares the primary backend, scoped per request', + profile: 'coder', + opts: { primaryProfile: 'default', globalRemote: true, profileRemoteOverride: false }, + expected: { backend: 'primary', descriptorProfile: 'coder', scopePath: true } + }, + { + name: 'a profile with its own remote override gets a pooled descriptor for that host', + profile: 'coder', + opts: { primaryProfile: 'default', globalRemote: true, profileRemoteOverride: true }, + expected: { backend: 'pool', descriptorProfile: null, scopePath: false } + }, + { + name: 'a local non-primary profile gets its own pooled backend', + profile: 'coder', + opts: { primaryProfile: 'default', globalRemote: false, profileRemoteOverride: false }, + expected: { backend: 'pool', descriptorProfile: null, scopePath: false } + } +] + +for (const route of ROUTES) { + test(`resolveProfileBackendRoute: ${route.name}`, () => { + assert.deepEqual(resolveProfileBackendRoute(route.profile, route.opts), route.expected) + }) +} + +test('resolveProfileBackendRoute only tags a descriptor when the backend is shared', () => { + // A pooled backend is already scoped to its profile, so tagging it would + // imply a second scope the caller must reconcile. Only the shared + // global-remote route carries one. + for (const route of ROUTES) { + const resolved = resolveProfileBackendRoute(route.profile, route.opts) + + assert.equal(Boolean(resolved.descriptorProfile), resolved.scopePath) + assert.ok(!resolved.descriptorProfile || resolved.backend === 'primary') + } +}) + // --- pathWithGlobalRemoteProfile --- test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => { @@ -199,6 +259,17 @@ test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => ) }) +test('pathWithGlobalRemoteProfile skips the primary profile, which the remote already serves', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', 'coder', { + globalRemote: true, + primaryProfile: 'coder', + profileRemoteOverride: false + }), + '/api/model/info' + ) +}) + test('pathWithGlobalRemoteProfile preserves existing query params', () => { assert.equal( pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index f7b1ac2e0d4..c15644b112a 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -350,16 +350,68 @@ function profileRemoteOverride(config, profile) { return { url, authMode: normAuthMode(entry.authMode), token: entry.token } } +export interface ProfileRouteOptions { + globalRemote?: boolean + primaryProfile?: null | string + profileRemoteOverride?: boolean +} + +export interface ProfileBackendRoute { + /** Which backend serves this profile: the window backend, or a pooled one. */ + backend: 'pool' | 'primary' + /** + * Profile to tag on the returned descriptor when the backend is shared and + * therefore not itself scoped to that profile. Null when the backend already + * belongs to the profile. + */ + descriptorProfile: null | string + /** Whether REST paths on this route must carry `?profile=` to be scoped. */ + scopePath: boolean +} + /** - * In global-remote mode one backend serves every Desktop profile, so REST calls - * that are scoped by renderer-side `request.profile` must carry that scope as a - * query parameter. Local pooled backends and per-profile remote overrides do not - * need this: they already run against a backend scoped to the target profile. + * The one place that answers "which backend serves profile P, and does its + * REST path need a profile scope?". Four routes, in precedence order: + * + * 1. The primary profile owns the window backend outright. + * 2. A profile with its own remote override gets a pooled descriptor for that + * host, which is already scoped to it. + * 3. A profile inheriting the app-global remote shares the primary backend — + * one host serves every profile — so it is scoped per request instead. + * 4. Any other local profile gets its own pooled backend, spawned with + * `--profile`, so its `HERMES_HOME` scopes it. + * + * Routing used to be spread across three overlapping predicates that each + * re-derived part of this table, which is how case 3 ended up registering + * reapable pool entries for backends it never owned. */ -function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) { +function resolveProfileBackendRoute(profile, opts: ProfileRouteOptions = {}): ProfileBackendRoute { + const scopedProfile = connectionScopeKey(profile) + const primaryProfile = connectionScopeKey(opts.primaryProfile) || 'default' + + if (!scopedProfile || scopedProfile === primaryProfile) { + return { backend: 'primary', descriptorProfile: null, scopePath: false } + } + + if (opts.profileRemoteOverride) { + return { backend: 'pool', descriptorProfile: null, scopePath: false } + } + + if (opts.globalRemote) { + return { backend: 'primary', descriptorProfile: scopedProfile, scopePath: true } + } + + return { backend: 'pool', descriptorProfile: null, scopePath: false } +} + +/** + * Add renderer-side `request.profile` to a REST path when the route says the + * serving backend is not already scoped to that profile. + */ +function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions = {}) { const scopedProfile = connectionScopeKey(profile) - if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) { + if (!resolveProfileBackendRoute(profile, opts).scopePath) { return path } @@ -506,6 +558,7 @@ export { profileRemoteOverride, profileSshOverride, resolveAuthMode, + resolveProfileBackendRoute, resolveTestWsUrl, RT_COOKIE_VARIANTS, savedProfileSsh, diff --git a/apps/desktop/electron/crash-forensics.test.ts b/apps/desktop/electron/crash-forensics.test.ts new file mode 100644 index 00000000000..ea53493b4c1 --- /dev/null +++ b/apps/desktop/electron/crash-forensics.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' + +import { describeCrashReason, installCrashForensics } from './crash-forensics' + +const harness = () => { + const listeners = new Map void>() + const flush = vi.fn() + const log = vi.fn() + + installCrashForensics({ + flush, + log, + target: { on: (event, listener) => listeners.set(event, listener) } + }) + + return { flush, listeners, log } +} + +describe('describeCrashReason', () => { + it('prefers a stack, then a message, for thrown errors', () => { + const withStack = new Error('boom') + withStack.stack = 'Error: boom\n at somewhere' + + expect(describeCrashReason(withStack)).toBe('Error: boom\n at somewhere') + + const withoutStack = new Error('boom') + withoutStack.stack = '' + + expect(describeCrashReason(withoutStack)).toBe('boom') + }) + + it('renders non-error rejections without throwing', () => { + expect(describeCrashReason('plain string')).toBe('plain string') + expect(describeCrashReason({ code: 'ECONNRESET' })).toBe('{"code":"ECONNRESET"}') + expect(describeCrashReason(undefined)).toBe('undefined') + + const circular: Record = {} + circular.self = circular + + expect(describeCrashReason(circular)).toBe('[object Object]') + }) +}) + +describe('installCrashForensics', () => { + it('records and synchronously flushes an uncaught exception', () => { + const { flush, listeners, log } = harness() + const error = new Error('renderer gone') + error.stack = 'Error: renderer gone\n at main' + + listeners.get('uncaughtException')?.(error) + + expect(log).toHaveBeenCalledWith('[main] Uncaught exception: Error: renderer gone\n at main') + expect(flush).toHaveBeenCalledTimes(1) + }) + + it('records and synchronously flushes an unhandled rejection', () => { + const { flush, listeners, log } = harness() + + listeners.get('unhandledRejection')?.('gateway ticket mint failed') + + expect(log).toHaveBeenCalledWith('[main] Unhandled rejection: gateway ticket mint failed') + expect(flush).toHaveBeenCalledTimes(1) + }) + + it('registers both handlers', () => { + const { listeners } = harness() + + expect([...listeners.keys()].sort()).toEqual(['uncaughtException', 'unhandledRejection']) + }) +}) diff --git a/apps/desktop/electron/crash-forensics.ts b/apps/desktop/electron/crash-forensics.ts new file mode 100644 index 00000000000..7ee0aedd48f --- /dev/null +++ b/apps/desktop/electron/crash-forensics.ts @@ -0,0 +1,51 @@ +/** + * Last-chance forensics for the Electron main process. + * + * Electron installs its own `uncaughtException` listener and only warns on + * unhandled rejections, so the app usually survives — but the reason lands on + * stderr alone, which is discarded entirely when the app is launched from + * Finder or the Start menu. Without a record in desktop.log, a main-process + * fault is invisible in a `hermes debug share` bundle and the user is left + * describing symptoms instead of showing a stack. + */ + +export interface CrashForensicsTarget { + on: (event: 'uncaughtException' | 'unhandledRejection', listener: (value: unknown) => void) => unknown +} + +export interface CrashForensicsOptions { + flush: () => void + log: (message: string) => void + target?: CrashForensicsTarget +} + +/** Render a thrown value for the log, preferring a stack over a bare message. */ +export function describeCrashReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.stack || reason.message || reason.name || 'Error' + } + + if (typeof reason === 'string') { + return reason + } + + try { + return JSON.stringify(reason) ?? String(reason) + } catch { + return String(reason) + } +} + +/** + * Record main-process faults to desktop.log and flush synchronously, since a + * fault that does prove fatal leaves no chance for the batched async flush. + */ +export function installCrashForensics({ flush, log, target = process }: CrashForensicsOptions): void { + const record = (label: string) => (reason: unknown) => { + log(`[main] ${label}: ${describeCrashReason(reason)}`) + flush() + } + + target.on('uncaughtException', record('Uncaught exception')) + target.on('unhandledRejection', record('Unhandled rejection')) +} diff --git a/apps/desktop/electron/find-in-page.test.ts b/apps/desktop/electron/find-in-page.test.ts new file mode 100644 index 00000000000..e902bb42966 --- /dev/null +++ b/apps/desktop/electron/find-in-page.test.ts @@ -0,0 +1,222 @@ +/** + * Unit tests for the pure find-in-page helpers. The IPC handlers in + * main.ts are the only consumer — the helpers below must keep the wire + * shape stable (match counter shape, defaults, no-throw-on-destroyed). + */ + +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' + +import { describe, test } from 'vitest' + +import { formatFoundInPage, installFoundInPageForwarder, performFind, stopFind } from './find-in-page' + +// Minimal webContents stub. The Electron.WebContents type is huge, so we +// model just the slice the helpers touch (`isDestroyed`, `findInPage`, +// `stopFindInPage`, `on`/`off`, `send`, `destroyed`, `emit`) and cast through +// `asWC()` at call sites. +interface FakeWebContents { + calls: { + find: Array<{ query: string; options: { forward: boolean; findNext: boolean } }> + stop: Array<'clearSelection' | 'keepSelection' | 'activateSelection'> + send: Array<{ channel: string; payload: unknown }> + } + isDestroyed: () => boolean + destroy: () => void + findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => void + stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void + send: (channel: string, payload: unknown) => void + on: typeof EventEmitter.prototype.on + off: typeof EventEmitter.prototype.off + emit: (event: string | symbol, ...args: unknown[]) => boolean +} + +function makeFakeWebContents(): FakeWebContents { + const emitter = new EventEmitter() + + const calls = { + find: [] as Array<{ query: string; options: { forward: boolean; findNext: boolean } }>, + stop: [] as Array<'clearSelection' | 'keepSelection' | 'activateSelection'>, + send: [] as Array<{ channel: string; payload: unknown }> + } + + let destroyed = false + + return { + calls, + isDestroyed: () => destroyed, + destroy() { + destroyed = true + emitter.emit('destroyed') + }, + findInPage(query: string, options: { forward: boolean; findNext: boolean }) { + calls.find.push({ query, options }) + }, + stopFindInPage(action: 'clearSelection' | 'keepSelection' | 'activateSelection') { + calls.stop.push(action) + }, + send(channel: string, payload: unknown) { + calls.send.push({ channel, payload }) + }, + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + emit: emitter.emit.bind(emitter) + } +} + +function asWC(fake: FakeWebContents): Electron.WebContents { + return fake as unknown as Electron.WebContents +} + +describe('formatFoundInPage', () => { + test('maps activeMatchOrdinal + matches onto the wire payload', () => { + assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 3, matches: 12 }), { + activeMatchOrdinal: 3, + count: 12 + }) + }) + + test('coerces missing fields to zero so the renderer never sees NaN', () => { + assert.deepEqual(formatFoundInPage({}), { activeMatchOrdinal: 0, count: 0 }) + assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 0, matches: 0 }), { + activeMatchOrdinal: 0, + count: 0 + }) + }) + + test('null / undefined inputs still produce a well-formed payload', () => { + assert.deepEqual(formatFoundInPage(null as unknown as { activeMatchOrdinal?: number; matches?: number }), { + activeMatchOrdinal: 0, + count: 0 + }) + assert.deepEqual(formatFoundInPage(undefined), { activeMatchOrdinal: 0, count: 0 }) + }) +}) + +describe('performFind', () => { + test('forwards the query and options to webContents.findInPage', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'hello', { forward: true, findNext: false }) + assert.deepEqual(wc.calls.find, [{ query: 'hello', options: { forward: true, findNext: false } }]) + }) + + test('defaults forward to true when omitted', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'x', { findNext: true }) + assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: true } }]) + }) + + test('defaults findNext to false when omitted', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'x', { forward: false }) + assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: false, findNext: false } }]) + }) + + test('treats null / non-object options as "all defaults"', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'x', null) + assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: false } }]) + }) + + test('coerces a non-string query to string (defensive against bad renderer payloads)', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 42 as unknown as string, null) + assert.equal(wc.calls.find[0].query, '42') + }) + + test('is a no-op when webContents is null', () => { + assert.doesNotThrow(() => performFind(null, 'q', null)) + }) + + test('is a no-op when webContents is destroyed (does not throw across IPC)', () => { + const wc = makeFakeWebContents() + wc.destroy() + performFind(asWC(wc), 'q', null) + assert.equal(wc.calls.find.length, 0) + }) +}) + +describe('stopFind', () => { + test('calls stopFindInPage with the default action (clearSelection)', () => { + const wc = makeFakeWebContents() + stopFind(asWC(wc)) + assert.deepEqual(wc.calls.stop, ['clearSelection']) + }) + + test('honors an explicit action argument', () => { + const wc = makeFakeWebContents() + stopFind(asWC(wc), 'keepSelection') + assert.deepEqual(wc.calls.stop, ['keepSelection']) + }) + + test('is a no-op when webContents is null or destroyed', () => { + assert.doesNotThrow(() => stopFind(null)) + const wc = makeFakeWebContents() + wc.destroy() + stopFind(asWC(wc)) + assert.equal(wc.calls.stop.length, 0) + }) +}) + +describe('installFoundInPageForwarder', () => { + test('forwards found-in-page to the sender as a formatted payload', () => { + const wc = makeFakeWebContents() + installFoundInPageForwarder(asWC(wc)) + // Drive the fake's emit directly — this exercises the same code path + // as Electron's actual `webContents.emit('found-in-page', …)`. + wc.emit('found-in-page', {}, { activeMatchOrdinal: 2, matches: 5 }) + assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 2, count: 5 } }]) + }) + + test('handles missing fields without throwing', () => { + const wc = makeFakeWebContents() + installFoundInPageForwarder(asWC(wc)) + wc.emit('found-in-page', {}, {}) + assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 0, count: 0 } }]) + }) + + test('skips send when webContents is destroyed at fire time', () => { + const wc = makeFakeWebContents() + installFoundInPageForwarder(asWC(wc)) + wc.destroy() + wc.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 }) + assert.equal(wc.calls.send.length, 0, 'destroyed webContents must not be sent to') + }) + + test('returned uninstall removes the listener', () => { + const wc = makeFakeWebContents() + const uninstall = installFoundInPageForwarder(asWC(wc)) + uninstall() + wc.emit('found-in-page', {}, { activeMatchOrdinal: 9, matches: 9 }) + assert.equal(wc.calls.send.length, 0, 'uninstalled listener must not fire') + }) + + test('returned uninstall on a null webContents is a safe no-op', () => { + const uninstall = installFoundInPageForwarder(null) + assert.doesNotThrow(() => uninstall()) + }) + + test('returned uninstall on a destroyed webContents is a safe no-op', () => { + const wc = makeFakeWebContents() + wc.destroy() + const uninstall = installFoundInPageForwarder(asWC(wc)) + assert.doesNotThrow(() => uninstall()) + }) + + // Regression: the original PR scoped the forwarder to the global mainWindow, + // so Cmd+F pressed in a secondary session window routed results back to the + // primary. Pin that the helper does NOT close over any window other than the + // webContents it was given — two forwarders installed on two distinct fakes + // must each send only to their own sender. + test('two forwarders installed on distinct webContents do not cross-fire', () => { + const wcA = makeFakeWebContents() + const wcB = makeFakeWebContents() + installFoundInPageForwarder(asWC(wcA)) + installFoundInPageForwarder(asWC(wcB)) + wcA.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 }) + assert.deepEqual(wcA.calls.send, [ + { channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 1, count: 1 } } + ]) + assert.equal(wcB.calls.send.length, 0, 'wcB must not receive wcA results') + }) +}) diff --git a/apps/desktop/electron/find-in-page.ts b/apps/desktop/electron/find-in-page.ts new file mode 100644 index 00000000000..509fc5dccd7 --- /dev/null +++ b/apps/desktop/electron/find-in-page.ts @@ -0,0 +1,120 @@ +/** + * Pure helpers for the desktop find-in-page bridge (Ctrl/Cmd+F). + * + * The renderer drives an Electron `webContents.findInPage` over IPC so it can + * reuse the native "find-in-page" experience (incremental search, match + * highlight, Enter to step, Shift+Enter to step backwards, Escape to clear) + * across chat transcripts and editor panels. Everything in this module is + * pure with respect to its inputs so the routing + payload shaping can be + * unit-tested without booting a BrowserWindow. + * + * Multi-window correctness: the IPC handlers in main.ts resolve the + * requesting window via `BrowserWindow.fromWebContents(event.sender)` so a + * Cmd+F pressed in a secondary session window searches THAT window, not the + * primary. The `found-in-page` results are forwarded back to the same sender + * — see {@link installFoundInPageForwarder}. + */ + +/** Match options accepted by the renderer's `findInPage` bridge call. */ +export interface FindInPageOptions { + /** Step direction. Defaults to `true` (forward). */ + forward?: boolean + /** + * `true` to advance to the next/previous match using the previous query; + * `false` to (re)search the current `query` from scratch. The renderer + * passes `false` on a fresh query and `true` on Enter / Shift+Enter. + */ + findNext?: boolean +} + +/** Payload shape sent back to the renderer on every `found-in-page` event. */ +export interface FoundInPagePayload { + /** 1-indexed ordinal of the active match, or 0 when none. */ + activeMatchOrdinal: number + /** Total matches in the document for the current query. */ + count: number +} + +/** + * Defensive projection of Electron's `found-in-page` event result. Electron + * exposes more fields (finalUpdate, selectionArea, etc.) that we don't need; + * keeping the projection explicit makes the wire shape auditable and keeps + * tests independent of the runtime type. + */ +export function formatFoundInPage(result: { activeMatchOrdinal?: number; matches?: number }): FoundInPagePayload { + return { + activeMatchOrdinal: Number(result?.activeMatchOrdinal ?? 0), + count: Number(result?.matches ?? 0) + } +} + +/** + * Issue a `findInPage` against the given `webContents`. No-op when the + * webContents is missing or destroyed — surfaces as a silent miss rather + * than throwing across the IPC boundary, matching Electron's own semantics + * for a destroyed renderer. + */ +export function performFind( + webContents: Electron.WebContents | null | undefined, + query: string, + options: FindInPageOptions | null | undefined +): void { + if (!webContents || webContents.isDestroyed()) { + return + } + + const opts = options && typeof options === 'object' ? options : {} + + webContents.findInPage(String(query ?? ''), { + forward: opts.forward !== false, + findNext: Boolean(opts.findNext) + }) +} + +/** + * Stop the current find and clear highlights. The default `action` matches + * what the renderer sends on Escape / close. + */ +export function stopFind( + webContents: Electron.WebContents | null | undefined, + action: 'clearSelection' | 'keepSelection' | 'activateSelection' = 'clearSelection' +): void { + if (!webContents || webContents.isDestroyed()) { + return + } + + webContents.stopFindInPage(action) +} + +/** + * Install a `found-in-page` listener on the given sender `webContents` and + * forward each result back to the SAME renderer (via `webContents.send`). + * + * Returns an uninstall function. Call it from `webContents.on('destroyed', …)` + * to avoid leaking the listener when the window goes away — Electron does + * not auto-detach webContents listeners on close. + * + * The forwarder is intentionally bound to a single sender rather than the + * primary window: a Cmd+F pressed in a secondary session window must + * highlight matches in THAT window, and the match counter must reflect + * THAT window's DOM, not the primary's. + */ +export function installFoundInPageForwarder(webContents: Electron.WebContents | null | undefined): () => void { + if (!webContents || webContents.isDestroyed()) { + return () => {} + } + + const handler = (_event: Electron.Event, result: Parameters[0]) => { + if (webContents.isDestroyed()) { + return + } + + webContents.send('hermes:found-in-page', formatFoundInPage(result)) + } + + webContents.on('found-in-page', handler) + + return () => { + webContents.off('found-in-page', handler) + } +} diff --git a/apps/desktop/electron/first-run-setup-gate.test.ts b/apps/desktop/electron/first-run-setup-gate.test.ts new file mode 100644 index 00000000000..f8b53525f0f --- /dev/null +++ b/apps/desktop/electron/first-run-setup-gate.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createFirstRunSetupGate } from './first-run-setup-gate' + +const bootstrapBackend = { + activeRoot: '/tmp/hermes-home/hermes-agent', + kind: 'bootstrap-needed', + platform: 'linux' +} + +function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function settledState(promise: Promise) { + return Promise.race([promise.then(() => 'resolved'), delay(10).then(() => 'pending')]) +} + +test('first-run setup gate skips non-bootstrap backends', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + await gate.wait({ kind: 'remote' }) + await gate.wait(null) + + assert.deepEqual(prompts, []) + assert.equal(gate.hasWaiter(), false) +}) + +test('first-run setup gate prompts once for concurrent waits', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + const first = gate.wait(bootstrapBackend) + const second = gate.wait(bootstrapBackend) + + assert.equal(gate.hasWaiter(), true) + assert.equal(prompts.length, 1) + assert.equal(await settledState(first), 'pending') + + gate.continueLocal() + + assert.deepEqual(await Promise.all([first, second]), ['continue-local', 'continue-local']) + assert.equal(gate.hasWaiter(), false) + assert.equal(gate.isLocalBootstrapConfirmed(), true) +}) + +test('continueLocal keeps the setup choice visible until bootstrap owns the overlay', async () => { + let hidden = 0 + const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + gate.continueLocal() + + assert.equal(await pending, 'continue-local') + assert.equal(hidden, 0) + assert.equal(gate.isLocalBootstrapConfirmed(), true) +}) + +test('retry reset preserves the local install confirmation', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + const pending = gate.wait(bootstrapBackend) + gate.continueLocal() + await pending + + gate.resetForRetry() + await gate.wait(bootstrapBackend) + + assert.equal(gate.isLocalBootstrapConfirmed(), true) + assert.equal(prompts.length, 1) + assert.equal(gate.hasWaiter(), false) +}) + +test('retry reset explicitly settles an active waiter without allowing local bootstrap', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + gate.resetForRetry() + + assert.equal(await pending, 'reset') + assert.equal(gate.hasWaiter(), false) + assert.equal(gate.isLocalBootstrapConfirmed(), false) +}) + +test('repair reset clears the local install confirmation and shows the gate again', async () => { + const prompts = [] + const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 }) + + const pending = gate.wait(bootstrapBackend) + gate.continueLocal() + await pending + + gate.resetForRepair() + const next = gate.wait(bootstrapBackend) + + assert.equal(gate.isLocalBootstrapConfirmed(), false) + assert.equal(prompts.length, 2) + assert.equal(gate.hasWaiter(), true) + + gate.continueLocal() + await next +}) + +test('remote apply settles the gated boot for remote re-resolution and hides the choice', async () => { + let hidden = 0 + const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + const resumedWaiter = gate.abandonForRemoteApply() + + assert.equal(resumedWaiter, true) + assert.equal(hidden, 1) + assert.equal(gate.hasWaiter(), false) + assert.equal(gate.isLocalBootstrapConfirmed(), false) + assert.equal(await pending, 'remote-applied') +}) + +test('remote apply without a waiter has no first-run side effects', async () => { + let hidden = 0 + const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 }) + const pending = gate.wait(bootstrapBackend) + + gate.continueLocal() + await pending + + assert.equal(gate.abandonForRemoteApply(), false) + assert.equal(hidden, 0) + assert.equal(gate.isLocalBootstrapConfirmed(), true) +}) diff --git a/apps/desktop/electron/first-run-setup-gate.ts b/apps/desktop/electron/first-run-setup-gate.ts new file mode 100644 index 00000000000..2d83cbeafe6 --- /dev/null +++ b/apps/desktop/electron/first-run-setup-gate.ts @@ -0,0 +1,146 @@ +interface FirstRunSetupBackend { + activeRoot?: string + kind?: string + platform?: string +} + +interface FirstRunSetupGateOptions { + hideChoice?: () => void + log?: (message: string) => void + onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void + promptChoice?: (backend: FirstRunSetupBackend) => void + stuckAfterMs?: number +} + +export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset' + +export function createFirstRunSetupGate({ + hideChoice, + log, + onStuck, + promptChoice, + stuckAfterMs = 120000 +}: FirstRunSetupGateOptions = {}) { + let localBootstrapConfirmed = false + + let waiter: { + promise: Promise + resolve: (decision: FirstRunSetupDecision) => void + } | null = null + + let stuckTimer: ReturnType | null = null + + const clearStuckTimer = () => { + if (stuckTimer) { + clearTimeout(stuckTimer) + stuckTimer = null + } + } + + const armStuckTimer = (backend: FirstRunSetupBackend) => { + clearStuckTimer() + + if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') { + return + } + + stuckTimer = setTimeout(() => { + onStuck?.(backend, stuckAfterMs) + log( + `[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` + + `(platform=${backend?.platform || 'unknown'})` + ) + }, stuckAfterMs) + + if (typeof stuckTimer.unref === 'function') { + stuckTimer.unref() + } + } + + const shouldGate = (backend?: FirstRunSetupBackend | null) => + Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed) + + const wait = async (backend?: FirstRunSetupBackend | null) => { + if (!shouldGate(backend)) { + return 'continue-local' as const + } + + if (waiter) { + return waiter.promise + } + + promptChoice?.(backend) + armStuckTimer(backend) + + let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {} + + const promise = new Promise(resolve => { + resolveWaiter = resolve + }) + + waiter = { promise, resolve: resolveWaiter } + + return promise + } + + const settleWaiter = (decision: FirstRunSetupDecision) => { + clearStuckTimer() + + if (!waiter) { + return false + } + + const activeWaiter = waiter + waiter = null + activeWaiter.resolve(decision) + + return true + } + + const continueLocal = () => { + localBootstrapConfirmed = true + settleWaiter('continue-local') + } + + const resetForRetry = () => { + // Reset paths are followed by a renderer reload / fresh startHermes() call. + // Settle the old boot explicitly so it cannot fall through into local + // bootstrap and cannot leak a forever-pending connection promise. + settleWaiter('reset') + } + + const resetForRepair = () => { + resetForRetry() + localBootstrapConfirmed = false + } + + const abandonForRemoteApply = () => { + // Resume the gated startHermes() with an explicit remote decision. The + // caller re-resolves the newly-persisted remote config instead of falling + // through into local bootstrap or leaking the original connection promise. + const resumedWaiter = settleWaiter('remote-applied') + + if (!resumedWaiter) { + return false + } + + localBootstrapConfirmed = false + hideChoice?.() + + return true + } + + const isLocalBootstrapConfirmed = () => localBootstrapConfirmed + const hasWaiter = () => Boolean(waiter) + + return { + abandonForRemoteApply, + continueLocal, + hasWaiter, + isLocalBootstrapConfirmed, + resetForRepair, + resetForRetry, + shouldGate, + wait + } +} diff --git a/apps/desktop/electron/first-run-setup-main-process.test.ts b/apps/desktop/electron/first-run-setup-main-process.test.ts new file mode 100644 index 00000000000..1dc1b920ef6 --- /dev/null +++ b/apps/desktop/electron/first-run-setup-main-process.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' + +import { test, vi } from 'vitest' + +import { applyConnectionChange } from './connection-apply' +import { createFirstRunSetupGate } from './first-run-setup-gate' +import { runPrimaryBackendStartup } from './primary-backend-startup' +import { rehomePrimaryConnection } from './primary-connection-rehome' + +test('a first-run bootstrap-needed remote apply connects without ensuring or bootstrapping locally', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + + const bootstrapBackend = { + activeRoot: '/tmp/hermes-home/hermes-agent', + kind: 'bootstrap-needed', + platform: 'linux' + } + + const candidateRemote = { + authMode: 'token', + baseUrl: 'https://gateway.example.com/hermes', + source: 'settings', + token: 'secret', + wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret' + } + + let savedRemote: typeof candidateRemote | null = null + + const resolveRemote = vi.fn(async () => savedRemote) + const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const })) + const runBootstrap = vi.fn() + + const ensureLocalRuntime = vi.fn(async backend => { + await runBootstrap() + + return { ...backend, command: 'hermes' } + }) + + const teardownPrimaryBackend = vi.fn(async () => {}) + const cancelSshBootstrap = vi.fn(async () => {}) + const teardownSsh = vi.fn(async () => {}) + const clearLocalBootstrapFailure = vi.fn() + const notifyConnectionApplied = vi.fn() + const waitForLocalStart = vi.fn(async () => {}) + const prepareLocalBackend = vi.fn(async () => bootstrapBackend) + + const pendingConnection = runPrimaryBackendStartup({ + connectRemote, + ensureLocalRuntime, + prepareLocalBackend, + resolveRemote, + waitForDecision: gate.wait, + waitForLocalStart + }) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + + // Mirrors the IPC handler's production ordering: persist the tested config, + // then re-home. The pending start must re-resolve this saved value. + savedRemote = candidateRemote + + await applyConnectionChange({ + cancelAndWait: cancelSshBootstrap, + isPrimary: true, + rehomePrimary: () => + rehomePrimaryConnection({ + clearLocalBootstrapFailure, + mode: 'remote', + notifyConnectionApplied, + resumeFirstRunRemote: gate.abandonForRemoteApply, + teardownPrimaryBackend + }), + scope: '', + sendApplied: notifyConnectionApplied, + stopPool: vi.fn(), + teardownPrimary: teardownPrimaryBackend, + teardownSsh + }) + + assert.deepEqual(await pendingConnection, { + kind: 'remote', + connection: { ...candidateRemote, mode: 'remote' } + }) + assert.deepEqual(resolveRemote.mock.calls, [[], []]) + assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]]) + assert.deepEqual(waitForLocalStart.mock.calls, [[]]) + assert.deepEqual(prepareLocalBackend.mock.calls, [[]]) + assert.equal(ensureLocalRuntime.mock.calls.length, 0) + assert.equal(runBootstrap.mock.calls.length, 0) + assert.deepEqual(cancelSshBootstrap.mock.calls, [['']]) + assert.deepEqual(teardownSsh.mock.calls, [['']]) + assert.equal(teardownPrimaryBackend.mock.calls.length, 0) + assert.equal(clearLocalBootstrapFailure.mock.calls.length, 1) + assert.equal(notifyConnectionApplied.mock.calls.length, 0) +}) + +test('a primary apply without an active first-run gate tears down before reconnect notification', async () => { + const order: string[] = [] + const clearLocalBootstrapFailure = vi.fn(() => order.push('clear-failure')) + + const teardownPrimaryBackend = vi.fn(async () => { + order.push('teardown') + }) + + const notifyConnectionApplied = vi.fn(() => order.push('notify')) + + assert.deepEqual( + await rehomePrimaryConnection({ + clearLocalBootstrapFailure, + mode: 'remote', + notifyConnectionApplied, + resumeFirstRunRemote: () => false, + teardownPrimaryBackend + }), + { resumedFirstRunRemote: false } + ) + assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]]) + assert.deepEqual(order, ['clear-failure', 'teardown', 'notify']) +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c448e3ac031..d88e1f78760 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -14,6 +14,7 @@ import { clipboard, dialog, net as electronNet, + globalShortcut, ipcMain, Menu, nativeImage, @@ -30,13 +31,15 @@ import { } from 'electron' import nodePty from 'node-pty' +import { classifyActiveRuntime } from './active-runtime-state' import { stopBackendChild as stopBackendChildImpl } from './backend-child' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' +import { isReauthRequiredError, waitForHermesReady } from './backend-health' import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' -import { shouldLatchBackendStartFailure } from './backend-start-failure' +import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' import { runBootstrap } from './bootstrap-runner' import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' @@ -61,10 +64,12 @@ import { profileRemoteOverride, profileSshOverride, resolveAuthMode, + resolveProfileBackendRoute, resolveTestWsUrl, savedProfileSsh, tokenPreview } from './connection-config' +import { describeCrashReason, installCrashForensics } from './crash-forensics' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { @@ -79,6 +84,8 @@ import { import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { findGitBash as _findGitBash } from './find-git-bash' +import { installFoundInPageForwarder, performFind, stopFind } from './find-in-page' +import { createFirstRunSetupGate } from './first-run-setup-gate' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' import { scanGitRepos } from './git-repo-scan' @@ -117,7 +124,13 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' -import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' +import { + oauthGuardMayHardFail, + oauthSessionIsLive, + resolveJsonBody, + resolveOauthRestAuth, + resolveReadinessProbeAuth +} from './native-auth-decisions' import { nativeRefreshUrl, type NativeTokenSet, @@ -128,9 +141,18 @@ import { import { runNativeLogin } from './native-oauth-login' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' +import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup' +import { rehomePrimaryConnection } from './primary-connection-rehome' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import { fetchPrimaryProfileSessions } from './profile-session-routing' +import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry' import * as remoteLifecycle from './remote-lifecycle' -import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' +import { + RemoteLivenessTracker, + RemoteRevalidationCoordinator, + revalidatePooledRemoteBackends, + revalidateRemoteConnection +} from './remote-liveness' import { buildSessionWindowUrl, chatWindowWebPreferences, @@ -363,9 +385,9 @@ if (IS_WINDOWS) { ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) // Keep the renderer running at full speed while the window is in the background -// or occluded. The chat transcript streams to screen through a -// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers) -// for backgrounded/occluded renderers, so without these the live answer stalls +// or occluded. The chat transcript streams to screen through a bounded timer +// flush; Chromium clamps timers for backgrounded/occluded renderers, so without +// these the live answer stalls // whenever the window loses focus (switching to your editor mid-turn, detached // devtools, another window covering it) and only paints on refocus or refresh. // `backgroundThrottling: false` on the BrowserWindow covers the blurred case; @@ -1012,9 +1034,22 @@ let bootstrapFailure = null // Latched non-bootstrap backend spawn failure — stops getConnection() from // respawning hermes serve backend children in a tight loop while boot is broken. let backendStartFailure = null +// Latched CONFIRMED remote reauth failure. Remote failures deliberately do not +// latch via backendStartFailure (they're usually transient and must stay +// retryable), but a rejected session cannot self-heal — and the non-latching +// path actively breaks recovery: each retry re-emits running:true and hides +// the boot-failure overlay, so the "Sign in" button flickers away before it +// can be clicked. Cleared on every recovery path and on a confirmed sign-in. +let remoteReauthFailure = null // Active first-launch install, so the renderer's Cancel button (and app quit) // can abort the in-flight install.sh/ps1 instead of leaving it running. let bootstrapAbortController = null +// Explicit "the user asked for a repair" flag. Repair used to signal intent by +// deleting the bootstrap marker, which stranded healthy installs whose only +// problem was a transient backend error (#72166). Intent now lives here, so +// repair can force the installer without destroying provenance about how the +// install was created. Cleared once the reinstall is under way. +let bootstrapRepairRequested = false let connectionConfigCache = null let connectionConfigCacheMtime = null const hermesLog = [] @@ -1185,6 +1220,14 @@ function rememberLog(chunk) { scheduleDesktopLogFlush() } +installCrashForensics({ flush: flushDesktopLogBufferSync, log: rememberLog }) + +// A rejected loadURL leaves a blank window and, unhandled, no trace anywhere +// the user can send us. `label` names the surface so the log says which one. +function loadWindowUrl(win, url, label) { + win.loadURL(url).catch(error => rememberLog(`${label} failed to load: ${describeCrashReason(error)}`)) +} + function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() @@ -1399,13 +1442,17 @@ let bootstrapState = { log: [], startedAt: null, completedAt: null, + setupChoice: null, unsupportedPlatform: null } +let firstRunSetupGate = null + function broadcastBootstrapEvent(ev) { if (ev.type === 'manifest') { bootstrapState.manifest = ev bootstrapState.active = true + bootstrapState.setupChoice = null bootstrapState.startedAt = bootstrapState.startedAt || Date.now() bootstrapState.stages = {} @@ -1433,14 +1480,30 @@ function broadcastBootstrapEvent(ev) { } else if (ev.type === 'failed') { bootstrapState.active = false bootstrapState.error = ev.error || 'unknown error' + bootstrapState.setupChoice = null } else if (ev.type === 'unsupported-platform') { bootstrapState.active = false + bootstrapState.setupChoice = null bootstrapState.unsupportedPlatform = { platform: ev.platform, activeRoot: ev.activeRoot, installCommand: ev.installCommand, docsUrl: ev.docsUrl } + } else if (ev.type === 'setup-choice') { + bootstrapState.active = false + bootstrapState.error = null + bootstrapState.manifest = null + bootstrapState.stages = {} + bootstrapState.setupChoice = ev.active + ? { + platform: ev.platform, + activeRoot: ev.activeRoot + } + : null + bootstrapState.unsupportedPlatform = null + } else if (ev.type === 'dismissed') { + resetBootstrapSnapshot() } if (!mainWindow || mainWindow.isDestroyed()) { @@ -1460,6 +1523,100 @@ function getBootstrapState() { return bootstrapState } +function resetBootstrapSnapshot() { + bootstrapState = { + active: false, + manifest: null, + stages: {}, + error: null, + log: [], + startedAt: null, + completedAt: null, + setupChoice: null, + unsupportedPlatform: null + } +} + +function promptFirstRunSetupChoice(backend) { + broadcastBootstrapEvent({ + type: 'setup-choice', + active: true, + platform: backend.platform || process.platform, + activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT + }) +} + +function hideFirstRunSetupChoice() { + if (bootstrapState.setupChoice) { + broadcastBootstrapEvent({ type: 'setup-choice', active: false }) + } +} + +function getFirstRunSetupGate() { + if (!firstRunSetupGate) { + firstRunSetupGate = createFirstRunSetupGate({ + hideChoice: hideFirstRunSetupChoice, + log: rememberLog, + onStuck: (_backend, stuckAfterMs) => { + updateBootProgress( + { + error: null, + message: `Still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)} seconds`, + phase: 'bootstrap.choice', + progress: 12, + running: true + }, + { allowDecrease: true } + ) + }, + promptChoice: promptFirstRunSetupChoice + }) + } + + return firstRunSetupGate +} + +async function waitForFirstRunSetupChoice(backend) { + const gate = getFirstRunSetupGate() + + if (!gate.shouldGate(backend)) { + return 'continue-local' + } + + updateBootProgress( + { + error: null, + message: 'Waiting for first-run setup choice', + phase: 'bootstrap.choice', + progress: 12, + running: true + }, + { allowDecrease: true } + ) + + return gate.wait(backend) +} + +function continueFirstRunLocalBootstrap() { + getFirstRunSetupGate().continueLocal() +} + +function abandonFirstRunSetupChoiceForRemoteApply() { + const gate = getFirstRunSetupGate() + + if (!gate.hasWaiter()) { + return false + } + + const resumedGatedConnection = gate.abandonForRemoteApply() + + if (resumedGatedConnection) { + broadcastBootstrapEvent({ type: 'dismissed' }) + } + + return resumedGatedConnection +} + function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress @@ -2655,6 +2812,11 @@ async function applyUpdates(opts = {}) { const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') + // ── Pre-flight state.db integrity guard (#68474) ───────────────── + // Emergency backup and header verification before the update touches + // anything. Runs while the backend is still alive. + preflightStateDb(HERMES_HOME, rememberLog) + // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we // spawn the updater. Without this the updater races a still-locked // hermes.exe (held by the backend child / its grandchildren) and the update @@ -2852,6 +3014,92 @@ function runningAppBundle() { return dir.endsWith('.app') ? dir : null } +// ── Pre-flight state.db integrity guard (#68474) ───────────────────── +// Take an emergency snapshot of state.db and verify the live copy is +// intact before any update process mutates the install. Runs in the +// desktop Electron process itself, before the backend is killed and +// before the updater is spawned — a separate safety net from the +// Python-level pre-update snapshot inside `hermes update`. +function preflightStateDb(hermesHome, rememberLog) { + const stateDbPath = path.join(hermesHome, 'state.db') + + if (!fileExists(stateDbPath)) { + rememberLog('[updates] state.db pre-flight: not found (fresh install?)') + + return + } + + try { + const stat = fs.statSync(stateDbPath) + + if (stat.size > 100) { + const fd = fs.openSync(stateDbPath, 'r') + const header = Buffer.alloc(16) + + fs.readSync(fd, header, 0, 16, 0) + fs.closeSync(fd) + + const expectedHeader = Buffer.from('SQLite format 3\0') + const headerOk = header.equals(expectedHeader) + + rememberLog( + `[updates] state.db pre-flight: size=${stat.size}, ` + + `headerOk=${headerOk}, headerHex=${header.toString('hex')}` + ) + + if (!headerOk) { + rememberLog( + '[updates] state.db header is INVALID before update — ' + + 'this indicates pre-existing corruption or a concurrent write issue' + ) + } + + // Emergency timestamped backup, separate from the Python-level snapshot. + const ts = new Date().toISOString().replace(/[:.]/g, '-') + + const emergencyPath = path.join(hermesHome, `state.db.pre-update-emergency-${ts}.bak`) + + try { + fs.copyFileSync(stateDbPath, emergencyPath) + const emergStat = fs.statSync(emergencyPath) + + rememberLog(`[updates] emergency state.db backup: ${emergencyPath} ` + `(${emergStat.size} bytes)`) + + // Prune to the 2 most recent emergency backups. + try { + const homeDir = fs.readdirSync(hermesHome) + + const backups = homeDir + .filter( + f => + f.startsWith('state.db.pre-update-emergency-') && + f.endsWith('.bak') && + f !== path.basename(emergencyPath) + ) + .sort() + .reverse() + + for (const old of backups.slice(2)) { + try { + fs.unlinkSync(path.join(hermesHome, old)) + } catch { + void 0 + } + } + } catch { + void 0 + } + } catch (copyErr) { + rememberLog(`[updates] emergency state.db backup failed: ${copyErr.message}`) + } + } else { + rememberLog(`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`) + } + } catch (statErr) { + rememberLog(`[updates] could not stat state.db before update: ${statErr.message}`) + } +} + function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'` } @@ -2870,9 +3118,12 @@ async function applyUpdatesPosixInApp(opts: any) { return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } } + // ── Pre-flight state.db integrity guard (#68474) ── + preflightStateDb(HERMES_HOME, rememberLog) + // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s // npm build can find them on a machine with no system Node. Windows portable - // Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin. + // Node lives directly under %LOCALAPPDATA%\\hermes\\node, not node\\bin. // PYTHONUNBUFFERED: `hermes update` writes to a pipe here, so CPython // block-buffers stdout and long quiet steps (the pre-update backup can zip // multi-GB archives for minutes) stream nothing to the progress UI — users @@ -3158,11 +3409,11 @@ function readJson(filePath) { } } -// Bootstrap-complete marker helpers. The marker is written ONCE by the -// first-launch bootstrap runner (Phase 1D) after install.ps1 stages succeed -// AND the user has finished initial configuration. On every subsequent boot -// we check `isBootstrapComplete()` and skip the bootstrap flow entirely if -// the marker is present and current-schema. +// Bootstrap-complete marker helpers. The marker is written by whichever +// installer ran: install.ps1, install.sh, the Rust bootstrap installer, or the +// first-launch bootstrap runner. It is provenance ("a bootstrap finished +// here"), NOT the launch gate -- activeRuntimeState() decides that, because a +// healthy runtime can predate the marker or outlive a repair that cleared it. // // Marker schema (version 1): // { @@ -3195,29 +3446,13 @@ function isActiveRuntimeUsable() { ) } -function isBootstrapComplete() { - const marker = readBootstrapMarker() - - if (!marker || typeof marker !== 'object') { - return false - } - - if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) { - return false - } - - if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) { - return false - } - +function activeRuntimeState() { // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes - // update`, which moves HEAD legitimately. The marker just attests "we - // ran the bootstrap successfully at least once." We DO additionally require - // a runnable venv: an interrupted or split-home install can leave the marker - // + checkout without a venv, and trusting that spawns a dead backend - // ("gateway offline") instead of re-running bootstrap to repair it. - return isActiveRuntimeUsable() + // update`, which moves HEAD legitimately. The marker only attests "a + // desktop-managed bootstrap ran here at least once"; runtime usability is + // what decides whether we can actually launch. + return classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable()) } function writeBootstrapMarker(payload) { @@ -3477,16 +3712,30 @@ function resolveHermesBackend(backendArgs) { } } - // 3. Bootstrap-complete ACTIVE_HERMES_ROOT -- the canonical install at - // %LOCALAPPDATA%\hermes\hermes-agent (Windows) or ~/.hermes/hermes-agent. - // The bootstrap marker means install.ps1 stages finished and the user - // completed initial configuration; we trust the install and go straight - // to spawning hermes. Updates flow through the in-app update path - // (applyUpdates -> git pull) or `hermes update` from the CLI. - if (isBootstrapComplete()) { + // 3. ACTIVE_HERMES_ROOT — the canonical install at + // %LOCALAPPDATA%\\hermes\\hermes-agent (Windows) or ~/.hermes/hermes-agent. + // A valid bootstrap marker proves Desktop finished the first-run install + // flow, but marker provenance is NOT the same thing as runtime usability: + // the CLI can create the exact same repo+venv layout, and older desktop + // builds could leave a healthy install behind without the marker. If the + // active runtime is usable, launch it directly; only fall through to + // bootstrap when the runtime itself is unusable. + const activeRuntime = activeRuntimeState() + + if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) { + if (!activeRuntime.hasValidMarker) { + rememberLog( + `[bootstrap] Active Hermes runtime at ${ACTIVE_HERMES_ROOT} is usable but the bootstrap marker is missing or stale; skipping first-run bootstrap.` + ) + } + return createActiveBackend(backendArgs) } + if (bootstrapRepairRequested) { + rememberLog('[bootstrap] repair requested; bypassing the usable active runtime to re-run the installer') + } + // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from // a previous tool-only setup, or pip-installed system-wide. Use it but // do NOT write a bootstrap marker; the user did this themselves and we @@ -3660,6 +3909,10 @@ async function ensureRuntime(backend) { bootstrapAbortController = new AbortController() + // The repair request has been honoured by reaching the installer; clear it + // so a later boot isn't forced through bootstrap again. + bootstrapRepairRequested = false + const bootstrapResult = await runBootstrap({ installStamp: backend.installStamp, activeRoot: backend.activeRoot, @@ -3754,10 +4007,10 @@ async function ensureRuntime(backend) { // No venv at the expected location AND no bootstrap-needed sentinel // means we have a half-installed checkout: .git exists, source files // exist, but venv is missing or broken. This shouldn't happen in - // normal flow because isBootstrapComplete() requires - // isHermesSourceRoot() and the bootstrap writes the marker only after - // install.ps1 succeeds. If we hit this, the user (or a deleted venv) - // broke the invariant; tell them to re-run the install. + // normal flow because activeRuntimeState() requires isHermesSourceRoot() + // plus an importable hermes_cli before it hands back the active runtime. + // If we hit this, the user (or a deleted venv) broke the invariant; tell + // them to re-run the install. throw new Error( `Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.' ) @@ -4603,40 +4856,88 @@ function closePreviewWatchers() { } } -async function waitForHermes(baseUrl, token, signal?) { - const deadline = Date.now() + 45_000 - let lastError = null +// Best-effort read of a gateway's advertised auth providers, cached per base +// URL for the life of the process. Used by the oauth pre-flight guard to tell +// a password-provider gateway (which cannot satisfy the bearer/cookie checks +// by design) from a real OAuth one. Any failure returns [] so callers keep the +// strict guard — backends predating /api/auth/providers are unaffected. +const gatewayAuthProvidersCache = new Map() - while (Date.now() < deadline) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') - error.kind = 'superseded' - throw error +async function gatewayAuthProviders(baseUrl) { + const cached = gatewayAuthProvidersCache.get(baseUrl) + + if (cached) { + return cached + } + + let providers = [] + + try { + const body = (await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })) as any + + if (Array.isArray(body?.providers)) { + providers = body.providers + .filter(p => p && typeof p === 'object') + .map(p => ({ name: String(p.name || ''), supportsPassword: Boolean(p.supports_password) })) + .filter(p => p.name) } + } catch { + // Optional metadata — an unreadable list keeps the strict guard. + } - try { - await fetchJson(`${baseUrl}/api/status`, token) + gatewayAuthProvidersCache.set(baseUrl, providers) - return - } catch (error) { - lastError = error - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, 500) - signal?.addEventListener( - 'abort', - () => { - clearTimeout(timer) - const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.') - aborted.kind = 'superseded' - reject(aborted) - }, - { once: true } - ) - }) + return providers +} + +// Build the readiness probe for a connection's auth mode. A gated gateway +// must be probed with the SAME credentials the rest of the connection uses: +// an anonymous probe 401s forever against a live session, and it can never +// see the 404 that identifies a backend predating /api/health (the auth gate +// answers before the SPA catch-all). `probeIsCredentialed` tells +// waitForHermesReady how to read a 401 — rejected session vs gated route. +async function buildReadinessHealthProbe(baseUrl, authMode, token) { + const nativeAt = authMode === 'oauth' ? await ensureNativeAccessToken(baseUrl).catch(() => null) : null + const probeAuth = resolveReadinessProbeAuth(authMode, nativeAt, token) + + if (probeAuth.kind === 'bearer') { + return { + // fetchJson takes the bearer via `options.bearer` — a raw `headers` + // option is ignored, so passing one here would silently probe + // uncredentialed and reintroduce the 401 loop. + probeHealth: (url, options: any = {}) => fetchJson(url, null, { ...options, bearer: probeAuth.token }), + probeIsCredentialed: true } } - throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`) + if (probeAuth.kind === 'cookie') { + return { + probeHealth: (url, options: any = {}) => fetchJsonViaOauthSession(url, options), + probeIsCredentialed: true + } + } + + if (probeAuth.kind === 'token' && probeAuth.token) { + return { + probeHealth: (url, options: any = {}) => fetchJson(url, probeAuth.token, options), + probeIsCredentialed: true + } + } + + return { probeHealth: fetchPublicJson, probeIsCredentialed: false } +} + +async function waitForHermes(baseUrl, token, signal?, authMode?) { + const { probeHealth, probeIsCredentialed } = await buildReadinessHealthProbe(baseUrl, authMode, token) + + return waitForHermesReady(baseUrl, { + token, + signal, + fetchPublicJson, + fetchJson: probeIsCredentialed ? (url, _token, options) => probeHealth(url, options) : fetchJson, + probeHealth, + probeIsCredentialed + }) } function getWindowButtonPosition() { @@ -4654,6 +4955,8 @@ function getNativeOverlayWidth() { function getWindowState(win = mainWindow) { return { isFullscreen: Boolean(win?.isFullScreen?.()), + isMinimized: Boolean(win?.isMinimized?.()), + isVisible: Boolean(win?.isVisible?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } @@ -6637,7 +6940,10 @@ async function buildRemoteConnection( // here would reject a freshly-completed native sign-in and loop the UI back // into "not signed in" even though mintGatewayWsTicket would succeed with // the stored bearer. - if (!oauthSessionIsLive(hasNativeSession(baseUrl), await hasLiveOauthSession(baseUrl))) { + if ( + !oauthSessionIsLive(hasNativeSession(baseUrl), await hasLiveOauthSession(baseUrl)) && + oauthGuardMayHardFail(await gatewayAuthProviders(baseUrl)) + ) { const err = new Error( 'Remote Hermes gateway uses OAuth, but you are not signed in. ' + 'Open Settings → Gateway and click "Sign in", or switch back to Local.' @@ -6878,7 +7184,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc forward: (localPort, remotePort) => ssh.forward(localPort, remotePort), cancelForward: (localPort, remotePort) => ssh.cancelForward(localPort, remotePort), pickLocalPort, - waitForHermes: (baseUrl, token) => waitForHermes(baseUrl, token, lease.signal), + waitForHermes: (baseUrl, token) => waitForHermes(baseUrl, token, lease.signal, 'token'), probeReuseProof: sshProbeReuseProof, adoptServedToken: adoptServedDashboardToken, rememberLog: sshRememberLog, @@ -7351,6 +7657,7 @@ function stopBackendChild(child) { // switch / crash recovery), which still resets boot progress + reloads. function resetHermesConnection({ soft = false } = {}) { backendStartFailure = null + remoteReauthFailure = null remoteLiveness.clear() const hermesProcess = backendConnectionState.invalidate() stopBackendChild(hermesProcess) @@ -7435,15 +7742,28 @@ function primaryProfileKey() { return readActiveDesktopProfile() || 'default' } -// Resolve a backend connection for the given profile. Routes the primary -// profile to startHermes() (the window backend: boot UI, bootstrap, remote -// mode), and any OTHER profile to a lazily-spawned pool backend. An empty / -// unknown profile resolves to the primary, so all legacy callers are unchanged. +// Options describing the current connection setup for `resolveProfileBackendRoute`. +function profileRouteOptions(profile) { + return { + globalRemote: globalRemoteActive(), + primaryProfile: primaryProfileKey(), + profileRemoteOverride: Boolean(profileHasRemoteOverride(profile)) + } +} + +// Resolve a backend connection for the given profile, per the routing table in +// resolveProfileBackendRoute(). An empty / unknown profile resolves to the +// primary, so legacy callers are unchanged. async function ensureBackend(profile) { const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey() + const route = resolveProfileBackendRoute(key, profileRouteOptions(key)) - if (key === primaryProfileKey()) { - return startHermes() + if (route.backend === 'primary') { + const connection = await startHermes() + + // 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 } const existing = backendPool.get(key) @@ -7456,7 +7776,15 @@ async function ensureBackend(profile) { evictLruPoolBackends(POOL_MAX_BACKENDS - 1) - const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() } + const entry = { + process: null, + port: null, + token: null, + connectionPromise: null, + lastActiveAt: Date.now(), + remoteBaseUrl: null + } + entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => { backendPool.delete(key) throw error @@ -7551,7 +7879,11 @@ async function spawnPoolBackend(profile, entry) { const remote = await resolveRemoteBackend(profile) if (remote) { - await waitForHermes(remote.baseUrl, remote.token) + await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode) + + // Recorded on the entry so revalidation can probe this descriptor without + // awaiting connectionPromise, which may still be pending for a sibling. + entry.remoteBaseUrl = remote.baseUrl return { ...remote, @@ -7743,6 +8075,13 @@ async function startHermes() { throw backendStartFailure } + // A confirmed remote reauth rejection is terminal until the user signs in. + // Short-circuiting here keeps the boot-failure overlay latched and its + // "Sign in" button clickable, instead of re-driving boot on every retry. + if (remoteReauthFailure) { + throw remoteReauthFailure + } + // E2E: simulate a boot failure without breaking the real backend. The boot // progresses a few steps, then fails with the given error message. if (BOOT_FAKE_ERROR) { @@ -7767,16 +8106,9 @@ async function startHermes() { let attemptedRemote = primaryBackendIsRemote() const connectionPromise = (async () => { - await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) - // Resolve for the desktop's primary profile so a per-profile remote - // override on the active profile is honored (falls back to env / global). - // Re-read once resolved so the classification tracks the value actually used. - attemptedRemote = primaryBackendIsRemote() - const remote = await resolveRemoteBackend(primaryProfileKey()) - - if (remote) { + const connectRemote = async remote => { await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24) - await waitForHermes(remote.baseUrl, remote.token) + await waitForHermes(remote.baseUrl, remote.token, undefined, remote.authMode) updateBootProgress({ phase: 'backend.ready', message: 'Remote Hermes backend is ready', @@ -7800,14 +8132,9 @@ async function startHermes() { } } - // Mutual exclusion with an in-app update (#50238). If this instance was - // relaunched while the Tauri updater is still applying an update, spawning - // a local backend now re-locks the venv shim and gets killed by the - // updater's straggler cleanup — looping. Park until the update finishes (or - // is detected stale), THEN start the backend. Local backends only; remote - // connections returned above and never touch the install tree. - await waitForUpdateToFinish() - + await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) + // Resolve for the desktop's primary profile so a per-profile remote + // override on the active profile is honored (falls back to env / global). const token = crypto.randomBytes(32).toString('base64url') // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0'] @@ -7822,8 +8149,32 @@ async function startHermes() { backendArgs.unshift('--profile', activeProfile) } - await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) - const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) + const setup = await runPrimaryBackendStartup({ + connectRemote, + ensureLocalRuntime: ensureRuntime, + prepareLocalBackend: async () => { + await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) + + return resolveHermesBackend(backendArgs) + }, + resolveRemote: () => { + // Classify immediately before each throwing resolve. This callback runs + // both for an already-saved remote and after first-run remote Apply. + attemptedRemote = primaryBackendIsRemote() + + return resolveRemoteBackend(primaryProfileKey()) + }, + waitForDecision: waitForFirstRunSetupChoice, + // Mutual exclusion with an in-app update (#50238). Remote connections + // return before this waiter; local starts park until the updater exits. + waitForLocalStart: waitForUpdateToFinish + }) + + if (setup.kind === 'remote') { + return setup.connection + } + + const backend = setup.backend // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. backend.args = getBackendArgsForRuntime(backend) const hermesCwd = resolveHermesCwd() @@ -7979,6 +8330,10 @@ async function startHermes() { throw error } + if (error instanceof FirstRunSetupResetError) { + throw error + } + const message = error instanceof Error ? error.message : String(error) // Only latch LOCAL boot failures. A remote failure (lapsed session / mint @@ -7990,6 +8345,12 @@ async function startHermes() { backendStartFailure = error instanceof Error ? error : new Error(message) } + // A confirmed reauth rejection latches separately: it can't self-heal, and + // leaving it unlatched hides the overlay's "Sign in" button on every retry. + if (shouldLatchRemoteReauthFailure({ attemptedRemote, isReauth: isReauthRequiredError(error) })) { + remoteReauthFailure = error instanceof Error ? error : new Error(message) + } + updateBootProgress( { error: message, @@ -8114,12 +8475,14 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch? wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) - win.loadURL( + loadWindowUrl( + win, buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), watch - }) + }), + 'Session window' ) return win @@ -8199,11 +8562,7 @@ function createInstanceWindow() { instanceWindows.delete(win) }) - if (DEV_SERVER) { - win.loadURL(DEV_SERVER) - } else { - win.loadURL(pathToFileURL(resolveRendererIndex()).toString()) - } + loadWindowUrl(win, DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(), 'Instance window') return win } @@ -8315,7 +8674,7 @@ function spawnPetOverlayWindow(bounds) { } }) - win.loadURL(petOverlayUrl()) + loadWindowUrl(win, petOverlayUrl(), 'Pet overlay') return win } @@ -8349,6 +8708,211 @@ function closePetOverlay() { petOverlayWindow = null } +// ── Quick Entry ───────────────────────────────────────────────────────────── +// +// A global shortcut summons a small frameless always-on-top composer from +// anywhere, so a prompt can be fired without raising the whole app. The window +// carries NO gateway connection: it hands its text to us, we forward it to the +// PRIMARY renderer, and that renderer submits through the same prompt path the +// normal composer uses (see store/quick-entry + hooks/use-quick-entry-bridge). +// +// Main owns the OS registration and the persisted preference (it must restore +// the shortcut on a cold launch without the renderer ever visiting Settings), +// same authority split as keep-awake. Registration failure is surfaced, never +// swallowed: a chord another app already owns comes back as `error: 'taken'`. +const QUICK_ENTRY_CONFIG_PATH = path.join(app.getPath('userData'), 'quick-entry.json') + +let quickEntryWindow = null + +// Latest state push from the primary renderer (connection + recent sessions), +// replayed to a quick window that spawns after the push happened. +let quickEntryLastState = null + +function readQuickEntrySettings() { + try { + return sanitizeQuickEntrySettings(JSON.parse(fs.readFileSync(QUICK_ENTRY_CONFIG_PATH, 'utf8'))) + } catch { + // Missing / unreadable / malformed → shipped defaults (enabled, default chord). + return sanitizeQuickEntrySettings(undefined) + } +} + +function writeQuickEntrySettings(settings) { + try { + fs.mkdirSync(path.dirname(QUICK_ENTRY_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(QUICK_ENTRY_CONFIG_PATH, JSON.stringify(settings, null, 2), 'utf8') + } catch (error) { + rememberLog(`[quick-entry] write failed: ${error.message}`) + } +} + +function quickEntryUrl() { + if (DEV_SERVER) { + return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=quick#/` + } + + return `${pathToFileURL(resolveRendererIndex()).toString()}?win=quick#/` +} + +function spawnQuickEntryWindow() { + const cursor = screen.getCursorScreenPoint() + const display = screen.getDisplayNearestPoint(cursor) + const bounds = quickEntryWindowBounds(display?.workArea) + + const win = new BrowserWindow({ + ...bounds, + frame: false, + transparent: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + // Same rationale as the pet overlay: on Windows/Linux keep the helper out + // of the taskbar/alt-tab list; on macOS use an NSPanel so the frameless + // capture window never becomes the app's cmd-tab anchor. + skipTaskbar: !IS_MAC, + hasShadow: true, + alwaysOnTop: true, + type: IS_MAC ? 'panel' : undefined, + hiddenInMissionControl: IS_MAC, + show: false, + backgroundColor: '#00000000', + webPreferences: { + preload: PRELOAD_PATH, + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true + } + }) + + win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') + win.setHiddenInMissionControl?.(true) + + try { + win.setVisibleOnAllWorkspaces( + true, + IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined + ) + } catch { + // Not supported everywhere — best effort. + } + + // Opts out of global UI zoom for the same reason as the pet overlay: it sizes + // its own OS window and a zoomed composer would overflow it. + wireCommonWindowHandlers(win, zoomWiringForWindowKind('quickEntry')) + + // Hide on blur. The window must never hold the user's focus captive — losing + // focus is the cheapest, least surprising dismiss (matches Spotlight). + win.on('blur', () => { + if (!win.isDestroyed()) { + win.hide() + } + }) + + win.on('closed', () => { + if (quickEntryWindow === win) { + quickEntryWindow = null + } + }) + + // Replay the last known gateway state as soon as the page can hear it — a + // freshly spawned quick window must not sit "disconnected" when the primary + // renderer already reported a live gateway. + win.webContents.on('did-finish-load', () => { + if (!win.isDestroyed() && quickEntryLastState) { + win.webContents.send('hermes:quick-entry:state', quickEntryLastState) + } + }) + + loadWindowUrl(win, quickEntryUrl(), 'Quick entry') + + return win +} + +// Move the (already-open) window to the display the cursor is on, so the chord +// summons it where the user is looking rather than where they last were. +function repositionQuickEntryWindow(win) { + try { + const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) + win.setBounds(quickEntryWindowBounds(display?.workArea)) + } catch (error) { + rememberLog(`[quick-entry] reposition failed: ${error.message}`) + } +} + +function showQuickEntryWindow() { + if (!quickEntryWindow || quickEntryWindow.isDestroyed()) { + quickEntryWindow = spawnQuickEntryWindow() + quickEntryWindow.once('ready-to-show', () => { + if (!quickEntryWindow?.isDestroyed()) { + quickEntryWindow.show() + quickEntryWindow.focus() + } + }) + + return + } + + repositionQuickEntryWindow(quickEntryWindow) + quickEntryWindow.show() + quickEntryWindow.focus() + // Re-summoned: tell the renderer to clear any stale draft and refocus. + quickEntryWindow.webContents.send('hermes:quick-entry:shown') +} + +function hideQuickEntryWindow() { + if (quickEntryWindow && !quickEntryWindow.isDestroyed()) { + quickEntryWindow.hide() + } +} + +// The chord toggles: pressing it while the composer is up puts it away, so one +// gesture does exactly one thing in both directions. +function toggleQuickEntryWindow() { + if (quickEntryWindow && !quickEntryWindow.isDestroyed() && quickEntryWindow.isVisible()) { + hideQuickEntryWindow() + + return + } + + showQuickEntryWindow() +} + +const quickEntryShortcut = createQuickEntryShortcut(globalShortcut, toggleQuickEntryWindow) + +function applyQuickEntrySettings(settings) { + const state = quickEntryShortcut.apply(settings) + + if (!settings.enabled) { + // Turning the feature off must not leave an orphan always-on-top window. + if (quickEntryWindow && !quickEntryWindow.isDestroyed()) { + quickEntryWindow.close() + } + + quickEntryWindow = null + } + + if (state.error === 'taken') { + rememberLog(`[quick-entry] shortcut ${state.shortcut} is already taken by another application`) + } else if (state.error === 'invalid') { + rememberLog(`[quick-entry] shortcut ${state.shortcut} is not a valid accelerator`) + } + + return { ...state, enabled: settings.enabled } +} + +function closeQuickEntryWindow() { + quickEntryShortcut.dispose() + + if (quickEntryWindow && !quickEntryWindow.isDestroyed()) { + quickEntryWindow.close() + } + + quickEntryWindow = null +} + function createWindow() { const icon = getAppIconPath() const savedWindowState = readWindowState() @@ -8375,9 +8939,9 @@ function createWindow() { show: false, backgroundColor: getWindowBackgroundColor(), // Shared with the secondary session windows (chatWindowWebPreferences) so - // both keep `backgroundThrottling: false` — the chat transcript streams via - // a requestAnimationFrame-gated flush that Chromium pauses for blurred - // windows, stalling the live answer until refocus. See session-windows.ts. + // both keep `backgroundThrottling: false` — the chat transcript uses a + // bounded timer flush that Chromium clamps for blurred windows, stalling + // the live answer until refocus. See session-windows.ts. webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) @@ -8445,6 +9009,10 @@ function createWindow() { mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + mainWindow.on('minimize', () => sendWindowStateChanged()) + mainWindow.on('restore', () => sendWindowStateChanged()) + mainWindow.on('hide', () => sendWindowStateChanged()) + mainWindow.on('show', () => sendWindowStateChanged()) // Reopen where the user left off. resized/moved settle once per drag; close is // the cross-platform backstop, flushed synchronously before the window is gone. @@ -8552,11 +9120,7 @@ function createWindow() { rememberLog(`[renderer console] ${text} (${src}:${lineNo})`) }) - if (DEV_SERVER) { - mainWindow.loadURL(DEV_SERVER) - } else { - mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString()) - } + loadWindowUrl(mainWindow, DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(), 'Renderer') // Start the Python backend NOW, in parallel with the renderer load — not on // did-finish-load. The backend cold boot (spawn → port announce → /api/status) @@ -8588,6 +9152,8 @@ ipcMain.handle('hermes:connection:revalidate', async () => { const connectionPromise = backendConnectionState.getPromise() if (!connectionPromise) { + await revalidatePool() + return { ok: true, rebuilt: false } } @@ -8595,14 +9161,17 @@ ipcMain.handle('hermes:connection:revalidate', async () => { // share this primary connection. Coalesce simultaneous requests so one outage // produces one failure observation rather than exhausting the whole streak. return remoteRevalidation.run(connectionPromise, async () => { - const result = await revalidateRemoteConnection({ - connectionPromise, - currentConnectionPromise: () => backendConnectionState.getPromise(), - log: rememberLog, - probe: fetchPublicJson, - resetConnection: resetHermesConnection, - tracker: remoteLiveness - }) + const [result] = await Promise.all([ + revalidateRemoteConnection({ + connectionPromise, + currentConnectionPromise: () => backendConnectionState.getPromise(), + log: rememberLog, + probe: fetchPublicJson, + resetConnection: resetHermesConnection, + tracker: remoteLiveness + }), + revalidatePool() + ]) // A rebuilt SSH connection must also tear down its tunnel/master before the // renderer re-dials (which only happens after this handler resolves), so the @@ -8620,6 +9189,20 @@ ipcMain.handle('hermes:connection:revalidate', async () => { return result }) }) + +// Pooled remote descriptors get the same treatment as the primary: they have no +// child process to signal their host's death, and the renderer's keepalive touch +// spares them from the idle reaper, so nothing else can retire a dead one. +function revalidatePool() { + return revalidatePooledRemoteBackends({ + entries: backendPool.entries(), + log: rememberLog, + probe: fetchPublicJson, + stopBackend: stopPoolBackend, + tracker: remoteLiveness + }) +} + ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) @@ -8793,40 +9376,38 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { await teardownPrimaryBackendAndWait() bootstrapFailure = null backendStartFailure = null - bootstrapState = { - active: false, - manifest: null, - stages: {}, - error: null, - log: [], - startedAt: null, - completedAt: null, - unsupportedPlatform: null - } + remoteReauthFailure = null + getFirstRunSetupGate().resetForRetry() + resetBootstrapSnapshot() return { ok: true } }) ipcMain.handle('hermes:bootstrap:repair', async () => { - // Forceful repair: drop the bootstrap-complete marker so the next - // startHermes() re-runs the full installer (refreshing a broken/partial - // venv), and clear any latched failure + live connection. The renderer - // reloads afterwards to re-drive the boot flow from scratch. - rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure') - - try { - if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) { - fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true }) - } - } catch (error) { - rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) - } + // Forceful repair: force the next startHermes() through the full installer + // (refreshing a broken/partial venv) and clear any latched failure + live + // connection. The renderer reloads afterwards to re-drive the boot flow. + // + // We do NOT delete the bootstrap marker here. Repair is also reachable from + // transient backend errors on a perfectly healthy install, and deleting the + // marker in that case stranded the app in first-run setup with no way back + // (#72166). The explicit flag carries the intent instead. + rememberLog('[bootstrap] repair requested by renderer; forcing reinstall + clearing latched failure') + bootstrapRepairRequested = true bootstrapFailure = null backendStartFailure = null + remoteReauthFailure = null + getFirstRunSetupGate().resetForRepair() resetHermesConnection() return { ok: true } }) +ipcMain.handle('hermes:bootstrap:continue-local', async () => { + rememberLog('[bootstrap] local install selected by renderer; continuing first-launch bootstrap') + continueFirstRunLocalBootstrap() + + return { ok: true } +}) ipcMain.handle('hermes:bootstrap:cancel', async () => { // Renderer's Cancel button during first-launch install. Abort the running // install script (SIGTERM via the runner's abortSignal). runBootstrap @@ -8925,6 +9506,9 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => }) _storeNativeTokens(baseUrl, tokens) + // Confirmed sign-in — release the reauth latch so the next + // startHermes() re-dials instead of replaying the stale rejection. + remoteReauthFailure = null return { ok: true, baseUrl, connected: true } } catch (error) { @@ -8941,7 +9525,16 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => // Legacy embedded-webview cookie flow. await openOauthLoginWindow(baseUrl) - return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) } + const connected = await hasOauthSessionCookie(baseUrl) + + // Only a CONFIRMED sign-in releases the latch. A cancelled/closed login + // window must leave it set, or the overlay's "Sign in" button starts + // flickering again on the next retry. + if (connected) { + remoteReauthFailure = null + } + + return { ok: true, baseUrl, connected } }) ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' @@ -9005,6 +9598,18 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { await applyConnectionChange({ cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value), isPrimary: !key || key === primaryProfileKey(), + rehomePrimary: () => + rehomePrimaryConnection({ + clearLocalBootstrapFailure: () => { + // A remote connection bypasses local runtime/bootstrap failures. Clear + // the local-install latch so unsupported/failure escape paths can re-home. + bootstrapFailure = null + }, + mode: config.mode, + notifyConnectionApplied: sendConnectionApplied, + resumeFirstRunRemote: abandonFirstRunSetupChoiceForRemoteApply, + teardownPrimaryBackend: teardownPrimaryBackendAndWait + }), scope, sendApplied: sendConnectionApplied, stopPool: stopPoolBackend, @@ -9224,12 +9829,7 @@ async function fetchProfilesSessionSlice(searchParams, remoteProfiles) { return remoteSessionList(requested, searchParams) } - const primary = await ensureBackend(null) - - return fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { - method: 'GET', - timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0, profile_totals: {} })) + return fetchPrimaryProfileSessions(searchParams, fetchJsonForProfile) } return mergeRemoteProfileSessions(searchParams, remoteProfiles) @@ -9244,12 +9844,7 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { const offset = Math.max(0, Number(searchParams.get('offset')) || 0) const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active' - const primary = await ensureBackend(null) - - const base = (await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, { - method: 'GET', - timeoutMs: DEFAULT_FETCH_TIMEOUT_MS - }).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))) as any + const base = (await fetchPrimaryProfileSessions(searchParams, fetchJsonForProfile)) as any // Over-fetch each remote from offset 0 (limit+offset rows) so the merged window // is correct for this page — mirrors the primary's per-profile over-fetch. @@ -9308,10 +9903,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { const connection = await ensureBackend(routeProfile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { - globalRemote: globalRemoteActive(), - profileRemoteOverride: profileHasRemoteOverride(profile) - }) + const requestPath = pathWithGlobalRemoteProfile(request.path, profile, profileRouteOptions(profile)) const url = `${connection.baseUrl}${requestPath}` @@ -9616,12 +10208,139 @@ ipcMain.on('hermes:keep-awake', (_event, on) => { } }) +// Quick Entry: the renderer reads the live registration state on settings mount +// and writes the preference back. Main is authoritative — it owns the OS +// accelerator — so both handlers return the state that ACTUALLY resulted, +// including `registered: false` + `error: 'taken'` when another app owns the +// chord. See electron/quick-entry.ts + store/quick-entry. +ipcMain.handle('hermes:quick-entry:settings:get', async () => { + const settings = readQuickEntrySettings() + const state = quickEntryShortcut.current() + + // Ground truth is what the last apply produced; the shortcut we report is the + // live one (a saved-but-rejected chord still shows what the user asked for). + return { + enabled: settings.enabled, + error: state.error, + registered: state.registered, + shortcut: settings.enabled ? state.shortcut : settings.shortcut + } +}) + +ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => { + const current = readQuickEntrySettings() + + const next = sanitizeQuickEntrySettings({ + enabled: patch?.enabled === undefined ? current.enabled : patch.enabled === true, + shortcut: typeof patch?.shortcut === 'string' && patch.shortcut.trim() ? patch.shortcut : current.shortcut + }) + + writeQuickEntrySettings(next) + + return applyQuickEntrySettings(next) +}) + +// Quick window → main → PRIMARY renderer. We never submit here: the renderer +// owns the one prompt-submit path, and forwarding keeps it that way. The +// payload is `{ target, text }` — target routing (current chat / a picked +// session / new) is the renderer's job too. +ipcMain.on('hermes:quick-entry:submit', (_event, payload) => { + hideQuickEntryWindow() + + const text = typeof payload?.text === 'string' ? payload.text.trim() : '' + + if (!text) { + return + } + + if (!mainWindow || mainWindow.isDestroyed()) { + rememberLog('[quick-entry] dropped a submit: no primary window to route it to') + + return + } + + // Deliberately does NOT raise/focus the main window — the user asked to fire + // a prompt from wherever they were, not to be yanked into the app. + mainWindow.webContents.send('hermes:quick-entry:submit', { + target: typeof payload?.target === 'string' && payload.target ? payload.target : 'current', + text + }) +}) + +// Primary renderer → main → quick window: gateway connection state + the +// recent-session list for the target picker. Cached so a quick window spawned +// AFTER the last push still boots from truth instead of "disconnected". +ipcMain.on('hermes:quick-entry:state', (_event, payload) => { + quickEntryLastState = payload ?? null + + if (quickEntryWindow && !quickEntryWindow.isDestroyed()) { + quickEntryWindow.webContents.send('hermes:quick-entry:state', payload) + } +}) + +ipcMain.on('hermes:quick-entry:dismiss', () => hideQuickEntryWindow()) + ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') } }) +// ── Find-in-page (Ctrl/Cmd+F) ───────────────────────────────────────────── +// The desktop supports multiple BrowserWindows (one primary plus any +// per-session secondary windows spawned via `hermes:window:openSession`). +// Find must run against the requesting window, not a global — otherwise +// Cmd+F pressed in a secondary session window would search the primary +// and the match counter would report matches the user can't see. Resolve +// the sender through `BrowserWindow.fromWebContents(event.sender)` and +// forward `found-in-page` results back to that same sender. + +// Lazily-installed forwarder per sender webContents. We track one +// uninstall fn per webContents id and prune entries when the sender goes +// away — Electron does not auto-detach webContents listeners on close, +// so the map is the cleanup path. +const foundInPageForwarders = new Map void>() + +function ensureFoundInPageForwarder(sender: Electron.WebContents): void { + if (foundInPageForwarders.has(sender.id)) { + return + } + + const uninstall = installFoundInPageForwarder(sender) + foundInPageForwarders.set(sender.id, uninstall) + + sender.once('destroyed', () => { + foundInPageForwarders.get(sender.id)?.() + foundInPageForwarders.delete(sender.id) + }) +} + +ipcMain.handle('hermes:find-in-page', (event, query, options) => { + const win = BrowserWindow.fromWebContents(event.sender) + + if (!win || win.isDestroyed()) { + return { count: 0 } + } + + ensureFoundInPageForwarder(event.sender) + performFind(win.webContents, query, options) + + // The match count arrives asynchronously via `found-in-page`; the + // synchronous return value is intentionally `{ count: 0 }` to mirror + // Electron's own `findInPage` return semantics (an opaque request id). + return { count: 0 } +}) + +ipcMain.handle('hermes:stop-find-in-page', event => { + const win = BrowserWindow.fromWebContents(event.sender) + + if (!win || win.isDestroyed()) { + return + } + + stopFind(win.webContents) +}) + ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => { if (!(await openPreviewInBrowser(url))) { throw new Error('Invalid preview URL') @@ -10632,6 +11351,10 @@ app.whenReady().then(() => { configureSpellChecker() registerPowerResumeListeners() keepAwake.set(readPersistedKeepAwake()) + // Quick Entry's global chord — registered on ready so a cold launch restores + // it without the renderer visiting Settings. A failed registration is logged + // here and surfaced in Settings via the IPC state (never silent). + applyQuickEntrySettings(readQuickEntrySettings()) createWindow() // Win/Linux cold start: the launching hermes:// URL is in our own argv. @@ -10710,6 +11433,10 @@ app.on('before-quit', event => { // pet can't keep the process alive or float over a quit app. closePetOverlay() + // Same for the Quick Entry composer — and release its global accelerator so a + // quitting Hermes never keeps another app's chord hostage. + closeQuickEntryWindow() + // Quitting mid-install should stop the installer, not orphan it. if (bootstrapAbortController) { try { diff --git a/apps/desktop/electron/native-auth-decisions.test.ts b/apps/desktop/electron/native-auth-decisions.test.ts index 09f648c8719..d4cfc068cd6 100644 --- a/apps/desktop/electron/native-auth-decisions.test.ts +++ b/apps/desktop/electron/native-auth-decisions.test.ts @@ -1,7 +1,7 @@ /** - * Regression tests for electron/native-auth-decisions.ts — the three pure - * decision seams behind the RFC 8252 native-app auth flow, each of which was a - * real runtime bug that the mocked flow tests could not catch. + * Regression tests for electron/native-auth-decisions.ts — the pure decision + * seams behind the RFC 8252 native-app auth flow, each of which was a real + * runtime bug that the mocked flow tests could not catch. * * Run via the vitest `electron` project (electron/**\/*.test.ts). */ @@ -10,7 +10,13 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' +import { + oauthGuardMayHardFail, + oauthSessionIsLive, + resolveJsonBody, + resolveOauthRestAuth, + resolveReadinessProbeAuth +} from './native-auth-decisions' // --- 1. body encoding (guards the double-JSON.stringify 422) --- @@ -66,3 +72,61 @@ test('resolveOauthRestAuth falls back to cookie when there is no native token', // Empty string is not a usable bearer — must fall back, not send "Bearer ". assert.deepEqual(resolveOauthRestAuth(''), { kind: 'cookie' }) }) + +// --- 4. readiness-probe auth (guards the credential-free 401 boot loop) --- + +test('resolveReadinessProbeAuth reuses the oauth bearer-vs-cookie choice', () => { + assert.deepEqual(resolveReadinessProbeAuth('oauth', 'native-at'), { kind: 'bearer', token: 'native-at' }) + assert.deepEqual(resolveReadinessProbeAuth('oauth', null), { kind: 'cookie' }) + assert.deepEqual(resolveReadinessProbeAuth('oauth', ''), { kind: 'cookie' }) +}) + +test('resolveReadinessProbeAuth sends the session token for a token gateway', () => { + assert.deepEqual(resolveReadinessProbeAuth('token', null, 'session-token'), { + kind: 'token', + token: 'session-token' + }) + assert.deepEqual(resolveReadinessProbeAuth('token', null, null), { kind: 'token', token: null }) +}) + +test('resolveReadinessProbeAuth stays public for local and unknown modes', () => { + // A loopback backend has no gate; sending credentials it never issued is + // meaningless, and an unknown mode must not invent a credential. + assert.deepEqual(resolveReadinessProbeAuth('local', 'native-at', 'session-token'), { kind: 'public' }) + assert.deepEqual(resolveReadinessProbeAuth(undefined, 'native-at', 'session-token'), { kind: 'public' }) + assert.deepEqual(resolveReadinessProbeAuth('something-new', null, null), { kind: 'public' }) +}) + +// --- 5. oauth guard vs password gateways (guards the false "not signed in") --- + +test('oauthGuardMayHardFail is false only when EVERY provider is password-based', () => { + assert.equal(oauthGuardMayHardFail([{ name: 'basic', supportsPassword: true }]), false) + assert.equal( + oauthGuardMayHardFail([ + { name: 'basic', supportsPassword: true }, + { name: 'ldap', supportsPassword: true } + ]), + false + ) +}) + +test('oauthGuardMayHardFail keeps the strict guard for oauth and mixed deployments', () => { + assert.equal(oauthGuardMayHardFail([{ name: 'nous', supportsPassword: false }]), true) + assert.equal( + oauthGuardMayHardFail([ + { name: 'nous', supportsPassword: false }, + { name: 'basic', supportsPassword: true } + ]), + true + ) +}) + +test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', () => { + // Backends predating /api/auth/providers, or an unreachable probe, must not + // silently weaken the guard. + assert.equal(oauthGuardMayHardFail([]), true) + assert.equal(oauthGuardMayHardFail(null), true) + assert.equal(oauthGuardMayHardFail(undefined), true) + assert.equal(oauthGuardMayHardFail('nonsense' as any), true) + assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true) +}) diff --git a/apps/desktop/electron/native-auth-decisions.ts b/apps/desktop/electron/native-auth-decisions.ts index d76746f669a..c0978c3ecdd 100644 --- a/apps/desktop/electron/native-auth-decisions.ts +++ b/apps/desktop/electron/native-auth-decisions.ts @@ -21,7 +21,17 @@ * native bearer when present, else the cookie partition. Cookie-only * routing returns 401 no_cookie for a cookieless native session. * - * All three are trivial once named; the value is the test that pins the + * 4. resolveReadinessProbeAuth — the boot readiness probe must authenticate + * the same way the rest of the connection does. A credential-free probe + * against a gated gateway 401s forever; worse, it cannot tell a missing + * route from a rejected session (see backend-health.ts). + * + * 5. oauthGuardMayHardFail — `auth_required: true` means "this gateway is + * gated", NOT "this gateway speaks OAuth". A password-provider gateway + * can satisfy neither the native-bearer nor the OAuth-partition-cookie + * check by design, so the pre-flight guard must not hard-fail it. + * + * All five are trivial once named; the value is the test that pins the * contract so the god-file call sites can't drift back to the buggy shape. */ @@ -60,3 +70,75 @@ export function resolveOauthRestAuth(nativeAccessToken: string | null | undefine return { kind: 'cookie' } } + +export type ReadinessProbeAuth = OauthRestAuth | { kind: 'token'; token: string | null } | { kind: 'public' } + +/** + * Decide how the boot readiness probe authenticates. + * + * The probe must present the SAME credentials the rest of the connection + * will use. A credential-free probe against a gated gateway 401s until the + * boot deadline even though the session is perfectly valid — and because the + * dashboard auth gate runs ahead of the SPA catch-all, an unknown `/api/*` + * path answers 401 rather than 404, so the probe also cannot detect a backend + * that predates `/api/health`. Sending credentials is what lets a missing + * route surface as a real 404 (see `isMissingHealthEndpointError`). + * + * `oauth` reuses `resolveOauthRestAuth` so the probe and every other oauth + * REST call make the identical bearer-vs-cookie choice. `token` presents the + * connection's session token. `local` (and anything unrecognized) stays + * public: a loopback backend has no gate, and sending credentials it never + * issued would be meaningless. + */ +export function resolveReadinessProbeAuth( + authMode: string | null | undefined, + nativeAccessToken?: string | null, + connectionToken?: string | null +): ReadinessProbeAuth { + if (authMode === 'oauth') { + return resolveOauthRestAuth(nativeAccessToken) + } + + if (authMode === 'token') { + return { kind: 'token', token: connectionToken ?? null } + } + + return { kind: 'public' } +} + +export interface AdvertisedAuthProvider { + name?: string + supportsPassword?: boolean +} + +/** + * Whether the oauth pre-flight guard may hard-fail a connection for "not + * signed in". + * + * `authModeFromStatus` maps the gateway's `auth_required: true` onto + * `'oauth'`, but that flag only means the dashboard is GATED — it says + * nothing about how you authenticate. A gateway whose providers are all + * username/password cannot satisfy the guard's checks by construction: + * `start_login` raises NotImplementedError, `/auth/native/authorize` rejects + * password providers, and its cookies are set by a plain password-login POST + * rather than the `/auth/callback` redirect the OAuth partition is primed + * for. Hard-failing there rejects a live session one line before the + * ws-ticket mint that would have succeeded against that very partition. + * + * Returns false only when EVERY advertised provider is password-based. An + * unknown or empty list keeps the strict guard, so backends that predate + * `/api/auth/providers` are unaffected. + */ +export function oauthGuardMayHardFail(providers: AdvertisedAuthProvider[] | null | undefined): boolean { + if (!Array.isArray(providers) || providers.length === 0) { + return true + } + + const named = providers.filter(provider => provider && typeof provider === 'object' && provider.name) + + if (named.length === 0) { + return true + } + + return !named.every(provider => provider.supportsPassword) +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7652a7688dd..99df85abd4c 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -36,6 +36,41 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) } }, + // Quick Entry: the global-hotkey mini composer window. Main owns the OS + // shortcut + the persisted preference; the quick window only captures text + // and hands it back, and the primary renderer submits it through the normal + // prompt path. + quickEntry: { + getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'), + setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch), + submit: payload => ipcRenderer.send('hermes:quick-entry:submit', payload), + dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'), + // Primary renderer → main → quick window: gateway connection state + the + // recent-session options the target picker offers. Main caches the latest + // payload so a freshly spawned quick window starts from truth. + pushState: payload => ipcRenderer.send('hermes:quick-entry:state', payload), + // Quick window subscribes to those pushes. + onState: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:quick-entry:state', listener) + + return () => ipcRenderer.removeListener('hermes:quick-entry:state', listener) + }, + // Main → primary renderer: a submit captured by the quick window. + onSubmit: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:quick-entry:submit', listener) + + return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener) + }, + // Main → quick window: you were just summoned (reset draft + refocus). + onShown: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:quick-entry:shown', listener) + + return () => ipcRenderer.removeListener('hermes:quick-entry:shown', listener) + } + }, getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), @@ -237,6 +272,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { // current snapshot via getBootstrapState() to recover after a devtools // reload mid-bootstrap. getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'), + continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'), resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'), repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'), cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'), @@ -267,5 +303,19 @@ contextBridge.exposeInMainWorld('hermesDesktop', { themes: { fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id), searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query) + }, + // Find-in-page (Ctrl/Cmd+F): delegates to Electron's + // webContents.findInPage on the IPC sender's window so a Cmd+F pressed + // in a secondary session window searches THAT window, not the primary. + // `onFoundInPage` returns the unsubscribe fn; the renderer wires it via + // `initFindInPageListener` in store/find-in-page.ts and tears it down + // when the FindBar unmounts. + findInPage: (query, options) => ipcRenderer.invoke('hermes:find-in-page', query, options), + stopFindInPage: () => ipcRenderer.invoke('hermes:stop-find-in-page'), + onFoundInPage: callback => { + const listener = (_event, result) => callback(result) + ipcRenderer.on('hermes:found-in-page', listener) + + return () => ipcRenderer.removeListener('hermes:found-in-page', listener) } }) diff --git a/apps/desktop/electron/primary-backend-startup.test.ts b/apps/desktop/electron/primary-backend-startup.test.ts new file mode 100644 index 00000000000..6d3f5c8f44d --- /dev/null +++ b/apps/desktop/electron/primary-backend-startup.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' + +import { test, vi } from 'vitest' + +import { createFirstRunSetupGate } from './first-run-setup-gate' +import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup' + +const bootstrapBackend = { + activeRoot: '/tmp/hermes-home/hermes-agent', + kind: 'bootstrap-needed', + platform: 'linux' +} + +function startupOptions(overrides: Record = {}) { + return { + connectRemote: vi.fn(async remote => ({ baseUrl: remote.baseUrl, mode: 'remote' as const })), + ensureLocalRuntime: vi.fn(async backend => ({ ...backend, command: 'hermes' })), + prepareLocalBackend: vi.fn(async () => bootstrapBackend), + resolveRemote: vi.fn(async () => null), + waitForDecision: vi.fn(async () => 'continue-local' as const), + waitForLocalStart: vi.fn(async () => {}), + ...overrides + } +} + +test('remote apply re-resolves the saved connection without ensuring a local runtime', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' } + let configuredRemote: typeof savedRemote | null = null + + const options = startupOptions({ + resolveRemote: vi.fn(async () => configuredRemote), + waitForDecision: gate.wait + }) + + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + configuredRemote = savedRemote + assert.equal(gate.abandonForRemoteApply(), true) + + assert.deepEqual(await pending, { + kind: 'remote', + connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' } + }) + assert.deepEqual(options.resolveRemote.mock.calls, [[], []]) + assert.deepEqual(options.connectRemote.mock.calls, [[savedRemote]]) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) + +test('an already-saved remote bypasses every local startup step', async () => { + const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' } + const options = startupOptions({ resolveRemote: vi.fn(async () => savedRemote) }) + + assert.deepEqual(await runPrimaryBackendStartup(options), { + kind: 'remote', + connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' } + }) + assert.equal(options.waitForLocalStart.mock.calls.length, 0) + assert.equal(options.prepareLocalBackend.mock.calls.length, 0) + assert.equal(options.waitForDecision.mock.calls.length, 0) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) + +test('remote apply fails clearly when no saved remote can be resolved', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const options = startupOptions({ waitForDecision: gate.wait }) + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + gate.abandonForRemoteApply() + + await assert.rejects(pending, /without a saved remote backend/) + assert.equal(options.connectRemote.mock.calls.length, 0) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) + +test('continue local waits for update exclusion and ensures the prepared runtime exactly once', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const runtimeBackend = { ...bootstrapBackend, command: 'hermes' } + + const options = startupOptions({ + ensureLocalRuntime: vi.fn(async () => runtimeBackend), + waitForDecision: gate.wait + }) + + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + gate.continueLocal() + + assert.deepEqual(await pending, { kind: 'local', backend: runtimeBackend }) + assert.deepEqual(options.waitForLocalStart.mock.calls, [[]]) + assert.deepEqual(options.prepareLocalBackend.mock.calls, [[]]) + assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[bootstrapBackend]]) + assert.deepEqual(options.resolveRemote.mock.calls, [[]]) +}) + +test('reset rejects with a typed error and never enters either backend', async () => { + const gate = createFirstRunSetupGate({ stuckAfterMs: 0 }) + const options = startupOptions({ waitForDecision: gate.wait }) + const pending = runPrimaryBackendStartup(options) + + await vi.waitFor(() => assert.equal(gate.hasWaiter(), true)) + gate.resetForRetry() + + await assert.rejects(pending, error => error instanceof FirstRunSetupResetError && error.firstRunSetupReset) + assert.equal(options.connectRemote.mock.calls.length, 0) + assert.equal(options.ensureLocalRuntime.mock.calls.length, 0) +}) diff --git a/apps/desktop/electron/primary-backend-startup.ts b/apps/desktop/electron/primary-backend-startup.ts new file mode 100644 index 00000000000..edbe5167b05 --- /dev/null +++ b/apps/desktop/electron/primary-backend-startup.ts @@ -0,0 +1,66 @@ +import type { FirstRunSetupDecision } from './first-run-setup-gate' + +export interface PrimaryBackendStartupOptions { + connectRemote: (remote: Remote) => Promise + ensureLocalRuntime: (backend: Backend) => Promise + prepareLocalBackend: () => Backend | Promise + resolveRemote: () => Promise + waitForDecision: (backend: Backend) => Promise + waitForLocalStart: () => Promise +} + +export type PrimaryBackendStartupResult = + | { kind: 'local'; backend: RuntimeBackend } + | { kind: 'remote'; connection: Connection } + +export class FirstRunSetupResetError extends Error { + readonly firstRunSetupReset = true + + constructor() { + super('First-run setup was reset before a choice completed.') + this.name = 'FirstRunSetupResetError' + } +} + +// Owns the production startHermes path up to the local process spawn. Keeping +// the full ordering here makes the first-run remote boundary executable in a +// test: an already-saved remote wins immediately; otherwise update exclusion +// and local backend resolution happen before the setup gate, and a remote Apply +// re-resolves persisted config without ever entering ensureRuntime/bootstrap. +export async function runPrimaryBackendStartup({ + connectRemote, + ensureLocalRuntime, + prepareLocalBackend, + resolveRemote, + waitForDecision, + waitForLocalStart +}: PrimaryBackendStartupOptions): Promise< + PrimaryBackendStartupResult +> { + const savedRemote = await resolveRemote() + + if (savedRemote) { + return { kind: 'remote', connection: await connectRemote(savedRemote) } + } + + await waitForLocalStart() + + const backend = await prepareLocalBackend() + const decision = await waitForDecision(backend) + + if (decision === 'remote-applied') { + const appliedRemote = await resolveRemote() + + if (!appliedRemote) { + throw new Error('First-run remote setup completed without a saved remote backend.') + } + + return { kind: 'remote', connection: await connectRemote(appliedRemote) } + } + + if (decision === 'reset') { + throw new FirstRunSetupResetError() + } + + return { kind: 'local', backend: await ensureLocalRuntime(backend) } +} diff --git a/apps/desktop/electron/primary-connection-rehome.ts b/apps/desktop/electron/primary-connection-rehome.ts new file mode 100644 index 00000000000..4952b8a9028 --- /dev/null +++ b/apps/desktop/electron/primary-connection-rehome.ts @@ -0,0 +1,35 @@ +export interface PrimaryConnectionRehomeOptions { + clearLocalBootstrapFailure: () => void + mode: string + notifyConnectionApplied: () => void + resumeFirstRunRemote: () => boolean + teardownPrimaryBackend: (options: { soft: boolean }) => Promise +} + +// Production seam shared by the connection-config IPC handler and the +// first-run integration test. A remote apply that resumes the active setup +// gate must keep that connection attempt alive; ordinary mode changes tear the +// current backend down before the renderer is told to reconnect. +export async function rehomePrimaryConnection({ + clearLocalBootstrapFailure, + mode, + notifyConnectionApplied, + resumeFirstRunRemote, + teardownPrimaryBackend +}: PrimaryConnectionRehomeOptions): Promise<{ resumedFirstRunRemote: boolean }> { + let resumedFirstRunRemote = false + + if (mode === 'remote') { + resumedFirstRunRemote = resumeFirstRunRemote() + clearLocalBootstrapFailure() + } + + if (resumedFirstRunRemote) { + return { resumedFirstRunRemote: true } + } + + await teardownPrimaryBackend({ soft: true }) + notifyConnectionApplied() + + return { resumedFirstRunRemote: false } +} diff --git a/apps/desktop/electron/profile-session-routing.test.ts b/apps/desktop/electron/profile-session-routing.test.ts new file mode 100644 index 00000000000..199740519bd --- /dev/null +++ b/apps/desktop/electron/profile-session-routing.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { fetchPrimaryProfileSessions } from './profile-session-routing' + +test('primary session reads use the profile-aware request path', async () => { + const calls: Array<{ profile: string | null; path: string }> = [] + const expected = { sessions: [{ id: 'session-1' }], total: 1, profile_totals: { default: 1 } } + + const result = await fetchPrimaryProfileSessions( + new URLSearchParams({ profile: 'default', limit: '20' }), + async (profile, path) => { + calls.push({ profile, path }) + + return expected + } + ) + + assert.deepEqual(calls, [{ profile: null, path: '/api/profiles/sessions?profile=default&limit=20' }]) + assert.equal(result, expected) +}) + +test('primary session reads preserve the empty-list fallback', async () => { + const result = await fetchPrimaryProfileSessions(new URLSearchParams({ profile: 'all' }), async () => { + throw new Error('remote unavailable') + }) + + assert.deepEqual(result, { sessions: [], total: 0, profile_totals: {} }) +}) diff --git a/apps/desktop/electron/profile-session-routing.ts b/apps/desktop/electron/profile-session-routing.ts new file mode 100644 index 00000000000..f31e22ca52a --- /dev/null +++ b/apps/desktop/electron/profile-session-routing.ts @@ -0,0 +1,19 @@ +export interface ProfileSessionsResponse { + sessions: unknown[] + total: number + profile_totals: Record + [key: string]: unknown +} + +type FetchJsonForProfile = (profile: string | null, path: string) => Promise + +export async function fetchPrimaryProfileSessions( + searchParams: URLSearchParams, + fetchJsonForProfile: FetchJsonForProfile +): Promise { + try { + return (await fetchJsonForProfile(null, `/api/profiles/sessions?${searchParams}`)) as ProfileSessionsResponse + } catch { + return { sessions: [], total: 0, profile_totals: {} } + } +} diff --git a/apps/desktop/electron/quick-entry.test.ts b/apps/desktop/electron/quick-entry.test.ts new file mode 100644 index 00000000000..fa8039791d4 --- /dev/null +++ b/apps/desktop/electron/quick-entry.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createQuickEntryShortcut, + DEFAULT_QUICK_ENTRY_SHORTCUT, + type GlobalShortcutLike, + parseQuickEntryShortcut, + quickEntryWindowBounds, + sanitizeQuickEntrySettings +} from './quick-entry' + +function fakeGlobalShortcut(options: { register?: boolean; taken?: string[] } = {}) { + const held = new Set(options.taken ?? []) + + const globalShortcut: GlobalShortcutLike = { + isRegistered: vi.fn((accelerator: string) => held.has(accelerator)), + register: vi.fn((accelerator: string) => { + if (options.register === false) { + return false + } + + held.add(accelerator) + + return true + }), + unregister: vi.fn((accelerator: string) => void held.delete(accelerator)) + } + + return { globalShortcut, held } +} + +describe('parseQuickEntryShortcut', () => { + it('normalizes casing, aliases, and modifier order', () => { + expect(parseQuickEntryShortcut('cmdorctrl+shift+space')).toEqual({ + accelerator: 'CommandOrControl+Shift+Space', + ok: true + }) + expect(parseQuickEntryShortcut(' Shift + CTRL + k ')).toEqual({ accelerator: 'Control+Shift+K', ok: true }) + expect(parseQuickEntryShortcut('Alt+f5')).toEqual({ accelerator: 'Alt+F5', ok: true }) + expect(parseQuickEntryShortcut('Meta+/')).toEqual({ accelerator: 'Super+/', ok: true }) + }) + + it('collapses duplicate modifiers', () => { + expect(parseQuickEntryShortcut('Ctrl+Control+Shift+J')).toEqual({ accelerator: 'Control+Shift+J', ok: true }) + }) + + it('requires a modifier so a global bind cannot swallow a bare key', () => { + expect(parseQuickEntryShortcut('K')).toEqual({ ok: false, reason: 'no-modifier' }) + expect(parseQuickEntryShortcut('Space')).toEqual({ ok: false, reason: 'no-modifier' }) + }) + + it('requires exactly one non-modifier key', () => { + expect(parseQuickEntryShortcut('Shift+Control')).toEqual({ ok: false, reason: 'no-key' }) + expect(parseQuickEntryShortcut('Shift+A+B')).toEqual({ ok: false, reason: 'invalid-key' }) + expect(parseQuickEntryShortcut('A+Shift')).toEqual({ ok: false, reason: 'invalid-modifier' }) + }) + + it('rejects empty, junk, and the reserved Escape key', () => { + expect(parseQuickEntryShortcut('')).toEqual({ ok: false, reason: 'empty' }) + expect(parseQuickEntryShortcut(' ')).toEqual({ ok: false, reason: 'empty' }) + expect(parseQuickEntryShortcut(null)).toEqual({ ok: false, reason: 'empty' }) + expect(parseQuickEntryShortcut('Ctrl+NotAKey')).toEqual({ ok: false, reason: 'invalid-key' }) + // Escape hides the window; binding it globally would make it un-toggleable. + expect(parseQuickEntryShortcut('Ctrl+Escape')).toEqual({ ok: false, reason: 'reserved' }) + }) + + it('accepts the shipped default unchanged', () => { + expect(parseQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)).toEqual({ + accelerator: DEFAULT_QUICK_ENTRY_SHORTCUT, + ok: true + }) + }) +}) + +describe('sanitizeQuickEntrySettings', () => { + it('defaults to enabled with the default shortcut', () => { + expect(sanitizeQuickEntrySettings(undefined)).toEqual({ enabled: true, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }) + expect(sanitizeQuickEntrySettings('not an object')).toEqual({ + enabled: true, + shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT + }) + }) + + it('keeps an explicit disable and normalizes a stored shortcut', () => { + expect(sanitizeQuickEntrySettings({ enabled: false, shortcut: 'alt+j' })).toEqual({ + enabled: false, + shortcut: 'Alt+J' + }) + }) + + it('falls back to the default when the stored shortcut is unusable', () => { + expect(sanitizeQuickEntrySettings({ enabled: true, shortcut: 'Q' })).toEqual({ + enabled: true, + shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT + }) + }) + + it('treats a non-boolean enabled as off (only `true` opts in once present)', () => { + expect(sanitizeQuickEntrySettings({ enabled: 'yes' }).enabled).toBe(false) + }) +}) + +describe('createQuickEntryShortcut', () => { + it('registers the normalized accelerator when enabled', () => { + const { globalShortcut } = fakeGlobalShortcut() + const onTrigger = vi.fn() + const controller = createQuickEntryShortcut(globalShortcut, onTrigger) + + const state = controller.apply({ enabled: true, shortcut: 'cmdorctrl+shift+space' }) + + expect(state).toEqual({ error: null, registered: true, shortcut: 'CommandOrControl+Shift+Space' }) + expect(globalShortcut.register).toHaveBeenCalledWith('CommandOrControl+Shift+Space', onTrigger) + expect(controller.current()).toEqual(state) + }) + + it('never registers while the setting is disabled', () => { + const { globalShortcut } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + const state = controller.apply({ enabled: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }) + + expect(globalShortcut.register).not.toHaveBeenCalled() + expect(state).toEqual({ error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }) + }) + + it('releases the old accelerator before registering a new one', () => { + const { globalShortcut, held } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + controller.apply({ enabled: true, shortcut: 'Alt+J' }) + controller.apply({ enabled: true, shortcut: 'Alt+K' }) + + expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J') + expect(held.has('Alt+J')).toBe(false) + expect(held.has('Alt+K')).toBe(true) + }) + + it('turning the feature off releases the live accelerator', () => { + const { globalShortcut, held } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + controller.apply({ enabled: true, shortcut: 'Alt+J' }) + const off = controller.apply({ enabled: false, shortcut: 'Alt+J' }) + + expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J') + expect(held.size).toBe(0) + expect(off.registered).toBe(false) + expect(off.error).toBeNull() + }) + + it("surfaces 'taken' when another app already owns the chord", () => { + const { globalShortcut } = fakeGlobalShortcut({ taken: ['Alt+J'] }) + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + const state = controller.apply({ enabled: true, shortcut: 'alt+j' }) + + expect(globalShortcut.register).not.toHaveBeenCalled() + expect(state).toEqual({ error: 'taken', registered: false, shortcut: 'Alt+J' }) + }) + + it("surfaces 'taken' when the OS refuses the registration", () => { + const { globalShortcut } = fakeGlobalShortcut({ register: false }) + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + expect(controller.apply({ enabled: true, shortcut: 'Alt+J' })).toEqual({ + error: 'taken', + registered: false, + shortcut: 'Alt+J' + }) + }) + + it("surfaces 'invalid' for an unusable shortcut without asking the OS", () => { + const { globalShortcut } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + expect(controller.apply({ enabled: true, shortcut: 'J' })).toEqual({ + error: 'invalid', + registered: false, + shortcut: 'J' + }) + expect(globalShortcut.register).not.toHaveBeenCalled() + }) + + it('survives a throwing globalShortcut', () => { + const globalShortcut: GlobalShortcutLike = { + isRegistered: () => false, + register: () => { + throw new Error('x11 grab failed') + }, + unregister: () => {} + } + + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + expect(controller.apply({ enabled: true, shortcut: 'Alt+J' }).error).toBe('taken') + }) + + it('dispose releases the accelerator and is idempotent', () => { + const { globalShortcut, held } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + controller.apply({ enabled: true, shortcut: 'Alt+J' }) + controller.dispose() + controller.dispose() + + expect(globalShortcut.unregister).toHaveBeenCalledTimes(1) + expect(held.size).toBe(0) + expect(controller.current().registered).toBe(false) + }) +}) + +describe('quickEntryWindowBounds', () => { + it('centers horizontally and sits below the top edge of the work area', () => { + const bounds = quickEntryWindowBounds({ height: 1000, width: 1600, x: 0, y: 0 }) + + expect(bounds.width).toBe(640) + expect(bounds.x).toBe((1600 - 640) / 2) + expect(bounds.y).toBeGreaterThan(0) + expect(bounds.y + bounds.height).toBeLessThanOrEqual(1000) + }) + + it('respects a display origin offset (second monitor)', () => { + const bounds = quickEntryWindowBounds({ height: 900, width: 1440, x: 1600, y: -200 }) + + expect(bounds.x).toBe(1600 + (1440 - 640) / 2) + expect(bounds.y).toBeGreaterThanOrEqual(-200) + }) + + it('stays inside a tiny work area', () => { + const bounds = quickEntryWindowBounds({ height: 120, width: 320, x: 0, y: 0 }) + + expect(bounds.width).toBeLessThanOrEqual(320) + expect(bounds.height).toBeLessThanOrEqual(120) + expect(bounds.y + bounds.height).toBeLessThanOrEqual(120) + }) + + it('falls back to the origin without a work area', () => { + expect(quickEntryWindowBounds()).toEqual({ height: 168, width: 640, x: 0, y: 0 }) + }) +}) diff --git a/apps/desktop/electron/quick-entry.ts b/apps/desktop/electron/quick-entry.ts new file mode 100644 index 00000000000..db2975696bd --- /dev/null +++ b/apps/desktop/electron/quick-entry.ts @@ -0,0 +1,426 @@ +/** + * Quick Entry — the global-hotkey mini composer. + * + * A small frameless always-on-top window that a global shortcut summons from + * anywhere so the user can fire a prompt at Hermes without raising the whole + * app. The window carries NO gateway connection of its own: it forwards the + * text to the primary renderer, which sends it through the SAME prompt-submit + * path the normal composer uses (see app/contrib/hooks/use-quick-entry-bridge). + * + * Everything Electron-free lives here so the parts that actually break a user — + * accelerator validation, "disabled means never register", and surfacing a + * shortcut another app already owns — are unit-testable without booting + * Electron. main.ts owns the BrowserWindow, the file I/O, and the real + * `globalShortcut`. + */ + +// Default matches the muscle memory of the apps this ports from (Claude +// Desktop's quick entry / ChatGPT's Quick Chat sit on a Cmd+Shift chord). +const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CommandOrControl+Shift+Space' + +// Compact capture surface: wide enough for a sentence, short enough to read as +// a HUD rather than a second app window. Height covers the composer row plus +// the session-target picker row; the renderer never grows the OS window in v1. +const QUICK_ENTRY_WINDOW_WIDTH = 640 +const QUICK_ENTRY_WINDOW_HEIGHT = 168 + +// Spotlight-ish placement: horizontally centered on the active display, a +// comfortable fraction down from the top rather than dead center. +const QUICK_ENTRY_TOP_FRACTION = 0.22 + +// Electron accelerator vocabulary (electronjs.org/docs/latest/api/accelerator). +// Kept as data so validation and the settings UI agree on one list. +const ACCELERATOR_MODIFIERS = new Set([ + 'alt', + 'altgr', + 'cmd', + 'cmdorctrl', + 'command', + 'commandorcontrol', + 'control', + 'ctrl', + 'meta', + 'option', + 'shift', + 'super' +]) + +const ACCELERATOR_KEYS = new Set([ + 'backspace', + 'delete', + 'down', + 'end', + 'enter', + 'escape', + 'home', + 'insert', + 'left', + 'medianexttrack', + 'mediaplaypause', + 'mediaprevioustrack', + 'mediastop', + 'pagedown', + 'pageup', + 'plus', + 'printscreen', + 'return', + 'right', + 'space', + 'tab', + 'up', + 'volumedown', + 'volumemute', + 'volumeup' +]) + +// Single printable characters Electron accepts verbatim, plus 0-9 / A-Z below. +const ACCELERATOR_PUNCTUATION = new Set([ + '!', + '"', + '#', + '$', + '%', + '&', + "'", + '(', + ')', + '*', + '+', + ',', + '-', + '.', + '/', + ':', + ';', + '<', + '=', + '>', + '?', + '@', + '[', + '\\', + ']', + '^', + '_', + '`', + '{', + '|', + '}', + '~' +]) + +/** Why a shortcut string was rejected. The renderer maps these to copy. */ +export type QuickEntryShortcutError = + | 'empty' + | 'invalid-key' + | 'invalid-modifier' + | 'no-key' + | 'no-modifier' + | 'reserved' + +export type QuickEntryShortcutParse = { ok: false; reason: QuickEntryShortcutError } | { accelerator: string; ok: true } + +function isAcceleratorKey(token: string): boolean { + if (ACCELERATOR_KEYS.has(token)) { + return true + } + + if (/^f([1-9]|1[0-9]|2[0-4])$/.test(token)) { + return true + } + + if (/^num(?:[0-9]|lock|dec|add|sub|mult|div)$/.test(token)) { + return true + } + + return token.length === 1 && (/^[a-z0-9]$/.test(token) || ACCELERATOR_PUNCTUATION.has(token)) +} + +/** + * Validate + normalize a user-typed accelerator. + * + * Rules beyond Electron's own grammar, both deliberate: + * - At least one modifier. A bare global key steals that key from EVERY app. + * - `Escape` can't be the key: inside the window Escape means "hide", so + * binding it globally would make the shortcut un-toggleable. + */ +export function parseQuickEntryShortcut(raw: unknown): QuickEntryShortcutParse { + if (typeof raw !== 'string' || !raw.trim()) { + return { ok: false, reason: 'empty' } + } + + const parts = raw + .split('+') + .map(part => part.trim()) + .filter(Boolean) + + if (parts.length === 0) { + return { ok: false, reason: 'empty' } + } + + const modifiers: string[] = [] + let key: null | string = null + + for (const part of parts) { + const lower = part.toLowerCase() + + if (ACCELERATOR_MODIFIERS.has(lower)) { + if (key) { + // A modifier after the key ("A+Shift") is not a valid accelerator. + return { ok: false, reason: 'invalid-modifier' } + } + + modifiers.push(lower) + + continue + } + + if (key) { + // Two non-modifier keys ("Shift+A+B"). + return { ok: false, reason: 'invalid-key' } + } + + if (!isAcceleratorKey(lower)) { + return { ok: false, reason: 'invalid-key' } + } + + key = lower + } + + if (!key) { + return { ok: false, reason: 'no-key' } + } + + if (modifiers.length === 0) { + return { ok: false, reason: 'no-modifier' } + } + + if (key === 'escape') { + return { ok: false, reason: 'reserved' } + } + + // Canonical casing so a saved shortcut round-trips identically no matter how + // the user typed it, and duplicate modifiers collapse. + const seen = new Set() + + const normalizedModifiers = modifiers + .map(modifier => CANONICAL_MODIFIER[modifier] ?? modifier) + .filter(modifier => (seen.has(modifier) ? false : (seen.add(modifier), true))) + // Stable display order (Electron itself is order-insensitive). + .sort((left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right)) + + return { accelerator: [...normalizedModifiers, canonicalKey(key)].join('+'), ok: true } +} + +const CANONICAL_MODIFIER: Record = { + alt: 'Alt', + altgr: 'AltGr', + cmd: 'Command', + cmdorctrl: 'CommandOrControl', + command: 'Command', + commandorcontrol: 'CommandOrControl', + control: 'Control', + ctrl: 'Control', + meta: 'Super', + option: 'Option', + shift: 'Shift', + super: 'Super' +} + +const MODIFIER_ORDER = ['CommandOrControl', 'Command', 'Control', 'Super', 'Alt', 'Option', 'AltGr', 'Shift'] + +const CANONICAL_KEY: Record = { + backspace: 'Backspace', + delete: 'Delete', + down: 'Down', + end: 'End', + enter: 'Enter', + escape: 'Escape', + home: 'Home', + insert: 'Insert', + medianexttrack: 'MediaNextTrack', + mediaplaypause: 'MediaPlayPause', + mediaprevioustrack: 'MediaPreviousTrack', + mediastop: 'MediaStop', + pagedown: 'PageDown', + pageup: 'PageUp', + plus: 'Plus', + printscreen: 'PrintScreen', + return: 'Return', + right: 'Right', + space: 'Space', + tab: 'Tab', + up: 'Up', + volumedown: 'VolumeDown', + volumemute: 'VolumeMute', + volumeup: 'VolumeUp', + left: 'Left' +} + +function canonicalKey(key: string): string { + if (CANONICAL_KEY[key]) { + return CANONICAL_KEY[key] + } + + if (/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) { + return key.toUpperCase() + } + + if (key.length === 1 && /^[a-z]$/.test(key)) { + return key.toUpperCase() + } + + return key +} + +/** The persisted shape of `quick-entry.json` (main-process owned). */ +export interface QuickEntrySettings { + enabled: boolean + shortcut: string +} + +/** + * Raw persisted JSON → usable settings. A malformed/absent file, or a shortcut + * that no longer validates (hand-edited, or from a future build), falls back to + * the default shortcut rather than leaving the feature un-summonable. + */ +export function sanitizeQuickEntrySettings(raw: unknown): QuickEntrySettings { + const record = raw && typeof raw === 'object' ? (raw as Record) : {} + const parsed = parseQuickEntryShortcut(record.shortcut) + + return { + // Default ON: the feature is inert until the shortcut is pressed. + enabled: record.enabled === undefined ? true : record.enabled === true, + shortcut: parsed.ok ? parsed.accelerator : DEFAULT_QUICK_ENTRY_SHORTCUT + } +} + +/** The slice of Electron's `globalShortcut` we use (injected for testing). */ +export interface GlobalShortcutLike { + isRegistered(accelerator: string): boolean + register(accelerator: string, callback: () => void): boolean + unregister(accelerator: string): void +} + +/** + * What Settings shows. `registered` is the ground truth (we asked the OS); + * `error` distinguishes "you turned it off" from "another app owns that chord", + * which is the failure this feature must never swallow. + */ +export interface QuickEntryRegistration { + error: null | QuickEntryRegistrationError + registered: boolean + shortcut: string +} + +export type QuickEntryRegistrationError = 'invalid' | 'taken' + +export interface QuickEntryShortcutController { + /** Registration state as of the last apply. */ + current(): QuickEntryRegistration + /** Release the shortcut (quit / feature off). Idempotent. */ + dispose(): void + /** Re-register to match `settings`. Returns the resulting state. */ + apply(settings: QuickEntrySettings): QuickEntryRegistration +} + +/** + * Owns the one live global accelerator. Single resolver so every caller — boot, + * the settings write, quit — gets the same answer and we can never leak two + * registrations for one feature. + * + * Disabled settings never touch `register()` at all: a user who turned Quick + * Entry off must not have their chord silently held hostage. + */ +export function createQuickEntryShortcut( + globalShortcut: GlobalShortcutLike, + onTrigger: () => void +): QuickEntryShortcutController { + let active: null | string = null + let state: QuickEntryRegistration = { error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT } + + const release = () => { + if (active) { + try { + globalShortcut.unregister(active) + } catch { + // Best effort — a dead accelerator must not block a re-register. + } + + active = null + } + } + + return { + apply(settings) { + const parsed = parseQuickEntryShortcut(settings.shortcut) + const shortcut = parsed.ok ? parsed.accelerator : settings.shortcut + + release() + + if (!settings.enabled) { + state = { error: null, registered: false, shortcut } + + return state + } + + if (!parsed.ok) { + state = { error: 'invalid', registered: false, shortcut } + + return state + } + + // `isRegistered` catches the common conflict before we ask, and + // `register()` returning false catches the rest (another process owns it + // OS-wide). Both land in the same surfaced 'taken' state. + let ok = false + + try { + ok = globalShortcut.isRegistered(parsed.accelerator) + ? false + : globalShortcut.register(parsed.accelerator, onTrigger) + } catch { + ok = false + } + + active = ok ? parsed.accelerator : null + state = { error: ok ? null : 'taken', registered: ok, shortcut: parsed.accelerator } + + return state + }, + current() { + return state + }, + dispose() { + release() + state = { ...state, error: null, registered: false } + } + } +} + +/** + * Where the quick window opens on a given display work area. Centered + * horizontally, a fraction down from the top, and clamped so it stays fully + * inside the work area on small/odd displays. + */ +export function quickEntryWindowBounds(workArea?: { height: number; width: number; x: number; y: number }): { + height: number + width: number + x: number + y: number +} { + const width = Math.min(QUICK_ENTRY_WINDOW_WIDTH, workArea?.width ?? QUICK_ENTRY_WINDOW_WIDTH) + const height = Math.min(QUICK_ENTRY_WINDOW_HEIGHT, workArea?.height ?? QUICK_ENTRY_WINDOW_HEIGHT) + + if (!workArea) { + return { height, width, x: 0, y: 0 } + } + + const x = Math.round(workArea.x + (workArea.width - width) / 2) + const maxY = workArea.y + workArea.height - height + const y = Math.round(Math.min(Math.max(workArea.y, workArea.y + workArea.height * QUICK_ENTRY_TOP_FRACTION), maxY)) + + return { height, width, x, y } +} + +export { DEFAULT_QUICK_ENTRY_SHORTCUT, QUICK_ENTRY_TOP_FRACTION, QUICK_ENTRY_WINDOW_HEIGHT, QUICK_ENTRY_WINDOW_WIDTH } diff --git a/apps/desktop/electron/remote-liveness.test.ts b/apps/desktop/electron/remote-liveness.test.ts index 6c52e11f69f..950d6fb1f58 100644 --- a/apps/desktop/electron/remote-liveness.test.ts +++ b/apps/desktop/electron/remote-liveness.test.ts @@ -6,6 +6,7 @@ import { REMOTE_LIVENESS_TIMEOUT_MS, RemoteLivenessTracker, RemoteRevalidationCoordinator, + revalidatePooledRemoteBackends, revalidateRemoteConnection } from './remote-liveness' @@ -251,3 +252,102 @@ describe('revalidateRemoteConnection', () => { expect(rejected.probe).not.toHaveBeenCalled() }) }) + +describe('revalidatePooledRemoteBackends', () => { + const harness = (entries: Array<[string, { process?: unknown; remoteBaseUrl?: null | string }]>) => { + const unreachable = new Set() + const log = vi.fn() + const stopBackend = vi.fn() + + const probe = vi.fn(async (url: string) => { + if ([...unreachable].some(base => url.startsWith(base))) { + throw new Error('unreachable') + } + + return {} + }) + + return { + log, + probe, + stopBackend, + unreachable, + run: (tracker: RemoteLivenessTracker) => + revalidatePooledRemoteBackends({ entries, log, probe, stopBackend, tracker }) + } + } + + it('probes only pooled entries backed by a remote host', async () => { + const local = { process: {}, remoteBaseUrl: null } + const spawning = { process: null, remoteBaseUrl: null } + const remote = { process: null, remoteBaseUrl: 'https://remote.example.com' } + + const pool = harness([ + ['local', local], + ['spawning', spawning], + ['remote', remote] + ]) + + await pool.run(new RemoteLivenessTracker()) + + expect(pool.probe).toHaveBeenCalledTimes(1) + expect(pool.probe).toHaveBeenCalledWith('https://remote.example.com/api/status', { + timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS + }) + expect(pool.stopBackend).not.toHaveBeenCalled() + }) + + it('drops a descriptor only after the shared failure limit', async () => { + const pool = harness([['coder', { process: null, remoteBaseUrl: 'https://remote.example.com/' }]]) + pool.unreachable.add('https://remote.example.com') + + const tracker = new RemoteLivenessTracker() + + for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) { + await expect(pool.run(tracker)).resolves.toEqual({ dropped: [] }) + expect(pool.stopBackend).not.toHaveBeenCalled() + } + + await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] }) + expect(pool.stopBackend).toHaveBeenCalledWith('coder') + }) + + it('clears the streak when the host answers again', async () => { + const pool = harness([['coder', { process: null, remoteBaseUrl: 'https://remote.example.com' }]]) + const tracker = new RemoteLivenessTracker() + + pool.unreachable.add('https://remote.example.com') + await pool.run(tracker) + + pool.unreachable.clear() + await pool.run(tracker) + + pool.unreachable.add('https://remote.example.com') + + for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) { + await expect(pool.run(tracker)).resolves.toEqual({ dropped: [] }) + } + + expect(pool.stopBackend).not.toHaveBeenCalled() + await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] }) + }) + + it('keeps a healthy sibling when another profile on a different host dies', async () => { + const pool = harness([ + ['coder', { process: null, remoteBaseUrl: 'https://dead.example.com' }], + ['writer', { process: null, remoteBaseUrl: 'https://live.example.com' }] + ]) + + pool.unreachable.add('https://dead.example.com') + + const tracker = new RemoteLivenessTracker() + + for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) { + await pool.run(tracker) + } + + await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] }) + expect(pool.stopBackend).toHaveBeenCalledTimes(1) + expect(pool.stopBackend).toHaveBeenCalledWith('coder') + }) +}) diff --git a/apps/desktop/electron/remote-liveness.ts b/apps/desktop/electron/remote-liveness.ts index eedc16682e7..6e29b395c48 100644 --- a/apps/desktop/electron/remote-liveness.ts +++ b/apps/desktop/electron/remote-liveness.ts @@ -117,6 +117,68 @@ export class RemoteLivenessTracker { } } +export interface PooledRemoteEntry { + process?: unknown + remoteBaseUrl?: null | string +} + +export interface RevalidatePooledRemoteBackendsOptions { + entries: Iterable<[string, PooledRemoteEntry]> + log: (message: string) => void + probe: (url: string, options: { timeoutMs: number }) => Promise + stopBackend: (profile: string) => void + tracker: RemoteLivenessTracker +} + +/** + * Probe pooled REMOTE descriptors and drop the dead ones. + * + * A pooled entry backed by a remote host has no child process, so the 'exit' + * handler that clears a dead local backend never fires, and the renderer's + * keepalive touch keeps the idle reaper off it. Without this the pool serves a + * descriptor for an unreachable host indefinitely. + * + * Entries share the primary's failure policy, keyed per base URL, so a profile + * pointing at the same host as another does not burn the streak twice as fast. + */ +export async function revalidatePooledRemoteBackends({ + entries, + log, + probe, + stopBackend, + tracker +}: RevalidatePooledRemoteBackendsOptions): Promise<{ dropped: string[] }> { + const remotes = [...entries].filter(([, entry]) => !entry.process && entry.remoteBaseUrl) + const dropped: string[] = [] + + await Promise.all( + remotes.map(async ([profile, entry]) => { + const baseUrl = String(entry.remoteBaseUrl).replace(/\/+$/, '') + + try { + await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS }) + tracker.recordSuccess(baseUrl) + } catch { + const failure = tracker.recordFailure(baseUrl) + + if (!failure.shouldReset) { + log( + `Pooled remote backend for profile "${profile}" failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping descriptor for retry.` + ) + + return + } + + log(`Pooled remote backend for profile "${profile}" failed liveness probe; dropping stale descriptor.`) + stopBackend(profile) + dropped.push(profile) + } + }) + ) + + return { dropped } +} + /** * Probe the cached primary remote connection and apply the failure policy. * The caller owns single-flight coordination; identity checks here ensure an diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index 5167593bfbf..b5f9cc6a8be 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => { test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => { // Regression: secondary session windows used to omit this flag, so a streamed - // answer stalled until the window regained focus (Chromium pauses the - // requestAnimationFrame-gated transcript flush for backgrounded windows). + // answer stalled until the window regained focus (Chromium clamps the + // transcript flush timer for backgrounded windows). const prefs = chatWindowWebPreferences('/tmp/preload.cjs') assert.equal(prefs.backgroundThrottling, false) diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 48597ee9e0e..46871b5384e 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // false`, so a streamed answer stalled until the window regained focus. // // `backgroundThrottling: false` is load-bearing: the transcript streams to the -// screen through a requestAnimationFrame-gated flush, which Chromium pauses for -// blurred/occluded windows. A streaming chat app must keep painting in the +// screen through a bounded timer flush, which Chromium clamps for blurred/ +// occluded windows. A streaming chat app must keep painting in the // background, so every chat window opts out. The preload path is injected // because it depends on the Electron entry's __dirname. function chatWindowWebPreferences(preloadPath: string) { diff --git a/apps/desktop/electron/spawn-helper-perms.test.ts b/apps/desktop/electron/spawn-helper-perms.test.ts index 26cb1fefaa4..d8c9ca034b5 100644 --- a/apps/desktop/electron/spawn-helper-perms.test.ts +++ b/apps/desktop/electron/spawn-helper-perms.test.ts @@ -8,7 +8,8 @@ import { needsExecBit, spawnHelperCandidates, type SpawnHelperFs, - withExecBits + withExecBits, + writableNodePtyRoot } from './spawn-helper-perms' interface FakeFile { @@ -56,6 +57,30 @@ function fakeFs( } } +test('rewrites an archived node-pty root to the matching unpacked tree exactly once', () => { + assert.equal( + writableNodePtyRoot('/Hermes.app/Contents/Resources/app.asar/dist/node_modules/node-pty'), + '/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/node-pty' + ) + assert.equal( + writableNodePtyRoot('/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/node-pty'), + '/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/node-pty' + ) +}) + +test('uses the unpacked helper when resolution reports an app.asar node-pty root', () => { + const archivedRoot = '/Hermes.app/Contents/Resources/app.asar/dist/node_modules/node-pty' + const unpackedRoot = writableNodePtyRoot(archivedRoot) + const helper = join(unpackedRoot, 'prebuilds', 'darwin-arm64', 'spawn-helper') + const fs = fakeFs({ [helper]: { mode: 0o644 } }, { [join(unpackedRoot, 'prebuilds')]: ['darwin-arm64'] }) + + const result = ensureSpawnHelperExecutable(archivedRoot, fs) + + assert.deepEqual(result.fixed, [helper]) + assert.deepEqual(result.errors, []) + assert.deepEqual(fs.chmods, [{ path: helper, mode: 0o755 }]) +}) + test('needsExecBit / withExecBits treat any missing exec bit as non-executable', () => { assert.equal(needsExecBit(0o644), true) assert.equal(needsExecBit(0o755), false) diff --git a/apps/desktop/electron/spawn-helper-perms.ts b/apps/desktop/electron/spawn-helper-perms.ts index 6dfd484dc80..684b77096ba 100644 --- a/apps/desktop/electron/spawn-helper-perms.ts +++ b/apps/desktop/electron/spawn-helper-perms.ts @@ -18,6 +18,14 @@ import { join } from 'node:path' const EXEC_BITS = 0o111 +// Electron exposes module paths inside app.asar even when electron-builder has +// unpacked the native payload beside it. `stat` can read an archived path, but +// chmod cannot mutate it (ENOTDIR). Native node-pty helpers belong in the +// writable app.asar.unpacked tree; leave an already-unpacked path unchanged. +export function writableNodePtyRoot(nodePtyRoot: string): string { + return nodePtyRoot.replace(/app\.asar(?!\.unpacked)/, 'app.asar.unpacked') +} + export interface SpawnHelperFs { existsSync(path: string): boolean readdirSync(path: string): string[] @@ -81,8 +89,9 @@ export function ensureSpawnHelperExecutable( fs: SpawnHelperFs = defaultFs ): EnsureSpawnHelperResult { const result: EnsureSpawnHelperResult = { fixed: [], errors: [] } + const writableRoot = writableNodePtyRoot(nodePtyRoot) - for (const path of spawnHelperCandidates(nodePtyRoot, fs)) { + for (const path of spawnHelperCandidates(writableRoot, fs)) { if (!fs.existsSync(path)) { continue } diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index 7d1d80d974f..e81ffeeb07f 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -90,15 +90,16 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc /** * Zoom-wiring decision per window kind. Chat windows (main + session) keep - * global UI zoom; the pet overlay opts out because it sizes its own OS window - * to the sprite and inheriting zoom would crop it. + * global UI zoom; the pet overlay and the Quick Entry composer opt out because + * they size their own OS window and inheriting zoom would crop/overflow them. * - * Extracted so the "pet opts out, everything else opts in" contract is + * Extracted so the "helper windows opt out, everything else opts in" contract is * unit-testable without booting a BrowserWindow or reading source. */ export const ZOOM_WINDOW_CONFIG = { chat: { zoom: true }, - petOverlay: { zoom: false } + petOverlay: { zoom: false }, + quickEntry: { zoom: false } } as const export function zoomWiringForWindowKind(kind) { diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 61abc0c90a0..7e02d1d7936 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -39,5 +39,43 @@ export default [ rules: { 'no-restricted-globals': ['warn', 'document'] } + }, + { + // Ban mirroring reactive values into refs via useEffect — the "atom-mirrored + // ref" antipattern. A ref synced from a nanostores atom via useEffect lags the + // atom by one render, which creates stale-read bugs in callbacks that read the + // ref (cancelRun sent session.interrupt to the wrong session; steerPrompt, + // restoreToMessage, editMessage all had closure-priority stale reads). The fix + // is to read $atom.get() directly in callbacks instead. This rule catches the + // mirroring effect at lint time so the pattern can't reappear. Legitimate + // non-atom ref writes inside useEffect (DOM instance refs, mount flags, request + // tokens, prop mirrors) get an eslint-disable-next-line with a comment. + files: ['src/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + // useEffect(() => { someRef.current = value }, [value]) + selector: + 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="AssignmentExpression"][body.left.type="MemberExpression"][body.left.property.name="current"]', + message: + 'Do not mirror reactive values into refs via useEffect. Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.' + }, + { + // useEffect(() => { someRef.current = value; ... }, [value]) + selector: + 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="BlockStatement"]:has(AssignmentExpression[left.type="MemberExpression"][left.property.name="current"])', + message: + 'Do not mirror reactive values into refs via useEffect. Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.' + }, + { + // useEffect(() => { setMutableRef(ref, value) }, [value]) + selector: + 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="BlockStatement"]:has(CallExpression[callee.name="setMutableRef"])', + message: + 'Do not mirror reactive values into refs via useEffect (setMutableRef included). Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.' + } + ] + } } ] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e40be3df818..1e3a6e10cee 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -111,7 +111,7 @@ "motion": "^12.38.0", "nanostores": "^1.3.0", "node-pty": "1.1.0", - "radix-ui": "^1.4.3", + "radix-ui": "^1.6.5", "react": "^19.2.5", "react-arborist": "^3.5.0", "react-dnd-html5-backend": "^14.0.3", @@ -147,6 +147,7 @@ "@typescript-eslint/eslint-plugin": "^8.59.1", "@typescript-eslint/parser": "^8.59.1", "@vitejs/plugin-react": "^6.0.1", + "bippy": "0.5.43", "concurrently": "^10.0.3", "cross-env": "^10.1.0", "electron": "40.10.2", diff --git a/apps/desktop/scripts/before-pack.mjs b/apps/desktop/scripts/before-pack.mjs index 8b2359dfba6..b76acfe32d2 100644 --- a/apps/desktop/scripts/before-pack.mjs +++ b/apps/desktop/scripts/before-pack.mjs @@ -57,7 +57,8 @@ * - electronPlatformName: 'win32' | 'darwin' | 'linux' * - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal) */ -import { existsSync, rmSync } from 'node:fs' +import { existsSync, rmSync, renameSync } from 'node:fs' +import path from 'node:path' import { Arch } from 'electron-builder' import { stageNodePty } from './stage-native-deps.mjs' @@ -75,10 +76,52 @@ export function cleanStaleAppOutDir(appOutDir) { return true } +/** + * Windows rollback material (#69179): before wiping the previous unpacked + * tree, preserve it as `.bak` — but ONLY when it holds the product + * exe (i.e. it is a previously-working build, not the corrupted partial state + * cleanStaleAppOutDir exists to remove). If the fresh pack then produces a + * Hermes.exe that Windows can't load (truncated PE from a corrupt cached + * Electron zip, wrong arch), the updater's integrity gate in + * `hermes desktop --build-only` (hermes_cli/main.py + * `_ensure_desktop_exe_launchable`) restores this .bak instead of leaving the + * user with "This app can't run on your computer". + * + * Returns true when the tree was preserved (appOutDir no longer exists), false + * when there was nothing worth preserving (caller falls through to the wipe). + * A rename failure (AV holding a handle) also returns false — the wipe is the + * safe fallback and matches pre-#69179 behavior exactly. + */ +export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') { + if (!appOutDir || typeof appOutDir !== 'string' || !existsSync(appOutDir)) { + return false + } + if (!existsSync(path.join(appOutDir, productExeName))) { + // Partial/corrupt tree (interrupted prior pack) — not rollback material. + return false + } + const backupDir = `${appOutDir}.bak` + try { + rmSync(backupDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + renameSync(appOutDir, backupDir) + return true + } catch { + return false + } +} + export default async function beforePack(context) { const appOutDir = context && context.appOutDir + const platformName = context && context.electronPlatformName try { - if (cleanStaleAppOutDir(appOutDir)) { + // Windows: keep the previous working build as rollback material for the + // post-build integrity gate (#69179) instead of destroying it. Falls + // through to the plain wipe when the old tree is partial/corrupt or the + // rename fails. + const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe` + if (platformName === 'win32' && preserveRollbackBackup(appOutDir, productExe)) { + console.log(`[before-pack] preserved previous unpacked dir for rollback: ${appOutDir}.bak`) + } else if (cleanStaleAppOutDir(appOutDir)) { console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`) } } catch (err) { diff --git a/apps/desktop/scripts/before-pack.test.mjs b/apps/desktop/scripts/before-pack.test.mjs index 44adf961618..d082ec4d2ad 100644 --- a/apps/desktop/scripts/before-pack.test.mjs +++ b/apps/desktop/scripts/before-pack.test.mjs @@ -4,7 +4,7 @@ import os from 'node:os' import path from 'node:path' import { test } from 'vitest' -import beforePack, { cleanStaleAppOutDir } from '../scripts/before-pack.mjs' +import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs' test('cleanStaleAppOutDir removes a populated unpacked directory', () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) @@ -50,3 +50,106 @@ test('beforePack default export resolves even when cleanup throws', async () => // remove; the contract under test is that the hook never rejects. await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' })) }) + +// ─── Windows rollback preservation (#69179) ──────────────────────────────── + +test('preserveRollbackBackup moves a working build to .bak', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8') + fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8') + + const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe') + + assert.equal(preserved, true) + // Original slot vacated so electron-builder stages into a clean tree... + assert.equal(fs.existsSync(appOutDir), false) + // ...and the previous working build is intact under .bak for rollback. + assert.equal( + fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), + 'MZ-old-build' + ) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('preserveRollbackBackup replaces a stale .bak from an older update', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current', 'utf8') + fs.mkdirSync(`${appOutDir}.bak`, { recursive: true }) + fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8') + + assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true) + assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current') + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('preserveRollbackBackup refuses a partial tree missing the product exe', () => { + // The corrupted partial state (interrupted prior pack) must NOT become + // rollback material — it is exactly what cleanStaleAppOutDir exists to wipe. + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8') + + assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false) + // Tree untouched; the caller's wipe path handles it. + assert.equal(fs.existsSync(appOutDir), true) + assert.equal(fs.existsSync(`${appOutDir}.bak`), false) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('preserveRollbackBackup ignores missing or invalid input', () => { + assert.equal(preserveRollbackBackup(''), false) + assert.equal(preserveRollbackBackup(undefined), false) + assert.equal(preserveRollbackBackup(null), false) + assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false) +}) + +test('beforePack on win32 preserves the previous build instead of wiping it', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'win-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8') + + // No packager info in the context → default 'Hermes.exe' product name. + // node-pty staging is skipped because arch is not a number here. + await beforePack({ appOutDir, electronPlatformName: 'win32' }) + + assert.equal(fs.existsSync(appOutDir), false) + assert.equal( + fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), + 'MZ-working' + ) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) + +test('beforePack on linux keeps the plain wipe (no .bak)', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-')) + try { + const appOutDir = path.join(tempRoot, 'linux-unpacked') + fs.mkdirSync(appOutDir, { recursive: true }) + fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'x', 'utf8') + + await beforePack({ appOutDir, electronPlatformName: 'linux' }) + + assert.equal(fs.existsSync(appOutDir), false) + assert.equal(fs.existsSync(`${appOutDir}.bak`), false) + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/scripts/diag-code-live.mjs b/apps/desktop/scripts/diag-code-live.mjs new file mode 100644 index 00000000000..98ec39973d9 --- /dev/null +++ b/apps/desktop/scripts/diag-code-live.mjs @@ -0,0 +1,25 @@ +// Is the tree-split preview path actually active in the running renderer? +// Checks the served source (what vite compiled) rather than guessing. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const out = await cdp.eval(`(async () => { + const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx') + const src = await res.text() + return JSON.stringify({ + previewShift: src.includes('previewShift'), + adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'), + structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'), + sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'), + rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider') + }) + })()`) + + console.log(out) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-drag-churn.mjs b/apps/desktop/scripts/diag-drag-churn.mjs new file mode 100644 index 00000000000..009c34e3d2c --- /dev/null +++ b/apps/desktop/scripts/diag-drag-churn.mjs @@ -0,0 +1,158 @@ +// Who re-renders the transcript during a sash drag? +// +// Standalone probe, not a benchmark: seeds tiles, then drags the sash while +// recording (a) render attribution and (b) every nanostores atom that notifies +// during the gesture. The idle-cost scenario proved the transcript re-renders +// ~18x above baseline during a drag but that the sash HANDLER is not the cause +// (identical counts at 0px and 60px displacement) — so this names the store +// that actually fires. +// +// node scripts/perf/diag-drag-churn.mjs [--port 9222] + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const TILES = 5 +const TURNS = 20 + +const setup = ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] } + ]) + window.__D__ = { ids: [] } + for (let n = 1; n <= ${TILES}; n++) { + const sid = 'diag-tile-' + n + const rid = 'diag-rt-' + n + const messages = [] + for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working.' }] }) + window.__D__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +// Drag, recording renders AND atom notifications together. +const DRAG = ` + (async () => { + const rc = window.__RENDER_COUNTS__ + const ac = window.__ATOM_CHURN__ + rc.start(); ac.start() + + const handle = document.querySelector('[role="separator"]') + if (!handle) { rc.stop(); ac.stop(); return JSON.stringify({ error: 'no sash' }) } + + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 30; i++) { + x += 2 + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) + + rc.stop(); ac.stop() + const all = rc.report(400) + const named = n => all.find(r => r.name === n) || null + return JSON.stringify({ + moved: Math.round(x - x0), + commits: rc.commits(), + renders: rc.report(14), + // The suspects: who at the TOP of the transcript tree re-rendered? + chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat', + 'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell'] + .map(n => ({ name: n, hit: named(n) })).filter(x => x.hit), + atoms: ac.report(30) + }) + })() +` + +const CLEANUP = ` + (() => { + if (window.__D__) { + for (const { sid, rid } of window.__D__.ids) { + const s = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__D__ = null + } + return 'cleaned' + })() +` + +const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222) +const { cdp, teardown } = await attach({ port }) + +try { + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup) + + if (ok !== 'ok') { + throw new Error(`setup failed: ${ok}`) + } + + for (let n = 1; n <= TILES; n++) { + await cdp.eval(reveal(`diag-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + const data = JSON.parse(await cdp.eval(DRAG)) + await cdp.eval(CLEANUP) + + console.log(`moved ${data.moved}px, ${data.commits} commits\n`) + console.log('RENDERS during drag:') + + for (const r of data.renders) { + console.log( + ` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` + + `props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` + + `ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}` + ) + } + + console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):') + + for (const { name, hit } of data.chain) { + console.log( + ` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` + + `props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}` + ) + } + + console.log('\nATOMS that notified during drag:') + + for (const a of data.atoms) { + console.log( + ` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` + + `fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}` + ) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-drag-trace.mjs b/apps/desktop/scripts/diag-drag-trace.mjs new file mode 100644 index 00000000000..01c4527fc06 --- /dev/null +++ b/apps/desktop/scripts/diag-drag-trace.mjs @@ -0,0 +1,217 @@ +// What is the drag actually spending time on? +// +// The render counters proved React is no longer the cost (commits 83 -> 12 +// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That +// pattern says a fixed per-frame floor outside React. This takes a real CDP +// trace of one sash drag and prints the category split — Recalculate Style, +// Layout, Paint, Scripting — so the next fix targets the actual cost instead +// of the next plausible-looking thing. +// +// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5] + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const TILES = Number(arg('tiles', 5)) +const TURNS = Number(arg('turns', 20)) + +const setup = ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] } + ]) + window.__T__ = { ids: [] } + for (let n = 1; n <= ${TILES}; n++) { + const sid = 'trace-tile-' + n + const rid = 'trace-rt-' + n + const messages = [] + for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working.' }] }) + window.__T__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced +// loop and make the window itself a function of the slowness. Fixed wall-clock +// pacing keeps the trace window comparable run to run. +const DRAG = ` + (async () => { + const handle = document.querySelector('[role="separator"]') + if (!handle) return 'none' + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 40; i++) { + x += (i < 20 ? 3 : -3) + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => setTimeout(r, 16)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) + return 'dragged' + })() +` + +const CLEANUP = ` + (() => { + if (window.__T__) { + for (const { sid, rid } of window.__T__.ids) { + const s = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__T__ = null + } + return 'cleaned' + })() +` + +const { cdp, teardown } = await attach({ port }) + +try { + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup) + + if (ok !== 'ok') { + throw new Error(`setup failed: ${ok}`) + } + + for (let n = 1; n <= TILES; n++) { + await cdp.eval(reveal(`trace-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + // Collect trace events for the drag window only. `cdp.on` is the client's + // only event API (no `once`), so completion is signalled through a flag. + const events = [] + let complete = false + cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? []))) + cdp.on('Tracing.tracingComplete', () => { + complete = true + }) + + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] } + }) + + const dragged = await cdp.eval(DRAG) + + await cdp.send('Tracing.end') + + for (let waited = 0; !complete && waited < 10000; waited += 200) { + await sleep(200) + } + + await cdp.eval(CLEANUP) + + // Sum self-time per timeline category. Nested events would double-count, so + // attribute each event's duration minus the duration of its direct children. + const INTERESTING = new Set([ + 'UpdateLayoutTree', // Recalculate Style + 'Layout', + 'Paint', + 'PaintImage', + 'Layerize', + 'UpdateLayer', + 'CompositeLayers', + 'FunctionCall', + 'EvaluateScript', + 'TimerFire', + 'EventDispatch', + 'HitTest', + 'ParseHTML', + 'CommitLoad' + ]) + + const totals = new Map() + let traced = 0 + + for (const e of events) { + if (e.ph !== 'X' || typeof e.dur !== 'number') { + continue + } + + traced += 1 + const name = e.name + + if (!INTERESTING.has(name)) { + continue + } + + totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000) + } + + console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`) + console.log('TIMELINE COST (ms, total duration by event):') + + const rows = [...totals.entries()].sort((a, b) => b[1] - a[1]) + + if (rows.length === 0) { + console.log(' (no timeline events — category filter or tracing domain unavailable)') + } + + for (const [name, ms] of rows) { + console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`) + } + + const style = totals.get('UpdateLayoutTree') ?? 0 + const layout = totals.get('Layout') ?? 0 + const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0) + + console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`) + + // Script dominates -> name the functions. Timeline FunctionCall events carry + // the callsite in args.data, so the top offenders can be attributed without + // a separate CPU profile. + const byFn = new Map() + + for (const e of events) { + if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') { + continue + } + + const d = e.args?.data ?? {} + const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}` + byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000) + } + + console.log('\nTOP SCRIPT CALLSITES (ms):') + + for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) { + console.log(` ${ms.toFixed(1).padStart(8)} ${name}`) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-key-latency.mjs b/apps/desktop/scripts/diag-key-latency.mjs new file mode 100644 index 00000000000..7a29bd46cad --- /dev/null +++ b/apps/desktop/scripts/diag-key-latency.mjs @@ -0,0 +1,47 @@ +// Typing latency, isolated: keystroke -> next paint, with and without an +// active stream. Distinguishes "input is slow" from "the frame budget is +// consumed by streaming flushes" — the fix differs completely. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +const TYPE = ` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent) + if (!el) return JSON.stringify({ error: 'no composer' }) + el.focus() + const perKey = [] + for (let i = 0; i < 30; i++) { + const ch = 'abcdefghij'[i % 10] + const t0 = performance.now() + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + perKey.push(performance.now() - t0) + // Human-ish 80ms cadence so streaming flushes interleave realistically. + await new Promise(r => setTimeout(r, 80)) + } + el.textContent = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + const sorted = [...perKey].sort((a, b) => a - b) + const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] + const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })() + return JSON.stringify({ + keyToPaint_p50: Math.round(pct(0.5) * 10) / 10, + keyToPaint_p95: Math.round(pct(0.95) * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + over16: perKey.filter(f => f > 16.7).length, + over33: perKey.filter(f => f > 33).length, + streamingParts: busy + }) + })() +` + +try { + await cdp.send('Runtime.enable') + console.log(await cdp.eval(TYPE)) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-live-state.mjs b/apps/desktop/scripts/diag-live-state.mjs new file mode 100644 index 00000000000..7526bf0e65f --- /dev/null +++ b/apps/desktop/scripts/diag-live-state.mjs @@ -0,0 +1,21 @@ +// Quick state probe of the running hgui instance via CDP. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const state = await cdp.eval(`(() => { + const rc = !!window.__RENDER_COUNTS__ + const pl = !!window.__PERF_LIVE__ + const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1 + const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)' + const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length + return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) }) + })()`) + + console.log(state) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-real-loop.mjs b/apps/desktop/scripts/diag-real-loop.mjs new file mode 100644 index 00000000000..e3bde3b3808 --- /dev/null +++ b/apps/desktop/scripts/diag-real-loop.mjs @@ -0,0 +1,149 @@ +// The real-app perf loop: drive HER hgui instance (real profile, real +// sessions) through the three interactions that matter — session switch, +// sidebar drag, composer typing — and report honest single-clock numbers. +// +// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6] +// +// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session +// switching is measured as the user feels it: click -> transcript painted. + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const SWITCHES = Number(arg('switches', 6)) + +const { cdp, teardown } = await attach({ port }) + +// --------------------------------------------------------------------------- +// Session switch: click a sidebar session row, await the transcript settling. +// Measures click -> first paint of the new transcript AND click -> settled +// (two rAFs with no further DOM mutation in the thread viewport). +// --------------------------------------------------------------------------- +const SWITCH = swaps => ` + (async () => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')] + .filter(el => el.offsetParent && (el.textContent ?? '').trim()) + if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length }) + + const results = [] + for (let i = 0; i < ${swaps}; i++) { + const row = rows[i % Math.min(rows.length, 4)] + const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]') + const t0 = performance.now() + row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 })) + row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 })) + row.click() + + // First paint: next two rAFs after the click. + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) + const firstPaint = performance.now() - t0 + + // Settled: no mutations in the viewport for 2 consecutive frames, capped 3s. + let lastMutation = performance.now() + const target = viewport() ?? document.body + const mo = new MutationObserver(() => { lastMutation = performance.now() }) + mo.observe(target, { childList: true, subtree: true, characterData: true }) + const deadline = performance.now() + 3000 + while (performance.now() < deadline) { + await new Promise(r => requestAnimationFrame(r)) + if (performance.now() - lastMutation > 120) break + } + mo.disconnect() + results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) }) + await new Promise(r => setTimeout(r, 250)) + } + return JSON.stringify(results) + })() +` + +// --------------------------------------------------------------------------- +// Drag the first visible sash, single-clock frames. +// --------------------------------------------------------------------------- +const DRAG = ` + (async () => { + const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0) + if (!handle) return JSON.stringify({ error: 'no sash' }) + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + const frames = [] + let last = performance.now() + handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y })) + for (let i = 0; i < 60; i++) { + x += (i < 30 ? 2 : -2) + window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now(); frames.push(now - last); last = now + } + window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y })) + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + return JSON.stringify({ + fps: Math.round((frames.length / total) * 1000 * 10) / 10, + p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: frames.filter(f => f > 33).length + }) + })() +` + +// --------------------------------------------------------------------------- +// Type into the composer, single-clock frames (one mark per keystroke frame). +// --------------------------------------------------------------------------- +const TYPE = ` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent) + if (!el) return JSON.stringify({ error: 'no composer' }) + el.focus() + const frames = [] + let last = performance.now() + for (let i = 0; i < 40; i++) { + const ch = 'the quick brown fox '[i % 20] + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now(); frames.push(now - last); last = now + await new Promise(r => setTimeout(r, 20)) + } + // Clear what we typed. + el.textContent = '' + el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) + const moving = frames + const total = moving.reduce((a, b) => a + b, 0) + const sorted = [...moving].sort((a, b) => a - b) + return JSON.stringify({ + fps: Math.round((moving.length / total) * 1000 * 10) / 10, + p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: moving.filter(f => f > 33).length + }) + })() +` + +try { + await cdp.send('Runtime.enable') + + console.log('== SESSION SWITCH (click -> paint / settled ms) ==') + console.log(await cdp.eval(SWITCH(SWITCHES))) + + await sleep(500) + console.log('\n== SIDEBAR DRAG ==') + console.log(await cdp.eval(DRAG)) + + await sleep(500) + console.log('\n== COMPOSER TYPING ==') + console.log(await cdp.eval(TYPE)) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-ro-storm.mjs b/apps/desktop/scripts/diag-ro-storm.mjs new file mode 100644 index 00000000000..f405aa65cd1 --- /dev/null +++ b/apps/desktop/scripts/diag-ro-storm.mjs @@ -0,0 +1,170 @@ +// How many ResizeObserver callbacks does one sash drag actually fire, and for +// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms +// but not whether that's a few expensive calls or a great many cheap ones — +// and the fix differs completely between those. +// +// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5] + +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const TILES = Number(arg('tiles', 5)) +const TURNS = Number(arg('turns', 20)) + +const setup = ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] } + ]) + window.__R__ = { ids: [] } + for (let n = 1; n <= ${TILES}; n++) { + const sid = 'ro-tile-' + n + const rid = 'ro-rt-' + n + const messages = [] + for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working.' }] }) + window.__R__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +// Patch ResizeObserver to count callbacks + distinct observed targets, then +// drag and report. Counting happens in the page so nothing crosses CDP per call. +// +// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created +// lazily on first use, so this patch must be installed BEFORE any surface +// mounts — otherwise the shared instance is a native one this wrapper never +// sees and every counter reads zero. `constructed` is the tell: a run showing +// a handful of constructions and zero callbacks means the patch landed late, +// not that the app stopped observing. +const INSTRUMENT = ` + (() => { + if (window.__ROSTATS__) return 'already' + const Native = window.ResizeObserver + const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false } + window.__ROSTATS__ = stats + window.ResizeObserver = class extends Native { + constructor(cb) { + super((entries, obs) => { + if (stats.on) { + stats.callbacks += 1 + stats.entries += entries.length + for (const e of entries) stats.targets.add(e.target) + } + return cb(entries, obs) + }) + stats.constructed += 1 + } + observe(...args) { + stats.observed += 1 + return super.observe(...args) + } + } + return 'patched' + })() +` + +const DRAG = ` + (async () => { + const s = window.__ROSTATS__ + s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true + const handle = document.querySelector('[role="separator"]') + if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) } + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + const t0 = performance.now() + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 40; i++) { + x += (i < 20 ? 3 : -3) + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => setTimeout(r, 16)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) + await new Promise(r => setTimeout(r, 300)) + s.on = false + return JSON.stringify({ + ms: Math.round(performance.now() - t0), + moves: 40, + constructed: s.constructed, + observed: s.observed, + callbacks: s.callbacks, + entries: s.entries, + distinctTargets: s.targets.size, + userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length + }) + })() +` + +const CLEANUP = ` + (() => { + if (window.__R__) { + for (const { sid, rid } of window.__R__.ids) { + const s = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__R__ = null + } + return 'cleaned' + })() +` + +const { cdp, teardown } = await attach({ port }) + +try { + await cdp.send('Runtime.enable') + await cdp.eval(INSTRUMENT) + + const ok = await cdp.eval(setup) + + if (ok !== 'ok') { + throw new Error(`setup failed: ${ok}`) + } + + for (let n = 1; n <= TILES; n++) { + await cdp.eval(reveal(`ro-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + const r = JSON.parse(await cdp.eval(DRAG)) + await cdp.eval(CLEANUP) + + console.log(JSON.stringify(r, null, 2)) + + if (r.moves) { + console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`) + console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-sidebar-dom.mjs b/apps/desktop/scripts/diag-sidebar-dom.mjs new file mode 100644 index 00000000000..64159ca6d5e --- /dev/null +++ b/apps/desktop/scripts/diag-sidebar-dom.mjs @@ -0,0 +1,27 @@ +// Dump the sidebar's actual DOM shape so selectors stop being guesses. +import { attach } from './perf/lib/launch.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +try { + await cdp.send('Runtime.enable') + + const out = await cdp.eval(`(() => { + const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside') + if (!sidebar) return '(no sidebar el)' + // Find clickable rows: anchors or buttons with text, depth-limited sample. + const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60) + const rows = clickables.map(el => ({ + tag: el.tagName.toLowerCase(), + slot: el.getAttribute('data-slot') ?? '', + cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40), + text: (el.textContent ?? '').trim().slice(0, 30), + visible: !!el.offsetParent + })).filter(r => r.text) + return JSON.stringify(rows.slice(0, 30), null, 1) + })()`) + + console.log(out) +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-switch-autopsy.mjs b/apps/desktop/scripts/diag-switch-autopsy.mjs new file mode 100644 index 00000000000..f90e0d52982 --- /dev/null +++ b/apps/desktop/scripts/diag-switch-autopsy.mjs @@ -0,0 +1,52 @@ +// Session-switch autopsy: click between the two heaviest rows repeatedly, +// recording per-switch (a) settled ms, (b) React commits, (c) top rendered +// components — so slow switches name themselves. +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`) + + return i === -1 ? fallback : process.argv[i + 1] +} + +const port = Number(arg('port', 9222)) +const ROUNDS = Number(arg('rounds', 8)) + +const { cdp, teardown } = await attach({ port }) + +const SWITCH_ONE = index => ` + (async () => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent) + if (rows.length < 2) return JSON.stringify({ error: 'rows' }) + const row = rows[${index} % 2] + const rc = window.__RENDER_COUNTS__ + rc.start() + const t0 = performance.now() + row.click() + let lastMutation = performance.now() + const mo = new MutationObserver(() => { lastMutation = performance.now() }) + mo.observe(document.body, { childList: true, subtree: true, characterData: true }) + const deadline = performance.now() + 4000 + while (performance.now() < deadline) { + await new Promise(r => requestAnimationFrame(r)) + if (performance.now() - lastMutation > 150) break + } + mo.disconnect() + rc.stop() + const settled = Math.round(performance.now() - t0 - 150) + const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)') + return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report }) + })() +` + +try { + await cdp.send('Runtime.enable') + + for (let i = 0; i < ROUNDS; i++) { + console.log(await cdp.eval(SWITCH_ONE(i))) + await sleep(400) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/diag-switch-trace.mjs b/apps/desktop/scripts/diag-switch-trace.mjs new file mode 100644 index 00000000000..8efe5462e3f --- /dev/null +++ b/apps/desktop/scripts/diag-switch-trace.mjs @@ -0,0 +1,74 @@ +// What happens during a SLOW session switch? Click a heavy row with tracing +// on, dump the style/layout/script split plus top callsites. +import { attach } from './perf/lib/launch.mjs' +import { sleep } from './perf/lib/cdp.mjs' + +const { cdp, teardown } = await attach({ port: 9222 }) + +const CLICK_HEAVIEST = ` + (() => { + const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent) + if (rows.length < 2) return 'need rows' + // Alternate between the first two rows so every run actually switches. + const current = location.hash + const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1] + target.click() + return 'clicked: ' + (target.textContent ?? '').slice(0, 40) + })() +` + +const events = [] +let complete = false +cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? []))) +cdp.on('Tracing.tracingComplete', () => { + complete = true +}) + +try { + await cdp.send('Runtime.enable') + + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { includedCategories: ['devtools.timeline'] } + }) + + console.log(await cdp.eval(CLICK_HEAVIEST)) + await sleep(2500) + console.log(await cdp.eval(CLICK_HEAVIEST)) + await sleep(2500) + + await cdp.send('Tracing.end') + + for (let w = 0; !complete && w < 10000; w += 200) { + await sleep(200) + } + + const totals = new Map() + const byFn = new Map() + + for (const e of events) { + if (e.ph !== 'X' || typeof e.dur !== 'number') { + continue + } + + totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000) + + if (e.name === 'FunctionCall') { + const d = e.args?.data ?? {} + const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}` + byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000) + } + } + + const style = totals.get('UpdateLayoutTree') ?? 0 + const layout = totals.get('Layout') ?? 0 + const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0) + console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`) + console.log('\nTOP CALLSITES:') + + for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) { + console.log(` ${ms.toFixed(1).padStart(8)} ${name}`) + } +} finally { + teardown?.() +} diff --git a/apps/desktop/scripts/live-drive.mjs b/apps/desktop/scripts/live-drive.mjs new file mode 100644 index 00000000000..a20d09f30cd --- /dev/null +++ b/apps/desktop/scripts/live-drive.mjs @@ -0,0 +1,290 @@ +// Live-drive harness for the REAL hgui instance on :9222. +// +// node scripts/live-drive.mjs status — targets, session count, perf-live armed? +// node scripts/live-drive.mjs fps [seconds] — raw rAF fps over N seconds (default 4) +// node scripts/live-drive.mjs drag — drag the sidebar sash, report fps + LoAF +// node scripts/live-drive.mjs type — type into composer, report fps + LoAF +// node scripts/live-drive.mjs switch — cycle through sidebar sessions, per-switch ms +// node scripts/live-drive.mjs send "msg" — submit a prompt in the focused session +// node scripts/live-drive.mjs eval "expr" — arbitrary page eval +// +// Attaches to the page target directly (no perf-harness deps) so it works on +// the app Brooklyn actually runs, with her profile, her sessions, her layout. + +import { WebSocket } from 'ws' + +const PORT = Number(process.env.CDP_PORT ?? 9222) + +async function attach() { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + const page = list.find(t => t.type === 'page' && !/devtools/.test(t.url)) + + if (!page) { + throw new Error('no page target on :' + PORT) + } + + const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 }) + await new Promise((resolve, reject) => { + ws.once('open', resolve) + ws.once('error', reject) + }) + + let id = 0 + const pending = new Map() + ws.on('message', raw => { + const msg = JSON.parse(raw) + + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id) + pending.delete(msg.id) + msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result) + } + }) + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const mid = ++id + pending.set(mid, { resolve, reject }) + ws.send(JSON.stringify({ id: mid, method, params })) + }) + + await send('Runtime.enable') + + const evaluate = async expression => { + const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) + + if (r.exceptionDetails) { + throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed') + } + + return r.result?.value + } + + return { evaluate, close: () => ws.close(), send } +} + +const FPS = seconds => ` + (async () => { + const frames = [] + let last = performance.now() + const end = last + ${seconds * 1000} + while (performance.now() < end) { + await new Promise(r => requestAnimationFrame(r)) + const now = performance.now() + frames.push(now - last) + last = now + } + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] + return { + fps: Math.round((frames.length / total) * 1000 * 10) / 10, + p95: Math.round(pct(0.95) * 10) / 10, + worst: Math.round(sorted[sorted.length - 1] * 10) / 10, + slow33: frames.filter(f => f > 33).length, + n: frames.length + } + })() +` + +// LoAF recorder for a window of work driven inside `body`. +const WITH_LOAF = body => ` + (async () => { + const lofs = [] + const po = new PerformanceObserver(list => { + for (const e of list.getEntries()) { + lofs.push({ + ms: Math.round(e.duration), + block: Math.round(e.blockingDuration ?? 0), + style: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0, + scripts: (e.scripts ?? []).filter(s => s.duration >= 5).map(s => + (s.invokerType ?? '') + ':' + (s.invoker ?? s.sourceFunctionName ?? '?') + '@' + + ((s.sourceURL ?? '').split('/').pop() ?? '') + ' ' + Math.round(s.duration) + 'ms') + }) + } + }) + po.observe({ type: 'long-animation-frame', buffered: false }) + const frames = [] + let last = performance.now() + let stop = false + const tick = () => { + if (stop) return + const now = performance.now() + frames.push(now - last) + last = now + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + ${body} + stop = true + po.disconnect() + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0 + return { + fps: total ? Math.round((frames.length / total) * 1000 * 10) / 10 : 0, + p95: Math.round(pct(0.95) * 10) / 10, + worst: sorted.length ? Math.round(sorted[sorted.length - 1] * 10) / 10 : 0, + slow33: frames.filter(f => f > 33).length, + longFrames: lofs.slice(0, 10) + } + })() +` + +const DRAG_BODY = ` + const handle = document.querySelector('[role="separator"]') + if (!handle) throw new Error('no sash') + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + for (let i = 0; i < 60; i++) { + x += (i < 30 ? 3 : -3) + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) +` + +const TYPE_BODY = ` + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]')) + if (!el) throw new Error('no composer') + el.focus() + for (let i = 0; i < 40; i++) { + const ch = 'the quick brown fox '[i % 20] + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + await new Promise(r => requestAnimationFrame(r)) + } +` + +// Session switching: click each sidebar session row, time route->settled. +const SWITCH = ` + (async () => { + const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')] + .filter(el => el.offsetParent !== null).slice(0, 8) + if (pick().length < 2) { + throw new Error('found ' + pick().length + ' session rows') + } + const times = [] + const labels = [] + for (let i = 0; i < Math.min(pick().length, 6); i++) { + // Re-query each iteration: a switch can re-render the sidebar and + // detach the previously captured nodes. + const row = pick()[i] + if (!row) break + labels.push((row.textContent || '').slice(0, 24)) + const t0 = performance.now() + row.click() + let calm = 0 + while (calm < 2 && performance.now() - t0 < 5000) { + const f0 = performance.now() + await new Promise(r => requestAnimationFrame(r)) + const dt = performance.now() - f0 + calm = dt < 20 ? calm + 1 : 0 + } + times.push(Math.round(performance.now() - t0)) + await new Promise(r => setTimeout(r, 400)) + } + return { switches: times, labels, avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length) } + })() +` + +const cmd = process.argv[2] ?? 'status' +const arg = process.argv[3] +const { evaluate, close } = await attach() + +try { + if (cmd === 'status') { + const r = await evaluate(`JSON.stringify({ + url: location.hash, + perfLive: typeof window.__PERF_LIVE__ !== 'undefined', + renderCounts: typeof window.__RENDER_COUNTS__ !== 'undefined', + tiles: document.querySelectorAll('[data-tree-group]').length, + sessions: document.querySelectorAll('[data-slot*="session-row"], [data-session-row]').length, + composers: [...document.querySelectorAll('[contenteditable="true"]')].length + })`) + console.log(r) + } else if (cmd === 'fps') { + console.log(JSON.stringify(await evaluate(FPS(Number(arg ?? 4))))) + } else if (cmd === 'drag') { + const r = await evaluate(WITH_LOAF(DRAG_BODY)) + console.log('drag', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 })) + for (const lf of r.longFrames) { + console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`) + } + } else if (cmd === 'type') { + const r = await evaluate(WITH_LOAF(TYPE_BODY)) + console.log('type', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 })) + for (const lf of r.longFrames) { + console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`) + } + } else if (cmd === 'switch') { + console.log(JSON.stringify(await evaluate(SWITCH))) + } else if (cmd === 'eval') { + console.log(JSON.stringify(await evaluate(arg))) + } else if (cmd === 'profile') { + // CPU-profile one session switch via CDP Profiler (Document Policy blocks + // the in-page Profiler API, CDP is exempt). arg = row label prefix. + await send('Profiler.enable') + await send('Profiler.setSamplingInterval', { interval: 200 }) + await send('Profiler.start') + const r = await evaluate(` + (async () => { + const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent !== null) + const row = pick().find(el => (el.textContent || '').startsWith(${JSON.stringify(arg ?? 'GUI')})) + if (!row) return 'row not found' + const t0 = performance.now() + row.click() + let calm = 0 + while (calm < 3 && performance.now() - t0 < 6000) { + const f0 = performance.now() + await new Promise(r => requestAnimationFrame(r)) + calm = (performance.now() - f0) < 20 ? calm + 1 : 0 + } + return Math.round(performance.now() - t0) + })() + `) + const { profile } = await send('Profiler.stop') + // Self-time per function. + const hitById = new Map() + for (let i = 0; i < profile.samples.length; i++) { + const id = profile.samples[i] + const dt = profile.timeDeltas[i] ?? 0 + hitById.set(id, (hitById.get(id) ?? 0) + dt) + } + const rows = [] + for (const node of profile.nodes) { + const us = hitById.get(node.id) + if (!us || us < 5000) continue + const f = node.callFrame + rows.push([Math.round(us / 1000), `${f.functionName || '(anon)'} @ ${(f.url || '').split('/').pop()}:${f.lineNumber}`]) + } + rows.sort((a, b) => b[0] - a[0]) + console.log('switch took', r, 'ms — top self-time:') + for (const [ms, name] of rows.slice(0, 18)) { + console.log(` ${String(ms).padStart(6)}ms ${name}`) + } + } else if (cmd === 'send') { + const r = await evaluate(` + (async () => { + const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]')) + if (!el) return 'no composer' + el.focus() + document.execCommand('insertText', false, ${JSON.stringify(arg ?? 'hello')}) + await new Promise(r => setTimeout(r, 120)) + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' })) + return 'sent' + })() + `) + console.log(r) + } else { + console.log('unknown cmd', cmd) + } +} finally { + close() +} diff --git a/apps/desktop/scripts/perf/README.md b/apps/desktop/scripts/perf/README.md index e8cfc575231..ec28a9b0d9b 100644 --- a/apps/desktop/scripts/perf/README.md +++ b/apps/desktop/scripts/perf/README.md @@ -51,6 +51,8 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent. | `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream | | `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing | | `transcript` | ci | large-transcript mount + paint cost | (new) | +| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) | +| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) | | `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) | | `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) | | `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump | diff --git a/apps/desktop/scripts/perf/baseline.json b/apps/desktop/scripts/perf/baseline.json index 392993f618c..6750b861673 100644 --- a/apps/desktop/scripts/perf/baseline.json +++ b/apps/desktop/scripts/perf/baseline.json @@ -1,9 +1,9 @@ { "_meta": { - "note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot — no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.", + "note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.", "platform": "darwin-arm64", "node": "v24.11.0", - "updated": "2026-07-19T23:16:01.227Z" + "updated": "2026-07-27T00:30:11.290Z" }, "scenarios": { "stream": { @@ -55,6 +55,25 @@ "dom_content_loaded_ms": 574, "nav_to_read_ms": 721 } + }, + "multitab": { + "metrics": { + "longtasks_n": 0, + "longtask_max_ms": 0, + "frame_p95_ms": 29.1, + "frame_p99_ms": 36.2, + "slow_frames_33": 9 + } + }, + "render-churn": { + "metrics": { + "sidebar_renders": 0, + "sidebar_wasted": 0, + "wasted_renders": 1704, + "total_renders": 8221, + "commits": 1352, + "wasted_notifies": 0 + } } } } diff --git a/apps/desktop/scripts/perf/scenarios/idle-cost.mjs b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs new file mode 100644 index 00000000000..c378f96ad97 --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/idle-cost.mjs @@ -0,0 +1,284 @@ +// What does the app cost while a turn is running but NOTHING is arriving? +// +// The user-visible symptom: with a thread spinning, resizing the sidebar or +// typing in the composer feels slow. That is not streaming cost — the stream +// is idle. It is the app re-rendering on its own, competing with the +// interaction for the main thread. +// +// This scenario holds N tiles in a busy state, pushes NO tokens, and measures: +// - idle_commits_per_s the renderer's self-inflicted commit rate +// - drag_fps fps while dragging the sidebar splitter +// - type_fps fps while typing in the composer +// +// A perfectly idle app scores 0 idle commits and pins both interactions at the +// display's refresh rate. Every idle commit is main-thread time stolen from an +// interaction the user can feel. +// +// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6] + +import { sleep } from '../lib/cdp.mjs' + +/** Seed `tiles` busy session tiles. Same publish path as `multitab` / + * `render-churn`, but the driver never runs — the turn just stays open. */ +const setup = (tiles, seedTurns) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + if (!window.__RENDER_COUNTS__) return 'no-render-counter' + + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] } + ]) + + window.__IDLE__ = { ids: [] } + for (let n = 1; n <= ${tiles}; n++) { + const sid = 'idle-tile-' + n + const rid = 'idle-rt-' + n + const messages = [] + for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i)) + // An OPEN assistant message: the turn is running, but no tokens arrive. + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: 'Working on it.' }] }) + + window.__IDLE__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + }) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +/** Measure the app's self-inflicted commit rate with nothing happening. */ +const idleCost = seconds => ` + (async () => { + const rc = window.__RENDER_COUNTS__ + rc.start() + const t0 = performance.now() + await new Promise(r => setTimeout(r, ${seconds} * 1000)) + const elapsed = (performance.now() - t0) / 1000 + rc.stop() + return JSON.stringify({ + elapsed, + commits: rc.commits(), + top: rc.report(12), + owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10) + }) + })() +` + +/** Record frame pacing across an interaction driven from the page. + * + * The gesture body drives itself on requestAnimationFrame, so it IS the frame + * clock — timing is taken from those same callbacks rather than a second, + * independent rAF ticker. Running two rAF consumers made the observer's + * deltas count the driver's frames as well as the app's and reported ~3fps + * where the interaction actually ran at ~23fps. `frames` is filled by the + * body via `__MARK__`. + * + * `record` MUST be false for any fps number you intend to believe: the render + * counter walks the whole fiber tree on every commit, so recording during a + * gesture measures the instrumentation as much as the app. Attribution and + * timing therefore run as two separate passes. */ +const withFrames = (body, record = false) => ` + (async () => { + const rc = window.__RENDER_COUNTS__ + ${record ? 'rc.start()' : ''} + const frames = [] + let last = performance.now() + // The body calls this once per frame it drives. + const __MARK__ = () => { + const now = performance.now() + frames.push(now - last) + last = now + } + ${body} + ${record ? 'rc.stop()' : ''} + const total = frames.reduce((a, b) => a + b, 0) + const sorted = [...frames].sort((a, b) => a - b) + const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0 + return JSON.stringify({ + fps: total ? (frames.length / total) * 1000 : 0, + p95: pct(0.95), + worst: sorted.length ? sorted[sorted.length - 1] : 0, + slow33: frames.filter(f => f > 33).length, + n: frames.length, + commits: ${record ? 'rc.commits()' : '0'}, + top: ${record ? 'rc.report(10)' : '[]'} + }) + })() +` + +/** Drag the sidebar splitter — the resize symptom. + * Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op), + * and reports how far it actually moved so a drag that silently did nothing + * shows up as `dragMoved: 0` instead of a confident wrong number. */ +const DRAG = withFrames(` + const handle = document.querySelector('[role="separator"]') + window.__DRAG_TARGET__ = handle ? 'separator' : 'none' + window.__DRAG_MOVED__ = 0 + if (handle) { + const box = handle.getBoundingClientRect() + const y = box.top + box.height / 2 + const x0 = box.left + box.width / 2 + let x = x0 + const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 } + handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y })) + // Out 60px then back — a real gesture, with a net displacement at the peak. + for (let i = 0; i < 30; i++) { + x += 2 + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + __MARK__() + } + window.__DRAG_MOVED__ = Math.round(x - x0) + for (let i = 0; i < 30; i++) { + x -= 2 + window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y })) + await new Promise(r => requestAnimationFrame(r)) + __MARK__() + } + window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y })) + } else { + await new Promise(r => setTimeout(r, 1000)) + } +`) + +/** Type into the composer — the keystroke symptom. */ +const TYPE = withFrames(` + const el = document.querySelector('[contenteditable="true"], textarea') + window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none' + if (el) { + el.focus() + for (let i = 0; i < 40; i++) { + const ch = 'performance testing '[i % 20] + el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch })) + if (el.tagName === 'TEXTAREA') { + el.value += ch + el.dispatchEvent(new Event('input', { bubbles: true })) + } else { + el.textContent += ch + el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' })) + } + el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch })) + // Wait for the frame this keystroke produces, then mark it — same clock + // discipline as DRAG, so typing fps is comparable to drag fps. + await new Promise(r => requestAnimationFrame(r)) + __MARK__() + await new Promise(r => setTimeout(r, 25)) + } + } else { + await new Promise(r => setTimeout(r, 1000)) + } +`) + +const CLEANUP = ` + (() => { + if (window.__IDLE__) { + for (const { sid, rid } of window.__IDLE__.ids) { + const states = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__IDLE__ = null + } + window.__RENDER_COUNTS__.clear() + return 'cleaned' + })() +` + +const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places + +export default { + name: 'idle-cost', + // NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a + // direct single-clock probe of the same gesture on the same build (57fps), + // and I could not reconcile the two — ruled out sash selection, tile setup, + // counter residue, and a 20s soak. Its RENDER attribution and idle commit + // rate are trustworthy and are what this scenario is for; the interaction + // fps is reported for investigation, not gated on, until that is explained. + tier: 'report', + description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.', + async run(cdp, opts = {}) { + const tiles = Number(opts.tiles ?? 5) + const seedTurns = Number(opts.turns ?? 20) + const seconds = Number(opts.seconds ?? 6) + + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup(tiles, seedTurns)) + + if (ok !== 'ok') { + throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`) + } + + for (let n = 1; n <= tiles; n++) { + await cdp.eval(reveal(`idle-tile-${n}`)) + await sleep(300) + } + + await sleep(1500) + + const idle = JSON.parse(await cdp.eval(idleCost(seconds))) + const drag = JSON.parse(await cdp.eval(DRAG)) + const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"') + const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0') + const type = JSON.parse(await cdp.eval(TYPE)) + const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"') + + await cdp.eval(CLEANUP) + + if (dragTarget === 'none') { + throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.') + } + + if (typeTarget === 'none') { + throw new Error('idle-cost: no composer found — the typing pass measured nothing.') + } + + return { + metrics: { + // Commits per second with a turn open and nothing arriving. Should be 0. + idle_commits_per_s: round(idle.commits / idle.elapsed), + idle_renders: idle.top.reduce((a, r) => a + r.renders, 0), + // Interaction smoothness while that churn competes for the main thread. + // Reported as a deficit from 60fps so "lower is better" matches the + // baseline gate's direction. + drag_fps_deficit: round(Math.max(0, 60 - drag.fps)), + drag_slow_frames: drag.slow33, + type_fps_deficit: round(Math.max(0, 60 - type.fps)), + type_slow_frames: type.slow33 + }, + detail: { + tiles, + dragTarget, + dragMoved, + idleSeconds: round(idle.elapsed), + dragFps: round(drag.fps), + dragP95: round(drag.p95), + dragWorst: round(drag.worst), + typeFps: round(type.fps), + typeP95: round(type.p95), + typeWorst: round(type.worst), + // Components whose OWN state changed with no prop change: the roots. + idleOwners: idle.owners, + idleTop: idle.top, + dragCommits: drag.commits, + dragTop: drag.top, + typeCommits: type.commits, + typeTop: type.top + } + } + } +} diff --git a/apps/desktop/scripts/perf/scenarios/index.mjs b/apps/desktop/scripts/perf/scenarios/index.mjs index d9aeece1eff..de53212e424 100644 --- a/apps/desktop/scripts/perf/scenarios/index.mjs +++ b/apps/desktop/scripts/perf/scenarios/index.mjs @@ -3,17 +3,25 @@ import coldStart from './cold-start.mjs' import firstToken from './first-token.mjs' +import idleCost from './idle-cost.mjs' import keystroke from './keystroke.mjs' +import multitab from './multitab.mjs' import profileSwitch from './profile-switch.mjs' +import renderChurn from './render-churn.mjs' import sessionSwitch from './session-switch.mjs' import stream from './stream.mjs' +import streamHistory from './stream-history.mjs' import submit from './submit.mjs' import transcript from './transcript.mjs' export const SCENARIOS = { [stream.name]: stream, + [streamHistory.name]: streamHistory, [keystroke.name]: keystroke, [transcript.name]: transcript, + [multitab.name]: multitab, + [renderChurn.name]: renderChurn, + [idleCost.name]: idleCost, [coldStart.name]: coldStart, [firstToken.name]: firstToken, [submit.name]: submit, diff --git a/apps/desktop/scripts/perf/scenarios/multitab.mjs b/apps/desktop/scripts/perf/scenarios/multitab.mjs new file mode 100644 index 00000000000..d93116f1285 --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/multitab.mjs @@ -0,0 +1,246 @@ +// Multi-tab working sessions: N session tiles stacked as tabs in the main +// zone, EVERY tab mounted (keep-alive), all streaming concurrently — the +// "5 tabs doing PR review" workload. Measures frame pacing + longtasks while +// the whole stack streams, which is where multitab renderers crawl. +// +// Drives the real pipeline synthetically (no backend, no credits): +// publishSessionState per session per flush — exactly what the gateway's +// delta flush does — via the __HERMES_SESSION_TILES__ hook. +// +// node scripts/perf/run.mjs multitab --spawn [--tiles 5] [--tokens 240] + +import { sleep } from '../lib/cdp.mjs' +import { frameHistogram, percentile } from '../lib/stats.mjs' + +// Same recorder pattern as stream.mjs (generation-guarded rAF + longtasks). +const RECORDERS = ` + (() => { + window.__FT_GEN__ = (window.__FT_GEN__ || 0) + 1 + const ftGen = window.__FT_GEN__ + window.__FT__ = { times: [], stop: false } + let last = performance.now() + const tick = () => { + if (window.__FT_GEN__ !== ftGen || window.__FT__.stop) return + const now = performance.now() + window.__FT__.times.push(now - last) + last = now + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + + window.__LT__ = { entries: [], stop: false } + try { + const po = new PerformanceObserver((list) => { + if (window.__LT__.stop) return + for (const e of list.getEntries()) window.__LT__.entries.push({ duration: e.duration, startTime: e.startTime }) + }) + po.observe({ entryTypes: ['longtask'] }) + window.__LT__.po = po + } catch {} + return 'armed' + })() +` + +const COLLECT = ` + (() => { + window.__FT__.stop = true + window.__LT__.stop = true + try { window.__LT__.po && window.__LT__.po.disconnect() } catch {} + return JSON.stringify({ frames: window.__FT__.times, longtasks: window.__LT__.entries }) + })() +` + +/** Page-side setup: open `tiles` session tiles stacked into the main zone, + * bind fake runtime ids, and seed each with a realistic transcript. */ +const setup = (tiles, seedTurns, streamSeed) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff in module ' + i + ' handle the error path?' }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: [ + '## Finding ' + i, '', + 'The handler swallows the rejection. Key points for hunk \\\`' + i + '\\\`:', '', + '- The catch block drops the original error.', + '- Retries are unbounded — see [the loop](https://example.com/loop).', '', + '\\\`\\\`\\\`ts', + 'async function retry' + i + '(fn: () => Promise) {', + ' for (;;) { try { return await fn() } catch {} }', + '}', + '\\\`\\\`\\\`', '', + '| path | covered |', '|---|---|', '| happy | yes |', '| error | no |', '' + ].join('\\n') }] } + ]) + + const state = (sid, rid) => { + const messages = [] + for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i)) + // Streaming tail the driver grows (--code seeds an open fence). + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] }) + return { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + } + } + + window.__MT__ = { ids: [], timer: null } + for (let n = 1; n <= ${tiles}; n++) { + const sid = 'perf-tile-' + n + const rid = 'perf-rt-' + n + window.__MT__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, state(sid, rid)) + } + return 'ok' + })() +` + +// Activate every tab once so keep-alive mounts the full stack (lazy mount: +// a never-activated tab stays unmounted, which would understate the cost). +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +/** Page-side driver: grow every tile's streaming tail by `chunk` each + * `intervalMs`, through the same publish path the gateway flush uses. */ +const drive = (chunk, intervalMs, totalTokens) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + let pushed = 0 + const tick = () => { + const states = hook.states() + for (const { rid } of window.__MT__.ids) { + const prev = states[rid] + if (!prev) continue + const messages = prev.messages.map(m => { + if (m.id !== prev.streamId) return m + const head = m.parts.slice(0, -1) + const last = m.parts[m.parts.length - 1] + return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] } + }) + hook.publish(rid, { ...prev, messages }) + } + pushed += 1 + if (pushed < ${totalTokens}) window.__MT__.timer = setTimeout(tick, ${intervalMs}) + else window.__MT__.done = true + } + window.__MT__.timer = setTimeout(tick, ${intervalMs}) + return 'driving' + })() +` + +const CLEANUP = ` + (() => { + if (window.__MT__) { + clearTimeout(window.__MT__.timer) + for (const { sid, rid } of window.__MT__.ids) { + window.__HERMES_SESSION_TILES__.publish(rid, { + ...window.__HERMES_SESSION_TILES__.states()[rid], busy: false, streamId: null + }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__MT__ = null + } + return 'cleaned' + })() +` + +export default { + name: 'multitab', + tier: 'ci', + description: 'N mounted session-tile tabs all streaming: frame pacing + longtasks.', + async run(cdp, opts = {}) { + const tiles = Number(opts.tiles ?? 5) + const seedTurns = Number(opts.turns ?? 20) + const tokens = Number(opts.tokens ?? 240) + // Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush. + const intervalMs = Number(opts.intervalMs ?? 33) + // --code: every tile grows ONE giant fenced code block with no settle + // boundaries — what a coding agent streams. The block re-parses and + // re-renders fully every flush (block memoization can't settle it), the + // documented worst case and the "5 tabs all coding" crawl. + const chunk = opts.code + ? ' const value = await resolve(ctx, { retry: true }) // step\n' + : (opts.chunk ?? 'A streamed review sentence with **bold**, `code`, and ordinary prose.\n\n') + const streamSeed = opts.code ? '```ts\n' : '' + + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed)) + + if (ok !== 'ok') { + throw new Error(`multitab setup failed (${ok}) — dev hooks missing? (needs a dev/probe renderer)`) + } + + // Mount every tab (keep-alive mounts on first activation), then settle. + for (let n = 1; n <= tiles; n++) { + await cdp.eval(reveal(`perf-tile-${n}`)) + await sleep(350) + } + + await sleep(1000) + await cdp.eval(RECORDERS) + await cdp.eval(drive(chunk, intervalMs, tokens)) + await sleep(tokens * intervalMs + 1500) + + const data = JSON.parse(await cdp.eval(COLLECT)) + await cdp.eval(CLEANUP) + + // Drop the first 500ms (recorder install + settle). + const frames = [] + let acc = 0 + + for (const f of data.frames) { + acc += f + + if (acc >= 500) { + frames.push(f) + } + } + + const ltDurations = data.longtasks.map(e => e.duration) + const windowS = frames.reduce((a, b) => a + b, 0) / 1000 + // The felt numbers: sustained fps over the window, and the fps of the + // worst 1-second slice (a 333ms frame IS "3fps" even if the average looks + // fine). Worst slice = max summed frame time in any sliding 1s window. + const avgFps = windowS ? frames.length / windowS : 0 + let worstFps = avgFps + + for (let i = 0, j = 0, sum = 0; j < frames.length; j++) { + sum += frames[j] + + while (sum > 1000) { + sum -= frames[i++] + } + + // Only a window that actually spans ~1s counts; short prefixes don't. + if (sum >= 900) { + worstFps = Math.min(worstFps, ((j - i + 1) / sum) * 1000) + } + } + + return { + metrics: { + longtasks_n: data.longtasks.length, + longtask_max_ms: Math.round((ltDurations.length ? Math.max(...ltDurations) : 0) * 10) / 10, + frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10, + frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10, + slow_frames_33: frames.filter(f => f > 33).length + }, + detail: { + tiles, + code: Boolean(opts.code), + windowS: Math.round(windowS * 10) / 10, + avgFps: Math.round(avgFps * 10) / 10, + worstSecondFps: Math.round(worstFps * 10) / 10, + frameHistogram: frameHistogram(frames) + } + } + } +} diff --git a/apps/desktop/scripts/perf/scenarios/render-churn.mjs b/apps/desktop/scripts/perf/scenarios/render-churn.mjs new file mode 100644 index 00000000000..d1322d58a65 --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/render-churn.mjs @@ -0,0 +1,259 @@ +// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which +// store published the update. Frame pacing (see `multitab`) tells you the cost; +// this tells you the cause. +// +// Drives the same synthetic pipeline as `multitab` — publishSessionState per +// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits — +// then reads the dev-only counters installed by `src/debug/`: +// +// window.__RENDER_COUNTS__ — per-component renders, attributed to +// props / hook state / parent-only ("wasted") +// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and +// notifications whose value was deep-equal to the +// previous one ("wasted") +// +// The headline metric is `sidebar_renders`: how many times the sidebar tree +// re-rendered while agents were typing in other tabs. It should be 0. +// +// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240] + +import { sleep } from '../lib/cdp.mjs' + +/** Components that make up the sidebar tree. A render of any of these while + * a background tab streams is work the user cannot see. */ +const SIDEBAR_COMPONENTS = [ + 'ChatSidebar', + 'SidebarSurface', + 'SessionRow', + 'SessionsSection', + 'CronJobsSection', + 'ProfileSwitcher', + 'VirtualSessionList', + 'WorkspaceGroup', + 'OverviewRow', + 'SessionStatusDot' +] + +/** Page-side setup: open `tiles` session tiles, seed each with a transcript. + * Mirrors `multitab.mjs` so the two scenarios measure the same workload. */ +const setup = (tiles, seedTurns) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + if (!window.__RENDER_COUNTS__) return 'no-render-counter' + if (!window.__ATOM_CHURN__) return 'no-atom-churn' + + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] } + ]) + + const state = (sid) => { + const messages = [] + for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: '' }] }) + return { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + } + } + + window.__RC__ = { ids: [], timer: null } + for (let n = 1; n <= ${tiles}; n++) { + const sid = 'churn-tile-' + n + const rid = 'churn-rt-' + n + window.__RC__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, state(sid)) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the + * same publish path the gateway's delta flush uses. */ +const drive = (chunk, intervalMs, totalTokens) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + let pushed = 0 + const tick = () => { + const states = hook.states() + for (const { rid } of window.__RC__.ids) { + const prev = states[rid] + if (!prev) continue + const messages = prev.messages.map(m => { + if (m.id !== prev.streamId) return m + const head = m.parts.slice(0, -1) + const last = m.parts[m.parts.length - 1] + return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] } + }) + hook.publish(rid, { ...prev, messages }) + } + pushed += 1 + if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs}) + else window.__RC__.done = true + } + window.__RC__.timer = setTimeout(tick, ${intervalMs}) + return 'driving' + })() +` + +/** Wait until the renderer stops committing on its own, so the recording window + * captures STREAMING cost and not whatever boot/hydration work happened to + * still be in flight. Returns `quiet:N` once commits hold still for `quietMs`. + * + * If it returns `timeout:...` the app never went idle at all — with tiles + * marked busy and NO driver running, that means something is ticking on its + * own. The report of what rendered during the wait is attached so the culprit + * is named rather than guessed at. */ +const quiesce = (quietMs, timeoutMs) => ` + (async () => { + const rc = window.__RENDER_COUNTS__ + rc.start() + const deadline = Date.now() + ${timeoutMs} + const startedAt = Date.now() + let last = -1 + let stableSince = Date.now() + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)) + const n = rc.commits() + if (n !== last) { last = n; stableSince = Date.now(); continue } + if (Date.now() - stableSince >= ${quietMs}) { rc.stop(); return 'quiet:' + n } + } + const idle = { + commits: last, + seconds: (Date.now() - startedAt) / 1000, + top: rc.report(8), + // Who OWNS the update? The component whose own hook state changed with + // no changed props is the root of a churn cascade; everything under it + // is collateral. Naming it is the difference between fixing the cause + // and memoizing a symptom. + owners: rc.report(200).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 8) + } + rc.stop() + return 'timeout:' + JSON.stringify(idle) + })() +` + +const START = ` + (() => { + window.__RENDER_COUNTS__.start() + window.__ATOM_CHURN__.start() + return 'recording' + })() +` + +const COLLECT = ` + (() => { + window.__RENDER_COUNTS__.stop() + window.__ATOM_CHURN__.stop() + return JSON.stringify({ + commits: window.__RENDER_COUNTS__.commits(), + renders: window.__RENDER_COUNTS__.report(200), + atoms: window.__ATOM_CHURN__.report(200) + }) + })() +` + +const CLEANUP = ` + (() => { + if (window.__RC__) { + clearTimeout(window.__RC__.timer) + for (const { sid, rid } of window.__RC__.ids) { + const states = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__RC__ = null + } + window.__RENDER_COUNTS__.clear() + window.__ATOM_CHURN__.clear() + return 'cleaned' + })() +` + +export default { + name: 'render-churn', + tier: 'ci', + description: 'N streaming tabs: per-component render attribution + store churn.', + async run(cdp, opts = {}) { + const tiles = Number(opts.tiles ?? 5) + const seedTurns = Number(opts.turns ?? 20) + const tokens = Number(opts.tokens ?? 240) + // Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush. + const intervalMs = Number(opts.intervalMs ?? 33) + const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n' + + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup(tiles, seedTurns)) + + if (ok !== 'ok') { + throw new Error( + `render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` + + '(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).' + ) + } + + // Mount every tab (keep-alive mounts on first activation), then settle. + for (let n = 1; n <= tiles; n++) { + await cdp.eval(reveal(`churn-tile-${n}`)) + await sleep(350) + } + + // Let the app go quiet before recording, so boot/hydration commits that + // happen to still be in flight don't land in the streaming window. This is + // what makes runs comparable — a fixed sleep let 2-4x of hydration churn + // leak in depending on machine load. + const settle = await cdp.eval(quiesce(600, 15000)) + await cdp.eval(START) + await cdp.eval(drive(chunk, intervalMs, tokens)) + await sleep(tokens * intervalMs + 1500) + + const data = JSON.parse(await cdp.eval(COLLECT)) + await cdp.eval(CLEANUP) + + const byName = new Map(data.renders.map(r => [r.name, r])) + const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean) + const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0) + const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0) + const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0) + const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0) + const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0) + + return { + metrics: { + // The hypothesis, as a number: sidebar renders while background tabs + // stream. Should be 0. + sidebar_renders: sidebarRenders, + sidebar_wasted: sidebarWasted, + // Renders with no changed props and no changed hook state — pure + // parent-driven work, across the whole tree. + wasted_renders: totalWasted, + total_renders: totalRenders, + commits: data.commits, + // Store notifications that published a value equal to the last one. + wasted_notifies: atomWasted + }, + detail: { + tiles, + tokens, + // 'quiet:N' = the app went idle before recording (comparable run). + // 'timeout:N' = it never did, so boot churn is mixed into the numbers. + settle, + sidebar: sidebarRows, + topRenders: data.renders.slice(0, 15), + topAtoms: data.atoms.slice(0, 15) + } + } + } +} diff --git a/apps/desktop/scripts/perf/scenarios/stream-history.mjs b/apps/desktop/scripts/perf/scenarios/stream-history.mjs new file mode 100644 index 00000000000..d807f3f3573 --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/stream-history.mjs @@ -0,0 +1,18 @@ +// Streaming into an ALREADY-LONG transcript. Same measurement as `stream`, but +// the history is mounted and allowed to settle before the recorders start, so +// what it captures is the per-delta cost that scales with transcript length — +// the regression reported in #69120. +// +// Report-only (tier: manual): the number depends on how much history the host +// can mount, so it is not gated against the committed baseline. + +import stream from './stream.mjs' + +export default { + name: 'stream-history', + tier: 'manual', + description: 'Streaming cost with a long settled transcript already mounted.', + run(cdp, opts = {}) { + return stream.run(cdp, { ...opts, historyTurns: Number(opts.historyTurns ?? 200) }) + } +} diff --git a/apps/desktop/scripts/perf/scenarios/stream.mjs b/apps/desktop/scripts/perf/scenarios/stream.mjs index 66ae3ca58d5..f32a4831b1e 100644 --- a/apps/desktop/scripts/perf/scenarios/stream.mjs +++ b/apps/desktop/scripts/perf/scenarios/stream.mjs @@ -70,7 +70,7 @@ const COLLECT = ` })() ` -function analyze(data, warmupMs) { +function analyze(data, warmupMs, extra = {}) { // Drop warm-up frames (recorder installs before the stream starts). const frames = [] let acc = 0 @@ -102,6 +102,7 @@ function analyze(data, warmupMs) { intermut_p95_ms: Math.round(percentile(interMut, 0.95) * 10) / 10 }, detail: { + ...extra, windowS: Math.round(windowS * 10) / 10, avgFps: windowS ? Math.round((frames.length / windowS) * 10) / 10 : 0, frameHistogram: frameHistogram(frames), @@ -128,13 +129,36 @@ export default { // noise unrelated to render cost). const chunk = opts.chunk ?? 'A streamed sentence with **bold**, `code`, and ordinary prose like a normal reply.\n\n' const real = Boolean(opts.real) + const historyTurns = Number(opts.historyTurns ?? 0) + const historySettleMs = Number(opts.historySettleMs ?? 1500) await cdp.send('Runtime.enable') + + // Mount the settled history BEFORE the recorders start, so the measurement + // window contains only streaming work — not the one-off mount cost. + if (historyTurns > 0) { + if (real) { + throw new Error('--historyTurns is only supported by the synthetic stream path') + } + + await cdp.eval(`window.__PERF_DRIVE__.loadTranscript(${historyTurns})`) + await sleep(historySettleMs) + + const mounted = Number(await cdp.eval('window.__PERF_DRIVE__.snapshotMsgs()')) + const expected = historyTurns * 2 + + if (mounted !== expected) { + throw new Error(`expected ${expected} preloaded history messages, got ${mounted}`) + } + } + await cdp.eval(RECORDERS) if (real) { // Backend path: fire a real prompt and wait for the stream to appear. - const baseCount = await cdp.eval(`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`) + const baseCount = await cdp.eval( + `document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length` + ) await typeIntoComposer(cdp, opts.prompt ?? 'count from 1 to 80, one number per line', { cps: 40 }) await cdp.eval(`(() => { const el = document.querySelector(${JSON.stringify(SELECTORS.composer)}) @@ -186,6 +210,6 @@ export default { await cdp.eval('window.__PERF_DRIVE__.reset()') } - return analyze(data, real ? 0 : 500) + return analyze(data, real ? 0 : 500, { historyTurns }) } } diff --git a/apps/desktop/scripts/probe-model-picker.mjs b/apps/desktop/scripts/probe-model-picker.mjs new file mode 100644 index 00000000000..d5585a36835 --- /dev/null +++ b/apps/desktop/scripts/probe-model-picker.mjs @@ -0,0 +1,80 @@ +// Model picker open latency probe: click the composer model pill, time until +// the dropdown/dialog content paints, repeat. Run with --cpuprofile via the +// harness runner once promoted; standalone for iteration. +// node scripts/perf/probe-model-picker.mjs [--port 9222] [--rounds 5] +import { CDP } from './perf/lib/cdp.mjs' + +const args = process.argv.slice(2) +const flag = name => { + const i = args.indexOf(`--${name}`) + return i >= 0 ? args[i + 1] : undefined +} +const port = Number(flag('port') ?? 9222) +const rounds = Number(flag('rounds') ?? 5) +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +const cdp = await CDP.connect({ port }) +await cdp.send('Runtime.enable') + +// Find the pill (dropdown path) — the composer model selector button. +const PILL = `(() => { + const btns = [...document.querySelectorAll('button[aria-label]')] + const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '') && b.closest('[data-slot]')) + return pill ? (pill.getAttribute('aria-label') || 'found') : null +})()` + +console.log('pill:', await cdp.eval(PILL)) + +const MEASURE = ` + (async () => { + const btns = [...document.querySelectorAll('button[aria-label]')] + const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '')) + if (!pill) return JSON.stringify({ error: 'no pill' }) + + const t0 = performance.now() + pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + pill.click() + + // Wait for menu/dialog content to exist AND paint (double rAF after found). + const found = await new Promise(resolve => { + const deadline = performance.now() + 5000 + const check = () => { + const menu = document.querySelector('[role="menu"], [role="dialog"] [cmdk-list]') + if (menu && menu.childElementCount > 0) { + requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now()))) + return + } + if (performance.now() > deadline) { resolve(null); return } + requestAnimationFrame(check) + } + check() + }) + + const openMs = found ? found - t0 : null + const rows = document.querySelectorAll('[role="menu"] [role="menuitem"], [cmdk-item]').length + + // Close: Escape. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + const menu = document.querySelector('[role="menu"], [role="dialog"]') + menu?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await new Promise(r => setTimeout(r, 300)) + + return JSON.stringify({ openMs, rows }) + })() +` + +const samples = [] + +for (let i = 0; i < rounds; i++) { + const raw = await cdp.eval(MEASURE, { awaitPromise: true }) + const r = JSON.parse(raw) + console.log(`round ${i}:`, r) + if (typeof r.openMs === 'number') samples.push(r.openMs) + await sleep(500) +} + +samples.sort((a, b) => a - b) +console.log('\nopen latency ms — min/median/max:', + Math.round(samples[0]), '/', Math.round(samples[Math.floor(samples.length / 2)]), '/', Math.round(samples.at(-1))) +cdp.close() diff --git a/apps/desktop/scripts/profile-model-picker.mjs b/apps/desktop/scripts/profile-model-picker.mjs new file mode 100644 index 00000000000..26e16d6e7c9 --- /dev/null +++ b/apps/desktop/scripts/profile-model-picker.mjs @@ -0,0 +1,60 @@ +// CPU-profile one model-picker open. +// node scripts/profile-model-picker.mjs [--port 9222] +import { writeFileSync } from 'node:fs' + +import { CDP } from './perf/lib/cdp.mjs' +import { cpuProfileTopSelf } from './perf/lib/stats.mjs' + +const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222) +const cdp = await CDP.connect({ port }) +await cdp.send('Runtime.enable') +await cdp.send('Profiler.enable') +await cdp.send('Profiler.setSamplingInterval', { interval: 100 }) + +const OPEN = ` + (async () => { + // Reset: close any open menu first. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await new Promise(r => setTimeout(r, 250)) + + const btns = [...document.querySelectorAll('button[aria-label]')] + const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '')) + if (!pill) return -1 + const t0 = performance.now() + pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + pill.click() + const found = await new Promise(resolve => { + const deadline = performance.now() + 8000 + const check = () => { + const menu = document.querySelector('[role="menu"]') + if (menu && menu.childElementCount > 0) { + requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now()))) + return + } + if (performance.now() > deadline) { resolve(-1); return } + requestAnimationFrame(check) + } + check() + }) + return found < 0 ? -1 : found - t0 + })() +` + +await cdp.send('Profiler.start') +const openMs = await cdp.eval(OPEN) +const { profile } = await cdp.send('Profiler.stop') + +console.log('openMs:', Math.round(openMs)) +const out = `/tmp/model-picker-open.cpuprofile` +writeFileSync(out, JSON.stringify(profile)) +console.log('wrote', out) +console.log('top self-time (ms):') + +for (const r of cpuProfileTopSelf(profile, 20)) { + console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(44)} ${r.url.split('/').slice(-2).join('/')}:${r.line}`) +} + +// Close the menu again. +await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`) +cdp.close() diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 098e59108ae..b5e15dd30cf 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -1,6 +1,7 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' +import { $artifactTabs } from '@/store/artifacts' import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' @@ -32,7 +33,7 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri // file tabs remain (the rail UI falls back to tabs[0]). Gating only on // `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look // broken with a file tab still on screen. - if ($previewTarget.get() || $filePreviewTabs.get().length > 0) { + if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) { return closeActiveRightRailTab() } diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 7939be35b6b..547a210f06f 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -50,6 +50,9 @@ export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind return 'command' } +/** True for a skill completion — the only kind offered mid-message. */ +export const isSkillItem = (item: Unstable_TriggerItem) => slashChipKindForItem(item) === 'skill' + /** A `/` query is at its arg stage once it's past the command name. */ export const slashArgStage = (query: string) => query.includes(' ') diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts new file mode 100644 index 00000000000..f3987982274 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { blurComposerInput } from './focus' +import { RICH_INPUT_SLOT } from './rich-editor' + +/** + * Inactive tabs keep their composer mounted, so an unscoped lookup can blur a + * background input and leave the one the user is typing in focused. + */ + +/** A composer input inside its own pane layer, hidden or not. */ +function mountInput(hidden = false) { + const layer = document.createElement('div') + const input = document.createElement('div') + input.dataset.slot = RICH_INPUT_SLOT + input.tabIndex = 0 + layer.toggleAttribute('data-pane-hidden', hidden) + layer.append(input) + document.body.append(layer) + + return input +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('blurComposerInput', () => { + it('blurs the foreground composer while a hidden tab matches first', () => { + const background = mountInput(true) + const foreground = mountInput() + + foreground.focus() + blurComposerInput() + + expect(document.activeElement).not.toBe(foreground) + expect(document.activeElement).not.toBe(background) + }) + + it('leaves focus alone when the composer does not hold it', () => { + const outside = document.createElement('button') + document.body.append(outside) + mountInput() + + outside.focus() + blurComposerInput() + + expect(document.activeElement).toBe(outside) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index a470edfcd95..7120fde2b1a 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,6 +10,8 @@ * steal focus from the composer effect. */ +import { queryVisible } from '@/components/pane-shell/pane-visibility' + import type { InlineRefInput } from './inline-refs' import { RICH_INPUT_SLOT } from './rich-editor' @@ -175,9 +177,11 @@ export const focusComposerInput = (el: HTMLElement | null) => { window.setTimeout(focus, 0) } -/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */ +/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). + * Skips inactive tabs — they stay mounted, so an unscoped lookup can land on a + * background composer and leave the visible one focused. */ export const blurComposerInput = () => { - const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null + const el = queryVisible(`[data-slot="${RICH_INPUT_SLOT}"]`) if (el && document.activeElement === el) { el.blur() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx new file mode 100644 index 00000000000..81401482f69 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx @@ -0,0 +1,130 @@ +import { act, cleanup, render } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { type ComposerAttachment, mainComposerScope, stashSessionDraft } from '@/store/composer' + +import type { QueueEditState } from '../composer-utils' + +import { useComposerDraft } from './use-composer-draft' + +const mockComposerApi = { setText: vi.fn() } + +vi.mock('@assistant-ui/react', () => ({ + useAui: () => ({ composer: () => mockComposerApi }), + useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }), + useComposerRuntime: () => ({ + getState: () => ({ text: '' }), + subscribe: () => () => undefined + }) +})) + +interface ProbeHarnessProps { + activeQueueSessionKey: string | null + onLayoutSnapshot: (attachments: ComposerAttachment[]) => void + sessionId: string +} + +function ProbeHarness({ activeQueueSessionKey, onLayoutSnapshot, sessionId }: ProbeHarnessProps) { + useComposerDraft({ + activeQueueSessionKey, + focusKey: null, + inputDisabled: false, + queueEditRef: { current: null as QueueEditState | null }, + sessionId + }) + + // useLayoutEffect fires synchronously right after the DOM commit, BEFORE + // the hook's per-thread scope-swap useEffect (a passive effect) has a + // chance to swap attachmentScope.$attachments over to the new session. A + // synchronous read here — the same read ChatBar's `attachments` prop + // performs at render time — observes the OUTGOING session's attachments. + useLayoutEffect(() => { + onLayoutSnapshot(mainComposerScope.$attachments.get()) + }) + + return null +} + +describe('useComposerDraft — attachment scope stays coherent with the committed session on switch (#59305)', () => { + afterEach(() => { + cleanup() + mainComposerScope.clear() + }) + + it('clears the outgoing session attachments by the layout phase right after switching sessions', () => { + const attachmentA: ComposerAttachment = { id: 'url-A', kind: 'url', label: 'A' } + stashSessionDraft('session-A', 'hi from A', [attachmentA]) + + const snapshots: ComposerAttachment[][] = [] + + const { rerender } = render( + snapshots.push(s)} sessionId="session-A" /> + ) + + // Mount loads session A's stashed attachment into the (module-level) main + // scope — confirms the fixture actually seeded the leak precondition. + expect(mainComposerScope.$attachments.get()).toEqual([attachmentA]) + + snapshots.length = 0 // drop the initial-mount snapshot; only the switch matters + + act(() => { + rerender( + snapshots.push(s)} + sessionId="session-B" + /> + ) + }) + + // By the layout phase the scope must already be B's (empty) — a submit + // fired the instant B renders must never ship session A's attachment. + expect(snapshots[0]).toEqual([]) + }) +}) + +describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => { + afterEach(() => { + cleanup() + mainComposerScope.clear() + vi.restoreAllMocks() + }) + + it('logs counts/kinds/scope on restore but never the raw url, refText, or label', () => { + const secretUrl = 'https://secret.example.com/private-workspace-path' + + const attachment: ComposerAttachment = { + id: 'url-secret', + kind: 'url', + label: 'do-not-leak-label', + refText: `@url:${secretUrl}` + } + + stashSessionDraft('session-secret', '', [attachment]) + + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) + + render( + undefined} + sessionId="session-secret" + /> + ) + + const rehydrateCalls = debugSpy.mock.calls.filter(call => call[0] === '[composer-rehydrate]') + expect(rehydrateCalls.length).toBeGreaterThan(0) + + const serialized = JSON.stringify(rehydrateCalls) + expect(serialized).not.toContain(secretUrl) + expect(serialized).not.toContain(attachment.label) + expect(serialized).not.toContain(attachment.refText) + + expect(rehydrateCalls[0]?.[1]).toMatchObject({ + attachmentCount: 1, + attachmentKinds: ['url'], + scope: 'session-secret' + }) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 11dc9534df0..43b8514cc35 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -1,5 +1,5 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' -import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' @@ -20,7 +20,7 @@ import { onComposerInsertRequest } from '../focus' import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs' -import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor' +import { composerPlainText, placeCaretEnd, REF_RE, renderComposerContents } from '../rich-editor' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -189,6 +189,21 @@ export function useComposerDraft({ stashSessionDraft(scope, text, attachments) const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { + // Diagnostic breadcrumb for #59305-class reports: identifies WHAT kind of + // state got restored into the composer (session switch, queue-edit + // restore, history browse) without logging any raw content. REF_RE has the + // global flag — testing against a throwaway clone avoids mutating the + // shared instance's lastIndex, which would otherwise corrupt this check on + // the next call. + if (attachments.length > 0 || new RegExp(REF_RE.source, REF_RE.flags).test(text)) { + console.debug('[composer-rehydrate]', { + attachmentCount: attachments.length, + attachmentKinds: attachments.map(a => a.kind), + hasTextRefs: new RegExp(REF_RE.source, REF_RE.flags).test(text), + scope: activeQueueSessionKeyRef.current + }) + } + attachmentScope.$attachments.set(cloneAttachments(attachments)) paintDraft(text, false) } @@ -231,6 +246,7 @@ export function useComposerDraft({ // source otherwise), and (3) schedule the debounced per-session stash. // Browsing history / editing a queued prompt suppress the stash so recalled // text never clobbers the draft. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const sync = () => { const text = composerRuntime.getState().text @@ -318,7 +334,17 @@ export function useComposerDraft({ // Per-thread draft swap — the composer's only session coupling. Lifecycle // never clears composer state; this effect alone stashes on leave, restores // on enter. Keyed writes are idempotent, so no skip-sentinel. - useEffect(() => { + // + // MUST be a layout effect, not a passive one: it swaps attachmentScope's + // module-level $attachments atom, and a passive effect fires only after the + // browser paints the new session's view — leaving a window where the DOM + // already shows session B while $attachments (and therefore ChatBar's + // `attachments` prop) still holds session A's chips. A submit fired in that + // window (e.g. a fast session switch immediately followed by Enter) would + // ship A's attachments into B's turn (#59305). useLayoutEffect closes the + // window by running before paint. + + useLayoutEffect(() => { // A pending debounce timer from the outgoing session is now stale — its // scope was correct when scheduled, but the authoritative stash below // (and the cleanup on the way out) already covers that text. Letting it @@ -344,6 +370,7 @@ export function useComposerDraft({ // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R // inside the debounce/rAF window would drop trailing keystrokes without this. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const flushPendingDraftPersist = () => { const scope = draftScopeRef.current @@ -381,6 +408,7 @@ export function useComposerDraft({ requestMainFocus, sessionIdRef, setComposerText, - stashAt + stashAt, + syncDraftFromEditor } } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index 318802bbab8..228758dd905 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -1,6 +1,12 @@ import { useAuiState } from '@assistant-ui/react' import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { + clearSurfaceVar, + COMPOSER_HEIGHT_VAR, + COMPOSER_SURFACE_HEIGHT_VAR, + setSurfaceVar +} from '@/app/chat/surface-vars' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { $composerPoppedOut } from '@/store/composer-popout' @@ -89,18 +95,16 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, // (Read globals here so the callback stays stable; mirror the popoutAllowed // gate since secondary windows are forced docked.) if ($composerPoppedOut.get() && !isSecondaryWindow()) { - const root = document.documentElement lastBucketedHeightRef.current = 0 lastBucketedSurfaceHeightRef.current = 0 - root.style.setProperty('--composer-measured-height', '0px') - root.style.setProperty('--composer-surface-measured-height', '0px') + setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px') + setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, '0px') return } const { height, width } = composer.getBoundingClientRect() const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height - const root = document.documentElement if (width > 0) { const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX @@ -135,7 +139,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, if (bucket !== lastBucketedHeightRef.current) { lastBucketedHeightRef.current = bucket - root.style.setProperty('--composer-measured-height', `${bucket}px`) + setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, `${bucket}px`) } } @@ -144,7 +148,7 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, if (bucket !== lastBucketedSurfaceHeightRef.current) { lastBucketedSurfaceHeightRef.current = bucket - root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) + setSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR, `${bucket}px`) } } }, [composerRef, composerSurfaceRef, editorRef]) @@ -160,12 +164,13 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, }, [poppedOut, syncComposerMetrics]) useEffect(() => { + const composer = composerRef.current + return () => { - const root = document.documentElement - root.style.removeProperty('--composer-measured-height') - root.style.removeProperty('--composer-surface-measured-height') + clearSurfaceVar(composer, COMPOSER_HEIGHT_VAR) + clearSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR) } - }, []) + }, [composerRef]) // Pill compacts on real width (tile/pane), OR when stacked for any reason // (viewport-narrow / wrapped) so the controls row never over-runs. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts index 0c2e4b61927..8d1bf8c1ee5 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts @@ -29,6 +29,7 @@ export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: Us const prevSessionIdRef = useRef(sessionId) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const prev = prevSessionIdRef.current prevSessionIdRef.current = sessionId diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index 4e813f548ae..dff3804bb69 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -322,6 +322,7 @@ export function useComposerQueue({ // never churns, so a change there is a real session switch and must NOT // migrate; only the runtime-derived key (queueSessionKey falsy → key is // sessionId) churns on a backend bounce/resume of the same conversation. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const prev = prevQueueKeyRef.current prevQueueKeyRef.current = activeQueueSessionKey diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index a09cd10ef29..b3bcaaa463e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -113,7 +113,9 @@ describe('useComposerSubmit busy-turn routing', () => { hook.result.current.submitDraft() }) - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/compress preserve context')) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('/compress preserve context', { composerScope: 'stored-session' }) + ) expect(clearDraft).toHaveBeenCalledTimes(1) expect(onSteer).not.toHaveBeenCalled() expect(queueCurrentDraft).not.toHaveBeenCalled() @@ -159,9 +161,26 @@ describe('useComposerSubmit busy-turn routing', () => { hook.result.current.submitDraft() }) - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ordinary question', { attachments: [] })) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('ordinary question', { + attachments: [], + composerScope: 'stored-session' + }) + ) expect(onSteer).not.toHaveBeenCalled() expect(queueCurrentDraft).not.toHaveBeenCalled() expect(onCancel).not.toHaveBeenCalled() }) + + it('threads the loaded composer scope through onSubmit for the #59305 submit-time guard', async () => { + const { hook, onSubmit } = renderSubmitHook({ text: 'hello' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('hello', expect.objectContaining({ composerScope: 'stored-session' })) + ) + }) }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index fb1cec2cf04..315157a96e5 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -89,7 +89,11 @@ export function useComposerSubmit({ stashAt(submittedScope, text, submittedAttachments) } - void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) + void Promise.resolve( + attachments + ? onSubmit(text, { attachments, composerScope: submittedScope }) + : onSubmit(text, { composerScope: submittedScope }) + ) .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) .catch(restore) } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts new file mode 100644 index 00000000000..c7a4e2f138a --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts @@ -0,0 +1,164 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { act, renderHook } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from '../rich-editor' + +import { useComposerTrigger } from './use-composer-trigger' + +/** A live contentEditable seeded with `text`, caret parked at the end. */ +function mountEditor(text: string) { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.contentEditable = 'true' + document.body.append(editor) + renderComposerContents(editor, text) + + const range = document.createRange() + range.selectNodeContents(editor) + range.collapse(false) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + return editor +} + +const item = (command: string, group = 'Skills'): Unstable_TriggerItem => ({ + id: command, + type: 'slash', + label: command.slice(1), + metadata: { command, display: command, meta: '', group, action: '', rawText: command } +}) + +function mountTrigger(editor: HTMLDivElement, items: Unstable_TriggerItem[]) { + const editorRef = createRef() as { current: HTMLDivElement | null } + editorRef.current = editor + + const draftRef = { current: composerPlainText(editor) } + + const adapter: Unstable_TriggerAdapter = { + categories: () => [], + categoryItems: () => [], + search: () => items + } + + const setComposerText = vi.fn() + + const hook = renderHook(() => + useComposerTrigger({ + at: { adapter: null, loading: false }, + draftRef, + editorRef, + requestMainFocus: vi.fn(), + setComposerText, + slash: { adapter, loading: false } + }) + ) + + return { draftRef, hook, setComposerText } +} + +describe('useComposerTrigger — slash anywhere in the prompt', () => { + it('opens the completion list for a slash typed mid-message', () => { + const editor = mountEditor('please run /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' }) + expect(hook.result.current.triggerItems).toHaveLength(1) + }) + + it('inserts the picked skill inline and keeps the surrounding prose intact', () => { + const editor = mountEditor('please run /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(item('/clean'))) + + // The `/cle` the user typed is replaced by the full command; "please run" + // in front of it survives untouched. + expect(composerPlainText(editor)).toBe('please run /clean ') + }) + + it('offers only skills mid-message, not app commands', () => { + // `/model` and `/new` act on the app — meaningless as a reference in prose. + const editor = mountEditor('please run /') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands'), item('/new', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean']) + }) + + it('still offers the full command set at the start of the prompt', () => { + const editor = mountEditor('/') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean', 'model']) + }) + + it('still opens the list for a slash at the start of the prompt', () => { + const editor = mountEditor('/cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toMatchObject({ kind: '/', query: 'cle' }) + expect(hook.result.current.trigger?.inline).toBeUndefined() + }) + + it('leaves a mid-message file path alone', () => { + const editor = mountEditor('open src/foo/bar') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toBeNull() + }) +}) + +describe('useComposerTrigger — free-text slash arguments', () => { + it('keeps a picked /goal command as editable text while retaining subcommand completion', () => { + const editor = mountEditor('/go') + const goal = item('/goal', 'Commands') + const { hook } = mountTrigger(editor, [goal]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(goal)) + + expect(composerPlainText(editor)).toBe('/goal ') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + expect(hook.result.current.trigger).not.toBeNull() + }) + + it('does not seal a multi-word /goal into a chip when the option list runs empty', () => { + const editor = mountEditor('/goal finish the full prompt') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(true) + expect(hook.result.current.commitTypedSlashDirective()).toBe(false) + expect(composerPlainText(editor)).toBe('/goal finish the full prompt') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + }) + + it('still commits a fully typed finite option as one directive chip', () => { + const editor = mountEditor('/personality creative') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(false) + act(() => { + expect(hook.result.current.commitTypedSlashDirective()).toBe(true) + }) + expect(composerPlainText(editor)).toBe('/personality creative ') + expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 20abc03309f..2df218e78f3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -2,9 +2,15 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' -import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' +import { desktopSlashCommandArgumentMode } from '@/lib/desktop-slash-commands' -import { COMPLETION_ACTIONS, slashArgStage, slashChipKindForItem, slashCommandToken } from '../composer-utils' +import { + COMPLETION_ACTIONS, + isSkillItem, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from '../composer-utils' import { composerPlainText, placeCaretEnd, @@ -83,11 +89,16 @@ export function useComposerTrigger({ const before = textBeforeCaret(editor) const found = detectTrigger(before ?? composerPlainText(editor)) - // The arg-stage popover is only useful for commands with an options screen. - // For a no-arg command it would dead-end on "No matches", so drop it — the - // directive is already complete. + // A text-only command has no completion screen once its prose begins. Mixed + // commands such as /goal stay live so their finite subcommands can still be + // suggested, while arbitrary goal text remains valid. + const argumentMode = + found?.kind === '/' && slashArgStage(found.query) + ? desktopSlashCommandArgumentMode(slashCommandToken(found.query)) + : null + const detected = - found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query)) + found?.kind === '/' && slashArgStage(found.query) && argumentMode !== 'options' && argumentMode !== 'mixed' ? null : found @@ -112,7 +123,13 @@ export function useComposerTrigger({ return } - setTriggerItems(triggerAdapter.search(trigger.query)) + const items = triggerAdapter.search(trigger.query) + + // Mid-message only offers SKILLS. A built-in like `/model` or `/new` acts + // on the app, so it's meaningless as a reference inside prose — only a + // skill reads as "handle this part with X". Filtering here rather than in + // the fetcher keeps one completion source for both shapes. + setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items) }, [trigger, triggerAdapter]) const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false @@ -122,6 +139,11 @@ export function useComposerTrigger({ // Space/Tab — neither should dead-end on a popover. const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length + const slashFreeTextArgStage = + trigger?.kind === '/' && + slashArgStage(trigger.query) && + ['mixed', 'text'].includes(desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) ?? '') + const closeTrigger = () => { setTrigger(null) setTriggerItems([]) @@ -136,9 +158,16 @@ export function useComposerTrigger({ // the completion list is empty because the arg is already fully typed (the // backend completer drops exact matches). Reuses the chip path via a // synthetic item whose serialized form is the verbatim text. - const commitTypedSlashDirective = () => { + const commitTypedSlashDirective = (): boolean => { if (trigger?.kind !== '/') { - return + return false + } + + // Free prose must stay ordinary contentEditable text. This guard also + // protects against a stale completion result reaching the keydown path + // before refreshTrigger has caught up with the latest DOM input. + if (desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) !== 'options') { + return false } const text = `/${trigger.query.trimEnd()}` @@ -156,6 +185,8 @@ export function useComposerTrigger({ rawText: text } }) + + return true } const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { @@ -191,17 +222,20 @@ export function useComposerTrigger({ // Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit // it — expand to its options step so the popover shows the inline list, just // as typing `/personality ` by hand would. A serialized value with a space is - // already an arg pick (`/personality alice`), so it commits normally. + // already an arg pick (`/personality alice`), so it commits normally. An + // inline (mid-message) pick never expands: it's a reference inside prose, so + // there's no command invocation for the args to belong to. const command = (item.metadata as { command?: string } | undefined)?.command ?? '' - const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) + const argumentMode = desktopSlashCommandArgumentMode(command) + const expandsToArgs = trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && argumentMode !== null const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) // No pill while expanding — the bare command stays plain text until an arg // is picked, at which point a single pill is emitted for the full command. const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null - const keepTriggerOpen = starter || expandsToArgs + const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text') const finish = () => { draftRef.current = composerPlainText(editor) @@ -273,6 +307,7 @@ export function useComposerTrigger({ refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, triggerItems, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx new file mode 100644 index 00000000000..bbffc396ecb --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx @@ -0,0 +1,193 @@ +import { render } from '@testing-library/react' +import { createRef, type RefObject } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerUndo } from './use-composer-undo' + +/** Mount the hook against a real contentEditable, exposing its API. */ +function mountUndo(editorRef: RefObject, onSync: () => string) { + const api: { current: ReturnType | null } = { current: null } + + const Harness = () => { + // Assigned during render on purpose: the tests drive the API imperatively + // right after mount, and this is a harness, not app state. + api.current = useComposerUndo({ editorRef, syncDraftFromEditor: onSync }) + + return null + } + + const view = render() + + return { api, view } +} + +function makeEditor(text: string) { + const editor = document.createElement('div') + editor.contentEditable = 'true' + // jsdom only focuses a contentEditable div when it's explicitly focusable; + // the real editor is reachable via the composer's focus bus. + editor.tabIndex = 0 + editor.append(document.createTextNode(text)) + document.body.append(editor) + + const ref = createRef() as RefObject + ref.current = editor + + return { editor, ref } +} + +const caretAtEnd = (editor: HTMLElement) => { + const range = document.createRange() + const selection = window.getSelection()! + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) +} + +describe('useComposerUndo', () => { + it('restores the pre-edit text, which is what a paste destroyed', () => { + const { editor, ref } = makeEditor('before') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + // Bank, then simulate the Range-based paste that Chromium never records. + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' PASTED')) + expect(editor.textContent).toBe('before PASTED') + + api.current!.undo() + expect(editor.textContent).toBe('before') + + api.current!.redo() + expect(editor.textContent).toBe('before PASTED') + + view.unmount() + editor.remove() + }) + + it('withUndoPoint banks only when the edit actually ran', () => { + const { editor, ref } = makeEditor('text') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + // A guard that declines must not consume an undo slot. + expect(api.current!.withUndoPoint(() => false)).toBe(false) + expect(api.current!.undo()).toBe(false) + + expect( + api.current!.withUndoPoint(() => { + editor.append(document.createTextNode('!')) + + return true + }) + ).toBe(true) + + api.current!.undo() + expect(editor.textContent).toBe('text') + + view.unmount() + editor.remove() + }) + + it('claims a native historyUndo aimed at the focused editor', () => { + const { editor, ref } = makeEditor('kept') + editor.focus() + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' extra')) + + // What Electron's Edit menu `{ role: 'undo' }` produces. + const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' }) + editor.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(editor.textContent).toBe('kept') + + view.unmount() + editor.remove() + }) + + it('ignores a historyUndo while another editor holds focus', () => { + const { editor, ref } = makeEditor('mine') + const { editor: other } = makeEditor('theirs') + + other.focus() + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' changed')) + + const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' }) + other.dispatchEvent(event) + + // Not ours to claim — the other surface keeps its native behavior. + expect(event.defaultPrevented).toBe(false) + expect(editor.textContent).toBe('mine changed') + + view.unmount() + editor.remove() + other.remove() + }) + + it('keeps two mounted composers independent', () => { + const { editor: main, ref: mainRef } = makeEditor('main') + const { editor: edit, ref: editRef } = makeEditor('edit') + + const mainUndo = mountUndo(mainRef, () => main.textContent || '') + const editUndo = mountUndo(editRef, () => edit.textContent || '') + + mainUndo.api.current!.recordUndoPoint() + main.append(document.createTextNode(' typed')) + + // Undoing in the edit composer must not touch the main composer's text. + editUndo.api.current!.undo() + expect(main.textContent).toBe('main typed') + + mainUndo.api.current!.undo() + expect(main.textContent).toBe('main') + expect(edit.textContent).toBe('edit') + + mainUndo.view.unmount() + editUndo.view.unmount() + main.remove() + edit.remove() + }) + + it('reset drops history so undo cannot cross a draft swap', () => { + const { editor, ref } = makeEditor('session A') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' edited')) + api.current!.resetUndoHistory() + + expect(api.current!.undo()).toBe(false) + expect(editor.textContent).toBe('session A edited') + + view.unmount() + editor.remove() + }) + + it('is inert when the editor ref is empty', () => { + const ref = createRef() as RefObject + const sync = vi.fn(() => '') + + const { api, view } = mountUndo(ref, sync) + + api.current!.recordUndoPoint() + + expect(api.current!.undo()).toBe(false) + expect(sync).not.toHaveBeenCalled() + + view.unmount() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts new file mode 100644 index 00000000000..72f891f6dac --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts @@ -0,0 +1,119 @@ +import { type RefObject, useCallback, useEffect, useMemo } from 'react' + +import { caretOffsetInEditor, composerPlainText, placeCaretAtOffset, renderComposerContents } from '../rich-editor' +import { type ComposerSnapshot, createComposerUndoHistory } from '../undo-history' + +interface UseComposerUndoArgs { + editorRef: RefObject + /** Push a restored snapshot back into draftRef + composer state. */ + syncDraftFromEditor: () => string +} + +/** + * Undo/redo for the rich composer. + * + * The editor mutates its DOM through `Range` to dodge Chromium's O(n²) editing + * pipeline (#45812), which also dodges Chromium's undo stack — so a paste was + * invisible to ⌘Z and the keystroke undid whatever edit came before it instead. + * We own the stack outright rather than half of it: every edit path records the + * pre-edit state here, and the editor claims ⌘Z / ⌘⇧Z itself. + */ +export function useComposerUndo({ editorRef, syncDraftFromEditor }: UseComposerUndoArgs) { + const history = useMemo(() => createComposerUndoHistory(), []) + + const snapshot = useCallback((): ComposerSnapshot => { + const editor = editorRef.current + + if (!editor) { + return { caret: 0, text: '' } + } + + return { caret: caretOffsetInEditor(editor), text: composerPlainText(editor) } + }, [editorRef]) + + /** Bank the current state before mutating the editor. `coalesce` marks a + * keystroke, so a run of typing collapses into one undo step. */ + const recordUndoPoint = useCallback( + (options?: { coalesce?: boolean }) => { + if (editorRef.current) { + history.record(snapshot(), options) + } + }, + [editorRef, history, snapshot] + ) + + const applySnapshot = useCallback( + (next: ComposerSnapshot | null) => { + const editor = editorRef.current + + if (!next || !editor) { + return false + } + + renderComposerContents(editor, next.text) + placeCaretAtOffset(editor, next.caret) + syncDraftFromEditor() + + return true + }, + [editorRef, syncDraftFromEditor] + ) + + /** Run a conditional edit, banking its pre-edit state only if it actually + * ran. The snapshot has to be taken first (the edit destroys the state we'd + * be saving), but recording unconditionally would clear the redo stack on + * every Backspace that falls through to the native path. */ + const withUndoPoint = useCallback( + (edit: () => boolean) => { + const before = snapshot() + const ran = edit() + + if (ran) { + history.record(before) + } + + return ran + }, + [history, snapshot] + ) + + const undo = useCallback(() => applySnapshot(history.undo(snapshot())), [applySnapshot, history, snapshot]) + const redo = useCallback(() => applySnapshot(history.redo(snapshot())), [applySnapshot, history, snapshot]) + + // A session/draft swap makes prior history meaningless — undoing into another + // conversation's text is worse than having no history at all. + const resetUndoHistory = useCallback(() => history.reset(), [history]) + + // Electron's Edit menu ships `{ role: 'undo' }`, whose accelerator the macOS + // menu bar consumes before the web contents sees the keystroke (the same + // hazard main.ts documents for ⌘W). It fires the native editing command, + // which knows nothing about our stack. Claim it at the document level while + // the composer holds focus, so the menu item and the keystroke agree. + useEffect(() => { + const onBeforeInput = (event: Event) => { + const inputType = (event as InputEvent).inputType + + if (inputType !== 'historyUndo' && inputType !== 'historyRedo') { + return + } + + if (document.activeElement !== editorRef.current) { + return + } + + event.preventDefault() + + if (inputType === 'historyUndo') { + undo() + } else { + redo() + } + } + + document.addEventListener('beforeinput', onBeforeInput, true) + + return () => document.removeEventListener('beforeinput', onBeforeInput, true) + }, [editorRef, redo, undo]) + + return { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts index 6da699b602a..32301cc8294 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -49,6 +49,7 @@ export function useLiveCompletionAdapter(options: { useEffect(() => () => cancelTimer(), [cancelTimer]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (enabled) { return diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 0b71507bfd1..79e1cc067da 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -231,6 +231,7 @@ export function useComposerPopoutGestures({ [clearTimer, poppedOut] ) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { // Coalesce drag updates to one per frame — pointermove can fire several times // between paints on high-Hz mice, and each update re-renders + clamps. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 2ec43c83c0b..33285f05aa1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -60,18 +60,22 @@ export function useVoiceConversation({ const statusRef = useRef('idle') const wasEnabledRef = useRef(enabled) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { enabledRef.current = enabled }, [enabled]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { mutedRef.current = muted }, [muted]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { busyRef.current = busy }, [busy]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { statusRef.current = status }, [status]) @@ -508,6 +512,7 @@ export function useVoiceConversation({ // Drive the loop: when a voice-submitted reply appears, open a live speech // session (which feeds itself from then on). Otherwise start listening when // idle between turns. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!enabled || muted) { return @@ -542,6 +547,7 @@ export function useVoiceConversation({ } }, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (enabled && !wasEnabledRef.current) { void start() diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1ce110f4e09..b5a76f34904 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -40,6 +40,7 @@ import { useComposerPopout } from './hooks/use-composer-popout' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' import { useComposerTrigger } from './hooks/use-composer-trigger' +import { useComposerUndo } from './hooks/use-composer-undo' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' import { useSlashCompletions } from './hooks/use-slash-completions' @@ -49,7 +50,7 @@ import { composerPlainText, deleteChipBeforeCaret, deleteSelectionInEditor, - insertPlainTextAtCaret, + insertComposerContentsAtCaret, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor' @@ -59,7 +60,9 @@ import { CodingStatusRow } from './status-stack/coding-row' import { extractClipboardImageBlobs } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' +import { isRedoShortcut, isUndoShortcut } from './undo-history' import { UrlDialog } from './url-dialog' +import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' export function ChatBar({ @@ -172,9 +175,24 @@ export function ChatBar({ requestMainFocus, sessionIdRef, setComposerText, - stashAt + stashAt, + syncDraftFromEditor } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + // Undo/redo. The rich editor bypasses Chromium's editing pipeline for speed, + // which also bypasses its undo stack — so we own the stack and every edit + // path below banks its pre-edit state through `recordUndoPoint`. + const { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } = useComposerUndo({ + editorRef, + syncDraftFromEditor + }) + + // Prior history belongs to the draft that just left — undoing into another + // conversation's text is worse than having none. + useEffect(() => { + resetUndoHistory() + }, [activeQueueSessionKey, resetUndoHistory]) + // "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or // an @url: directive into the draft). const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({ @@ -286,6 +304,7 @@ export function ChatBar({ refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, triggerItems, @@ -356,6 +375,23 @@ export function ChatBar({ scheduleFlushEditorToDraft(event.currentTarget) } + // Native typing/deleting mutates the DOM through Chromium's editing pipeline, + // whose undo stack we've taken over — so bank the pre-edit state here, before + // the change lands. `beforeinput` is the only hook that still sees the old + // text. Consecutive keystrokes coalesce into one entry, so ⌘Z steps back by a + // burst rather than a character. + const handleEditorBeforeInput = (event: FormEvent) => { + const inputType = (event.nativeEvent as InputEvent).inputType + + // Undo/redo are ours (handled in useComposerUndo + keydown), and IME preedit + // is not a committed edit — compositionend is where that text becomes real. + if (inputType === 'historyUndo' || inputType === 'historyRedo' || composingRef.current) { + return + } + + recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' }) + } + const handlePaste = (event: ClipboardEvent) => { const imageBlobs = extractClipboardImageBlobs(event.clipboardData) @@ -402,7 +438,12 @@ export function ChatBar({ } event.preventDefault() - insertPlainTextAtCaret(event.currentTarget, pastedText) + + // Links in the paste land as `@url:` chips rather than a wall of URL text — + // the same reference the "Add URL" dialog inserts, parsed in place so a link + // mid-sentence keeps its position. + recordUndoPoint() + insertComposerContentsAtCaret(event.currentTarget, linkifyUrls(pastedText)) scheduleFlushEditorToDraft(event.currentTarget) } @@ -416,6 +457,23 @@ export function ChatBar({ return } + // Undo/redo before anything else — we own the stack (see useComposerUndo), + // so these never reach Chromium's native history, which has no record of + // the Range-based edits the rich editor makes. + if (isUndoShortcut(event.nativeEvent)) { + event.preventDefault() + undo() + + return + } + + if (isRedoShortcut(event.nativeEvent)) { + event.preventDefault() + redo() + + return + } + // Plain Backspace right after a directive chip: remove the chip + its // auto-inserted trailing space as one unit, so deleting a directive never // leaves an orphaned space. (Modified backspaces stay native.) @@ -424,7 +482,7 @@ export function ChatBar({ !event.metaKey && !event.ctrlKey && !event.altKey && - deleteChipBeforeCaret(event.currentTarget) + withUndoPoint(() => deleteChipBeforeCaret(event.currentTarget)) ) { event.preventDefault() flushEditorToDraft(event.currentTarget) @@ -434,7 +492,19 @@ export function ChatBar({ // Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large // drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through. - if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) { + if ( + (event.key === 'Backspace' || event.key === 'Delete') && + withUndoPoint(() => deleteSelectionInEditor(event.currentTarget)) + ) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + + // A typed link finished with a space chips like a pasted one — the space + // itself rides along inside the insert. + if (withUndoPoint(() => chipTypedUrlOnSpace(event))) { event.preventDefault() flushEditorToDraft(event.currentTarget) @@ -475,7 +545,9 @@ export function ChatBar({ // options step, and an arg option commits the full `/cmd arg` chip. Space // is slash-only (an `@` mention takes a literal space) and gated to a // non-empty query so a bare `/ ` still types a space. - const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) + const acceptOnSpace = + event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) && !slashFreeTextArgStage + const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace if (accept) { @@ -510,11 +582,12 @@ export function ChatBar({ slashArgStage(trigger.query) && trigger.query.trim() ) { - event.preventDefault() - triggerKeyConsumedRef.current = true - commitTypedSlashDirective() + if (commitTypedSlashDirective()) { + event.preventDefault() + triggerKeyConsumedRef.current = true - return + return + } } // ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in @@ -777,6 +850,7 @@ export function ChatBar({ contentEditable={!inputDisabled} data-placeholder={placeholder} data-slot={RICH_INPUT_SLOT} + onBeforeInput={handleEditorBeforeInput} onBlur={() => window.setTimeout(closeTrigger, 80)} onCompositionEnd={event => { composingRef.current = false diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index 5fd62f4cc94..5e282f6a040 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -4,7 +4,13 @@ import { contextPath } from '@/lib/chat-runtime' import type { DroppedFile } from '../hooks/use-composer-actions' -import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor' +import { + composerPlainText, + normalizeComposerEditorDom, + placeCaretEnd, + refChipElement, + RICH_INPUT_SLOT +} from './rich-editor' /** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ export type InlineRefInput = string | { kind: string; label?: string; value: string } @@ -92,7 +98,12 @@ function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | slice.setStart(range.endContainer, range.endOffset) } + // Carry the editor's slot marker: composerPlainText appends a trailing "\n" + // to any other block element, so a bare
made `beforeText` always look + // like it ended in whitespace and the separating space was never inserted — + // a chip dropped after a word came out glued to it (`review@file:...`). const container = document.createElement('div') + container.dataset.slot = RICH_INPUT_SLOT container.appendChild(slice.cloneContents()) return composerPlainText(container) diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index b5a6e976805..2a26db4094c 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -11,7 +11,7 @@ import { useI18n } from '@/i18n' import { ChevronDown } from '@/lib/icons' import { formatModelStatusLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' -import { $currentModelSource, setModelPickerOpen } from '@/store/session' +import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session' import type { ChatBarState } from './types' @@ -48,6 +48,7 @@ export function ModelPill({ const fastMode = useStore(view.$fast) const reasoningEffort = useStore(view.$reasoningEffort) const modelSource = useStore($currentModelSource) + const defaultEffort = useStore($defaultReasoningEffort) const runtimeId = useStore(view.$runtimeId) const [open, setOpen] = useState(false) @@ -68,7 +69,9 @@ export function ModelPill({ ) : ( <> {currentModel.trim() ? ( - {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + + {formatModelStatusLabel(currentModel, { defaultEffort, fastMode, reasoningEffort })} + ) : ( )} diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index 12e3e9613ef..842765388ff 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -4,10 +4,11 @@ import { insertInlineRefsIntoEditor } from './inline-refs' import { composerPlainText, deleteSelectionInEditor, - insertPlainTextAtCaret, + insertComposerContentsAtCaret, normalizeComposerEditorDom, refChipElement, renderComposerContents, + replaceBeforeCaret, RICH_INPUT_SLOT } from './rich-editor' @@ -70,16 +71,40 @@ describe('insertInlineRefsIntoEditor', () => { expect(editor.querySelector(':scope > div')).toBeNull() expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') }) + + it('separates a chip from the word the caret sits after', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(document.createTextNode('review')) + document.body.append(editor) + caretIn(editor) + + expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') + + editor.remove() + }) + + it('does not double the space when one is already there', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(document.createTextNode('review ')) + document.body.append(editor) + caretIn(editor) + + expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') + + editor.remove() + }) }) -describe('insertPlainTextAtCaret', () => { +describe('insertComposerContentsAtCaret', () => { it('inserts multiline text as text nodes + br', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) caretIn(editor) - insertPlainTextAtCaret(editor, 'one\ntwo\nthree') + insertComposerContentsAtCaret(editor, 'one\ntwo\nthree') expect(editor.querySelectorAll('br').length).toBe(2) expect(composerPlainText(editor)).toBe('one\ntwo\nthree') @@ -102,12 +127,76 @@ describe('insertPlainTextAtCaret', () => { selection.removeAllRanges() selection.addRange(range) - insertPlainTextAtCaret(editor, 'cd') + insertComposerContentsAtCaret(editor, 'cd') expect(composerPlainText(editor)).toBe('abcdef') editor.remove() }) + + it('lands directives in the text as chips', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now') + + expect(editor.querySelectorAll('[data-ref-kind="url"]').length).toBe(1) + expect(composerPlainText(editor)).toBe('read @url:`https://example.dev/a` now') + + editor.remove() + }) +}) + +describe('replaceBeforeCaret', () => { + it('swaps the token before the caret and leaves the caret after the insert', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'see foo' + document.body.append(editor) + + const text = editor.firstChild! + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(text, 7) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + const fragment = document.createDocumentFragment() + fragment.append(refChipElement('file', '`src/foo.ts`'), document.createTextNode(' ')) + + expect(replaceBeforeCaret(editor, 3, fragment)).toBe(true) + expect(composerPlainText(editor)).toBe('see @file:`src/foo.ts` ') + expect(selection.getRangeAt(0).collapsed).toBe(true) + + editor.remove() + }) + + it('leaves the editor alone when the caret has no room for the token', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'hi' + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, 2) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + const fragment = document.createDocumentFragment() + fragment.append(document.createTextNode('x')) + + expect(replaceBeforeCaret(editor, 20, fragment)).toBe(false) + expect(composerPlainText(editor)).toBe('hi') + + editor.remove() + }) }) describe('deleteSelectionInEditor', () => { diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 71491b87496..7bf770eda17 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -11,6 +11,7 @@ import { directiveIconElement, directiveIconSvg, formatRefValue, + refChipLabel, slashChipClass, type SlashChipKind, slashIconElement @@ -34,10 +35,6 @@ export function unquoteRef(raw: string) { return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '') } -export function refLabel(id: string) { - return id.split(/[\\/]/).filter(Boolean).pop() || id -} - /** Always-quote variant of formatRefValue — chips need a fence even for safe values. */ export function quoteRefValue(value: string) { if (!value.includes('`')) { @@ -59,7 +56,9 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` - return `${directiveIconSvg(kind)}${escapeHtml(displayLabel || refLabel(id))}` + const label = displayLabel || refChipLabel(kind, id) + + return `${directiveIconSvg(kind)}${escapeHtml(label)}` } export function refChipElement(kind: string, rawValue: string, displayLabel?: string) { @@ -69,12 +68,13 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st const label = document.createElement('span') chip.contentEditable = 'false' + chip.title = id chip.dataset.refText = text chip.dataset.refId = id chip.dataset.refKind = kind chip.className = DIRECTIVE_CHIP_CLASS label.className = 'truncate' - label.textContent = displayLabel || refLabel(id) + label.textContent = displayLabel || refChipLabel(kind, id) chip.append(directiveIconElement(kind), label) return chip @@ -144,14 +144,15 @@ function composerSelectionRange(editor: HTMLElement) { return { range, selection } } -/** Insert plain text at the caret (replacing any selection). Pastes use this - * instead of `execCommand('insertText')` — Chromium's editing pipeline is - * ~O(n²) on large multiline blobs. */ -export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { +/** Insert text at the caret (replacing any selection), with any `@kind:value` + * directives in it landing as chips. Pastes use this instead of + * `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large + * multiline blobs. */ +export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) { const hit = composerSelectionRange(editor) const fragment = document.createDocumentFragment() - appendTextWithBreaks(fragment, text) + appendComposerContents(fragment, text) const tail = fragment.lastChild @@ -172,6 +173,41 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { } } +/** Swap the `length` characters immediately before a collapsed caret for + * `fragment`, leaving the caret after it. Returns whether it ran — a caret that + * isn't inside a text node holding the whole token is left alone. */ +export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) { + const hit = composerSelectionRange(editor) + + if (!hit?.range.collapsed) { + return false + } + + const { startContainer, startOffset } = hit.range + + if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) { + return false + } + + const range = document.createRange() + const tail = fragment.lastChild + + range.setStart(startContainer, startOffset - length) + range.setEnd(startContainer, startOffset) + range.deleteContents() + range.insertNode(fragment) + + if (tail) { + range.setStartAfter(tail) + } + + range.collapse(true) + hit.selection.removeAllRanges() + hit.selection.addRange(range) + + return true +} + /** Backspace at a collapsed caret immediately after a chip: delete the chip AND * the single trailing space we auto-insert after it, atomically — so removing a * directive never strands an orphaned space (the contenteditable-driven cleanup @@ -296,6 +332,106 @@ export function placeCaretEnd(element: HTMLElement) { selection?.addRange(range) } +/** The caret's offset in `composerPlainText` coordinates, so it can be restored + * after the editor is re-rendered from text (undo/redo). A chip counts as its + * whole `@kind:value` text — the same units the snapshot measures. */ +export function caretOffsetInEditor(editor: HTMLElement): number { + const selection = window.getSelection() + const range = selection?.rangeCount ? selection.getRangeAt(0) : null + + if (!range || !editor.contains(range.commonAncestorContainer)) { + return composerPlainText(editor).length + } + + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + + // The scratch container must carry the editor's slot marker: composerPlainText + // appends a trailing "\n" to any other block element, which would inflate + // every offset by one and land the restored caret a character late. + const container = document.createElement('div') + container.dataset.slot = RICH_INPUT_SLOT + container.append(before.cloneContents()) + + return composerPlainText(container).length +} + +/** Place the caret `offset` characters into the editor, in the same + * `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a + * chip it would otherwise split, since a chip is a single atomic unit. */ +export function placeCaretAtOffset(editor: HTMLElement, offset: number) { + const selection = window.getSelection() + + if (!selection) { + return + } + + let remaining = offset + + const walk = (node: Node): Range | null => { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const length = (child.textContent || '').length + + if (remaining <= length) { + const range = document.createRange() + range.setStart(child, remaining) + range.collapse(true) + + return range + } + + remaining -= length + + continue + } + + if (child.nodeType !== Node.ELEMENT_NODE) { + continue + } + + const el = child as HTMLElement + + // Chips and
are atomic: consume their serialized length whole. + if (el.dataset.refText || el.tagName === 'BR') { + const length = el.dataset.refText ? el.dataset.refText.length : 1 + + if (remaining < length) { + const range = document.createRange() + range.setStartBefore(el) + range.collapse(true) + + return range + } + + remaining -= length + + continue + } + + const hit = walk(el) + + if (hit) { + return hit + } + } + + return null + } + + const range = walk(editor) + + if (range) { + selection.removeAllRanges() + selection.addRange(range) + + return + } + + placeCaretEnd(editor) +} + /** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */ function isBlankNode(node: ChildNode | null): boolean { if (!node) { diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index aee735e4aea..5093658af88 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -91,6 +91,7 @@ export const CodingStatusRow = memo(function CodingStatusRow({ const worktreeReq = useStore($newWorktreeRequest) const lastWorktreeReqRef = useRef(worktreeReq) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (worktreeReq === lastWorktreeReqRef.current) { return diff --git a/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx new file mode 100644 index 00000000000..09a7454b9c7 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { $goalsBySession, type SessionGoal } from '@/store/goals' + +import { ComposerStatusStack } from './index' + +// The stack measures itself into a surface var — jsdom has no ResizeObserver. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', ResizeObserverStub) + +const SID = 'sess-goal-1' + +const goal = (status: SessionGoal['status'], title = 'ship the feature', detail?: string): SessionGoal => ({ + detail, + status, + title, + updatedAt: Date.now() +}) + +function renderStack(sessionId: null | string = SID) { + return render( + + + + + + ) +} + +describe('ComposerStatusStack goal indicator', () => { + beforeEach(() => { + $goalsBySession.set({}) + }) + + afterEach(() => { + cleanup() + $goalsBySession.set({}) + }) + + it('renders nothing when the session has no goal', () => { + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) + + it('shows an active goal with its title', () => { + $goalsBySession.set({ [SID]: goal('active') }) + + renderStack() + + expect(screen.getByText('Goal active')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('labels a paused goal as paused', () => { + $goalsBySession.set({ [SID]: goal('paused') }) + + renderStack() + + expect(screen.getByText('Goal paused')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('shows the continuation detail line for an active goal', () => { + $goalsBySession.set({ [SID]: goal('active', 'ship it', 'Continuing toward goal (3/20)') }) + + renderStack() + + expect(screen.getByText('Continuing toward goal (3/20)')).toBeTruthy() + }) + + it('scopes the indicator to the goal-owning session', () => { + $goalsBySession.set({ 'other-session': goal('active') }) + + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 739080be8b8..fbbd2b94d5a 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'rea import { useNavigate } from 'react-router-dom' import { blurComposerInput } from '@/app/chat/composer/focus' +import { clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars' import { AGENTS_ROUTE } from '@/app/routes' import { BillingBanner } from '@/components/billing-banner' import { composerDockCard } from '@/components/chat/composer-dock' @@ -11,6 +12,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { $billingBlock } from '@/store/billing-block' import { @@ -22,6 +24,7 @@ import { type StatusGroup, stopBackgroundProcess } from '@/store/composer-status' +import { refreshSessionGoal } from '@/store/goals' import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $threadScrolledUp } from '@/store/thread-scroll' import { openSessionInNewWindow } from '@/store/windows' @@ -41,12 +44,25 @@ const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\. // Real codicons per group (no sparkles): a checklist for todos, the agent glyph // for subagents, a background process glyph for background tasks. const GROUP_ICON: Record = { + goal: 'target', todo: 'checklist', subagent: 'agent', background: 'server-process' } const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { + if (group.type === 'goal') { + const status = group.items[0]?.goalStatus + + return status === 'paused' + ? s.goalPaused + : status === 'waiting' + ? s.goalWaiting + : status === 'done' + ? s.goalDone + : s.goalActive + } + if (group.type === 'todo') { return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) } @@ -69,23 +85,25 @@ interface ComposerStatusStackProps { export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) { const { t } = useI18n() const navigate = useNavigate() - const itemsBySession = useStore($statusItemsBySession) - const previewsBySession = useStore($previewStatusBySession) + // Subscribe to THIS session's slice only. Both maps churn on other + // sessions' activity (subagent ticks, background polls, preview updates in + // any tile); a whole-map `useStore` re-rendered every mounted stack — one + // per open tile — on all of it. The per-key arrays are referentially stable + // across unrelated writes, so the slice hook bails out unless OUR session's + // items actually changed. + const items = useSessionSlice($statusItemsBySession, sessionId) + const previews = useSessionSlice($previewStatusBySession, sessionId) const scrolledUp = useStore($threadScrolledUp) const billing = useStore($billingBlock) - const groups = useMemo( - () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), - [itemsBySession, sessionId] - ) - - const previews = sessionId ? (previewsBySession[sessionId] ?? []) : [] + const groups = useMemo(() => groupStatusItems(items), [items]) // Seed from the registry on session open; event-driven refreshes (terminal / // process tool completions) live in use-message-stream. useEffect(() => { if (sessionId) { void refreshBackgroundProcesses(sessionId) + void refreshSessionGoal(sessionId) } }, [sessionId]) @@ -153,7 +171,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro ) : undefined } - defaultCollapsed={group.type !== 'todo'} + defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'} icon={} label={groupLabel(group, t.statusStack)} > @@ -197,12 +215,12 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro // height never sees it. Publish our own measured height — bucketed like the // composer's, to avoid style invalidation churn — so the thread's // last-message clearance can add it and the stack never hides messages. + // Scoped to THIS surface: tiles render their own stack (see surface-vars.ts). useLayoutEffect(() => { - const root = document.documentElement const el = stackRef.current if (!visible || !el) { - root.style.removeProperty('--status-stack-measured-height') + clearSurfaceVar(el, STATUS_STACK_VAR) return } @@ -214,7 +232,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro if (bucket !== last) { last = bucket - root.style.setProperty('--status-stack-measured-height', `${bucket}px`) + setSurfaceVar(el, STATUS_STACK_VAR, `${bucket}px`) } } @@ -224,7 +242,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro return () => { observer.disconnect() - root.style.removeProperty('--status-stack-measured-height') + clearSurfaceVar(el, STATUS_STACK_VAR) } }, [visible]) diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 6857be46ccf..2c43743c40c 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -25,6 +25,24 @@ const TODO_GLYPHS: Record, { icon // Left slot: braille spinner while running, otherwise a small status dot // (green = done, red = failed) so the slot is always filled and rows align. function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode { + if (item.type === 'goal') { + if (item.goalStatus === 'paused') { + return + } + + if (item.goalStatus === 'done') { + return + } + + return ( + + ) + } + if (item.todoStatus === 'pending') { return ( )} + {item.type === 'goal' && item.currentTool && ( + + {item.currentTool} + + )} {failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && ( {s.exit(item.exitCode)} diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index 6c6a20780f6..ca2ac803576 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -44,14 +44,25 @@ describe('detectTrigger', () => { it('does not treat file-style paths as slash triggers', () => { expect(detectTrigger('src/foo/bar')).toBeNull() expect(detectTrigger('/path/to/file')).toBeNull() + // Mid-message paths stay excluded too: a path keeps going past the command + // token, so the trailing-anchored inline trigger never matches it. + expect(detectTrigger('check src/foo/bar')).toBeNull() + expect(detectTrigger('look at /usr/local/bin')).toBeNull() + expect(detectTrigger('and/or')).toBeNull() }) - it('does not trigger slash popover mid-message', () => { - expect(detectTrigger('hello /')).toBeNull() - expect(detectTrigger('hello /skill')).toBeNull() + it('treats a mid-message slash as an inline reference', () => { + // Skills have to be reachable anywhere in a prompt, not just at position 0. + expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 }) + expect(detectTrigger('hello /clean')).toEqual({ kind: '/', inline: true, query: 'clean', tokenLength: 6 }) + expect(detectTrigger('text\n/skill')).toEqual({ kind: '/', inline: true, query: 'skill', tokenLength: 6 }) + }) + + it('does not carry arg completion into an inline slash reference', () => { + // Only a position-0 slash is a real invocation, so `/personality alic` + // mid-message is prose — the trigger ends at the command token. expect(detectTrigger('hello there /personality alic')).toBeNull() - expect(detectTrigger('text\n/skill')).toBeNull() - expect(detectTrigger('multi word message /')).toBeNull() + expect(detectTrigger('run /tools enable foo')).toBeNull() }) it('still anchors at-mention triggers strictly at the token edge', () => { diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index b9b6adc07f1..8bc6663210e 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -1,22 +1,35 @@ import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images' export interface TriggerState { + /** True for a `/` typed mid-message — an inline skill/command reference in + * prose rather than a command invocation. Arg completion doesn't apply. */ + inline?: boolean kind: '@' | '/' query: string tokenLength: number } // `@` triggers stop at the first whitespace — `@file:path` and `@diff` are -// single tokens. `/` triggers keep going so the popover stays live while the -// user types args (`/personality alic` → arg completer suggests `alice`). -// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file -// paths like `src/foo/bar`. +// single tokens. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids +// matching file paths like `src/foo/bar`. // -// Slash commands only execute at the beginning of a message, so the `/` -// trigger is anchored strictly at position 0 — not after whitespace — to -// avoid opening the popover mid-message (e.g. `hello /`). +// `/` triggers fire in two shapes, because a slash means two different things +// depending on where it sits: +// +// - At position 0 it's a COMMAND invocation the app executes (SLASH_COMMAND_RE +// is `^`-anchored, and so is the backend's). The popover stays live past the +// command name so arg completion works (`/personality alic` → `alice`). +// - After whitespace it's an inline REFERENCE the user is dropping into prose +// ("clean this up with /clean"). The text submits as an ordinary message, so +// there are no args to complete — the trigger is a single token that ends at +// the next space, exactly like `@`. +// +// The inline shape is what makes skills reachable anywhere in a prompt. Both +// shapes need the trailing `$`: detection runs against the text BEFORE the +// caret, so the match must end where the user is typing. const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ -const SLASH_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ +const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ +const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ export function blobDedupeKey(blob: Blob): string { @@ -107,10 +120,20 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { } export function detectTrigger(textBefore: string): TriggerState | null { - const slash = SLASH_TRIGGER_RE.exec(textBefore) + const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore) - if (slash) { - return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length } + if (command) { + return { kind: '/', query: command[2], tokenLength: 1 + command[2].length } + } + + // An inline `/skill` is a reference dropped into prose, so it carries no args + // and the whole match is the token the chip replaces. + const inline = SLASH_INLINE_TRIGGER_RE.exec(textBefore) + + if (inline) { + const query = inline[2] ?? '' + + return { inline: true, kind: '/', query, tokenLength: 1 + query.length } } const at = AT_TRIGGER_RE.exec(textBefore) diff --git a/apps/desktop/src/app/chat/composer/undo-history.test.ts b/apps/desktop/src/app/chat/composer/undo-history.test.ts new file mode 100644 index 00000000000..5ce477a8020 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + caretOffsetInEditor, + composerPlainText, + placeCaretAtOffset, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' +import { createComposerUndoHistory, isRedoShortcut, isUndoShortcut } from './undo-history' + +const key = (over: Partial = {}) => + ({ altKey: false, ctrlKey: false, key: 'z', metaKey: false, shiftKey: false, ...over }) as KeyboardEvent + +describe('undo/redo shortcut recognition', () => { + it('claims Cmd+Z and Ctrl+Z as undo, but not with Shift or Alt', () => { + expect(isUndoShortcut(key({ metaKey: true }))).toBe(true) + expect(isUndoShortcut(key({ ctrlKey: true }))).toBe(true) + expect(isUndoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(false) + expect(isUndoShortcut(key({ altKey: true, metaKey: true }))).toBe(false) + expect(isUndoShortcut(key({ key: 'a', metaKey: true }))).toBe(false) + expect(isUndoShortcut(key())).toBe(false) + }) + + it('claims Cmd+Shift+Z everywhere and Ctrl+Y as redo', () => { + expect(isRedoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(true) + expect(isRedoShortcut(key({ ctrlKey: true, shiftKey: true }))).toBe(true) + expect(isRedoShortcut(key({ ctrlKey: true, key: 'y' }))).toBe(true) + expect(isRedoShortcut(key({ metaKey: true }))).toBe(false) + expect(isRedoShortcut(key({ altKey: true, ctrlKey: true, key: 'y' }))).toBe(false) + }) + + it('never treats one keystroke as both undo and redo', () => { + const chords = [ + key({ metaKey: true }), + key({ ctrlKey: true }), + key({ metaKey: true, shiftKey: true }), + key({ ctrlKey: true, shiftKey: true }), + key({ ctrlKey: true, key: 'y' }) + ] + + for (const chord of chords) { + expect(isUndoShortcut(chord) && isRedoShortcut(chord)).toBe(false) + } + }) +}) + +describe('composer undo history', () => { + const snap = (text: string, caret = text.length) => ({ caret, text }) + + it('steps back through discrete edits, newest first', () => { + const history = createComposerUndoHistory() + + history.record(snap('')) + history.record(snap('a')) + history.record(snap('ab')) + + expect(history.undo(snap('abc'))?.text).toBe('ab') + expect(history.undo(snap('ab'))?.text).toBe('a') + expect(history.undo(snap('a'))?.text).toBe('') + expect(history.undo(snap(''))).toBeNull() + }) + + it('redoes back to the state undo left, and stops at the newest', () => { + const history = createComposerUndoHistory() + + history.record(snap('before')) + + const undone = history.undo(snap('before + pasted')) + + expect(undone?.text).toBe('before') + expect(history.redo(snap('before'))?.text).toBe('before + pasted') + expect(history.redo(snap('before + pasted'))).toBeNull() + }) + + it('restores the caret along with the text', () => { + const history = createComposerUndoHistory() + + history.record({ caret: 3, text: 'hello world' }) + + expect(history.undo({ caret: 0, text: 'changed' })).toEqual({ caret: 3, text: 'hello world' }) + }) + + it('collapses a run of typing into one entry, so undo steps back by a burst', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 50 + history.record(snap('h'), { coalesce: true }) + now += 50 + history.record(snap('he'), { coalesce: true }) + + // One burst → one entry, back to the state before the burst started. + expect(history.undo(snap('hel'))?.text).toBe('') + expect(history.undo(snap(''))).toBeNull() + }) + + it('starts a new entry once the typing pause exceeds the coalesce window', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 5_000 + history.record(snap('word one'), { coalesce: true }) + + expect(history.undo(snap('word one two'))?.text).toBe('word one') + expect(history.undo(snap('word one'))?.text).toBe('') + }) + + it('does not coalesce a paste into the typing burst that preceded it', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 20 + // A paste is a discrete edit — no coalesce flag. + history.record(snap('typed ')) + + expect(history.undo(snap('typed PASTED'))?.text).toBe('typed ') + expect(history.undo(snap('typed '))?.text).toBe('') + }) + + it('drops the redo stack once a new edit lands', () => { + const history = createComposerUndoHistory() + + history.record(snap('one')) + history.undo(snap('one two')) + history.record(snap('one')) + + expect(history.redo(snap('one three'))).toBeNull() + }) + + it('ignores a no-op edit so undo never looks stuck for a press', () => { + const history = createComposerUndoHistory() + + history.record(snap('same')) + history.record(snap('same')) + + expect(history.undo(snap('same'))?.text).toBe('same') + expect(history.undo(snap('same'))).toBeNull() + }) + + it('bounds the stack, discarding the oldest entries', () => { + const history = createComposerUndoHistory(3) + + for (const text of ['a', 'b', 'c', 'd', 'e']) { + history.record(snap(text)) + } + + expect(history.undo(snap('f'))?.text).toBe('e') + expect(history.undo(snap('e'))?.text).toBe('d') + expect(history.undo(snap('d'))?.text).toBe('c') + expect(history.undo(snap('c'))).toBeNull() + }) + + it('reset clears both directions', () => { + const history = createComposerUndoHistory() + + history.record(snap('a')) + history.undo(snap('ab')) + history.reset() + + expect(history.undo(snap('ab'))).toBeNull() + expect(history.redo(snap('ab'))).toBeNull() + }) +}) + +describe('caret offsets in composerPlainText coordinates', () => { + let editor: HTMLDivElement + + beforeEach(() => { + editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + }) + + const caretAfter = (node: Node, offset: number) => { + const range = document.createRange() + const selection = window.getSelection()! + range.setStart(node, offset) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + it('round-trips a caret in plain text', () => { + renderComposerContents(editor, 'hello world') + placeCaretAtOffset(editor, 5) + + expect(caretOffsetInEditor(editor)).toBe(5) + }) + + it('counts a chip as its whole @kind:value text', () => { + editor.append( + document.createTextNode('see '), + refChipElement('file', '`src/a.ts`'), + document.createTextNode(' now') + ) + + const chipText = '@file:`src/a.ts`' + // Caret at the very end = everything before it. + placeCaretAtOffset(editor, composerPlainText(editor).length) + + expect(caretOffsetInEditor(editor)).toBe(4 + chipText.length + 4) + }) + + it('lands the caret before a chip rather than splitting it', () => { + editor.append(document.createTextNode('a '), refChipElement('file', '`x.ts`')) + + // An offset that falls midway through the chip's serialized text. + placeCaretAtOffset(editor, 2 + 3) + + expect(caretOffsetInEditor(editor)).toBe(2) + }) + + it('counts a line break as one character', () => { + renderComposerContents(editor, 'one\ntwo') + placeCaretAtOffset(editor, 5) + + expect(caretOffsetInEditor(editor)).toBe(5) + expect(composerPlainText(editor)).toBe('one\ntwo') + }) + + it('clamps past-the-end offsets to the end instead of throwing', () => { + renderComposerContents(editor, 'short') + placeCaretAtOffset(editor, 999) + + expect(caretOffsetInEditor(editor)).toBe(5) + }) + + it('reports the end when the selection is outside the editor', () => { + renderComposerContents(editor, 'hello') + + const outside = document.createElement('div') + outside.textContent = 'elsewhere' + document.body.append(outside) + caretAfter(outside.firstChild!, 3) + + expect(caretOffsetInEditor(editor)).toBe(5) + + outside.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/undo-history.ts b/apps/desktop/src/app/chat/composer/undo-history.ts new file mode 100644 index 00000000000..62bed71be83 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.ts @@ -0,0 +1,126 @@ +/** + * The composer's own undo stack. + * + * The rich editor mutates its DOM through `Range` rather than the browser's + * editing commands — `execCommand('insertText')` is ~O(n²) on large multiline + * blobs and froze the composer for seconds on a big paste (#45812). The cost of + * that bypass is that those mutations never reach Chromium's undo stack, so + * ⌘Z skipped straight past a paste and undid whatever came *before* it, leaving + * the pasted text stranded. + * + * Owning the whole stack is the only coherent fix: a half-owned one interleaves + * our snapshots with Chromium's own typing entries and undoes them out of order. + * So every composer edit — typed or programmatic — records here, and the editor + * intercepts ⌘Z / ⌘⇧Z instead of letting the native command run. + * + * Snapshots are plain text + a caret offset, not DOM: the editor already + * round-trips losslessly through `composerPlainText`/`renderComposerContents`, + * so text is the smallest thing that fully restores a state. + */ + +export interface ComposerSnapshot { + caret: number + text: string +} + +/** Consecutive typing inside this window collapses into one undo entry, so ⌘Z + * steps back by a burst the way a native editor does — not one character. */ +const COALESCE_WINDOW_MS = 600 + +const DEFAULT_LIMIT = 200 + +export interface ComposerUndoHistory { + /** Drop all history and start over from `snapshot`'s state (session swap). */ + reset: () => void + /** Redo one step. `current` is the live state, banked for a subsequent undo. */ + redo: (current: ComposerSnapshot) => ComposerSnapshot | null + /** Bank the state that existed *before* an edit. `coalesce` merges this into + * the previous entry when it lands inside the typing window. */ + record: (previous: ComposerSnapshot, options?: { coalesce?: boolean }) => void + /** Undo one step. `current` is the live state, banked for a subsequent redo. */ + undo: (current: ComposerSnapshot) => ComposerSnapshot | null +} + +export function createComposerUndoHistory( + limit = DEFAULT_LIMIT, + now: () => number = () => Date.now() +): ComposerUndoHistory { + let past: ComposerSnapshot[] = [] + let future: ComposerSnapshot[] = [] + let lastRecordedAt = 0 + let lastWasCoalescable = false + + const record: ComposerUndoHistory['record'] = (previous, options) => { + const coalesce = options?.coalesce ?? false + const at = now() + const merges = coalesce && lastWasCoalescable && past.length > 0 && at - lastRecordedAt < COALESCE_WINDOW_MS + + lastRecordedAt = at + lastWasCoalescable = coalesce + // A fresh edit invalidates anything the user had redone past. + future = [] + + // Merging keeps the OLDER snapshot — the entry already holds the state from + // the start of the burst, which is what ⌘Z should step back to. + if (merges) { + return + } + + // A no-op edit (same text) would make ⌘Z look broken for one press. + if (past[past.length - 1]?.text === previous.text) { + return + } + + past.push(previous) + + if (past.length > limit) { + past = past.slice(past.length - limit) + } + } + + const step = (from: ComposerSnapshot[], to: ComposerSnapshot[], current: ComposerSnapshot) => { + const next = from.pop() + + if (!next) { + return null + } + + to.push(current) + // Any traversal ends the typing burst, so the next keystroke opens a new entry. + lastWasCoalescable = false + + return next + } + + return { + record, + redo: current => step(future, past, current), + reset: () => { + past = [] + future = [] + lastRecordedAt = 0 + lastWasCoalescable = false + }, + undo: current => step(past, future, current) + } +} + +/** True for the keystroke that means "undo" (⌘Z / Ctrl+Z, without Shift). */ +export function isUndoShortcut(event: Pick) { + return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z' +} + +/** True for "redo" — ⌘⇧Z everywhere, plus Ctrl+Y on Windows/Linux. */ +export function isRedoShortcut(event: Pick) { + if (event.altKey) { + return false + } + + const key = event.key.toLowerCase() + + if ((event.metaKey || event.ctrlKey) && event.shiftKey && key === 'z') { + return true + } + + return event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'y' +} diff --git a/apps/desktop/src/app/chat/composer/url-refs.test.ts b/apps/desktop/src/app/chat/composer/url-refs.test.ts new file mode 100644 index 00000000000..febbd533ce4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/url-refs.test.ts @@ -0,0 +1,98 @@ +import type { KeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' + +import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor' +import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs' + +/** An editor holding `text` with a collapsed caret at `caret`, plus the space + * keydown the composer would hand `chipTypedUrlOnSpace`. */ +const spaceOn = (text: string, caret: number) => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = text + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, caret) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent } +} + +describe('linkifyUrls', () => { + it('rewrites a bare link as a url directive', () => { + expect(linkifyUrls('https://example.dev/a/b')).toBe('@url:`https://example.dev/a/b`') + }) + + it('keeps the link in place mid-sentence and leaves its punctuation behind', () => { + expect(linkifyUrls('read https://example.dev/a. then stop')).toBe('read @url:`https://example.dev/a`. then stop') + }) + + it('keeps balanced parens but drops the one that closed the sentence', () => { + expect(linkifyUrls('(see https://en.wikipedia.org/wiki/A_(b))')).toBe( + '(see @url:`https://en.wikipedia.org/wiki/A_(b)`)' + ) + }) + + it('rewrites every link in a multi-link paste', () => { + expect(linkifyUrls('http://a.dev and https://b.dev')).toBe('@url:`http://a.dev` and @url:`https://b.dev`') + }) + + it('leaves a link that is already a directive alone', () => { + expect(linkifyUrls('@url:`https://example.dev`')).toBe('@url:`https://example.dev`') + }) + + it('leaves text without a scheme alone', () => { + expect(linkifyUrls('example.dev/a and src/foo.ts')).toBe('example.dev/a and src/foo.ts') + }) +}) + +describe('chipTypedUrlOnSpace', () => { + it('chips a link typed right before the caret and adds the space', () => { + const { editor, event } = spaceOn('see https://example.dev/a', 25) + + expect(chipTypedUrlOnSpace(event)).toBe(true) + expect(composerPlainText(editor)).toBe('see @url:`https://example.dev/a` ') + + editor.remove() + }) + + it('keeps sentence punctuation outside the chip', () => { + const { editor, event } = spaceOn('https://example.dev.', 20) + + expect(chipTypedUrlOnSpace(event)).toBe(true) + expect(composerPlainText(editor)).toBe('@url:`https://example.dev`. ') + + editor.remove() + }) + + it('ignores a caret that is not sitting on a link', () => { + const { editor, event } = spaceOn('https://example.dev is nice', 27) + + expect(chipTypedUrlOnSpace(event)).toBe(false) + expect(composerPlainText(editor)).toBe('https://example.dev is nice') + + editor.remove() + }) + + it('ignores a scheme with no host yet', () => { + const { editor, event } = spaceOn('https://', 8) + + expect(chipTypedUrlOnSpace(event)).toBe(false) + + editor.remove() + }) + + it('leaves a modified space alone', () => { + const { editor, event } = spaceOn('https://example.dev', 19) + + expect(chipTypedUrlOnSpace({ ...event, altKey: true })).toBe(false) + expect(composerPlainText(editor)).toBe('https://example.dev') + + editor.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/url-refs.ts b/apps/desktop/src/app/chat/composer/url-refs.ts new file mode 100644 index 00000000000..580abdc1c6f --- /dev/null +++ b/apps/desktop/src/app/chat/composer/url-refs.ts @@ -0,0 +1,103 @@ +/** + * Bare-link recognition for the composer. A link the user pastes or types is the + * same thing the "+ → Add URL" dialog inserts, so it becomes an `@url:` + * directive: a chip that truncates instead of a wall of URL text, and a + * reference the gateway resolves. + */ +import type { KeyboardEvent } from 'react' + +import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor' +import { textBeforeCaret } from './text-utils' + +// An explicit scheme only — `example.com` bare is too easy to hit by accident +// (a filename, a version, a sentence). Brackets and quotes fence a URL in prose; +// parens don't, so they stay in and an unbalanced tail is trimmed below. +const URL_RE = /https?:\/\/[^\s<>[\]{}"'`]+/gi +const TYPED_URL_RE = /(?:^|\s)(https?:\/\/[^\s<>[\]{}"'`]+)$/i + +/** A URL at the end of a sentence carries the punctuation that ended it. */ +function splitUrlTail(raw: string) { + let url = raw.replace(/[,.;:!?]+$/, '') + + while (url.endsWith(')') && url.split(')').length > url.split('(').length) { + url = url.slice(0, -1) + } + + return { trailing: raw.slice(url.length), url } +} + +/** A URL needs a host past the scheme to be worth chipping. */ +const hasHost = (url: string) => /^https?:\/\/[^/\s]/i.test(url) + +/** Rewrite bare links in `text` as `@url:` directives, leaving links that are + * already part of a directive alone. Returns `text` unchanged when there are + * none. */ +export function linkifyUrls(text: string) { + REF_RE.lastIndex = 0 + + const fenced = Array.from(text.matchAll(REF_RE)).map(match => { + const start = match.index ?? 0 + + return { end: start + match[0].length, start } + }) + + let out = '' + let cursor = 0 + + for (const match of text.matchAll(URL_RE)) { + const start = match.index ?? 0 + const { url } = splitUrlTail(match[0]) + + if (!hasHost(url) || fenced.some(span => start >= span.start && start < span.end)) { + continue + } + + out += `${text.slice(cursor, start)}@url:${quoteRefValue(url)}` + cursor = start + url.length + } + + return out + text.slice(cursor) +} + +/** A plain space finishing a typed link commits it as a chip (followed by + * whatever punctuation ended it, then the space). Returns whether it ran, so a + * keydown handler can fall through on anything else. */ +export function chipTypedUrlOnSpace(event: KeyboardEvent) { + if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + const editor = event.currentTarget + + // Runs on every space, so bail on the cheap native read before paying for the + // caret range walk (same guard shape as the trigger detector). + if (!editor.textContent?.includes('://')) { + return false + } + + const before = textBeforeCaret(editor) + const match = before ? TYPED_URL_RE.exec(before) : null + const token = match?.[1] + + if (!token) { + return false + } + + const { trailing, url } = splitUrlTail(token) + + if (!hasHost(url)) { + return false + } + + const fragment = document.createDocumentFragment() + + fragment.append(refChipElement('url', quoteRefValue(url))) + + if (trailing) { + fragment.append(document.createTextNode(trailing)) + } + + fragment.append(document.createTextNode(' ')) + + return replaceBeforeCaret(editor, token.length, fragment) +} diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 42e4554b992..186c226b84a 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -44,6 +44,7 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt // DnD can't be cancelled at the OS level, so we drop the overlay and arm a // guard that swallows the trailing drop instead. Top escape layer + capture // stop so it doesn't also fire a handler behind the drag (see drag-session). + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (dragKind === null) { return diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index bd18be0c483..40a8922d453 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -1,14 +1,16 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' +import type { ReadableAtom } from 'nanostores' import type * as React from 'react' -import { Suspense, useCallback, useEffect, useMemo } from 'react' +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { useLocation } from 'react-router-dom' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { $sessionTileDragging, $sessionTileEdgeHover } from '@/components/pane-shell/tree/store' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' @@ -42,7 +44,7 @@ import { import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' -import { routeSessionId } from '../routes' +import { primaryRouteSelectedSessionId, routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' @@ -69,7 +71,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onCancel: () => Promise | void onAddContextRef: (refText: string, label?: string, detail?: string) => void onAddUrl: (url: string) => void - onBranchInNewChat: (messageId: string) => void + onBranchInNewChat?: (messageId: string) => void maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void @@ -174,6 +176,30 @@ interface ChatRuntimeBoundaryProps { const NO_MESSAGES: ChatMessage[] = [] +/** + * The view's $messages, live only while this surface is the VISIBLE tab. + * + * Keep-alive keeps every ever-active tab MOUNTED (tree-group.tsx), so without + * this gate a hidden tab re-renders its entire thread on every streaming + * delta flush (~30×/s) — five busy tabs quintuple the per-token render cost + * and the app crawls. Hidden tabs freeze their transcript instead (status + * dots stay live through the separate status atoms) and catch up in one + * commit on reveal — the subscribe fires immediately with the current value. + */ +function useMessagesWhileVisible($messages: ReadableAtom): ChatMessage[] { + const visible = usePaneVisible() + const [messages, setMessages] = useState(() => $messages.get()) + + // nanostores types the listener value ReadonlyIfObject; the store publishes + // a fresh array per flush, so the cast is safe and avoids a per-token clone. + useEffect( + () => (visible ? $messages.subscribe(value => setMessages(value as ChatMessage[])) : undefined), + [$messages, visible] + ) + + return messages +} + /** * Owns the $messages subscription and the assistant-ui external-store runtime. * @@ -193,7 +219,7 @@ function ChatRuntimeBoundary({ onThreadMessagesChange, suppressMessages }: ChatRuntimeBoundaryProps) { - const storeMessages = useStore(useSessionView().$messages) + const storeMessages = useMessagesWhileVisible(useSessionView().$messages) const messages = suppressMessages ? NO_MESSAGES : storeMessages const runtimeMessageRepository = useRuntimeMessageRepository(messages) @@ -285,11 +311,18 @@ export function ChatView({ const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) // Durable composer/queue scope (lineage root) so auto-compression tip rotation - // does not wipe an in-progress draft or orphan /queue entries. - const queueSessionKey = useMemo( - () => resolveComposerSessionKey(selectedSessionId, sessions), - [selectedSessionId, sessions] - ) + // does not wipe an in-progress draft or orphan /queue entries. For the + // primary view, the route is authoritative over the store selection — the + // latter can be momentarily null/stale mid-switch, which used to leak into + // the composer's scope key (#59305). A tile has no route, so it always uses + // its own selection directly. + const queueSessionKey = useMemo(() => { + const effectiveSelectedSessionId = isPrimary + ? primaryRouteSelectedSessionId(location.pathname, selectedSessionId) + : selectedSessionId + + return resolveComposerSessionKey(effectiveSelectedSessionId, sessions) + }, [isPrimary, location.pathname, selectedSessionId, sessions]) // When the tip row arrives after compression, migrate any tip-keyed stash onto // the durable lineage key before the composer remounts onto that key. @@ -437,6 +470,7 @@ export function ChatView({ 'relative isolate flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className )} + data-chat-surface="" data-composer-target={composerScope.target} data-session-anchor={sessionAnchor} > diff --git a/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx new file mode 100644 index 00000000000..c463ee84c41 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-pane.tsx @@ -0,0 +1,222 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useState } from 'react' + +import { CopyButton } from '@/components/ui/copy-button' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { artifactDownloadName } from '@/lib/artifact-detect' +import { downloadTextFile } from '@/lib/download-text' +import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $artifactRegistry, + $artifactVersionSelection, + type ArtifactRecord, + selectArtifactVersion +} from '@/store/artifacts' +import { notifyError } from '@/store/notifications' + +import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers' +import { PreviewEmptyState } from './preview-file' + +type ArtifactViewMode = 'preview' | 'source' + +const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const + +const HEADER_BUTTON_CLASS = + 'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + +/** Write the composed document to a real temp file through the existing + * buffer-save IPC, then hand it to the OS browser. A blob/data URL can't + * cross into the OS default browser, so a file on disk is the honest path. */ +async function openHtmlInBrowser(content: string): Promise { + const bridge = window.hermesDesktop + + if (!bridge?.saveImageBuffer || !bridge.openExternal) { + throw new Error('Desktop bridge unavailable') + } + + const bytes = new TextEncoder().encode(composeArtifactHtml(content)) + const path = await bridge.saveImageBuffer(bytes, '.html') + + if (!path) { + throw new Error('Could not write artifact file') + } + + const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}` + + if (bridge.openPreviewInBrowser) { + await bridge.openPreviewInBrowser(fileUrl) + + return + } + + await bridge.openExternal(fileUrl) +} + +function VersionStepper({ + current, + onSelect, + total +}: { + current: number + onSelect: (index: number) => void + total: number +}) { + const { t } = useI18n() + const copy = t.artifactPane + + if (total < 2) { + return null + } + + return ( +
+ + + + {copy.versionOf(current + 1, total)} + + + +
+ ) +} + +export function ArtifactPane({ artifactId }: { artifactId: string }) { + const { t } = useI18n() + const copy = t.artifactPane + const registry = useStore($artifactRegistry) + const versionSelection = useStore($artifactVersionSelection) + // View mode is per-pane, ephemeral: renderable artifacts open in preview. + const [userMode, setUserMode] = useState(null) + + // Reset the explicit mode when the pane is reused for another artifact. + useEffect(() => { + setUserMode(null) + }, [artifactId]) + + const record = useMemo(() => { + for (const records of Object.values(registry)) { + const found = records.find(candidate => candidate.id === artifactId) + + if (found) { + return found + } + } + + return null + }, [artifactId, registry]) + + if (!record) { + return + } + + const isRenderable = record.kind === 'html' || record.kind === 'svg' + const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1) + const version = record.versions[versionIndex]! + const isCurrentVersion = versionIndex >= record.versions.length - 1 + const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source' + const downloadName = artifactDownloadName(record.kind, record.language, record.title) + + const modeLabel: Record = { + preview: copy.modePreview, + source: copy.modeSource + } + + return ( +
+
+
+ selectArtifactVersion(artifactId, index)} + total={record.versions.length} + /> + {!isCurrentVersion && ( + + )} +
+ {isRenderable && + (['preview', 'source'] as const).map(candidate => ( + + ))} +
+ + + + + {record.kind === 'html' && window.hermesDesktop && ( + + + + )} +
+
+
+ {mode === 'preview' && isRenderable ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx new file mode 100644 index 00000000000..00ed9a58a58 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/artifact-renderers.tsx @@ -0,0 +1,107 @@ +import DOMPurify from 'dompurify' +import { useMemo } from 'react' +import ShikiHighlighter from 'react-shiki' + +import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window' +import type { ArtifactKind } from '@/lib/artifact-detect' + +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +const SOURCE_CHUNK_LINES = 200 +const SOURCE_LINE_PX = 20 +const SOURCE_OVERSCAN_LINES = 400 + +/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row + * windowing as the file preview's SourceView so a 5k-line artifact scrolls + * smoothly, minus the gutter drag/selection machinery (artifact content has no + * on-disk path to reference lines against). */ +export function ArtifactSourceView({ language, text }: { language: string; text: string }) { + const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) + const lastChunk = chunks.at(-1) + const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: SOURCE_OVERSCAN_LINES, + rowPx: SOURCE_LINE_PX, + rowsPerChunk: SOURCE_CHUNK_LINES, + totalRows: totalLines + }) + + const visibleChunks = chunks.slice(startChunk, endChunk + 1) + + return ( +
+
+ {beforeRows > 0 &&
} + {visibleChunks.map(chunk => ( +
+ + {chunk.text} + +
+ ))} + {afterRows > 0 &&
} +
+
+ ) +} + +/** Wrap an HTML fragment in a minimal document shell; full documents pass + * through untouched. Keeps generated fragments (no /) rendering + * with sane defaults instead of quirks-mode soup. */ +export function composeArtifactHtml(content: string): string { + if (/]|', + '', + '', + content, + '' + ].join('\n') +} + +/** + * Sandboxed live renderer for html/svg artifact content. + * + * HTML runs in an `