diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 145d742d5b12..cecbd4515c8c 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -15,6 +15,9 @@ outputs: python: description: Run Python tests / ruff / ty / windows-footguns. value: ${{ steps.classify.outputs.python }} + python_prod: + description: Python changes outside tests/ — gates product jobs (Desktop E2E, Docker). + value: ${{ steps.classify.outputs.python_prod }} frontend: description: Run the TypeScript testing matrix + desktop build. value: ${{ steps.classify.outputs.frontend }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3441d7bb6db3..beb9c41f6a73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: timeout-minutes: 10 outputs: python: ${{ steps.classify.outputs.python }} + python_prod: ${{ steps.classify.outputs.python_prod }} frontend: ${{ steps.classify.outputs.frontend }} site: ${{ steps.classify.outputs.site }} scan: ${{ steps.classify.outputs.scan }} @@ -89,7 +90,19 @@ jobs: e2e-desktop: name: Desktop E2E needs: detect - if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' + # python_prod (not python): the Playwright suite exercises the built app + # + `hermes serve` backend, which never import anything under tests/. + # Tests-only PRs (~17% of commits) skip this 5-minute job — the longest + # single job in the workflow — while still running the full pytest lanes. + # + # ⛔ TEMPORARILY DISABLED (Aug 2, 2026, Teknium) — the suite is red on + # every PR and on main itself since the Aug 1 night engines/npm churn + # (#76499 → #76562 → #76575): the mock-backend Electron window never + # gets a title, so boot/chat/setup/interim specs all fail identically + # regardless of the PR's diff (verified on #76573 and the docs-only + # #76582). Tracking issue: #76627 (assigned: Ari). To re-enable, + # delete the `false &&` below — nothing else changed. + if: ${{ false && (needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true') }} uses: ./.github/workflows/e2e-desktop.yml docs-site: @@ -137,8 +150,10 @@ jobs: needs: detect # Trusted main pushes run docker.yml directly so its container-publish # environment secrets never cross this reusable-workflow call. PR runs - # remain build/test-only and secret-free. - if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true') + # remain build/test-only and secret-free. Gated on python_prod (not + # python): the image copies installed code, never tests/ — tests-only + # PRs skip the build. + if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true') uses: ./.github/workflows/docker.yml supply-chain: diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 3ac2c4741f89..588d7707ea76 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -65,10 +65,14 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm cache-dependency-path: website/package-lock.json + - name: grab npm 12 + run: | + npm i -g npm@12 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 41acf1790f48..cf775f89e032 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -15,10 +15,14 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm cache-dependency-path: website/package-lock.json + - name: grab npm 12 + run: | + npm i -g npm@12 + - name: Install website dependencies uses: ./.github/actions/retry with: diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index bdaaa6d6cbb3..b951c60eadb2 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -39,8 +39,13 @@ jobs: # ── Node ─────────────────────────────────────────────────────────── - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm + + - name: grab npm 12 + run: | + npm i -g npm@12 + # Full npm ci (not --ignore-scripts): electron's postinstall # downloads the binary we launch, and node-pty's native build is # needed for the terminal pane. @@ -56,7 +61,7 @@ jobs: # fetching a manifest from raw.githubusercontent.com on EVERY job — # a transient fetch failure fails the whole job (2026-07-28 slice-5 # incident). Pinned, the binary downloads directly; no manifest hop. - version: "0.9.28" + version: '0.9.28' enable-cache: true cache-dependency-glob: | pyproject.toml @@ -106,11 +111,11 @@ jobs: npx playwright test --reporter=list fi env: - CI: "true" + CI: 'true' # Ensure no real API keys leak into the test env. - OPENROUTER_API_KEY: "" - OPENAI_API_KEY: "" - NOUS_API_KEY: "" + OPENROUTER_API_KEY: '' + OPENAI_API_KEY: '' + NOUS_API_KEY: '' # ── Save updated baselines to cache (main only) ─────────────────── - name: Save updated baselines to cache diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index 2dfbe7e0b17a..d0fa3513e86c 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -67,9 +67,13 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm + - name: grab npm 12 + run: | + npm i -g npm@12 + # --ignore-scripts: eslint only needs TS sources + eslint packages. - uses: ./.github/actions/retry with: diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 25786b1c95b6..9119e0c7a9b9 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -15,8 +15,13 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm + + - name: grab npm 12 + run: | + npm i -g npm@12 + - uses: ./.github/actions/retry with: command: npm ci --ignore-scripts @@ -61,8 +66,13 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm + + - name: grab npm 12 + run: | + npm i -g npm@12 + - uses: ./.github/actions/retry with: command: npm ci diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 455ede33dd56..c3aaa50a7b5a 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -43,11 +43,13 @@ jobs: uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 with: # Scan explicit lockfiles rather than recursing, so we only look at - # the three sources of truth and skip vendored / test / worktree dirs. + # the five sources of truth and skip vendored / test / worktree dirs. scan-args: |- --lockfile=uv.lock --lockfile=package-lock.json --lockfile=website/package-lock.json + --lockfile=plugins/platforms/photon/sidecar/package-lock.json + --lockfile=scripts/whatsapp-bridge/package-lock.json # The upstream reusable workflow uploads this exact file under its # fixed artifact name, which the wrapper downloads below. results-file-name: osv-results.sarif diff --git a/.npmrc b/.npmrc index 2c652d8914cc..d25bd185bbe1 100644 --- a/.npmrc +++ b/.npmrc @@ -45,3 +45,7 @@ min-release-age-exclude[]=vite min-release-age-exclude[]=rolldown min-release-age-exclude[]=@rolldown/* min-release-age-exclude[]=@oxc-project/types + +# ink needs +min-release-age-exclude[]=lightningcss +min-release-age-exclude[]=postcss diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000000..6f4247a6255c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/.python-version b/.python-version new file mode 100644 index 000000000000..2c0733315e41 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Dockerfile b/Dockerfile index 6d6e9ac2d91c..2de6192715ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,14 +41,14 @@ RUN apt-get -o Acquire::Retries=3 update && \ 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 -# upstream node:22 image instead so we can stay on a supported LTS without -# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used -# so the produced binary links against glibc 2.36, which runs cleanly on -# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major -# is a one-line ARG change; see #4977. -FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source +# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x +# which reached EOL in April 2026 — we copy node + npm from the upstream +# node:26 image instead (Hermes pins its toolchain to Node 26 everywhere). +# Bookworm-based slim image used so the produced binary links +# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc +# 2.41) runtime. Bumping to a new Node major is a one-line ARG change; see +# #4977. +FROM node:26-bookworm-slim@sha256:9e6f9357d371591e32ab6f2d8a26d63bdd0d17c29eee3f4f3e7e454d9634bf73 AS node_source FROM debian:13.4 # Disable Python stdout buffering to ensure logs are printed immediately. @@ -70,7 +70,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright # hermes process, the dashboard, and per-profile gateways. RUN apt-get -o Acquire::Retries=3 update && \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - 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 && \ + ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 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 @@ -151,17 +151,20 @@ RUN useradd -u 10000 -m -d /opt/data hermes COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ -# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS -# installs from the upstream image. npm and npx are recreated as symlinks -# because they're symlinks in the source image (and need to live on PATH). +# Node 26: copy the node binary plus the bundled npm JS install from the +# upstream image. npm and npx are recreated as symlinks because they're +# symlinks in the source image (and need to live on PATH). +# +# No corepack: Node unbundled it upstream, so node:26 ships only npm in +# /usr/local/lib/node_modules. Nothing here needs it — no package.json +# declares a `packageManager`, and no build step shells out to yarn or pnpm. +# # See node_source stage at the top of the file for the version-bump # rationale (#4977). COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/ COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm -COPY --from=node_source /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ - ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ - ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx WORKDIR /opt/hermes @@ -400,6 +403,8 @@ ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages # Recursion is impossible because the shim exec's the venv binary by # absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for # the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1). +COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes +COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-dispatch.sh # Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported # the venv bin onto PATH; Architecture B's main-wrapper.sh does the @@ -416,27 +421,37 @@ ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}" RUN mkdir -p /opt/data VOLUME [ "/opt/data" ] -# s6-overlay's /init is PID 1. It sets up the supervision tree, runs -# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services -# declared in /etc/s6-overlay/s6-rc.d/, then exec's its remaining -# argv as the container's "main program" with stdin/stdout/stderr -# inherited (this is what makes interactive --tui work). When the -# main program exits, /init begins stage 3 shutdown and the container -# exits with the program's exit code. Replaces tini — see Phase 2 of -# docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md. +# The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly. +# When the image really owns PID 1 (normal Docker / Podman), the dispatcher +# execs `/init` and preserves the full s6 supervision tree. When a platform +# wraps the image entrypoint under its own PID-1 init (Fly Machines, +# `docker run --init`, some schedulers), `/init` would abort with +# `can only run as pid 1`; in that case the dispatcher falls back to +# `stage2-hook.sh` + `main-wrapper.sh` directly so foreground commands still +# work. See #38349. +# +# On the PID-1 path, s6-overlay's /init sets up the supervision tree, runs +# /etc/cont-init.d/* (our stage2 hook), starts s6-rc services declared in +# /etc/s6-overlay/s6-rc.d/, then exec's its remaining argv as the container's +# "main program" with stdin/stdout/stderr inherited (this is what makes +# interactive --tui work). When the main program exits, /init begins stage 3 +# shutdown and the container exits with the program's exit code. Replaces +# tini — see Phase 2 of docs/plans/2026-05-07-s6-overlay-dynamic-subagent-gateways.md. # # We use the ENTRYPOINT+CMD split rather than CMD alone so the # wrapper is prepended to user-supplied args automatically: # -# docker run → /init main-wrapper.sh (CMD default) -# docker run chat -q "hi" → /init main-wrapper.sh chat -q hi -# docker run sleep infinity → /init main-wrapper.sh sleep infinity -# docker run --tui → /init main-wrapper.sh --tui +# docker run → entrypoint-dispatch.sh (CMD default) +# docker run chat -q "hi" → entrypoint-dispatch.sh chat -q hi +# docker run sleep infinity → entrypoint-dispatch.sh sleep infinity +# docker run --tui → entrypoint-dispatch.sh --tui # # main-wrapper.sh handles arg routing (bare-exec vs. hermes # subcommand vs. no-args), drops to the hermes user via s6-setuidgid, # and exec's the final program so its exit code becomes the container -# exit code. Without the wrapper-as-ENTRYPOINT, leading-dash args -# like `--version` would be intercepted by /init's POSIX shell. -ENTRYPOINT [ "/init", "/opt/hermes/docker/main-wrapper.sh" ] +# exit code. The dispatcher preserves that contract across both the +# supervised PID-1 path and the non-PID-1 fallback path. Without the +# wrapper-as-ENTRYPOINT, leading-dash args like `--version` would be +# intercepted by /init's POSIX shell. +ENTRYPOINT [ "/opt/hermes/docker/entrypoint-dispatch.sh" ] CMD [ ] diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index fb9ed95450c3..7b549006bc27 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -247,16 +247,22 @@ def main(argv: list[str] | None = None) -> None: import acp from .server import HermesACPAgent - # MCP tool discovery from config.yaml — run before asyncio.run() so - # it's safe to use blocking waits. (ACP also registers per-session - # 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). + # MCP tool discovery from config.yaml — fire-and-forget in a + # background daemon thread so the ACP server becomes responsive + # immediately while MCP servers connect. Previously this blocked + # asyncio.run() for 2-5 s. (ACP also registers per-session 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). # 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() + from hermes_cli.mcp_startup import start_background_mcp_discovery + + start_background_mcp_discovery( + logger=logger, + thread_name="acp-mcp-discovery", + ) except Exception: logger.debug("MCP tool discovery failed at ACP startup", exc_info=True) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 1b0046fe32c0..f6e0462ec9d1 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -1037,6 +1037,102 @@ async def _register_session_mcp_servers( exc_info=True, ) + def _schedule_mcp_late_refresh(self, state: SessionState) -> None: + """Refresh the agent's tool snapshot when background MCP discovery lands late. + + ACP entry.py starts MCP tool discovery in a background daemon thread so a + slow/dead configured server can't block ``asyncio.run()``. ``_make_agent`` + briefly joins that thread (``wait_for_mcp_discovery``, bounded ~1.5s) so + already-spawning fast servers land in the snapshot — but a server slower + than the bound lands *after* the agent is built, leaving its tools absent + for the whole session. + + This schedules an off-critical-path daemon that waits for discovery to + finish (bounded 30s), then rebuilds the snapshot via the shared + ``refresh_agent_mcp_tools`` helper — the same rebuild ``/reload-mcp`` + performs, but automatic. Mirrors the TUI late-refresh (PR #48403). + + Cache safety: the rebuild only runs while the session is still + pre-first-turn (no API call made yet → nothing cached to invalidate). + Once the user has sent a message we leave the snapshot frozen rather + than break the cached prompt prefix mid-conversation; servers that land + later are picked up cache-safely by the between-turns prologue refresh + (``agent/turn_context.py``) at the next turn boundary. The marginal + value of this pre-first-turn daemon is therefore freshness in the + window [session created → first message] — e.g. the "Available tools" + listing a client may request before the first prompt. + No-op when discovery already finished, when the join times out, when the + registry was unchanged, or when the session was closed while waiting. + """ + try: + from hermes_cli.mcp_startup import mcp_discovery_in_flight + except Exception: + return + if not mcp_discovery_in_flight(): + return + + import threading + + agent = state.agent + session_id = state.session_id + + def _wait_then_refresh() -> None: + try: + from hermes_cli.mcp_startup import join_mcp_discovery + + if not join_mcp_discovery(timeout=30.0): + return + + # Session may have been closed while we waited. In-memory-only + # lookup on purpose: ``get_session()`` falls through to a DB + # restore that builds a whole new AIAgent as a side effect just + # to decide "no-op" here (the TUI equivalent also checks its + # in-memory dict only). + with self.session_manager._lock: + current = self.session_manager._sessions.get(session_id) + if current is None or current.agent is not agent: + return + + # Cache safety: never rebuild the tool list once the conversation + # has started — that would invalidate the cached prompt prefix. + # Serialized with turn start: ``prompt()`` flips ``is_running`` + # under ``runtime_lock`` before dispatching, so holding it here + # (and bailing when a turn is already running) closes the window + # where the guard passes but the first prompt starts before the + # refresh publishes — which would swap ``tools=`` mid-turn and + # break the just-created cache prefix. + with current.runtime_lock: + if current.is_running: + return + if ( + int(getattr(agent, "_user_turn_count", 0) or 0) > 0 + or int(getattr(agent, "_api_call_count", 0) or 0) > 0 + ): + return + + from tools.mcp_tool import refresh_agent_mcp_tools + + added = refresh_agent_mcp_tools(agent, quiet_mode=True) + if added: + logger.info( + "Session %s: late MCP refresh added %d tools: %s", + session_id, + len(added), + ", ".join(sorted(added)), + ) + except Exception: + logger.debug( + "Session %s: late MCP refresh failed", + session_id, + exc_info=True, + ) + + threading.Thread( + target=_wait_then_refresh, + name=f"acp-mcp-late-refresh-{session_id}", + daemon=True, + ).start() + # ---- ACP lifecycle ------------------------------------------------------ async def initialize( @@ -1343,6 +1439,7 @@ async def new_session( ) -> NewSessionResponse: state = self.session_manager.create_session(cwd=cwd) await self._register_session_mcp_servers(state, mcp_servers) + self._schedule_mcp_late_refresh(state) logger.info("New session %s (cwd=%s)", state.session_id, cwd) self._schedule_available_commands_update(state.session_id) self._schedule_usage_update(state) @@ -1367,6 +1464,7 @@ async def load_session( logger.warning("load_session: session %s not found", session_id) return None await self._register_session_mcp_servers(state, mcp_servers) + self._schedule_mcp_late_refresh(state) logger.info("Loaded session %s", session_id) # Per ACP spec, `session/load` must stream the prior conversation back # to the client via `session/update` notifications BEFORE responding, @@ -1414,6 +1512,7 @@ async def resume_session( logger.warning("resume_session: session %s not found, creating new", session_id) state = self.session_manager.create_session(cwd=cwd) await self._register_session_mcp_servers(state, mcp_servers) + self._schedule_mcp_late_refresh(state) logger.info("Resumed session %s", state.session_id) # See `load_session` above for the spec rationale — replay must # complete before the response so clients receive the full transcript @@ -1769,7 +1868,7 @@ def _run_agent() -> dict: # 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, + session_key=session_id, session_id=session_id, cwd=state.cwd, ) except Exception: session_tokens = None diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 6f1e17a07f57..6e16016a6b3b 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -648,6 +648,30 @@ def _make_agent( logger.debug("ACP session falling back to default provider resolution", exc_info=True) _register_task_cwd(session_id, cwd) + + # Bounded wait for background MCP discovery so already-spawning fast + # servers land in the agent's tool snapshot. ACP entry.py fires + # discovery in a background daemon thread (start_background_mcp_discovery); + # the agent snapshots tools once at build (run_agent/agent_init) and + # never re-reads the registry, so without this join a reachable-but- + # slow configured server would be invisible for the whole session. + # ``ensure_mcp_discovery_before_agent_build`` also (re)starts discovery + # when the entry.py spawn never ran or exited with zero connected + # servers (the retry-after-zero-connected allowance), making this + # construction site self-sufficient. Bounded by + # ``mcp_discovery_timeout`` (config.yaml, default ~1.5s) so a dead + # server can't block — servers that miss the bound are picked up by + # the automatic late-refresh (see HermesACPAgent._schedule_mcp_late_refresh). + try: + from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build + + ensure_mcp_discovery_before_agent_build( + logger=logger, + thread_name="acp-mcp-discovery", + ) + except Exception: + logger.debug("ACP: bounded MCP discovery wait failed", exc_info=True) + agent = AIAgent(**kwargs) # Codex app-server sessions are spawned lazily on the first turn. Stamp # the ACP workspace onto the agent so the Codex runtime starts from the diff --git a/agent/agent_init.py b/agent/agent_init.py index c24c5c65ff00..d36d1607a897 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -834,18 +834,34 @@ def init_agent( agent._use_prompt_caching, agent._use_native_cache_layout = ( agent._anthropic_prompt_cache_policy() ) + agent._cache_disabled = False # Anthropic supports "5m" (default) and "1h" cache TTL tiers. Read from # config.yaml under prompt_caching.cache_ttl; unknown values keep "5m". # 1h tier costs 2x on write vs 1.25x for 5m, but amortizes across long # sessions with >5-minute pauses between turns (#14971). + # + # Setting cache_ttl to a falsy value (false / null / "off" / "disabled" / + # "no" / "none") disables prompt caching entirely. This is useful for + # OAuth subscription users where cache writes bill against "extra usage" + # or for third-party proxies that inject their own cache_control markers + # (#13477). The disable propagates through anthropic_prompt_cache_policy() + # and restore_primary_runtime() so it survives /model switches and + # fallback re-derivation (#33555). agent._cache_ttl = "5m" try: from hermes_cli.config import load_config_readonly as _load_pc_cfg + from agent.agent_runtime_helpers import cache_ttl_means_disabled + _pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {} _ttl = _pc_cfg.get("cache_ttl", "5m") if _ttl in {"5m", "1h"}: agent._cache_ttl = _ttl + elif cache_ttl_means_disabled(_ttl): + agent._use_prompt_caching = False + agent._use_native_cache_layout = False + agent._cache_ttl = None + agent._cache_disabled = True except Exception: pass @@ -1221,16 +1237,20 @@ def init_agent( _fb_entries = [fallback_model] _fb_resolved = False for _fb in _fb_entries: - _fb_explicit_key = (_fb.get("api_key") or "").strip() or None - if not _fb_explicit_key: - _fb_key_env = (_fb.get("key_env") or _fb.get("api_key_env") or "").strip() - if _fb_key_env: - _fb_explicit_key = os.getenv(_fb_key_env, "").strip() or None - _fb_client, _fb_model = resolve_provider_client( - _fb["provider"], model=_fb["model"], raw_codex=True, - explicit_base_url=_fb.get("base_url"), - explicit_api_key=_fb_explicit_key, - ) + try: + from hermes_cli.fallback_config import resolve_entry_api_key + _fb_explicit_key = resolve_entry_api_key(_fb) + _fb_client, _fb_model = resolve_provider_client( + _fb["provider"], model=_fb["model"], raw_codex=True, + explicit_base_url=_fb.get("base_url"), + explicit_api_key=_fb_explicit_key, + ) + except Exception as _fb_exc: + logger.debug( + "Init-time fallback entry %s failed: %s", + _fb.get("provider"), _fb_exc, + ) + continue if _fb_client is not None: agent.provider = _fb["provider"] agent.model = _fb_model or _fb["model"] @@ -1465,7 +1485,17 @@ def init_agent( set_current_session_id(agent.session_id) except Exception: - os.environ["HERMES_SESSION_ID"] = agent.session_id + # Preserve the root-agent legacy fallback, but never let delegated + # construction publish a child ID process-wide even if the ContextVar + # bridge itself failed to import. + try: + from agent.delegation_context import is_delegated_child_context + + delegated_child = is_delegated_child_context() + except Exception: + delegated_child = False + if not delegated_child: + os.environ["HERMES_SESSION_ID"] = agent.session_id # Session logs go into ~/.hermes/sessions/ alongside gateway sessions hermes_home = get_hermes_home() diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1b592c206986..12bed0c666fd 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1475,6 +1475,12 @@ def restore_primary_runtime(agent) -> bool: "use_native_cache_layout", agent.api_mode == "anthropic_messages" and agent.provider == "anthropic", ) + # If the operator has disabled caching via config (cache_ttl is + # falsy → _cache_disabled flag is set), the disable must survive + # runtime snapshot restoration (#33555). + if getattr(agent, "_cache_disabled", False): + agent._use_prompt_caching = False + agent._use_native_cache_layout = False # ── Rebuild client for the primary provider ── if agent.provider == "moa": @@ -1834,6 +1840,143 @@ def dump_api_request_debug( +def _direct_native_anthropic_tool_cache_capability( + agent, + *, + provider: Optional[str] = None, + base_url: Optional[str] = None, + api_mode: Optional[str] = None, + model: Optional[str] = None, +) -> bool: + """Return whether this resolved destination accepts native tool markers.""" + eff_base_url = base_url if base_url is not None else (agent.base_url or "") + eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "") + return ( + eff_api_mode == "anthropic_messages" + and base_url_hostname(eff_base_url) == "api.anthropic.com" + ) + + +def cache_ttl_means_disabled(ttl: Any) -> bool: + """Return True when a ``prompt_caching.cache_ttl`` value means caching off. + + Single source of truth for the disable-synonym detection shared by + ``agent_init`` (live-agent ``_cache_disabled`` flag) and the stub policy + paths below. Keeping one predicate prevents the two sites from drifting + (a synonym added in only one place would recreate #76085). + + Unknown values (e.g. ``"2h"``, integers) are NOT a disable — callers keep + caching enabled with the default TTL, matching ``agent_init``. + """ + if ttl in ("5m", "1h"): + return False + if ttl is False or ttl is None: + return True + return str(ttl).lower() in ("off", "false", "disabled", "no", "none") + + +def prompt_caching_disabled_from_config() -> bool: + """Return True when ``prompt_caching.cache_ttl`` is configured as off. + + Same disable detection as ``agent_init`` (via ``cache_ttl_means_disabled``) + so stub-based policy paths (MoA slot decoration, auxiliary fallback + replan) honor the same config contract without holding a live + ``AIAgent`` (#76085 / #33555). + """ + try: + from hermes_cli.config import load_config_readonly + + pc_cfg = load_config_readonly().get("prompt_caching", {}) or {} + ttl = pc_cfg.get("cache_ttl", "5m") + except Exception: + return False + return cache_ttl_means_disabled(ttl) + + +def blank_cache_policy_stub(cache_disabled: Optional[bool] = None): + """Build the destination-identity-blank stub for ``anthropic_prompt_cache_policy``. + + Single sanctioned constructor for that stub. Callers that resolve cache + policy against a destination identified out-of-band (not a live + ``AIAgent``) must go through here so ``_cache_disabled`` is never left + off a hand-rolled ``SimpleNamespace`` (#76085). + + When ``cache_disabled`` is omitted, falls back to the global config so + stub paths without an agent snapshot still honor an operator disable. + """ + from types import SimpleNamespace + + if cache_disabled is None: + cache_disabled = prompt_caching_disabled_from_config() + return SimpleNamespace( + provider="", + base_url="", + api_mode="", + model="", + _cache_disabled=bool(cache_disabled), + ) + + +def plan_cache_sections_for_destination( + messages: list, + tools: Optional[list], + *, + provider: str, + base_url: str, + api_mode: str, + model: str, + cache_disabled: Optional[bool] = None, +) -> Tuple[list, list]: + """Plan request-local cache sections for one resolved destination. + + Shared core of the synchronous acting-aggregator (MoA) and auxiliary + fallback senders: resolve the cache policy for the destination's real + provider/base_url/api_mode/model, then either return stripped canonical + copies (non-caching route) or a :func:`build_prompt_cache_plan` layout + (caching route, with the direct-native tool marker when the destination + is api.anthropic.com on the Messages wire). + + Never mutates ``messages`` or ``tools`` — both return values are + request-local copies. + + ``cache_disabled`` threads the operator's ``prompt_caching.cache_ttl`` + disable into the blank policy stub. When omitted, the live config is + consulted so MoA/auxiliary paths cannot re-enable markers after the + user turned caching off (#76085). + """ + from agent.prompt_caching import ( + build_prompt_cache_plan, + strip_anthropic_cache_control, + strip_anthropic_tool_cache_control, + ) + + stub = blank_cache_policy_stub(cache_disabled) + should_cache, native_layout = anthropic_prompt_cache_policy( + stub, + provider=provider, + base_url=base_url, + api_mode=api_mode, + model=model, + ) + if not should_cache: + canonical_messages = copy.deepcopy(messages or []) + strip_anthropic_cache_control(canonical_messages) + return canonical_messages, strip_anthropic_tool_cache_control(tools) + plan = build_prompt_cache_plan( + messages, + tools, + native_anthropic=native_layout, + direct_native_tool_cache=_direct_native_anthropic_tool_cache_capability( + stub, + provider=provider, + base_url=base_url, + api_mode=api_mode, + model=model, + ), + ) + return plan.messages, plan.tools + + def anthropic_prompt_cache_policy( agent, *, @@ -1860,13 +2003,25 @@ def anthropic_prompt_cache_policy( gateway implements the Anthropic cache_control contract (MiniMax, Zhipu GLM, LiteLLM's Anthropic proxy mode all do). - Qwen / Alibaba-family models on OpenCode, OpenCode Go, and direct - Alibaba (DashScope) also honour Anthropic-style ``cache_control`` - markers on OpenAI-wire chat completions. Upstream pi-mono #3392 / - pi #3393 documented this for opencode-go Qwen. Without markers - these providers serve zero cache hits, re-billing the full prompt - on every turn. + Qwen models on OpenCode and direct Alibaba (DashScope), plus DeepSeek + models on OpenCode, also honour Anthropic-style ``cache_control`` markers + on OpenAI-wire chat completions. Upstream pi-mono #3392 / pi #3393 + documented this for opencode-go Qwen; #24617 reports the same gateway + contract for DeepSeek. Without markers these providers serve zero cache + hits, re-billing the full prompt on every turn. + + If the operator has set ``prompt_caching.cache_ttl`` to a falsy value + (``false``, ``null``, ``"off"``, etc.) in config.yaml, prompt caching + is fully disabled — this early return ensures the disable survives + ``/model`` switches, fallback re-derivation, and runtime snapshot + restoration (#33555). We check ``"_cache_disabled"`` (set by + init_agent when the disable is detected) rather than ``_cache_ttl`` + directly, because ``_cache_ttl`` is not yet set when the policy runs + during the initial ``init_agent`` call. """ + if getattr(agent, "_cache_disabled", False): + return (False, False) + eff_provider = (provider if provider is not None else agent.provider) or "" eff_base_url = base_url if base_url is not None else (agent.base_url or "") eff_api_mode = api_mode if api_mode is not None else (agent.api_mode or "") @@ -1980,16 +2135,22 @@ def anthropic_prompt_cache_policy( if is_minimax_provider or is_minimax_host: return True, True - # Qwen/Alibaba on OpenCode (Zen/Go) and native DashScope: OpenAI-wire - # transport that accepts Anthropic-style cache_control markers and - # rewards them with real cache hits. Without this branch - # qwen3.6-plus on opencode-go reports 0% cached tokens and burns - # through the subscription on every turn. + # Qwen on OpenCode (Zen/Go) and native DashScope, plus DeepSeek on + # OpenCode only: OpenAI-wire transports that accept Anthropic-style + # cache_control markers and reward them with real cache hits. Keep direct + # Alibaba specific to Qwen; its catalog does not establish the same + # contract for DeepSeek. model_is_qwen = "qwen" in model_lower + model_is_deepseek = "deepseek" in model_lower + provider_is_opencode = provider_lower in { + "opencode", "opencode-zen", "opencode-go", + } provider_is_alibaba_family = provider_lower in { "opencode", "opencode-zen", "opencode-go", "alibaba", } - if provider_is_alibaba_family and model_is_qwen: + if (provider_is_alibaba_family and model_is_qwen) or ( + provider_is_opencode and model_is_deepseek + ): # Envelope layout (native_anthropic=False): markers on inner # content parts, not top-level tool messages. Matches # pi-mono's "alibaba" cacheControlFormat. @@ -2082,6 +2243,31 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # restore, request-scoped); auxiliary_client builds its own clients and keeps # SDK retries because it is NOT wrapped by the conversation loop. client_kwargs.setdefault("max_retries", 0) + # Defense-in-depth: guarantee Copilot requests carry the integration + # headers regardless of which build path we came through. The primary + # header wiring lives in `_apply_client_headers_for_base_url`, but two + # rebuild paths (`primary_recovery`, `restore_primary` in this module) + # reconstruct the client purely from a `_primary_runtime` snapshot and do + # NOT re-run that wiring. If the snapshot's client_kwargs ever lacks + # `default_headers` (older snapshot, header-less resolver result), the + # client goes out WITHOUT `Copilot-Integration-Id: vscode-chat`; the + # Copilot server then routes it to the "copilot-language-server" integrator + # whose model allowlist omits enterprise-only models (claude-opus-4.8) → + # HTTP 400 model_not_available_for_integrator on every turn. This chokepoint + # is the single place every primary OpenAI client passes through, so filling + # missing Copilot headers here closes the whole class. We only ADD missing + # keys — never override headers a caller deliberately set. + try: + if base_url_host_matches(str(client_kwargs.get("base_url", "")), "githubcopilot.com"): + from hermes_cli.models import copilot_default_headers + existing = dict(client_kwargs.get("default_headers") or {}) + existing_lower = {k.lower() for k in existing} + for hk, hv in copilot_default_headers().items(): + if hk.lower() not in existing_lower: + existing[hk] = hv + client_kwargs["default_headers"] = existing + except Exception: + _ra().logger.debug("Copilot default-header guard skipped", exc_info=True) # Uses the module-level `OpenAI` name, resolved lazily on first # access via __getattr__ below. Tests patch via `run_agent.OpenAI`. client = _ra().OpenAI(**client_kwargs) @@ -3773,6 +3959,9 @@ def force_close_tcp_sockets(client: Any) -> int: "restore_primary_runtime", "extract_reasoning", "dump_api_request_debug", + "prompt_caching_disabled_from_config", + "blank_cache_policy_stub", + "plan_cache_sections_for_destination", "anthropic_prompt_cache_policy", "create_openai_client", "switch_model", diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 0d59d94c9c32..7457119921ed 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -24,6 +24,18 @@ from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars +from agent.secret_scope import get_secret as _get_secret + + +def _getenv(name: str, default: str = "") -> str: + """Profile-scoped replacement for os.getenv on credential reads. + + Routes through the secret scope (Workstream A): identical to os.getenv + when multiplexing is off, scope-aware (and fail-closed on an unscoped + read) when on. Mirrors the same wrapper in hermes_cli/runtime_provider.py. + """ + val = _get_secret(name, default) + return val if val is not None else default # NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls # ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.) @@ -1358,7 +1370,7 @@ def resolve_anthropic_token() -> Optional[str]: creds = read_claude_code_credentials() # 1. Hermes-managed OAuth/setup token env var - token = os.getenv("ANTHROPIC_TOKEN", "").strip() + token = _getenv("ANTHROPIC_TOKEN").strip() if token: preferred = _prefer_refreshable_claude_code_token(token, creds) if preferred: @@ -1366,7 +1378,7 @@ def resolve_anthropic_token() -> Optional[str]: return token # 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens) - cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + cc_token = _getenv("CLAUDE_CODE_OAUTH_TOKEN").strip() if cc_token: preferred = _prefer_refreshable_claude_code_token(cc_token, creds) if preferred: @@ -1385,7 +1397,7 @@ def resolve_anthropic_token() -> Optional[str]: # 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY. # This remains as a compatibility fallback for pre-migration Hermes configs. - api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() + api_key = _getenv("ANTHROPIC_API_KEY").strip() if api_key: return api_key @@ -1428,7 +1440,7 @@ def run_oauth_setup_token() -> Optional[str]: # Check env vars that may have been set for env_var in ("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN"): - val = os.getenv(env_var, "").strip() + val = _getenv(env_var).strip() if val: return val diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 96dce03fae99..8decf5dd22c4 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -14,6 +14,10 @@ 6. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN) 7. None +OpenRouter fallback cost guard: ``auxiliary.free_only: true`` restricts the +step-2 fallback to ``:free`` SKUs; ``auxiliary.openrouter_model`` overrides +the default. A one-time WARNING is logged for non-``:free`` models. + Resolution order for vision/multimodal tasks (auto mode): 1. Selected main provider, if it is one of the supported vision backends below 2. OpenRouter @@ -42,6 +46,7 @@ import contextlib import contextvars +import copy import functools import hashlib import inspect @@ -54,7 +59,7 @@ import uuid from pathlib import Path # noqa: F401 — used by test mocks from types import SimpleNamespace -from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse, parse_qs, urlunparse # NOTE: `from openai import OpenAI` is deliberately NOT at module top — the @@ -1226,11 +1231,19 @@ def _on_each_event(_event: Any) -> None: event_stream = self._client.responses.create(**stream_kwargs) try: - final = _consume_codex_event_stream( - event_stream, - model=resp_kwargs.get("model"), - on_event=_on_each_event, - ) + # Some Codex-compatible hosts accept ``stream=True`` but return + # a completed Responses object instead of an SSE iterator. Do + # not hand that object to the event consumer: typed Responses + # (and compatibility shims such as SimpleNamespace) are not + # event streams and may not be iterable at all. + if hasattr(event_stream, "output"): + final = event_stream + else: + final = _consume_codex_event_stream( + event_stream, + model=str(resp_kwargs.get("model") or model), + on_event=_on_each_event, + ) finally: close_fn = getattr(event_stream, "close", None) if callable(close_fn): @@ -2150,8 +2163,63 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: # ── Provider resolution helpers ───────────────────────────────────────────── +_paid_lane_warned: set = set() + + +def _is_free_model(model: Optional[str]) -> bool: + """True when ``model`` is an OpenRouter free SKU (``:free`` suffix).""" + return bool(model) and str(model).strip().endswith(":free") + + +def _aux_openrouter_settings() -> Tuple[bool, str]: + """Read free_only and openrouter_model from config in one pass. + + Returns (free_only, model) — defaults (False, _OPENROUTER_MODEL) on any + config-read failure. + """ + try: + from hermes_cli.config import cfg_get, load_config_readonly + + cfg = load_config_readonly() + free_only = bool(cfg_get(cfg, "auxiliary", "free_only", default=False)) + val = cfg_get(cfg, "auxiliary", "openrouter_model") + model = val.strip() if isinstance(val, str) and val.strip() else _OPENROUTER_MODEL + return free_only, model + except Exception: + return False, _OPENROUTER_MODEL + + +def _warn_paid_lane_once(model: str) -> None: + """Log a WARNING the first time a non-:free OpenRouter model is engaged.""" + if model in _paid_lane_warned: + return + _paid_lane_warned.add(model) + logger.warning( + "Auxiliary client: PAID lane engaged for auxiliary task — OpenRouter " + "fallback model %r is not a :free SKU and may incur real spend. Set " + "auxiliary.free_only: true to restrict auxiliary fallbacks to free " + "models, or auxiliary.openrouter_model to a :free model.", + model, + ) + def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]: + free_only, cfg_model = _aux_openrouter_settings() + or_model = model or cfg_model + if free_only and not _is_free_model(or_model): + logger.warning( + "Auxiliary client: auxiliary.free_only is enabled but the " + "OpenRouter fallback model %r is not a :free SKU — skipping the " + "OpenRouter fallback. Set auxiliary.openrouter_model to a :free " + "model (e.g. nvidia/nemotron-3-ultra-550b-a55b:free) or disable " + "auxiliary.free_only.", + or_model, + ) + _mark_provider_unhealthy("openrouter", ttl=60) + return None, None + if not _is_free_model(or_model): + _warn_paid_lane_once(or_model) + pool_present, entry = _select_pool_entry("openrouter") if pool_present: or_key = explicit_api_key or _pool_runtime_api_key(entry) @@ -2159,7 +2227,7 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL logger.debug("Auxiliary client: OpenRouter via pool") return _create_openai_client(api_key=or_key, base_url=base_url, - default_headers=build_or_headers()), model or _OPENROUTER_MODEL + default_headers=build_or_headers()), or_model # Pool exists but is exhausted (no usable runtime key) — fall through to # the OPENROUTER_API_KEY env-var path rather than failing outright. logger.debug("Auxiliary client: OpenRouter pool exhausted, trying OPENROUTER_API_KEY") @@ -2170,7 +2238,7 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op return None, None logger.debug("Auxiliary client: OpenRouter") return _create_openai_client(api_key=or_key, base_url=OPENROUTER_BASE_URL, - default_headers=build_or_headers()), model or _OPENROUTER_MODEL + default_headers=build_or_headers()), or_model def _describe_openrouter_unavailable() -> str: @@ -2664,6 +2732,7 @@ def _relay_sync_stream( model_name=str(kwargs.get("model") or fallback_model), finalizer=dict, metadata=metadata, + completed_response_predicate=lambda value: hasattr(value, "choices"), ) _RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "") _RUNTIME_MAIN_COMPAT_LOCK = threading.Lock() @@ -4171,23 +4240,13 @@ def _auth_refresh_provider_for_route( return normalized -def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[float]: - """Resolve a per-entry ``timeout`` for a configured fallback candidate. - - A fallback candidate previously inherited the exact timeout the primary - provider was called with. When that deadline was tuned for the primary - (or the primary simply consumed its whole budget before failing over), - the fallback aborted on the same clock even when independently healthy — - a 163k-token compression that needs ~90s on the fallback died at the - primary's 30s deadline every turn (#62452). +def _fallback_chain_entry(task: Optional[str], fb_label: str) -> Optional[Dict[str, Any]]: + """Resolve the configured ``fallback_chain`` entry a label points at. - Entries in ``auxiliary..fallback_chain`` may declare their own - ``timeout`` (seconds). This helper reads it by parsing the entry index - out of the label minted by :func:`_try_configured_fallback_chain` - (``fallback_chain[]()`` — our own stable format). Returns - ``None`` when the label is not a configured-chain candidate, the entry - has no ``timeout``, or the value is invalid — callers then keep the - task-level timeout, preserving existing behavior. + Labels minted by :func:`_try_configured_fallback_chain` carry the entry + index in our own stable format (``fallback_chain[]()``). + Returns ``None`` when the label is not a configured-chain candidate or + the index no longer resolves to a dict entry. """ if not task or not fb_label: return None @@ -4197,14 +4256,129 @@ def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[floa try: chain = _get_auxiliary_task_config(task).get("fallback_chain") entry = chain[int(m.group(1))] if isinstance(chain, list) else None - raw = entry.get("timeout") if isinstance(entry, dict) else None except Exception: return None + return entry if isinstance(entry, dict) else None + + +def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[float]: + """Resolve a per-entry ``timeout`` for a configured fallback candidate. + + A fallback candidate previously inherited the exact timeout the primary + provider was called with. When that deadline was tuned for the primary + (or the primary simply consumed its whole budget before failing over), + the fallback aborted on the same clock even when independently healthy — + a 163k-token compression that needs ~90s on the fallback died at the + primary's 30s deadline every turn (#62452). + + Entries in ``auxiliary..fallback_chain`` may declare their own + ``timeout`` (seconds). Returns ``None`` when the label is not a + configured-chain candidate, the entry has no ``timeout``, or the value + is invalid — callers then keep the task-level timeout, preserving + existing behavior. + """ + entry = _fallback_chain_entry(task, fb_label) + raw = entry.get("timeout") if entry else None if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw > 0: return float(raw) return None +def _fallback_provider_from_label(label: str) -> str: + """Recover the provider identifier from a fallback display label.""" + match = re.match(r"(?:fallback_chain\[\d+\]|main-agent)\(([^)]+)\)$", label or "") + return match.group(1).strip() if match else str(label or "").strip() + + +class _FallbackDestination(NamedTuple): + provider: str + base_url: str + api_mode: Optional[str] + model: Optional[str] + + +def _complete_fallback_destination( + provider: str, + base_url: str, + api_mode: Optional[str], + model: Optional[str], +) -> _FallbackDestination: + if not api_mode: + if _endpoint_speaks_anthropic_messages(base_url): + api_mode = "anthropic_messages" + else: + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + + runtime = resolve_runtime_provider( + requested=provider, + explicit_base_url=base_url or None, + target_model=model or "", + ) + api_mode = str(runtime.get("api_mode") or "").strip() or None + except Exception: + pass + return _FallbackDestination(provider, base_url, api_mode, model) + + +def _fallback_destination_from_entry( + entry: Dict[str, Any], + fb_client: Any, + fb_model: Optional[str], +) -> _FallbackDestination: + provider = str(entry.get("provider") or "").strip() + base_url = str( + entry.get("base_url") or getattr(fb_client, "base_url", "") or "" + ).strip() + api_mode = str( + entry.get("api_mode") or entry.get("transport") or "" + ).strip() or None + model = fb_model or str(entry.get("model") or "").strip() or None + return _complete_fallback_destination(provider, base_url, api_mode, model) + + +def _fallback_destination( + task: Optional[str], + fb_client: Any, + fb_model: Optional[str], + fb_label: str, +) -> _FallbackDestination: + """Return the resolved route identity used by a fallback request.""" + attached = getattr(fb_client, "_hermes_fallback_destination", None) + if isinstance(attached, _FallbackDestination): + return attached + + provider = _fallback_provider_from_label(fb_label) + base_url = str(getattr(fb_client, "base_url", "") or "") + api_mode = None + model = fb_model + + entry = _fallback_chain_entry(task, fb_label) + if entry is not None: + return _fallback_destination_from_entry(entry, fb_client, fb_model) + + return _complete_fallback_destination(provider, base_url, api_mode, model) + + +def _replan_synchronous_cache_sections( + messages: list, + tools: Optional[list], + *, + destination: _FallbackDestination, +) -> tuple[list, list]: + """Strip source decoration and plan one synchronous destination locally.""" + from agent.agent_runtime_helpers import plan_cache_sections_for_destination + + return plan_cache_sections_for_destination( + messages, + tools, + provider=destination.provider, + base_url=destination.base_url, + api_mode=destination.api_mode or "", + model=destination.model or "", + ) + + def _call_fallback_candidate_sync( fb_client: Any, fb_model: Optional[str], @@ -4247,36 +4421,70 @@ def _call_fallback_candidate_sync( task or "call", fb_label, fb_timeout, effective_timeout, ) effective_timeout = fb_timeout - fb_base = str(getattr(fb_client, "base_url", "") or "") + destination = _fallback_destination(task, fb_client, fb_model, fb_label) + fallback_messages, fallback_tools = _replan_synchronous_cache_sections( + messages, + tools, + destination=destination, + ) fb_kwargs = _build_call_kwargs( - fb_label, fb_model, messages, + destination.provider, destination.model, fallback_messages, temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=effective_timeout, + tools=fallback_tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base, task=task) + base_url=destination.base_url, task=task) try: return _validate_llm_response( - _relay_sync_completion(fb_client, fb_kwargs, provider=fb_label), task) + _relay_sync_completion( + fb_client, + fb_kwargs, + provider=destination.provider, + api_mode=destination.api_mode, + ), + task, + ) except Exception as fb_err: if not _is_auth_error(fb_err): raise - fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base) + fb_provider = _auth_refresh_provider_for_route( + destination.provider, destination.base_url + ) if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider): - retry_client, retry_model = _get_cached_client(fb_provider, fb_model) + retry_client, retry_model = _get_cached_client( + fb_provider, + destination.model, + base_url=destination.base_url or None, + api_mode=destination.api_mode, + ) if retry_client is not None: + retry_destination = _FallbackDestination( + fb_provider, + destination.base_url + or str(getattr(retry_client, "base_url", "") or ""), + destination.api_mode, + retry_model or destination.model, + ) + retry_messages, retry_tools = _replan_synchronous_cache_sections( + messages, + tools, + destination=retry_destination, + ) retry_kwargs = _build_call_kwargs( - fb_provider, retry_model or fb_model, messages, + retry_destination.provider, + retry_destination.model, + retry_messages, temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=effective_timeout, + tools=retry_tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) + base_url=retry_destination.base_url, task=task) try: return _validate_llm_response( _relay_sync_completion( retry_client, retry_kwargs, - provider=fb_provider, + provider=retry_destination.provider, + api_mode=retry_destination.api_mode, ), task, ) @@ -4319,43 +4527,71 @@ async def _call_fallback_candidate_async( task or "call", fb_label, fb_timeout, effective_timeout, ) effective_timeout = fb_timeout - fb_base = str(getattr(fb_client, "base_url", "") or "") + destination = _fallback_destination(task, fb_client, fb_model, fb_label) + fallback_messages, fallback_tools = _replan_synchronous_cache_sections( + messages, + tools, + destination=destination, + ) fb_kwargs = _build_call_kwargs( - fb_label, fb_model, messages, + destination.provider, destination.model, fallback_messages, temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=effective_timeout, + tools=fallback_tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base, task=task) + base_url=destination.base_url, task=task) try: return _validate_llm_response( await _relay_async_completion( fb_client, fb_kwargs, - provider=fb_label, + provider=destination.provider, + api_mode=destination.api_mode, ), task, ) except Exception as fb_err: if not _is_auth_error(fb_err): raise - fb_provider = _auth_refresh_provider_for_route(fb_label, fb_base) + fb_provider = _auth_refresh_provider_for_route( + destination.provider, destination.base_url + ) if fb_provider not in {"auto", "", None} and _refresh_provider_credentials(fb_provider): retry_client, retry_model = _get_cached_client( - fb_provider, fb_model, async_mode=True) + fb_provider, + destination.model, + async_mode=True, + base_url=destination.base_url or None, + api_mode=destination.api_mode, + ) if retry_client is not None: + retry_destination = _FallbackDestination( + fb_provider, + destination.base_url + or str(getattr(retry_client, "base_url", "") or ""), + destination.api_mode, + retry_model or destination.model, + ) + retry_messages, retry_tools = _replan_synchronous_cache_sections( + messages, + tools, + destination=retry_destination, + ) retry_kwargs = _build_call_kwargs( - fb_provider, retry_model or fb_model, messages, + retry_destination.provider, + retry_destination.model, + retry_messages, temperature=temperature, max_tokens=max_tokens, - tools=tools, timeout=effective_timeout, + tools=retry_tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) + base_url=retry_destination.base_url, task=task) try: return _validate_llm_response( await _relay_async_completion( retry_client, retry_kwargs, - provider=fb_provider, + provider=retry_destination.provider, + api_mode=retry_destination.api_mode, ), task, ) @@ -4732,14 +4968,15 @@ def _try_configured_fallback_for_unavailable_client( def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]: - """Resolve inline or env-backed API key from a fallback-chain entry.""" - explicit = str(entry.get("api_key") or "").strip() - if explicit: - return explicit - key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip() - if key_env: - return os.getenv(key_env, "").strip() or None - return None + """Resolve inline or env-backed API key from a fallback-chain entry. + + Delegates to the centralized, secret-scope-aware resolver so this path + doesn't leak another profile's credential via a raw ``os.getenv`` under + gateway multiplexing (see ``hermes_cli.fallback_config.resolve_entry_api_key``). + """ + from hermes_cli.fallback_config import resolve_entry_api_key + + return resolve_entry_api_key(entry) def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]: @@ -4751,13 +4988,21 @@ def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optio base_url = str(entry.get("base_url") or "").strip() or None api_key = _fallback_entry_api_key(entry) api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None - return resolve_provider_client( + client, resolved_model = resolve_provider_client( provider, model=model, explicit_base_url=base_url, explicit_api_key=api_key, api_mode=api_mode, ) + if client is not None: + try: + client._hermes_fallback_destination = _fallback_destination_from_entry( + entry, client, resolved_model + ) + except Exception: + pass + return client, resolved_model def _try_main_fallback_chain( @@ -8109,6 +8354,16 @@ def call_llm( kwargs["stream"] = True if stream_options: kwargs["stream_options"] = stream_options + if task == "moa_aggregator" and isinstance(client, CodexAuxiliaryClient): + # CodexAuxiliaryClient (openai-codex, xai-oauth, and any other + # Responses-shim provider) consumes the provider stream internally + # and returns a completed response object. Routing that nested + # MoA stream through Relay's generic managed stream makes the + # manager iterate the completed SimpleNamespace itself (#55933). + # Return the provider call directly; the MoA facade converts a + # completed response into a one-chunk delta iterator at its + # boundary. + return client.chat.completions.create(**kwargs) return _relay_sync_stream( client, kwargs, diff --git a/agent/background_review.py b/agent/background_review.py index bc58ac59d993..ed6df7e3ed4b 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -284,6 +284,15 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] " • One-off task narratives. A user asking 'summarize today's " "market' or 'analyze this PR' is not a class of work that warrants " "a skill.\n\n" + " • Unresolved failures: if the session ended WITHOUT actually " + "finding a working method — you tried several things, none worked, " + "and told the user to check manually — do NOT write those attempts " + "up as a 'reliable workflow' or 'recommended approach'. That presents " + "an untested sequence of failures as validated guidance a future " + "session will trust and repeat. Either say 'Nothing to save', or, " + "only if you are independently confident of a real working alternative " + "(not something you are merely guessing might work), capture ONLY that " + "alternative — never the dead ends, and never dressed up as best practice.\n\n" "If a tool failed because of setup state, capture the FIX (install " "command, config step, env var to set) under an existing setup or " "troubleshooting skill — never 'this tool does not work' as a " @@ -377,6 +386,15 @@ def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict] " • One-off task narratives. A user asking 'summarize today's " "market' or 'analyze this PR' is not a class of work that warrants " "a skill.\n\n" + " • Unresolved failures: if the session ended WITHOUT actually " + "finding a working method — you tried several things, none worked, " + "and told the user to check manually — do NOT write those attempts " + "up as a 'reliable workflow' or 'recommended approach'. That presents " + "an untested sequence of failures as validated guidance a future " + "session will trust and repeat. Either say 'Nothing to save', or, " + "only if you are independently confident of a real working alternative " + "(not something you are merely guessing might work), capture ONLY that " + "alternative — never the dead ends, and never dressed up as best practice.\n\n" "If a tool failed because of setup state, capture the FIX (install " "command, config step, env var to set) under an existing setup or " "troubleshooting skill — never 'this tool does not work' as a " diff --git a/agent/browser_provider.py b/agent/browser_provider.py index 75e88e584f31..3e96fa9c85e6 100644 --- a/agent/browser_provider.py +++ b/agent/browser_provider.py @@ -26,6 +26,7 @@ "session_name": str, # unique name for agent-browser --session "bb_session_id": str, # provider session ID (for close/cleanup) "cdp_url": str, # CDP websocket URL + "expires_at": str, # optional provider-authoritative ISO timestamp "features": dict, # feature flags that were enabled "external_call_id": str, # optional, managed-gateway billing key } @@ -96,6 +97,7 @@ def create_session(self, task_id: str) -> Dict[str, object]: "session_name": str, # unique name for agent-browser --session "bb_session_id": str, # provider session ID (for close/cleanup) "cdp_url": str, # CDP websocket URL + "expires_at": str, # optional provider-authoritative ISO timestamp "features": dict, # feature flags that were enabled } diff --git a/agent/browser_registry.py b/agent/browser_registry.py index 122eab4e565a..f5d548d7cf36 100644 --- a/agent/browser_registry.py +++ b/agent/browser_registry.py @@ -37,19 +37,27 @@ from __future__ import annotations import logging -import threading -from typing import Dict, List, Optional +from typing import List, Optional from agent.browser_provider import BrowserProvider +from agent.plugin_profile_scope import ( + bind_profile_key, + ProfileKeyLike, + ProfileProviderRegistry, + selected_profile_key, +) logger = logging.getLogger(__name__) -_providers: Dict[str, BrowserProvider] = {} -_lock = threading.Lock() +_registry: ProfileProviderRegistry[BrowserProvider] = ProfileProviderRegistry() +_lock = _registry.lock +_providers = _registry.compatibility_mapping() -def register_provider(provider: BrowserProvider) -> None: +def register_provider( + provider: BrowserProvider, *, profile_key: Optional[ProfileKeyLike] = None +) -> None: """Register a cloud browser provider. Re-registration (same ``name``) overwrites the previous entry and logs @@ -64,9 +72,7 @@ def register_provider(provider: BrowserProvider) -> None: name = provider.name if not isinstance(name, str) or not name.strip(): raise ValueError("Browser provider .name must be a non-empty string") - with _lock: - existing = _providers.get(name) - _providers[name] = provider + existing = _registry.register(name, provider, profile_key=profile_key) if existing is not None: logger.debug( "Browser provider '%s' re-registered (was %r)", @@ -79,19 +85,21 @@ def register_provider(provider: BrowserProvider) -> None: ) -def list_providers() -> List[BrowserProvider]: +def list_providers( + *, profile_key: Optional[ProfileKeyLike] = None +) -> List[BrowserProvider]: """Return all registered providers, sorted by name.""" - with _lock: - items = list(_providers.values()) + items = _registry.list(profile_key=profile_key) return sorted(items, key=lambda p: p.name) -def get_provider(name: str) -> Optional[BrowserProvider]: +def get_provider( + name: str, *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[BrowserProvider]: """Return the provider registered under *name*, or None.""" if not isinstance(name, str): return None - with _lock: - return _providers.get(name.strip()) + return _registry.get(name, profile_key=profile_key) # --------------------------------------------------------------------------- @@ -110,7 +118,9 @@ def get_provider(name: str) -> Optional[BrowserProvider]: ) -def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]: +def _resolve( + configured: Optional[str], *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[BrowserProvider]: """Resolve the active browser provider. Resolution rules (in order): @@ -143,13 +153,14 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]: matches the legacy preference; the dispatcher then falls back to local browser mode. """ - with _lock: - snapshot = dict(_providers) + key = selected_profile_key(profile_key) + snapshot = _registry.snapshot(profile_key=key) def _is_available_safe(p: BrowserProvider) -> bool: """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" try: - return bool(p.is_available()) + with bind_profile_key(key): + return bool(p.is_available()) except Exception as exc: # noqa: BLE001 logger.warning( "Browser provider %s.is_available() raised %s — treating as unavailable", @@ -188,5 +199,4 @@ def _is_available_safe(p: BrowserProvider) -> bool: def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" - with _lock: - _providers.clear() + _registry.reset_for_tests() diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 1c49a59e2a3c..e40333f0e601 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1120,9 +1120,10 @@ def _call(): -def build_api_kwargs(agent, api_messages: list) -> dict: +def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = None) -> dict: """Build the keyword arguments dict for the active API mode.""" - tools_for_api = agent.tools + if tools_for_api is None: + tools_for_api = agent.tools if agent.api_mode == "anthropic_messages": _transport = agent._get_transport() @@ -1788,19 +1789,17 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool # Pass base_url and api_key from fallback config so custom # endpoints (e.g. Ollama Cloud) resolve correctly instead of # falling through to OpenRouter defaults. + from hermes_cli.fallback_config import resolve_entry_api_key + fb_base_url_hint = (fb.get("base_url") or "").strip() or None - fb_api_key_hint = (fb.get("api_key") or "").strip() or None - if not fb_api_key_hint: - # key_env and api_key_env are both documented aliases (see - # _normalize_custom_provider_entry in hermes_cli/config.py). - fb_key_env = (fb.get("key_env") or fb.get("api_key_env") or "").strip() - if fb_key_env: - fb_api_key_hint = os.getenv(fb_key_env, "").strip() or None + fb_api_key_hint = resolve_entry_api_key(fb) # For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env # when no explicit key is in the fallback config. Host match # (not substring) — see GHSA-76xc-57q6-vm5m. if fb_base_url_hint and base_url_host_matches(fb_base_url_hint, "ollama.com") and not fb_api_key_hint: - fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None + from agent.secret_scope import get_secret + + fb_api_key_hint = get_secret("OLLAMA_API_KEY") or None fb_client, _resolved_fb_model = resolve_provider_client( fb_provider, model=fb_model, raw_codex=True, explicit_base_url=fb_base_url_hint, @@ -3979,7 +3978,7 @@ def _call(): " To avoid this delay, set display.streaming: false " "in config.yaml\n" ) - logger.info( + logger.exception( "Streaming failed before delivery: %s", e, ) diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 23708eca9729..8f64f64b76f3 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -14,6 +14,7 @@ import json import logging import re +import unicodedata import uuid from types import SimpleNamespace from typing import Any, Dict, List, Optional @@ -73,6 +74,79 @@ def _classify_responses_issuer( ) +# The ChatGPT Codex backend reserves these Harmony wire tokens. If their +# literal spellings are replayed anywhere in request text, the backend rejects +# the request before inference with ``invalid_prompt: Request blocked.``. +# Category-Cf handling covers persisted sessions from an earlier U+200B weak +# defang; fullwidth bars survive format-character stripping while keeping the +# inspected source legible. +_HARMONY_CONTROL_TOKEN_RE = re.compile( + r"<\|(start|end|channel|message|constrain|return|call)\|>" +) +_FULLWIDTH_PIPE = "\uff5c" + + +def _neutralize_harmony_tokens(text: str) -> str: + """Keep Harmony source readable without emitting reserved wire tokens.""" + if not text or "<" not in text or "|" not in text: + return text + + replacement = rf"<{_FULLWIDTH_PIPE}\1{_FULLWIDTH_PIPE}>" + if not any(unicodedata.category(char) == "Cf" for char in text): + return _HARMONY_CONTROL_TOKEN_RE.sub(replacement, text) + + # U+200B is confirmed to be stripped by the Codex backend before its + # reserved-token check. Treat every Unicode format control equivalently so + # moving the character elsewhere in the token (or swapping in another Cf) + # cannot recreate the same visually hidden form. + visible_chars: List[str] = [] + original_positions: List[int] = [] + for index, char in enumerate(text): + if unicodedata.category(char) == "Cf": + continue + visible_chars.append(char) + original_positions.append(index) + + visible_text = "".join(visible_chars) + matches = list(_HARMONY_CONTROL_TOKEN_RE.finditer(visible_text)) + if not matches: + return text + + result: List[str] = [] + original_cursor = 0 + for match in matches: + original_start = original_positions[match.start()] + original_end = original_positions[match.end() - 1] + 1 + result.append(text[original_cursor:original_start]) + result.append(f"<{_FULLWIDTH_PIPE}{match.group(1)}{_FULLWIDTH_PIPE}>") + original_cursor = original_end + result.append(text[original_cursor:]) + return "".join(result) + + +def _neutralize_harmony_structure(value: Any) -> Any: + """Neutralize JSON-like values; normalize tuples and reject unsafe keys. + + Rewriting an object key could desynchronize a tool schema from the executor + contract, so a reserved token there is rejected explicitly instead. + """ + if isinstance(value, str): + return _neutralize_harmony_tokens(value) + if isinstance(value, (list, tuple)): + return [_neutralize_harmony_structure(item) for item in value] + if isinstance(value, dict): + normalized = {} + for key, item in value.items(): + if isinstance(key, str) and _neutralize_harmony_tokens(key) != key: + raise ValueError( + "Reserved Harmony tokens in a JSON object key cannot be " + "neutralized without changing its contract." + ) + normalized[key] = _neutralize_harmony_structure(item) + return normalized + return value + + # --------------------------------------------------------------------------- # Multimodal content helpers # --------------------------------------------------------------------------- @@ -627,10 +701,16 @@ def _preflight_codex_input_items( raw_items: Any, *, is_github_responses: bool = False, + sanitize_harmony_tokens: bool = False, ) -> List[Dict[str, Any]]: if not isinstance(raw_items, list): raise ValueError("Codex Responses input must be a list of input items.") + sanitize_text = ( + _neutralize_harmony_tokens + if sanitize_harmony_tokens + else lambda text: text + ) normalized: List[Dict[str, Any]] = [] seen_ids: set = set() for idx, item in enumerate(raw_items): @@ -651,7 +731,7 @@ def _preflight_codex_input_items( arguments = json.dumps(arguments, ensure_ascii=False) elif not isinstance(arguments, str): arguments = str(arguments) - arguments = arguments.strip() or "{}" + arguments = sanitize_text(arguments.strip() or "{}") normalized.append( { @@ -685,7 +765,7 @@ def _preflight_codex_input_items( if ptype == "input_text": text = part.get("text") if isinstance(text, str) and text: - cleaned.append({"type": "input_text", "text": text}) + cleaned.append({"type": "input_text", "text": sanitize_text(text)}) elif ptype == "input_image": url = part.get("image_url") if isinstance(url, str) and url: @@ -709,7 +789,7 @@ def _preflight_codex_input_items( { "type": "function_call_output", "call_id": call_id.strip(), - "output": output, + "output": sanitize_text(output), } ) continue @@ -722,14 +802,21 @@ def _preflight_codex_input_items( if item_id in seen_ids: continue seen_ids.add(item_id) - reasoning_item = {"type": "reasoning", "encrypted_content": encrypted} + reasoning_item: Dict[str, Any] = { + "type": "reasoning", + "encrypted_content": encrypted, + } # Do NOT include the "id" in the outgoing item — with # store=False (our default) the API tries to resolve the # id server-side and returns 404. The id is still used # above for local deduplication via seen_ids. summary = item.get("summary") if isinstance(summary, list): - reasoning_item["summary"] = summary + reasoning_item["summary"] = ( + _neutralize_harmony_structure(summary) + if sanitize_harmony_tokens + else summary + ) else: reasoning_item["summary"] = [] normalized.append(reasoning_item) @@ -758,7 +845,7 @@ def _preflight_codex_input_items( text = "" if not isinstance(text, str): text = str(text) - normalized_content.append({"type": "output_text", "text": text}) + normalized_content.append({"type": "output_text", "text": sanitize_text(text)}) if not normalized_content: raise ValueError(f"Codex Responses input[{idx}] message item must contain at least one text part.") normalized_item: Dict[str, Any] = { @@ -798,7 +885,7 @@ def _preflight_codex_input_items( for part_idx, part in enumerate(content): if isinstance(part, str): if part: - validated.append({"type": text_type, "text": part}) + validated.append({"type": text_type, "text": sanitize_text(part)}) continue if not isinstance(part, dict): raise ValueError( @@ -809,7 +896,7 @@ def _preflight_codex_input_items( text = part.get("text", "") if not isinstance(text, str): text = str(text or "") - validated.append({"type": text_type, "text": text}) + validated.append({"type": text_type, "text": sanitize_text(text)}) elif ptype in {"input_image", "image_url"}: image_ref = part.get("image_url", "") detail = part.get("detail") @@ -833,7 +920,7 @@ def _preflight_codex_input_items( if not isinstance(content, str): content = str(content) - normalized.append({"role": role, "content": content}) + normalized.append({"role": role, "content": sanitize_text(content)}) continue raise ValueError( @@ -848,6 +935,7 @@ def _preflight_codex_api_kwargs( *, allow_stream: bool = False, is_github_responses: bool = False, + sanitize_harmony_tokens: bool = False, ) -> Dict[str, Any]: if not isinstance(api_kwargs, dict): raise ValueError("Codex Responses request must be a dict.") @@ -868,10 +956,13 @@ def _preflight_codex_api_kwargs( if not isinstance(instructions, str): instructions = str(instructions) instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY + if sanitize_harmony_tokens: + instructions = _neutralize_harmony_tokens(instructions) normalized_input = _preflight_codex_input_items( api_kwargs.get("input"), is_github_responses=is_github_responses, + sanitize_harmony_tokens=sanitize_harmony_tokens, ) tools = api_kwargs.get("tools") @@ -928,6 +1019,9 @@ def _preflight_codex_api_kwargs( } ) + if sanitize_harmony_tokens and normalized_tools is not None: + normalized_tools = _neutralize_harmony_structure(normalized_tools) + store = api_kwargs.get("store", False) if store is not False: raise ValueError("Codex Responses contract requires 'store' to be false.") diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 7b8bed6137bb..c01084c4a449 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1365,12 +1365,6 @@ def _interrupt_or_superseded() -> bool: on_event=_on_event, interrupt_check=_interrupt_or_superseded, ) - # The terminal SSE frame is contractually last. Request the - # end-of-stream marker so Relay can run its response finalizer - # and close the physical attempt scope before Hermes returns. - if not agent._interrupt_requested: - for _ignored in event_stream: - pass except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: if attempt < max_stream_retries: logger.debug( @@ -1386,6 +1380,25 @@ def _interrupt_or_superseded() -> bool: return event_stream.final_response raise + # A terminal response has already been assembled at this point + # (``final`` is built), so a transport error while draining the + # rest of the iterator — done only to let Relay run its response + # finalizer — must NOT discard it or trigger a new physical + # request. Record it as a non-fatal finalization warning and + # still return the already-completed, already-billed response. + if not agent._interrupt_requested: + try: + for _ignored in event_stream: + pass + except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: + logger.warning( + "Codex Responses stream transport finalization failed " + "after a terminal response was already received; " + "returning the completed response instead of " + "retrying. %s error=%s", + agent._client_log_context(), exc, + ) + if final.status in {"incomplete", "failed"}: logger.warning( "Codex Responses stream terminal status=%s " diff --git a/agent/context_compressor.py b/agent/context_compressor.py index c4a58c10ac8c..e8688d253b2f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -636,6 +636,12 @@ def _collect_protected_skill_names( # high for small/light tails, but using all 20 as a hard floor here would bring # back the old large-tool-output case where nothing can be compacted. _MAX_TAIL_MESSAGE_FLOOR = 8 + +# Pre-LLM feasibility skip (#60451): when the compressible middle is below +# this fraction of threshold_tokens (and a prior real-usage ineffectiveness +# strike exists), skip the LLM summary call — deterministic dropping alone +# recovers the negligible savings such a summary could deliver. +_FEASIBILITY_SKIP_MIDDLE_FRACTION = 0.10 # Under context pressure (protected-tail tool bodies alone exceed the soft # tail budget), demote large completed tool/file outputs even inside the # protected region — but always keep this many trailing messages verbatim so @@ -764,15 +770,52 @@ def _serialized_length_for_budget(value: Any) -> int: # Responses sessions in particular carry ``codex_reasoning_items`` blobs of # ``encrypted_content`` that can dominate the serialized session (a measured # 214-turn session held ~115K tokens / 27% of its payload there — #55572). +# +# ``reasoning_details`` is handled separately (see +# ``_reasoning_details_text_chars``): its signed/base64 envelope is excluded +# from the budget, mirroring the preflight estimator's exclusion in +# ``model_metadata._estimate_message_tokens_without_images`` (#73298). _REPLAY_BUDGET_KEYS = ( "reasoning", "reasoning_content", - "reasoning_details", "codex_reasoning_items", "codex_message_items", ) +def _reasoning_details_text_chars(value: Any) -> int: + """Textual thinking chars inside a ``reasoning_details`` envelope. + + ``reasoning_details`` carries provider thinking blocks: the actual + thinking TEXT plus opaque signed/base64 envelope blobs (Anthropic + ``signature``, redacted ``data``, encrypted payloads). The envelope is + never billed at anything near chars/4 by the provider and — on every + transport except Codex Responses — is replayed for at most the newest + assistant turn, so charging it on every message inflated the tail-budget + walk and silently shrank the surviving tail (#73298, second site). + + Count only the thinking text (the #51800 lesson: real reasoning text + MUST stay visible to the budget), skip everything else. + """ + if not value: + return 0 + if isinstance(value, str): + return len(value) + total = 0 + if isinstance(value, dict): + value = [value] + if isinstance(value, list): + for part in value: + if isinstance(part, str): + total += len(part) + elif isinstance(part, dict): + for text_key in ("thinking", "text", "summary"): + text = part.get(text_key) + if isinstance(text, str): + total += len(text) + return total + + def _estimate_msg_budget_tokens(msg: dict) -> int: """Token estimate for one message in the tail-protection budget walks. @@ -804,6 +847,17 @@ def _estimate_msg_budget_tokens(msg: dict) -> int: tokens += estimate_tokens_rough(str(tc)) for key in _REPLAY_BUDGET_KEYS: tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN + # reasoning_details: charge only the thinking TEXT, never the signed / + # base64 envelope (#73298 second site; mirrors the preflight estimator's + # exclusion in model_metadata). When the same thinking text already rides + # in ``reasoning``/``reasoning_content`` (measured byte-identical on + # Anthropic-wire sessions), skip it here entirely so the prose is not + # charged twice on top of the envelope exclusion. + if not (msg.get("reasoning") or msg.get("reasoning_content")): + tokens += ( + _reasoning_details_text_chars(msg.get("reasoning_details")) + // _CHARS_PER_TOKEN + ) return tokens @@ -1281,11 +1335,13 @@ def on_session_reset(self) -> None: self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False + self._last_feasibility_skip = False self._last_aux_model_failure_error = None 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._prellm_skip_count = 0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False self._last_compression_made_progress = False @@ -1337,6 +1393,7 @@ def _begin_compression_telemetry( "protected_head_tokens": None, "protected_tail_tokens": None, "middle_window_tokens": None, + "prellm_skip_count": 0, "aux_prompt_tokens": None, "aux_output_reservation": None, "aux_provider": "", @@ -1547,11 +1604,13 @@ def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> Non self._consecutive_timeout_failures = 0 self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False + self._last_feasibility_skip = False self._last_aux_model_failure_error = None 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._prellm_skip_count = 0 self._fallback_compression_streak = 0 self._verify_compaction_cleared_threshold = False self._last_compression_made_progress = False @@ -1578,6 +1637,7 @@ def bind_session_state(self, session_db: Any = None, session_id: str = "") -> No self._consecutive_timeout_failures = 0 self._fallback_compression_streak = 0 self._ineffective_compression_count = 0 + self._prellm_skip_count = 0 self._anti_thrash_recovery_deadline = 0.0 self.get_active_compression_failure_cooldown() self._load_fallback_compression_streak() @@ -1722,9 +1782,34 @@ def _record_ineffective_compression_verdict(self, count: int) -> None: self._ineffective_compression_count = count self._persist_ineffective_compression_count() - def record_completed_compaction(self, *, used_fallback: bool = False) -> None: - """Record one completed boundary and its summary quality.""" + def record_completed_compaction( + self, *, used_fallback: bool = False, feasibility_skip: bool = False, + ) -> None: + """Record one completed boundary and its summary quality. + + ``feasibility_skip=True`` marks a deliberate pre-LLM skip (#60451): + the boundary is streak-NEUTRAL for ``_fallback_compression_streak`` + (neither incremented nor reset). It still arms the real-usage + effectiveness verdict (``_verify_compaction_cleared_threshold``) on + purpose — a skipped-summary drop that fails to clear the threshold is + exactly the incompressible-transcript case the ineffective-strike + breaker exists for, and its recovery probe bounds the block. + """ self._verify_compaction_cleared_threshold = True + if feasibility_skip: + # A deliberate pre-LLM feasibility skip (#60451) is not a + # summary-quality verdict: it must neither extend a fallback + # streak (two skips would otherwise latch the >= 2 breaker and + # disable compression entirely — including the cheap deterministic + # dropping the skip exists to reach) nor reset one (a skip proves + # nothing about the summary model's health). + if not self.quiet_mode: + logger.info( + "Compaction completed via pre-LLM feasibility skip; " + "fallback_compression_streak unchanged (%d)", + self._fallback_compression_streak, + ) + return if used_fallback: self._fallback_compression_streak += 1 if not self.quiet_mode: @@ -1934,6 +2019,7 @@ def update_model( # trigger invalidates them. Keep the durable copy in sync so a # restart doesn't resurrect strikes this recalibration just voided. self._record_ineffective_compression_verdict(0) + self._prellm_skip_count = 0 if runtime_changed: self._fallback_compression_streak = 0 self._persist_fallback_compression_streak() @@ -2166,6 +2252,10 @@ def __init__( self._micro_compact_consecutive_failures: int = 0 self._micro_compact_last_failure_cursor: int = -1 self._micro_compact_defrag_threshold_tokens: int = 2000 + # Set by _defrag_rolling_summary when it pops _DB_PERSISTED_MARKER + # from a live dict in place; consumed by finalize_turn to invalidate + # the agent's bounded flush-scan cursor (sibling of the #75170 site). + self._flush_scan_cursor_invalidated: bool = False self._micro_compact_passes: int = 0 self._micro_compact_tokens_saved_total: int = 0 # Cadence: run a pass every Nth completed turn. Each pass rewrites @@ -2227,6 +2317,9 @@ def __init__( # 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 + # Pre-LLM feasibility skips (#60451). Observability only; NEVER feeds + # the ineffectiveness strike latch or the fallback streak breaker. + self._prellm_skip_count: int = 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. @@ -2248,6 +2341,7 @@ def __init__( # (gateway hygiene, /compress) can surface a visible warning. self._last_summary_dropped_count: int = 0 self._last_summary_fallback_used: bool = False + self._last_feasibility_skip: bool = False # When summary generation fails we now ABORT compression entirely # and return the original messages unchanged instead of dropping # the middle window with a static placeholder. Callers inspect @@ -4236,6 +4330,12 @@ def _find_context_summaries( end: int, ) -> list[tuple[int, str]]: """Find handoff summaries inside a compression window.""" + n = len(messages) + # Defensive: clamp bounds so a caller passing an out-of-range end + # (e.g. tail-cut returning len(messages)+1 when head_end >= n) + # cannot trigger IndexError. (#75588) + start = max(0, min(start, n)) + end = max(start, min(end, n)) summaries: list[tuple[int, str]] = [] for idx in range(start, end): content = messages[idx].get("content") @@ -5005,7 +5105,7 @@ def _find_tail_cut_by_tokens( # exists to prevent. Re-align FORWARD (never backward, which would give # the floor's message back) so a raised cut skips to the end of the # group and the whole call/result pair is summarised together. - return self._align_boundary_forward(messages, max(cut_idx, head_end + 1)) + return min(n, self._align_boundary_forward(messages, max(cut_idx, head_end + 1))) # ------------------------------------------------------------------ # ContextEngine: manual /compress preflight @@ -5322,6 +5422,15 @@ def _defrag_rolling_summary( # Content changed after a possible flush — clear the persisted # stamp so the DB sync/flush rewrites the row. entry.pop(_DB_PERSISTED_MARKER, None) + # Sibling of the finalize_turn pop site (#75170): this pop + # also strips the marker from a LIVE dict in place, so the + # bounded flush-scan cursor would identity-skip the rewritten + # marker and the defragged summary would never reach state.db. + # The compressor holds no agent reference, so raise a flag the + # finalizer consumes to invalidate agent._db_flush_scan_prefix. + # (The pop sites at module scope — fresh copies in + # strip-marker helpers — break identity and need no flag.) + self._flush_scan_cursor_invalidated = True break logger.info( "Micro-compaction defrag: rolling summary re-summarized " @@ -5784,7 +5893,11 @@ def compress( 1. Prune old tool results (cheap pre-pass, no LLM call) 2. Protect head messages (system prompt + first exchange) 3. Find tail boundary by token budget (~20K tokens of recent context) - 4. Summarize middle turns with structured LLM prompt + 4. Summarize middle turns with structured LLM prompt (skipped + pre-LLM when the middle is below + ``_FEASIBILITY_SKIP_MIDDLE_FRACTION`` of the threshold after a + prior real-usage ineffectiveness strike — the deterministic + fallback drop recovers the negligible savings instead) 5. On re-compression, iteratively update the previous summary Blank platform-echo user rows trailing the latest actionable user @@ -5804,7 +5917,9 @@ def compress( everything else. Inspired by Claude Code's ``/compact``. force: If True, clear any active summary-failure cooldown before running so a manual ``/compress`` can retry immediately after - an auto-compression abort. Auto-compress callers pass False. + an auto-compression abort, and bypass the pre-LLM feasibility + skip so an explicit user request always exercises the full + summary path. Auto-compress callers pass False. memory_context: Optional provider-supplied context to preserve in the summary prompt. Whitespace-only values are ignored. """ @@ -5812,6 +5927,7 @@ def compress( # after compress() returns to decide whether to surface a warning. self._last_summary_dropped_count = 0 self._last_summary_fallback_used = False + self._last_feasibility_skip = False self._last_summary_error = None self._last_aux_model_failure_error = None self._last_aux_model_failure_model = None @@ -6094,12 +6210,65 @@ def _window_row(idx: int, msg: Dict[str, Any]): ) # Phase 3: Generate structured summary - summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages) - summary = self._generate_summary( - turns_to_summarize, - focus_topic=summary_focus_topic, - memory_context=memory_context, - ) + + # Pre-LLM feasibility check: if the middle section is too small to + # yield meaningful token savings, skip the expensive LLM summarization + # call and fall through to the deterministic message-dropping path + # (which is cheap and always applicable). Without this guard a + # tool-heavy session where the protected tail already holds most of + # the tokens can burn 500+ seconds on a summary call that replaces a + # few lightweight messages, leaving the total token count essentially + # unchanged. + # + # Only fires after at least one prior real-usage ineffectiveness + # strike. The check READS ``_ineffective_compression_count`` but + # never writes it: that strike counter is fed exclusively by real + # provider token counts (see the anti-thrashing verdict in + # _update_token_usage), and consumers latch at >= 2 to disable + # compression entirely. Feasibility skips are tracked separately + # in ``_prellm_skip_count`` for observability. + # + # Skipped when ``force=True`` (manual /compress) so auth/error + # handling paths are always exercised on explicit user request. + feasibility_skip = False + if not force and self._ineffective_compression_count >= 1: + # _record_compression_regions already estimated this exact window + # into the telemetry dict above; reuse it so the log line and + # telemetry can never disagree. The regions helper no-ops when the + # telemetry attr isn't a dict, so fall back to a fresh estimate + # when the key is absent/None (0 is a legitimate value). + middle_tokens = telemetry.get("middle_window_tokens") + if middle_tokens is None: + middle_tokens = estimate_messages_tokens_rough(turns_to_summarize) + if middle_tokens < int( + self.threshold_tokens * _FEASIBILITY_SKIP_MIDDLE_FRACTION + ): + feasibility_skip = True + self._last_feasibility_skip = True + self._prellm_skip_count += 1 + telemetry["prellm_skip_count"] = self._prellm_skip_count + if not self.quiet_mode: + logger.warning( + "Compression: middle section (%d tokens at indices " + "%d-%d) is below %.0f%% of threshold (%d tokens) — " + "skipping LLM summarization, proceeding with " + "deterministic message dropping. prellm_skip_count=%d", + middle_tokens, compress_start, compress_end, + _FEASIBILITY_SKIP_MIDDLE_FRACTION * 100, + self.threshold_tokens, self._prellm_skip_count, + ) + + if feasibility_skip: + summary = None # No LLM call; Phase 4 inserts the deterministic fallback + else: + # Deriving the auto focus topic scans recent user turns — only pay + # for it when a summary will actually be generated. + summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages) + summary = self._generate_summary( + turns_to_summarize, + focus_topic=summary_focus_topic, + memory_context=memory_context, + ) # If summary generation failed, behavior splits on # ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure): @@ -6121,7 +6290,7 @@ def _window_row(idx: int, msg: Dict[str, Any]): # of these cases, rotating into a child session with a placeholder # summary degrades the conversation for zero benefit. Preserve it # unchanged until access is restored or connectivity recovers. - if not summary and ( + if not summary and not feasibility_skip and ( self.abort_on_summary_failure or self._last_summary_auth_failure or self._last_summary_network_failure @@ -6201,15 +6370,26 @@ def _window_row(idx: int, msg: Dict[str, Any]): # content-free "N messages were removed" marker. if not summary: if not self.quiet_mode: - logger.warning("Summary generation failed — inserting deterministic fallback context summary") + if feasibility_skip: + logger.info("Feasibility skip — inserting deterministic fallback context summary") + else: + logger.warning("Summary generation failed — inserting deterministic fallback context summary") n_dropped = compress_end - compress_start self._last_summary_dropped_count = n_dropped self._last_summary_fallback_used = True telemetry["fallback_used"] = True - telemetry["failure_class"] = telemetry.get("failure_class") or "summary_generation_failed" + if feasibility_skip: + # Deliberate optimization, not a summary failure — keep the + # telemetry class distinct so dashboards don't count skips + # as aux-model breakage. + telemetry["failure_class"] = telemetry.get("failure_class") or "feasibility_skip" + else: + telemetry["failure_class"] = telemetry.get("failure_class") or "summary_generation_failed" summary = self._build_static_fallback_summary( turns_to_summarize, - reason=self._last_summary_error, + # A stale error from an earlier real failure must not be + # embedded into a deliberate feasibility skip's fallback. + reason=None if feasibility_skip else self._last_summary_error, ) tail_messages: List[Dict[str, Any]] = [] @@ -6258,16 +6438,18 @@ def _window_row(idx: int, msg: Dict[str, Any]): None, ) first_tail_role = None + first_tail_visible_idx: Optional[int] = None if tail_messages: - first_tail_role = next( + first_tail_visible_idx, first_tail_role = next( ( - role - for role in ( - _template_visible_role(m) for m in tail_messages + (idx, role) + for idx, role in ( + (idx, _template_visible_role(m)) + for idx, m in enumerate(tail_messages) ) if role is not None ), - None, + (None, None), ) # When the only protected head message is the system prompt, the # summary becomes the first *visible* message in the API request @@ -6294,11 +6476,27 @@ def _window_row(idx: int, msg: Dict[str, Any]): # If no user-role message survives in either the protected head or the # preserved tail, the summary MUST carry role="user" so the request # always has at least one user turn. + # + # A bare role check is not enough: the tail's sole surviving user + # turn can be image-only (a screenshot with no caption). The newest + # image-bearing user message is the ``_strip_historical_media`` + # anchor and is kept byte-for-byte, so it never gains a text + # placeholder — its role is "user" but its text content is empty, + # which backends checking for actual query text still reject. Count + # only user messages with non-empty text as "surviving"; when the + # guard fires, the real (never fabricated) summary text lands in a + # role="user" slot, which is always non-empty (falls back to + # ``_build_static_fallback_summary`` above when generation fails). if not _force_user_leading: + def _is_nonempty_user_turn(message: Dict[str, Any]) -> bool: + return message.get("role") == "user" and bool( + _content_text_for_contains(message.get("content")).strip() + ) + _user_survives = any( - message.get("role") == "user" for message in compressed + _is_nonempty_user_turn(message) for message in compressed ) or any( - message.get("role") == "user" for message in tail_messages + _is_nonempty_user_turn(message) for message in tail_messages ) if not _user_survives: _force_user_leading = True @@ -6354,9 +6552,27 @@ def _window_row(idx: int, msg: Dict[str, Any]): ), }) + # Default merge target: literal tail index 0. For an ordinary + # alternation collision the summary only has to stay *invisible* to + # the template, and a leading template-exempt row (bare tool-call + # assistant message, tool result) is the ideal carrier — it absorbs + # the summary without adding a visible turn, and it leaves the live + # tail user message intact as the model's actual prompt. Retargeting + # to the first template-visible row here would convert that live + # request into the summary carrier for no benefit. + # + # The forced repair path is the exception. There the merge is not + # about alternation but about guaranteeing at least one genuinely + # non-empty role="user" message (an image-only or otherwise + # text-empty surviving user row). An exempt carrier cannot satisfy + # that invariant, so the summary text must land on the + # template-visible row itself. + _merge_target_idx = 0 + if _force_user_leading and first_tail_visible_idx is not None: + _merge_target_idx = first_tail_visible_idx for tail_idx, msg in enumerate(tail_messages): - if _merge_summary_into_tail and tail_idx == 0: - # Merge the summary into the first (post-strip) tail message. + if _merge_summary_into_tail and tail_idx == _merge_target_idx: + # Merge the summary into the tail message that collided. old_content = msg.get("content", "") if _force_user_leading and summary_role == "user": # The summary must be part of the first user-visible diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 8308b618da76..81b8ca71ac16 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1838,6 +1838,9 @@ def _release_lock() -> None: _compression_used_fallback = bool( getattr(agent.context_compressor, "_last_summary_fallback_used", False) ) + _compression_feasibility_skip = bool( + getattr(agent.context_compressor, "_last_feasibility_skip", False) + ) # If compression aborted (aux LLM failed to produce a usable summary) # the compressor returns the input messages unchanged. Surface the @@ -2348,6 +2351,7 @@ def _release_lock() -> None: record_boundary( agent.context_compressor, used_fallback=_compression_used_fallback, + feasibility_skip=_compression_feasibility_skip, ) else: agent.context_compressor._verify_compaction_cleared_threshold = True diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 3ca96898cf99..b7e3f9afc1c7 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -70,8 +70,9 @@ ) from agent.process_bootstrap import _install_safe_stdio from agent.prompt_caching import ( - apply_anthropic_cache_control, + build_prompt_cache_plan, strip_anthropic_cache_control, + strip_anthropic_tool_cache_control, ) from agent.retry_utils import ( adaptive_rate_limit_backoff, @@ -194,6 +195,54 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text agent._stream_needs_break = True +def _is_copilot_provider(agent: Any) -> bool: + """Delegate to ``AIAgent._is_copilot_provider`` (single owner of the check). + + ``agent.provider`` is not always the normalized ``copilot`` slug — + ``/model`` and profile configs can leave the alias ``github-copilot`` (or + ``github``) in place, and a bare ``provider == "copilot"`` gate silently + skips credential recovery for those spellings. + """ + try: + return bool(agent._is_copilot_provider()) + except Exception: + return (getattr(agent, "provider", "") or "").strip().lower() in { + "copilot", + "github-copilot", + "github", + } + + +def _is_stale_copilot_credential_error(status_code: Optional[int], error_message: str) -> bool: + """Detect a Copilot 400 that is really a STALE / DEGRADED credential. + + Copilot surfaces a stale or degraded credential as an HTTP 400 rather than a + clean 401. Two body markers indicate this class: + + - ``model_not_available_for_integrator`` — the request reached the + restricted ``copilot-language-server`` integrator (the server's fallback + when it receives a raw OAuth token instead of an exchanged API token), + whose model allowlist omits enterprise-only models. + - ``model_not_supported`` / "the requested model is not supported" — the + cached bearer's Copilot entitlement rotated out from under a long-lived + process. + + Matched narrowly (status 400 AND a specific marker) so a genuinely wrong + model name — a real 400 — never triggers the single-shot re-exchange. The + caller enforces copilot-provider scoping and the single-shot guard. + """ + lowered = (error_message or "").lower() + is_400 = status_code == 400 or "error code: 400" in lowered + if not is_400: + return False + return ( + "model_not_available_for_integrator" in lowered + or "not available for integrator" in lowered + or "model_not_supported" in lowered + or "the requested model is not supported" in lowered + ) + + def _image_error_max_dimension(error: Exception) -> Optional[int]: """Extract a provider-reported image dimension ceiling, if present.""" parts = [] @@ -893,7 +942,8 @@ def _redecorate_prompt_cache_for_provider( *, system_message=None, moa_prepared: Optional[Dict[str, Any]] = None, -) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: + tools_for_api: Optional[List[Dict[str, Any]]] = None, +) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]] | tuple[List[Dict[str, Any]], Optional[Dict[str, Any]], List[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 @@ -903,10 +953,9 @@ def _redecorate_prompt_cache_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. + reasoning_details recoveries already applied), never a pristine + pre-decoration snapshot. MoA guidance is peeled and rebased without + decoration; the acting aggregator plans its resolved destination later. """ messages: List[Dict[str, Any]] = [ dict(m) if isinstance(m, dict) else m for m in (api_messages or []) @@ -917,6 +966,21 @@ def _redecorate_prompt_cache_for_provider( messages = _peel_moa_guidance(messages, guidance) strip_anthropic_cache_control(messages) + planned_tools = strip_anthropic_tool_cache_control( + tools_for_api if tools_for_api is not None else getattr(agent, "tools", []) + ) + + if prepared is not None and getattr(agent, "provider", None) == "moa": + # Prepared MoA state is canonical: the synchronous acting-aggregator + # sender owns its destination-local cache plan after it resolves the slot. + 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"] + if tools_for_api is None: + return messages, prepared + return messages, prepared, planned_tools # Direct attribute access matches the call-block decoration site — the # flags are unconditionally initialized on AIAgent, and a getattr @@ -924,31 +988,25 @@ def _redecorate_prompt_cache_for_provider( 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( + direct_tool_cache = getattr( + agent, + "_direct_native_anthropic_tool_cache_capability", + lambda: False, + )() + plan = build_prompt_cache_plan( messages, + planned_tools, cache_ttl=agent._cache_ttl, native_anthropic=agent._use_native_cache_layout, static_system_prefix=static if isinstance(static, str) else None, + direct_native_tool_cache=direct_tool_cache, ) + messages = plan.messages + planned_tools = plan.tools - 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 + if tools_for_api is None: + return messages, prepared + return messages, prepared, planned_tools def _apply_context_engine_selection( @@ -1700,12 +1758,8 @@ def run_conversation( # regardless of ordering (a single-space pad here previously had to # be sequenced after normalization to survive, forking the concept). - # 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). + # Build the request-local cache sections only after every transcript + # mutation. The canonical tool registry stays undecorated. # # Runs LAST, after every message mutation above. Marking earlier # defeats the prefix stability the mutations exist to create: @@ -1720,10 +1774,12 @@ def run_conversation( # 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: + tools_for_api = agent.tools + if agent._use_prompt_caching and agent.provider != "moa": _static_system_prefix = getattr(agent, "_cached_system_prompt_static", None) - api_messages = apply_anthropic_cache_control( + _initial_cache_plan = build_prompt_cache_plan( api_messages, + tools_for_api, cache_ttl=agent._cache_ttl, native_anthropic=agent._use_native_cache_layout, static_system_prefix=( @@ -1731,7 +1787,10 @@ def run_conversation( if isinstance(_static_system_prefix, str) else None ), + direct_native_tool_cache=agent._direct_native_anthropic_tool_cache_capability(), ) + api_messages = _initial_cache_plan.messages + tools_for_api = _initial_cache_plan.tools # Build a persistent-MoA request before measuring compression pressure. # MoA reference output is injected into the aggregator prompt, but it @@ -2067,15 +2126,22 @@ def run_conversation( # 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 = ( + api_messages, _moa_prepared_request, tools_for_api = ( _redecorate_prompt_cache_for_provider( agent, api_messages, system_message=system_message, moa_prepared=_moa_prepared_request, + tools_for_api=tools_for_api, ) ) - api_kwargs = agent._build_api_kwargs(api_messages) + if tools_for_api == agent.tools: + api_kwargs = agent._build_api_kwargs(api_messages) + else: + api_kwargs = agent._build_api_kwargs( + api_messages, + tools_for_api=tools_for_api, + ) if agent._force_ascii_payload: _sanitize_structure_non_ascii(api_kwargs) if agent.api_mode == "codex_responses": @@ -2083,6 +2149,7 @@ def run_conversation( api_kwargs, allow_stream=False, is_github_responses=agent._is_copilot_url(), + sanitize_harmony_tokens=agent._is_codex_backend(), ) # Copilot x-initiator: the first API call of a user turn is # marked "user" so Copilot bills a premium request; tool-loop @@ -2242,6 +2309,7 @@ def _perform_api_call(next_api_kwargs): next_api_kwargs, allow_stream=False, is_github_responses=agent._is_copilot_url(), + sanitize_harmony_tokens=agent._is_codex_backend(), ) if _use_streaming: return agent._interruptible_streaming_api_call( @@ -3859,7 +3927,7 @@ def _perform_api_call(next_api_kwargs): print(f"{agent.log_prefix} • Verify stored credentials: {_dhh}/auth.json") print(f"{agent.log_prefix} • Switch providers temporarily: /model --provider openrouter") if ( - agent.provider == "copilot" + _is_copilot_provider(agent) and status_code == 401 and not _retry.copilot_auth_retry_attempted ): @@ -4698,6 +4766,13 @@ def _perform_api_call(next_api_kwargs): provider=agent.provider, api_mode=agent.api_mode, ) + # Persist an explicit provider-reported limit before + # compression/retry. The next request can be rate + # limited, omit usage, or the process can restart; none + # of those should discard metadata the provider already + # confirmed. Keep the probe flags as a best-effort + # post-success retry if this write cannot complete. + save_context_length(agent.model, agent.base_url, new_ctx) # Context probing flags — only set on built-in # compressor (plugin engines manage their own). This # value came from the provider, so it is safe to cache. @@ -4865,6 +4940,32 @@ def _perform_api_call(next_api_kwargs): ) and not is_context_length_error if is_client_error: + # Copilot self-heal BEFORE fallback: a stale/degraded + # credential surfaces as a 400 + # ``model_not_available_for_integrator`` / + # ``model_not_supported`` (not a clean 401), so the 401 + # refresh path above never fired. Force a fresh token + # exchange + client rebuild and retry once on the SAME + # provider — a fresh 437-char API token routes to the + # correct integrator and the model becomes available again. + # Single-shot guard prevents looping on a genuinely + # unavailable model. Copilot-scoped so other providers' + # real 400s are untouched. + if ( + _is_copilot_provider(agent) + and not _retry.copilot_stale_cred_retry_attempted + and _is_stale_copilot_credential_error( + status_code, str(getattr(api_error, "message", "") or api_error) + ) + ): + _retry.copilot_stale_cred_retry_attempted = True + if agent._try_recover_stale_copilot_credential(): + agent._buffer_vprint( + "🔐 Copilot credential re-exchanged after " + "model_not_available 400. Retrying request..." + ) + retry_count = 0 + continue # Try fallback before aborting — a different provider may # not have the same issue (rate limit, auth, etc.). Only # announce the attempt when a fallback chain actually diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 08b0c0ea6b97..715f8b8b174c 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -28,10 +28,13 @@ _auth_store_lock, _codex_access_token_is_expiring, _decode_jwt_claims, + _global_auth_file_path, _load_auth_store, _load_provider_state, + _load_provider_state_with_source, _resolve_kimi_base_url, _resolve_zai_base_url, + _same_path, _save_auth_store, _save_provider_state, _store_provider_state, @@ -1039,32 +1042,58 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None try: with _auth_store_lock(): auth_store = _load_auth_store() - # Decide BEFORE writing whether this profile is reading the - # grant from the global root (no own providers. block) vs. - # genuinely shadowing it. A pool refresh rotates single-use - # OAuth refresh tokens, so a profile that resolved the grant - # from root MUST write the rotated chain back to root too — - # otherwise root keeps a revoked refresh token and every other - # profile reading the stale root grant dies with - # refresh_token_reused / invalid_grant once its access token - # expires. This mirrors the xAI write-through in - # hermes_cli.auth._save_xai_oauth_tokens (#43589); the pool - # refresh path is the Codex/xAI analog reported in #48415. _wt_provider_id = { "nous": "nous", "openai-codex": "openai-codex", "xai-oauth": "xai-oauth", }.get(self.provider) - write_through_to_root = bool(_wt_provider_id) and not ( - isinstance(auth_store.get("providers"), dict) - and isinstance( - auth_store["providers"].get(_wt_provider_id), dict - ) - ) + # Resolve state and track which store it came from — the + # source path tells us whether this profile genuinely owns + # its provider block or is reading from the global root. + # #74339: the old key-presence check decided write-through + # on whether the profile had ``providers.`` BEFORE the + # save — correct for the first refresh but self-sealing + # because ``_store_provider_state`` unconditionally creates + # that key inside the same function. Once the profile has + # the key, every subsequent refresh silently disables the + # root write-through and root keeps a revoked refresh token. + # + # Fix: use ``_load_provider_state_with_source`` to learn + # where the state was resolved from. When the grant was + # resolved from the global root, write back *only* to root + # and skip ``_store_provider_state`` for the profile so the + # profile does not accrue a shadowing ``providers.`` + # key that blocks both the root fallback and the write-through + # on subsequent calls. if self.provider == "nous": - state = _load_provider_state(auth_store, "nous") + state, source_path = _load_provider_state_with_source( + auth_store, "nous" + ) if state is None: return + elif self.provider == "openai-codex": + state, source_path = _load_provider_state_with_source( + auth_store, "openai-codex" + ) + if not isinstance(state, dict): + return + elif self.provider == "xai-oauth": + state, source_path = _load_provider_state_with_source( + auth_store, "xai-oauth" + ) + if not isinstance(state, dict): + return + else: + return + + global_root = _global_auth_file_path() + is_from_root = bool( + source_path is not None + and global_root is not None + and _same_path(source_path, global_root) + ) + + if self.provider == "nous": state["access_token"] = entry.access_token if entry.refresh_token: state["refresh_token"] = entry.refresh_token @@ -1082,12 +1111,8 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None state[extra_key] = val if entry.inference_base_url: state["inference_base_url"] = entry.inference_base_url - _store_provider_state(auth_store, "nous", state, set_active=False) elif self.provider == "openai-codex": - state = _load_provider_state(auth_store, "openai-codex") - if not isinstance(state, dict): - return tokens = state.get("tokens") if not isinstance(tokens, dict): return @@ -1096,12 +1121,8 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None tokens["refresh_token"] = entry.refresh_token if entry.last_refresh: state["last_refresh"] = entry.last_refresh - _store_provider_state(auth_store, "openai-codex", state, set_active=False) elif self.provider == "xai-oauth": - state = _load_provider_state(auth_store, "xai-oauth") - if not isinstance(state, dict): - return tokens = state.get("tokens") if not isinstance(tokens, dict): return @@ -1110,16 +1131,26 @@ def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None tokens["refresh_token"] = entry.refresh_token if entry.last_refresh: state["last_refresh"] = entry.last_refresh - _store_provider_state(auth_store, "xai-oauth", state, set_active=False) - else: - return - - _save_auth_store(auth_store) - if write_through_to_root and _wt_provider_id: + if is_from_root and _wt_provider_id: + # Grant was resolved from root — write back to root + # only. Do NOT call _store_provider_state on the + # profile auth_store (it would create a shadowing + # providers. key that disables write-through on + # the next refresh — #74339). + # _load_provider_state has root fallback, so the + # profile can always read fresh tokens from root + # without needing its own providers block. _write_through_provider_state_to_global_root( _wt_provider_id, state ) + else: + # Profile genuinely owns this provider — write to + # the profile store as normal. + _store_provider_state( + auth_store, self.provider, state, set_active=False + ) + _save_auth_store(auth_store) except Exception as exc: logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc) @@ -2336,6 +2367,20 @@ def _env_val(key: str) -> str: token, source = resolve_copilot_token() if token: api_token, enterprise_base_url = get_copilot_api_token(token) + # Observability: get_copilot_api_token falls back to returning + # the RAW token when the exchange fails. A raw ~40-char token + # sent to the Copilot API is routed to the fallback + # "copilot-language-server" integrator, whose allowlist omits + # enterprise-only models (claude-opus-4.8) → HTTP 400 on every + # turn. exchange_copilot_token now retries + reuses a persisted + # JWT, so this should be rare; surface it at WARNING so a + # recurrence is visible in logs instead of failing silently. + if api_token == token and not enterprise_base_url: + logger.warning( + "Copilot token exchange degraded to RAW token (exchange " + "unavailable); enterprise-only models may 400 with " + "model_not_available_for_integrator until exchange recovers." + ) source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}" if not _is_suppressed(provider, source_name): active_sources.add(source_name) @@ -2506,6 +2551,20 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool changed = False active_sources: Set[str] = set() + # Copilot has its own dedicated seeding branch (see `_seed_credentials` + # for provider == "copilot") which exchanges the raw ghu_ OAuth token + # for the ~437-char api token via `get_copilot_api_token`. If we let + # the generic env-var loop below run for copilot, it re-reads + # COPILOT_GITHUB_TOKEN from .env and shoves the RAW 40-char token in + # as `access_token`, overwriting the correctly-exchanged token. That + # bypasses the Copilot token exchange entirely and causes 400s with + # "not available for integrator copilot-language-server" (the server's + # fallback integrator when it receives a raw OAuth token instead of + # an api token). Skip the generic loop here — the copilot-specific + # branch is authoritative. + if provider == "copilot": + return False, active_sources + # Prefer ~/.hermes/.env over os.environ — the user's config file is the # authoritative source for Hermes credentials. Stale env vars from parent # processes (Codex CLI, test scripts, etc.) should not override deliberate diff --git a/agent/curator_backup.py b/agent/curator_backup.py index ca0cc7636254..8a65825464e4 100644 --- a/agent/curator_backup.py +++ b/agent/curator_backup.py @@ -541,6 +541,33 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]: +def _unstage(moved: List[Tuple[Path, Path]]) -> List[str]: + """Move staged entries back to their original paths. + + ``shutil.move`` moves *into* an existing destination directory rather than + replacing it, so a partially-completed extract leaves debris that would + otherwise bury the user's real skill one level deeper + (``skills/foo/foo/``) while the tree still looks populated. Clear whatever + the failed extract created at each original path first. The staged copy is + authoritative, and the pre-rollback safety snapshot is the undo handle for + the extract's own output. + + Returns the names that could not be restored, so the caller can report an + incomplete recovery instead of claiming the state was restored. + """ + failed: List[str] = [] + for orig, dest in moved: + try: + if orig.is_dir() and not orig.is_symlink(): + shutil.rmtree(orig) + elif orig.exists() or orig.is_symlink(): + orig.unlink() + shutil.move(str(dest), str(orig)) + except OSError: + failed.append(orig.name) + return failed + + def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]]: """Restore ``~/.hermes/skills/`` from a snapshot. @@ -609,11 +636,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path] moved.append((entry, dest)) except OSError as e: # Best-effort rollback of the move - for orig, dest in moved: - try: - shutil.move(str(dest), str(orig)) - except OSError: - pass + _unstage(moved) try: shutil.rmtree(staged, ignore_errors=True) except OSError: @@ -638,12 +661,30 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path] # Python < 3.12 — no filter kwarg tf.extractall(str(skills)) except (OSError, tarfile.TarError) as e: - # Best-effort recover: move staged contents back - for orig, dest in moved: + # Best-effort recover. A partial extract can leave entries the + # original tree never had, so drop those first, otherwise the + # "restored" tree is the user's skills plus a slice of the snapshot. + staged_names = {orig.name for orig, _ in moved} + for entry in list(skills.iterdir()): + if entry.name in _EXCLUDE_TOP_LEVEL or entry.name in staged_names: + continue try: - shutil.move(str(dest), str(orig)) + if entry.is_dir() and not entry.is_symlink(): + shutil.rmtree(entry) + else: + entry.unlink() except OSError: pass + unrestored = _unstage(moved) + if unrestored: + # Do not claim a clean restore we did not achieve, and keep the + # staging dir so the entries can be recovered by hand. + return ( + False, + f"snapshot extract failed: {e} - could not restore " + f"{', '.join(sorted(unrestored))}; staged copies kept at {staged}", + None, + ) try: shutil.rmtree(staged, ignore_errors=True) except OSError: diff --git a/agent/delegation_context.py b/agent/delegation_context.py index b80bbbe00fbf..41fe7f56ac17 100644 --- a/agent/delegation_context.py +++ b/agent/delegation_context.py @@ -31,11 +31,21 @@ @contextmanager -def delegated_child_context() -> Iterator[None]: - """Mark the current execution context as a delegate_task child.""" +def delegated_child_context(session_id: str | None = None) -> Iterator[None]: + """Mark child execution and isolate its task-local session identity. + + Child construction calls ``set_current_session_id`` internally, so even a + context entered without an id must restore the parent's ContextVar. Child + execution passes its explicit id and receives it only for this scope. + """ token = _DELEGATED_CHILD_CONTEXT.set(True) try: - yield + # Import lazily: session_context calls is_delegated_child_context() when + # deciding whether the compatibility os.environ mirror is safe. + from gateway.session_context import scoped_current_session_id + + with scoped_current_session_id(session_id): + yield finally: _DELEGATED_CHILD_CONTEXT.reset(token) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 27df08f58ea9..8ac0b6c87234 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -836,6 +836,19 @@ def _result(reason: FailoverReason, **overrides) -> ClassifiedError: if classified is not None: return classified + # Local MoA streaming compatibility errors are adapter-shape bugs, not a + # provider outage. Falling back to another model would silently switch the + # user's selected MoA route to a single-model answer (#55933 follow-up). + if provider_lower == "moa" and ( + "'types.SimpleNamespace' object is not iterable" in str(error) + or "'types.SimpleNamespace' object has no attribute 'index'" in str(error) + ): + return _result( + FailoverReason.format_error, + retryable=False, + should_fallback=False, + ) + # Local MoA config drift is deterministic: a persisted session can retain # a preset name that was later renamed/deleted. Retrying the same lookup # cannot recover and makes a clear config error look like an API outage. diff --git a/agent/image_gen_registry.py b/agent/image_gen_registry.py index 47538c8cf25e..8c4eef6e8d91 100644 --- a/agent/image_gen_registry.py +++ b/agent/image_gen_registry.py @@ -21,19 +21,27 @@ from __future__ import annotations import logging -import threading -from typing import Dict, List, Optional +from typing import List, Optional from agent.image_gen_provider import ImageGenProvider +from agent.plugin_profile_scope import ( + bind_profile_key, + ProfileKeyLike, + ProfileProviderRegistry, + selected_profile_key, +) logger = logging.getLogger(__name__) -_providers: Dict[str, ImageGenProvider] = {} -_lock = threading.Lock() +_registry: ProfileProviderRegistry[ImageGenProvider] = ProfileProviderRegistry() +_lock = _registry.lock +_providers = _registry.compatibility_mapping() -def register_provider(provider: ImageGenProvider) -> None: +def register_provider( + provider: ImageGenProvider, *, profile_key: Optional[ProfileKeyLike] = None +) -> None: """Register an image generation provider. Re-registration (same ``name``) overwrites the previous entry and logs @@ -48,31 +56,33 @@ def register_provider(provider: ImageGenProvider) -> None: name = provider.name if not isinstance(name, str) or not name.strip(): raise ValueError("Image gen provider .name must be a non-empty string") - with _lock: - existing = _providers.get(name) - _providers[name] = provider + existing = _registry.register(name, provider, profile_key=profile_key) if existing is not None: logger.debug("Image gen provider '%s' re-registered (was %r)", name, type(existing).__name__) else: logger.debug("Registered image gen provider '%s' (%s)", name, type(provider).__name__) -def list_providers() -> List[ImageGenProvider]: +def list_providers( + *, profile_key: Optional[ProfileKeyLike] = None +) -> List[ImageGenProvider]: """Return all registered providers, sorted by name.""" - with _lock: - items = list(_providers.values()) + items = _registry.list(profile_key=profile_key) return sorted(items, key=lambda p: p.name) -def get_provider(name: str) -> Optional[ImageGenProvider]: +def get_provider( + name: str, *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[ImageGenProvider]: """Return the provider registered under *name*, or None.""" if not isinstance(name, str): return None - with _lock: - return _providers.get(name.strip()) + return _registry.get(name, profile_key=profile_key) -def get_active_provider() -> Optional[ImageGenProvider]: +def get_active_provider( + *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[ImageGenProvider]: """Resolve the currently-active provider. Reads ``image_gen.provider`` from config.yaml; falls back per the @@ -89,11 +99,13 @@ def get_active_provider() -> Optional[ImageGenProvider]: ``is_available()`` so we don't pick a provider the user has no credentials for. """ + key = selected_profile_key(profile_key) configured: Optional[str] = None try: from hermes_cli.config import load_config_readonly - cfg = load_config_readonly() + with bind_profile_key(key): + cfg = load_config_readonly() section = cfg.get("image_gen") if isinstance(cfg, dict) else None if isinstance(section, dict): raw = section.get("provider") @@ -102,13 +114,13 @@ def get_active_provider() -> Optional[ImageGenProvider]: except Exception as exc: logger.debug("Could not read image_gen.provider from config: %s", exc) - with _lock: - snapshot = dict(_providers) + snapshot = _registry.snapshot(profile_key=key) def _is_available_safe(p: ImageGenProvider) -> bool: """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" try: - return bool(p.is_available()) + with bind_profile_key(key): + return bool(p.is_available()) except Exception as exc: # noqa: BLE001 logger.debug("image_gen provider %s.is_available() raised %s", p.name, exc) return False @@ -141,5 +153,4 @@ def _is_available_safe(p: ImageGenProvider) -> bool: def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" - with _lock: - _providers.clear() + _registry.reset_for_tests() diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 2671e7ccd32d..fc9bea59307b 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -35,6 +35,7 @@ from typing import Any, Dict, Optional from hermes_cli._subprocess_compat import windows_hide_flags +from hermes_constants import find_node_executable logger = logging.getLogger("agent.lsp.install") @@ -249,9 +250,12 @@ def _install_npm( peer deps that npm doesn't auto-pull (typescript-language-server needs ``typescript`` next to it; intelephense ships standalone). """ - npm = shutil.which("npm") + # Managed npm first: $HERMES_HOME/node is not on an arbitrary process's + # PATH, so a bare which() misses the Node that Hermes installed and + # reports "npm not on PATH" on a machine that has a perfectly good one. + npm = find_node_executable("npm") if npm is None: - logger.info("[install] cannot install %s: npm not on PATH", pkg) + logger.info("[install] cannot install %s: no usable npm found", pkg) return None staging = hermes_lsp_bin_dir().parent # /lsp/ install_targets = [pkg] + list(extra_pkgs or []) diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 173816c8cbd1..26e9523ec34d 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -13,6 +13,7 @@ import re import threading from concurrent.futures import ThreadPoolExecutor, wait as _futures_wait +from types import SimpleNamespace from typing import Any from agent.auxiliary_client import call_llm @@ -382,6 +383,8 @@ def _merge_slot_extra_body( def _maybe_apply_moa_cache_control( messages: list[dict[str, Any]], runtime: dict[str, Any], + *, + cache_disabled: bool | None = None, ) -> list[dict[str, Any]]: """Decorate an advisor or aggregator request with cache_control when its route honors it. @@ -395,17 +398,27 @@ def _maybe_apply_moa_cache_control( Returns the messages unchanged on any resolution error or when the policy says the route doesn't honor markers. + + ``cache_disabled`` (or the live config when omitted) is stamped onto the + policy stub so ``prompt_caching.cache_ttl: off`` is not bypassed by the + blank-agent pattern (#76085). """ try: - from types import SimpleNamespace - - from agent.agent_runtime_helpers import anthropic_prompt_cache_policy + from agent.agent_runtime_helpers import ( + anthropic_prompt_cache_policy, + blank_cache_policy_stub, + ) from agent.prompt_caching import apply_anthropic_cache_control + # Prefer an explicit kwarg, then a snapshot on the runtime dict + # (threaded from the live agent), else config via the stub factory. + if cache_disabled is None and "_cache_disabled" in runtime: + cache_disabled = runtime.get("_cache_disabled") + # The policy function reads agent.* only as fallbacks for kwargs we - # don't pass; provide a stub so the slot is judged purely on its own - # resolved runtime. - stub = SimpleNamespace(provider="", base_url="", api_mode="", model="") + # don't pass; blank_cache_policy_stub is the only sanctioned stub + # so _cache_disabled cannot be left off again (#76085). + stub = blank_cache_policy_stub(cache_disabled) should_cache, native_layout = anthropic_prompt_cache_policy( stub, provider=runtime.get("provider") or "", @@ -431,6 +444,7 @@ def _run_reference( max_tokens: int | None = None, reference_timeout: float | None = None, context_length_cache: Any = None, + cache_disabled: bool | None = None, ) -> tuple[str, str, Any]: """Call one reference model and return ``(label, text, accounting)``. @@ -492,7 +506,12 @@ def _run_reference( # caching is opt-in per request. OpenAI-family advisors are untouched # (their caching is automatic; markers are ignored harmlessly, but we # only decorate when the policy says the route honors them). - messages = _maybe_apply_moa_cache_control(messages, runtime) + # Pin the live agent disable onto the runtime so advisor decoration + # tracks conversation state, not a fresh config re-read (#76085). + cache_runtime = runtime + if cache_disabled is not None: + cache_runtime = {**runtime, "_cache_disabled": cache_disabled} + messages = _maybe_apply_moa_cache_control(messages, cache_runtime) # Per-slot max_tokens takes precedence over the preset-level # reference_max_tokens passed in by the caller. This lets each # reference model have its own output cap independently. @@ -796,6 +815,9 @@ def _run_references_parallel( # instead of re-probing metadata sources per reference (dict get/set is # GIL-atomic; a rare duplicate probe on a first-use race is harmless). _ctx_len_cache: dict[tuple[str, str], int | None] = {} + cache_disabled = ( + getattr(agent, "_cache_disabled", None) if agent is not None else None + ) try: for idx, slot in enumerate(reference_models): if slot.get("provider") == "moa": @@ -814,6 +836,7 @@ def _run_references_parallel( max_tokens=max_tokens, reference_timeout=reference_timeout, context_length_cache=_ctx_len_cache, + cache_disabled=cache_disabled, ) ] = idx @@ -1261,6 +1284,19 @@ def aggregate_moa_context( agg_label = _slot_label(aggregator) agg_runtime = _slot_runtime(aggregator) + # Pin the live agent disable onto synthesis decoration so mid-session + # config flips cannot re-enable markers on this path alone (#76085). + # Same not-None guard as _run_reference: stamping None would be a no-op + # (present-None falls through to the config fallback anyway). + agg_cache_runtime = agg_runtime + _agg_cache_disabled = ( + getattr(agent, "_cache_disabled", None) if agent is not None else None + ) + if _agg_cache_disabled is not None: + agg_cache_runtime = { + **agg_runtime, + "_cache_disabled": _agg_cache_disabled, + } try: # Same cache_control decoration as _run_reference's advisor calls # (see _maybe_apply_moa_cache_control) — this synthesis call is a @@ -1273,7 +1309,7 @@ def aggregate_moa_context( # breakpoints, even when the resolved aggregator slot is a # cache-honoring route (e.g. Claude on OpenRouter/native Anthropic). agg_messages = _maybe_apply_moa_cache_control( - [{"role": "user", "content": synth_prompt}], agg_runtime + [{"role": "user", "content": synth_prompt}], agg_cache_runtime ) response = call_llm( task="moa_aggregator", @@ -1300,6 +1336,53 @@ def aggregate_moa_context( ) +def _completed_response_as_stream_chunk(response: Any) -> Any: + """Convert a completed Chat Completions response into one delta stream chunk. + + MoA's outer streaming consumer expects ``choices[0].delta`` chunks. A + completed aggregator response carries ``choices[0].message`` instead; adapt + it here, at the MoA facade boundary, so provider-specific Relay behavior and + other transports remain untouched. + """ + + choices = getattr(response, "choices", None) + first_choice = choices[0] if isinstance(choices, (list, tuple)) and choices else None + message = getattr(first_choice, "message", None) + raw_tool_calls = getattr(message, "tool_calls", None) + tool_call_deltas = None + if isinstance(raw_tool_calls, (list, tuple)) and raw_tool_calls: + tool_call_deltas = [] + for index, tc in enumerate(raw_tool_calls): + function = getattr(tc, "function", None) + tool_call_deltas.append(SimpleNamespace( + index=getattr(tc, "index", index), + id=getattr(tc, "id", None), + type=getattr(tc, "type", None) or "function", + function=SimpleNamespace( + name=getattr(function, "name", None), + arguments=getattr(function, "arguments", None), + ), + )) + delta = SimpleNamespace( + content=getattr(message, "content", None), + tool_calls=tool_call_deltas, + reasoning_content=getattr(message, "reasoning_content", None), + reasoning=getattr(message, "reasoning", None), + reasoning_details=getattr(message, "reasoning_details", None), + ) + choice = SimpleNamespace( + index=getattr(first_choice, "index", 0), + delta=delta, + finish_reason=getattr(first_choice, "finish_reason", None) or "stop", + ) + return SimpleNamespace( + id=getattr(response, "id", None), + model=getattr(response, "model", None), + choices=[choice], + usage=getattr(response, "usage", None), + ) + + def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str) -> None: """Attach the per-turn reference block at the END of the aggregator prompt. @@ -1609,6 +1692,52 @@ def _call_prepared_aggregator( max_tokens: Any = agg_kwargs.get("max_tokens") tools: Any = agg_kwargs.get("tools") extra_body: Any = agg_kwargs.get("extra_body") + agg_runtime = _slot_runtime(aggregator) + try: + from agent.agent_runtime_helpers import ( + plan_cache_sections_for_destination, + ) + + guidance = prepared.get("guidance") + planning_messages = agg_messages + if guidance: + planning_messages = peel_reference_guidance( + agg_messages, + str(guidance), + ) + # plan_cache_sections_for_destination never mutates its inputs + # and always returns request-local copies, so the prepared + # state stays canonical. + # Tri-state: only pass a bool when a live agent snapshot exists. + # Prepared-aggregator facades built via __new__ have no _agent; + # getattr(self._agent, ...) raises and bool(None-agent) would + # force False and suppress the planner's config fallback (#76085). + _agent = getattr(self, "_agent", None) + _cache_disabled = ( + getattr(_agent, "_cache_disabled", None) + if _agent is not None + else None + ) + agg_messages, tools = plan_cache_sections_for_destination( + planning_messages, + tools, + provider=agg_runtime.get("provider") or "", + base_url=agg_runtime.get("base_url") or "", + api_mode=agg_runtime.get("api_mode") or "", + model=agg_runtime.get("model") or "", + cache_disabled=_cache_disabled, + ) + if guidance: + _attach_reference_guidance(agg_messages, str(guidance)) + except Exception as exc: # pragma: no cover - cache planning must not block MoA + # Warning, not debug: since the call-block site skips MoA, this + # block is the aggregator's ONLY decoration path — a silent + # failure here ships an undecorated request and regresses the + # exact 0%-cache MoA failure the planning exists to prevent. + logger.warning( + "MoA aggregator cache plan failed — sending undecorated " + "request (cache misses expected): %s", exc, + ) # Record the exact aggregator INPUT (incl. the injected reference # context) into the pending trace so a trace captures what the # aggregator actually saw, not a reconstruction. Traces are a @@ -1649,7 +1778,6 @@ def _call_prepared_aggregator( # actually governs the aggregator stream, not just call_llm's default. if api_kwargs.get("timeout") is not None: stream_kwargs["timeout"] = api_kwargs["timeout"] - agg_runtime = _slot_runtime(aggregator) # _slot_runtime may carry the provider's request_overrides.extra_body; # pop it and merge with the caller's extra_body (caller wins) so the # explicit kwarg below never collides with **agg_runtime. @@ -1685,6 +1813,14 @@ def _call_prepared_aggregator( self._pending_trace["aggregator_output"] = _extract_text(_agg_response) except Exception: # pragma: no cover - defensive self._pending_trace["aggregator_output"] = None + if stream and hasattr(_agg_response, "choices"): + # Some aggregator adapters (notably openai-codex Responses) consume + # their provider stream internally and return a completed response + # object even when the acting consumer requested token streaming. + # The outer chat-completions streaming loop expects delta chunks; + # hand it a one-chunk iterator instead of letting it iterate the + # SimpleNamespace response itself (#55933). + return iter((_completed_response_as_stream_chunk(_agg_response),)) return _agg_response def create(self, **api_kwargs: Any) -> Any: @@ -2174,8 +2310,24 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: except Exception: pass + resolved_preset = preset_name + if resolved_preset is None and getattr(agent, "provider", None) == "moa": + resolved_preset = getattr(agent, "model", None) + + resolved_preset = str(resolved_preset or "default") + try: + from hermes_cli.config import load_config + from hermes_cli.moa_config import normalize_moa_config + + moa_cfg = normalize_moa_config(load_config().get("moa") or {}) + presets = moa_cfg.get("presets") or {} + if resolved_preset not in presets: + resolved_preset = moa_cfg.get("default_preset") or "default" + except Exception: + resolved_preset = "default" + return MoAClient( - str(preset_name or getattr(agent, "model", None) or "default"), + resolved_preset, reference_callback=_moa_reference_relay, # Thread the agent through so the reference fan-out wait can be # aborted on a user interrupt (see _run_references_parallel). diff --git a/agent/model_metadata.py b/agent/model_metadata.py index cec891dbb788..42d841009fa3 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -273,6 +273,27 @@ def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None: # Default context length when no detection method succeeds. DEFAULT_FALLBACK_CONTEXT = CONTEXT_PROBE_TIERS[0] +# (model, base_url) pairs that already emitted the fallback warning. +# The fallback result itself is deliberately never cached, so without this +# the warning would repeat on every resolution for the same unknown model. +_FALLBACK_WARNED: set = set() + + +def _warn_context_length_fallback(model: str, base_url: str) -> None: + """Warn (once per model+endpoint) that context detection failed and the + hard default is being used, so small-context models (8K, 32K) don't + silently get 256K and cause hard-to-debug API failures.""" + key = (model, base_url or "") + if key in _FALLBACK_WARNED: + return + _FALLBACK_WARNED.add(key) + logger.warning( + "Could not determine context length for model %r (base_url=%s) " + "— falling back to %s tokens. Set model.context_length in " + "config.yaml to override.", + model, base_url or "default", f"{DEFAULT_FALLBACK_CONTEXT:,}", + ) + # Minimum context length required to run Hermes Agent. Models with fewer # tokens cannot maintain enough working memory for tool-calling workflows. # Sessions, model switches, and cron jobs should reject models below this. @@ -634,12 +655,17 @@ def _is_known_provider_base_url(base_url: str) -> bool: def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]: - """Return metadata confirmed only for the Kimi Coding endpoint. + """Return context metadata confirmed for one provider endpoint. Kimi Coding serves K3 under the bare slug ``k3``, but users may also configure or select the public-facing aliases ``kimi-k3`` and ``kimi-k3-cot``. Only canonical ``https://api.kimi.com/coding`` endpoints (legacy Moonshot keys do not serve K3) get the 1 Mi context window. + + NVIDIA NIM serves ``deepseek-ai/deepseek-v4-pro`` with a 262,144-token + window even though DeepSeek's native endpoint serves the V4 family with a + 1M window. Keep the lower limit scoped to NVIDIA instead of weakening the + global model-family metadata. """ normalized = _normalize_base_url(base_url) try: @@ -659,6 +685,18 @@ def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]: and model.strip().lower() in {"k3", "kimi-k3", "kimi-k3-cot"} ): return 1_048_576 + if ( + parsed.scheme.lower() == "https" + and (parsed.hostname or "").lower() == "integrate.api.nvidia.com" + and port in (None, 443) + and parsed.username is None + and parsed.password is None + and parsed.path.rstrip("/") == "/v1" + and not parsed.query + and not parsed.fragment + and model.strip().lower() == "deepseek-ai/deepseek-v4-pro" + ): + return 262_144 return None @@ -2582,6 +2620,9 @@ def get_model_context_length( f"{length:,}", model, default_model, ) return length + # Same silent-256K bug class as the step-9 fallback below — + # warn here too so custom/local endpoints aren't left invisible. + _warn_context_length_fallback(model, base_url) return DEFAULT_FALLBACK_CONTEXT # 4. Anthropic /v1/models API (only for regular API keys, not OAuth) @@ -2754,7 +2795,10 @@ def get_model_context_length( if default_model in model_lower: return length - # 9. Default fallback — 256K + # 9. Default fallback — warn (deduped per model+endpoint) so + # small-context models don't silently get 256K. See + # _warn_context_length_fallback for rationale. + _warn_context_length_fallback(model, base_url) return DEFAULT_FALLBACK_CONTEXT @@ -2965,19 +3009,48 @@ def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int: return count * cost_per_image -def _estimate_message_chars(msg: Dict[str, Any]) -> int: - """Char count for token estimation, excluding base64 image data. +def _wire_message_shadow(msg: Dict[str, Any]) -> Dict[str, Any]: + """Shadow of a message holding only what the provider actually receives. - Base64 images are counted via `_count_image_tokens` instead; including - their raw chars here would massively overestimate token usage. + Two adjustments to the raw persisted dict: + + * ``api_content`` is a SUBSTITUTE for ``content``, not an addition to it. + ``turn_context.substitute_api_content()`` pops the sidecar and overwrites + ``content`` at every API-bound build site, so exactly one of the two is + ever sent. Counting both double-counts any message whose sidecar differs + from its clean stored content (2.00x on a 40KB sidecar). + + The substitution mirrors that helper's guard exactly: only a non-empty + STRING sidecar on a ``user``/``assistant`` row displaces ``content``. + Any other sidecar shape is popped and discarded on the wire without + touching ``content``, so a shadow that substituted unconditionally + would UNDERcount those rows — the dangerous direction, since it makes + compaction fire too late and the turn dies on a hard context error. + * Base64 image payloads are replaced with a placeholder; they are charged + separately at a flat rate by ``_count_image_tokens``, and counting their + raw chars here would massively overestimate usage. """ - if not isinstance(msg, dict): - return len(str(msg)) + sidecar = msg.get("api_content") + sidecar_wins = ( + isinstance(sidecar, str) + and bool(sidecar) + and msg.get("role") in ("user", "assistant") + ) shadow: Dict[str, Any] = {} for k, v in msg.items(): - if k == "_anthropic_content_blocks": + if k in ("_anthropic_content_blocks", "reasoning_details"): + continue + if k == "api_content": + # Always popped before the request is built; only counted when it + # actually replaces ``content``. + if sidecar_wins: + shadow["content"] = v continue if k == "content": + if sidecar_wins: + # The sidecar wins on the wire; skip the clean copy so the + # same logical content is not counted twice. + continue if isinstance(v, list): cleaned = [] for part in v: @@ -2995,36 +3068,25 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int: shadow[k] = v else: shadow[k] = v - return len(str(shadow)) + return shadow + + +def _estimate_message_chars(msg: Dict[str, Any]) -> int: + """Char count for token estimation, excluding base64 image data. + + Base64 images are counted via `_count_image_tokens` instead; including + their raw chars here would massively overestimate token usage. + """ + if not isinstance(msg, dict): + return len(str(msg)) + return len(str(_wire_message_shadow(msg))) def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int: """Token estimate for a message shadow with image payloads stripped.""" if not isinstance(msg, dict): return estimate_tokens_rough(str(msg)) - shadow: Dict[str, Any] = {} - for k, v in msg.items(): - if k == "_anthropic_content_blocks": - continue - if k == "content": - if isinstance(v, list): - cleaned = [] - for part in v: - if isinstance(part, dict): - if part.get("type") in {"image", "image_url", "input_image"}: - cleaned.append({"type": part.get("type"), "image": "[stripped]"}) - else: - cleaned.append(part) - else: - cleaned.append(part) - shadow[k] = cleaned - elif isinstance(v, dict) and v.get("_multimodal"): - shadow[k] = v.get("text_summary", "") - else: - shadow[k] = v - else: - shadow[k] = v - return estimate_tokens_rough(str(shadow)) + return estimate_tokens_rough(str(_wire_message_shadow(msg))) def estimate_request_tokens_rough( diff --git a/agent/plugin_profile_scope.py b/agent/plugin_profile_scope.py new file mode 100644 index 000000000000..688f47df5a8a --- /dev/null +++ b/agent/plugin_profile_scope.py @@ -0,0 +1,429 @@ +"""Profile identity and transactional storage for plugin provider registries. + +The gateway can serve several Hermes profiles in one Python process. Provider +plugins are therefore process-global code but profile-local state. This module +provides the narrow shared seam used by provider registries and plugin loading: + +* :class:`ProfileKey` is a normalized immutable identity; +* :func:`bind_profile_key` selects an identity with ``ContextVar`` isolation; +* :func:`bound_to_profile` freezes that identity for delayed callbacks; and +* :func:`provider_registration_transaction` atomically rolls back registrations + when one plugin load fails. + +Legacy single-profile callers do not need to pass a key. The current profile is +resolved lazily from ``HERMES_HOME`` through ``get_active_profile_name()``. +""" + +from __future__ import annotations + +import inspect +import os +import threading +from collections.abc import MutableMapping +from contextlib import contextmanager +from contextvars import ContextVar, Token +from dataclasses import dataclass +from functools import wraps +from pathlib import Path +from typing import Callable, Dict, Generic, Iterator, List, Optional, TypeVar, Union + +from hermes_constants import ( + get_default_hermes_root, + reset_hermes_home_override, + set_hermes_home_override, +) + + +@dataclass(frozen=True, order=True) +class ProfileKey: + """Canonical, immutable key for profile-owned process state.""" + + value: str + + def __post_init__(self) -> None: + value = self.value + if not isinstance(value, str): + raise TypeError("profile key must be a string") + stripped = value.strip() + if not stripped: + raise ValueError("profile key cannot be empty") + if stripped[:7].casefold() == "custom:": + normalized = "custom:" + stripped[7:] + else: + normalized = stripped.casefold() + object.__setattr__(self, "value", normalized) + + def __str__(self) -> str: + return self.value + + +ProfileKeyLike = Union[ProfileKey, str, os.PathLike[str]] +_BOUND_PROFILE_KEY: ContextVar[Optional[ProfileKey]] = ContextVar( + "plugin_provider_profile_key", default=None +) + + +def _profile_key_from_runtime() -> ProfileKey: + """Resolve the active runtime profile without freezing import-time state.""" + try: + from hermes_cli.profiles import get_active_profile_name + + name = get_active_profile_name() + if name and name != "custom": + return ProfileKey(name) + except Exception: + pass + + # Custom HERMES_HOME paths must not all collapse to the key ``custom``. + try: + from hermes_constants import get_hermes_home + + home = Path(get_hermes_home()).expanduser().resolve(strict=False) + return ProfileKey(f"custom:{os.path.normcase(str(home))}") + except Exception: + return ProfileKey("default") + + +def normalize_profile_key(profile_key: Optional[ProfileKeyLike] = None) -> ProfileKey: + """Return a canonical immutable profile key. + + ``None`` means the currently bound profile, or the active Hermes runtime + profile when no explicit binding exists. Explicit strings are normalized + case-insensitively. Paths are represented by their normalized absolute + location so distinct custom homes cannot collide. + """ + if profile_key is None: + bound = _BOUND_PROFILE_KEY.get() + return bound if bound is not None else _profile_key_from_runtime() + if isinstance(profile_key, ProfileKey): + return profile_key + if isinstance(profile_key, os.PathLike): + path = Path(profile_key).expanduser().resolve(strict=False) + return ProfileKey(f"custom:{os.path.normcase(str(path))}") + if not isinstance(profile_key, str): + raise TypeError("profile key must be a ProfileKey, string, path, or None") + return ProfileKey(profile_key) + + +def current_profile_key() -> ProfileKey: + """Return the selected profile key, resolved once for this call.""" + return normalize_profile_key() + + +def selected_profile_key(profile_key: Optional[ProfileKeyLike] = None) -> ProfileKey: + """Freeze an explicit or contextual profile identity for one operation.""" + return normalize_profile_key(profile_key) + + +def freeze_profile_key(profile_key: Optional[ProfileKeyLike] = None) -> ProfileKey: + """Public alias emphasizing capture for delayed work.""" + return selected_profile_key(profile_key) + + +def set_profile_key(profile_key: ProfileKeyLike) -> Token: + """Bind *profile_key* and return a token suitable for reset.""" + return _BOUND_PROFILE_KEY.set(normalize_profile_key(profile_key)) + + +def reset_profile_key(token: Token) -> None: + """Restore a prior profile-key binding.""" + _BOUND_PROFILE_KEY.reset(token) + + +def _profile_home_for_key(profile_key: ProfileKey) -> Path: + """Resolve the config/state home owned by a canonical profile key.""" + if profile_key.value.startswith("custom:"): + raw_path = profile_key.value[7:] + if not raw_path: + raise ValueError("custom profile key must include a path") + return Path(raw_path) + + root = get_default_hermes_root() + if profile_key.value == "default": + return root + return root / "profiles" / profile_key.value + + +@contextmanager +def bind_profile_key(profile_key: ProfileKeyLike) -> Iterator[ProfileKey]: + """Select one profile identity and Hermes home in this context. + + Provider lookup can read config or invoke provider callbacks after the + process has entered another profile's runtime scope. Binding only the + registry identity would then pair one profile's provider bucket with a + different profile's ``config.yaml``. Keep both ContextVars aligned while + leaving the process-wide ``HERMES_HOME`` environment untouched. + """ + key = normalize_profile_key(profile_key) + profile_token = _BOUND_PROFILE_KEY.set(key) + home_token = set_hermes_home_override(_profile_home_for_key(key)) + try: + yield key + finally: + reset_hermes_home_override(home_token) + _BOUND_PROFILE_KEY.reset(profile_token) + + +def bound_to_profile( + callback: Callable, profile_key: Optional[ProfileKeyLike] = None +) -> Callable: + """Return a callback that always runs with the profile selected now. + + This is the safe bridge for thread targets, futures, and other callbacks + invoked after their originating profile runtime scope has exited. + """ + key = selected_profile_key(profile_key) + if inspect.iscoroutinefunction(callback): + @wraps(callback) + async def _async_bound(*args, **kwargs): + with bind_profile_key(key): + return await callback(*args, **kwargs) + + return _async_bound + + @wraps(callback) + def _bound(*args, **kwargs): + with bind_profile_key(key): + return callback(*args, **kwargs) + + return _bound + + +_MISSING = object() + + +class _RegistrationTransaction: + def __init__(self, profile_key: ProfileKey): + self.profile_key = profile_key + self._undo: List[Callable[[], None]] = [] + self.failed = False + + def record(self, profile_key: ProfileKey, undo: Callable[[], None]) -> None: + if profile_key != self.profile_key: + self.failed = True + raise RuntimeError( + "plugin registration transaction cannot mutate a different profile " + f"({profile_key} != {self.profile_key})" + ) + self._undo.append(undo) + + def rollback(self) -> None: + for undo in reversed(self._undo): + undo() + self._undo.clear() + + +_ACTIVE_TRANSACTION: ContextVar[Optional[_RegistrationTransaction]] = ContextVar( + "plugin_registration_transaction", default=None +) + + +def record_registration_undo( + profile_key: ProfileKeyLike, + undo: Callable[[], None], +) -> bool: + """Attach a generation-safe undo callback to the active plugin transaction. + + Returns ``False`` when no transaction is active so ordinary launch-time + registration keeps its historical behavior. Callers own the compare-before- + restore guard that prevents an undo from clobbering a later writer. + """ + transaction = _ACTIVE_TRANSACTION.get() + if transaction is None: + return False + transaction.record(selected_profile_key(profile_key), undo) + return True + + +@contextmanager +def plugin_registration_transaction( + profile_key: Optional[ProfileKeyLike] = None, +) -> Iterator[ProfileKey]: + """Atomically apply all manager/profile-owned plugin publications.""" + key = selected_profile_key(profile_key) + active = _ACTIVE_TRANSACTION.get() + if active is not None: + if active.profile_key != key: + active.failed = True + raise RuntimeError( + "cannot nest a plugin registration transaction for a different profile" + ) + try: + yield key + except BaseException: + active.failed = True + raise + return + + transaction = _RegistrationTransaction(key) + transaction_token = _ACTIVE_TRANSACTION.set(transaction) + try: + with bind_profile_key(key): + try: + yield key + except BaseException: + transaction.failed = True + transaction.rollback() + raise + if transaction.failed: + transaction.rollback() + raise RuntimeError("plugin registration transaction failed closed") + finally: + _ACTIVE_TRANSACTION.reset(transaction_token) + + +# Backward-compatible name retained for provider-registry callers and tests. +provider_registration_transaction = plugin_registration_transaction + + +T = TypeVar("T") + + +class CurrentProfileProviderMapping(MutableMapping[str, T], Generic[T]): + """Compatibility mapping exposing only the caller's current profile.""" + + def __init__(self, registry: "ProfileProviderRegistry[T]"): + self._registry = registry + + def __getitem__(self, name: str) -> T: + value = self._registry.get(name) + if value is None: + raise KeyError(name) + return value + + def __setitem__(self, name: str, provider: T) -> None: + self._registry.register(name, provider) + + def __delitem__(self, name: str) -> None: + if not self._registry.delete(name): + raise KeyError(name) + + def __iter__(self): + return iter(self._registry.snapshot()) + + def __len__(self) -> int: + return len(self._registry.snapshot()) + + def clear(self) -> None: + self._registry.clear_profile() + + +class ProfileProviderRegistry(Generic[T]): + """Thread-safe profile-keyed provider map with transactional writes.""" + + def __init__(self, *, normalize_name: Callable[[str], str] = lambda name: name.strip()): + self._normalize_name = normalize_name + self._providers: Dict[ProfileKey, Dict[str, T]] = {} + self._generations: Dict[ProfileKey, Dict[str, int]] = {} + self._next_generation = 0 + self._lock = threading.RLock() + + def _new_generation(self) -> int: + self._next_generation += 1 + return self._next_generation + + @property + def lock(self) -> threading.RLock: + """Compatibility lock for legacy private registry readers.""" + return self._lock + + def compatibility_mapping(self) -> CurrentProfileProviderMapping[T]: + """Return a mapping view scoped dynamically to the current profile.""" + return CurrentProfileProviderMapping(self) + + def register( + self, + name: str, + provider: T, + *, + profile_key: Optional[ProfileKeyLike] = None, + ) -> Optional[T]: + key = selected_profile_key(profile_key) + provider_name = self._normalize_name(name) + transaction = _ACTIVE_TRANSACTION.get() + with self._lock: + bucket = self._providers.setdefault(key, {}) + generations = self._generations.setdefault(key, {}) + previous = bucket.get(provider_name, _MISSING) + previous_generation = generations.get(provider_name, _MISSING) + written_generation = self._new_generation() + + if transaction is not None: + def _undo() -> None: + with self._lock: + current_bucket = self._providers.get(key) + current_generations = self._generations.get(key) + if ( + current_bucket is None + or current_generations is None + or current_generations.get(provider_name, _MISSING) + != written_generation + ): + # A later writer owns the slot; rollback must not + # resurrect stale state over that committed value. + return + if previous is _MISSING: + current_bucket.pop(provider_name, None) + current_generations.pop(provider_name, None) + else: + current_bucket[provider_name] = previous # type: ignore[assignment] + current_generations[provider_name] = previous_generation # type: ignore[assignment] + if not current_bucket: + self._providers.pop(key, None) + self._generations.pop(key, None) + + transaction.record(key, _undo) + + bucket[provider_name] = provider + generations[provider_name] = written_generation + return None if previous is _MISSING else previous # type: ignore[return-value] + + def get( + self, name: str, *, profile_key: Optional[ProfileKeyLike] = None + ) -> Optional[T]: + key = selected_profile_key(profile_key) + provider_name = self._normalize_name(name) + with self._lock: + return self._providers.get(key, {}).get(provider_name) + + def snapshot( + self, *, profile_key: Optional[ProfileKeyLike] = None + ) -> Dict[str, T]: + key = selected_profile_key(profile_key) + with self._lock: + return dict(self._providers.get(key, {})) + + def list(self, *, profile_key: Optional[ProfileKeyLike] = None) -> List[T]: + return list(self.snapshot(profile_key=profile_key).values()) + + def delete( + self, name: str, *, profile_key: Optional[ProfileKeyLike] = None + ) -> bool: + key = selected_profile_key(profile_key) + provider_name = self._normalize_name(name) + with self._lock: + bucket = self._providers.get(key) + if bucket is None or provider_name not in bucket: + return False + del bucket[provider_name] + generations = self._generations.get(key) + if generations is not None: + generations.pop(provider_name, None) + if not bucket: + self._providers.pop(key, None) + self._generations.pop(key, None) + return True + + def clear_profile( + self, *, profile_key: Optional[ProfileKeyLike] = None + ) -> None: + key = selected_profile_key(profile_key) + with self._lock: + self._providers.pop(key, None) + self._generations.pop(key, None) + + def reset_for_tests(self) -> None: + with self._lock: + self._providers.clear() + self._generations.clear() + self._next_generation = 0 diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 6bd88fa194f9..0f748f8b2bd2 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1083,6 +1083,7 @@ def _probe_remote_backend(env_type: str) -> str | None: "docker_env": config.get("docker_env", {}), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), "docker_extra_args": config.get("docker_extra_args", []), + "docker_shm_size": config.get("docker_shm_size", "1g"), "docker_persist_across_processes": config.get("docker_persist_across_processes", True), "docker_orphan_reaper": config.get("docker_orphan_reaper", True), } diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index 1a0326c79df3..a012f143f2c7 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -11,9 +11,27 @@ """ import copy +from dataclasses import dataclass from typing import Any, Dict, List +@dataclass(frozen=True) +class PromptCachePlan: + """Request-local message and tool sections with their cache markers.""" + + messages: List[Dict[str, Any]] + tools: List[Dict[str, Any]] + + @property + def marker_count(self) -> int: + """Wire-visible cache markers in this plan (computed on demand). + + Only tests consume this; keeping it lazy avoids walking every + message part and tool schema on the per-request hot path. + """ + return _count_cache_markers(self.messages, self.tools) + + def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool = False) -> None: """Add cache_control to a single message, handling all format variations.""" role = msg.get("role", "") @@ -89,12 +107,28 @@ def _apply_system_cache_markers( static_system_prefix: str | None, *, native_anthropic: bool, + mark_suffix: bool = True, + fallback_to_whole: bool = True, ) -> int: - """Mark the static system prefix and full prompt when they can be split. + """Mark the static system prefix (and optionally the full prompt). 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. + + ``mark_suffix=False`` is the tool-cache-plan layout: only the static + prefix carries a marker, the volatile suffix rides unmarked (its + breakpoint budget is spent on the tools array instead). + + ``fallback_to_whole=False`` skips marking entirely when the prefix + split is not possible (no prefix, mismatched prefix, non-string + content) instead of marking the whole message. + + When the prompt IS exactly the static prefix (empty suffix), the whole + message is marked as a single block — never a two-part split with an + empty text block, which Anthropic rejects. + + Returns the number of markers applied (0, 1, or 2). """ content = message.get("content") if ( @@ -105,16 +139,26 @@ def _apply_system_cache_markers( ): suffix = content[len(static_system_prefix):] if suffix: + suffix_part: dict = {"type": "text", "text": suffix} + if mark_suffix: + suffix_part["cache_control"] = cache_marker message["content"] = [ { "type": "text", "text": static_system_prefix, "cache_control": cache_marker, }, - {"type": "text", "text": suffix, "cache_control": cache_marker}, + suffix_part, ] - return 2 - + return 2 if mark_suffix else 1 + # Empty suffix: the stored prompt IS the static prefix. Mark it as + # one whole block — a [marked-prefix, ""] split would put an empty + # text block on the wire (HTTP 400 on native Anthropic). + _apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic) + return 1 + + if not fallback_to_whole: + return 0 _apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic) return 1 @@ -172,6 +216,135 @@ def strip_anthropic_cache_control( return api_messages +def strip_anthropic_tool_cache_control(tools: List[Dict[str, Any]] | None) -> List[Dict[str, Any]]: + """Return copied tools without request-local Anthropic cache markers.""" + cleaned = copy.deepcopy(tools or []) + for tool in cleaned: + if isinstance(tool, dict): + tool.pop("cache_control", None) + return cleaned + + +def _count_cache_markers(messages: List[Dict[str, Any]], tools: List[Dict[str, Any]]) -> int: + """Count the wire-visible cache markers in a request-local plan.""" + count = sum( + 1 + for message in messages + if isinstance(message, dict) and "cache_control" in message + ) + count += sum( + 1 + for message in messages + if isinstance(message, dict) and isinstance(message.get("content"), list) + for part in message["content"] + if isinstance(part, dict) and "cache_control" in part + ) + return count + sum( + 1 for tool in tools if isinstance(tool, dict) and "cache_control" in tool + ) + + +def _completed_transaction_endpoint_indexes( + messages: List[Dict[str, Any]], *, native_anthropic: bool, +) -> List[int]: + """Select legal ends of completed tool runs and ordinary turns.""" + endpoints: List[int] = [] + index = 0 + while index < len(messages): + message = messages[index] + if not isinstance(message, dict) or message.get("role") == "system": + index += 1 + continue + + if message.get("role") == "assistant" and message.get("tool_calls"): + result_start = index + 1 + result_end = result_start + while result_end < len(messages): + result = messages[result_end] + if not isinstance(result, dict) or result.get("role") != "tool": + break + result_end += 1 + if result_end > result_start: + endpoint = result_end - 1 + if _can_carry_marker(messages[endpoint], native_anthropic): + endpoints.append(endpoint) + index = result_end + continue + + if message.get("role") == "tool": + while index < len(messages): + result = messages[index] + if not isinstance(result, dict) or result.get("role") != "tool": + break + index += 1 + continue + + if message.get("role") == "user" and index + 1 < len(messages): + index += 1 + continue + + if ( + message.get("role") == "assistant" + and message.get("content") in (None, "") + ): + index += 1 + continue + + if _can_carry_marker(message, native_anthropic): + endpoints.append(index) + index += 1 + return endpoints + + +def build_prompt_cache_plan( + api_messages: List[Dict[str, Any]], + tools: List[Dict[str, Any]] | None, + *, + cache_ttl: str = "5m", + native_anthropic: bool = False, + static_system_prefix: str | None = None, + direct_native_tool_cache: bool = False, +) -> PromptCachePlan: + """Build isolated cache sections for one resolved request destination.""" + messages = copy.deepcopy(api_messages or []) + strip_anthropic_cache_control(messages) + planned_tools = strip_anthropic_tool_cache_control(tools) + + if not direct_native_tool_cache or not planned_tools: + planned_messages = apply_anthropic_cache_control( + messages, + cache_ttl=cache_ttl, + native_anthropic=native_anthropic, + static_system_prefix=static_system_prefix, + ) + return PromptCachePlan(messages=planned_messages, tools=planned_tools) + + marker = _build_marker(cache_ttl) + if ( + messages + and isinstance(messages[0], dict) + and messages[0].get("role") == "system" + ): + # Tool-cache layout: only the static prefix carries a system-side + # marker; the volatile suffix's budget is spent on the tools array. + _apply_system_cache_markers( + messages[0], + marker, + static_system_prefix, + native_anthropic=True, + mark_suffix=False, + fallback_to_whole=False, + ) + planned_tools[-1]["cache_control"] = dict(marker) + for endpoint in _completed_transaction_endpoint_indexes( + messages, + native_anthropic=True, + )[-2:]: + _apply_cache_marker(messages[endpoint], marker, native_anthropic=True) + + return PromptCachePlan(messages=messages, tools=planned_tools) + + def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", diff --git a/agent/redact.py b/agent/redact.py index b104ad4c22fa..ea70246a9079 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -111,6 +111,23 @@ r"fw-[A-Za-z0-9]{30,}", # Fireworks AI API key r"fw_[A-Za-z0-9]{30,}", # Fireworks AI API key r"fpk_[A-Za-z0-9]{30,}", # Fireworks AI project key + # GitLab token families (each pattern keeps a full literal prefix so the + # _PREFIX_SUBSTRINGS pre-screen stays false-negative-free). Ported from + # openclaw/openclaw#112954; follow-up invited in #4541. + r"glpat-[A-Za-z0-9_\-]{10,}", # GitLab personal access token + r"gloas-[A-Za-z0-9_\-]{10,}", # GitLab OAuth application secret + r"gldt-[A-Za-z0-9_\-]{10,}", # GitLab deploy token + r"glrt-[A-Za-z0-9_.\-]{10,}", # GitLab runner authentication token (routable tokens are dotted) + r"glrtr-[A-Za-z0-9_.\-]{10,}", # GitLab runner registration token (routable) + r"glcbt-[A-Za-z0-9_\-]{10,}", # GitLab CI/CD job token + r"glptt-[A-Za-z0-9_\-]{10,}", # GitLab pipeline trigger token + r"glft-[A-Za-z0-9_\-]{10,}", # GitLab feed token + r"glimt-[A-Za-z0-9_\-]{10,}", # GitLab incoming mail token + r"glagent-[A-Za-z0-9_\-]{10,}", # GitLab agent (KAS) token + r"glsoat-[A-Za-z0-9_\-]{10,}", # GitLab service-account access token + r"glffct-[A-Za-z0-9_\-]{10,}", # GitLab feature-flags client token + r"glwt-[A-Za-z0-9_\-]{10,}", # GitLab workspace token + r"GR1348941[A-Za-z0-9_\-]{10,}", # GitLab legacy runner registration token ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name. @@ -154,9 +171,13 @@ r"^(?:os\.(?:getenv|environ)|process\.env|\$ENV\{)" ) # Namespaced (dotted) key: the secret word may sit anywhere in a dotted path. +# NOTE(perf): possessive quantifiers (py3.11+) replace the nested quantifier +# ``(?:[A-Za-z0-9_\-]+\.)+`` (exponential backtracking on long dotted runs). +# The ``*`` runs bordering {_SECRET_CFG_NAMES} must stay backtrackable +# (secret words are matchable by the class, e.g. ``app.api.key=…``). _CFG_DOTTED_RE = re.compile( - rf"((?:[A-Za-z0-9_\-]+\.)+[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*" - rf"|[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*\.[A-Za-z0-9_.\-]+)" + rf"([A-Za-z0-9_\-]++\.[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*+" + rf"|[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*\.[A-Za-z0-9_.\-]++)" rf"={_CFG_VALUE}", re.IGNORECASE, ) @@ -175,8 +196,10 @@ # is masked by _AUTH_HEADER_RE); ``auth_token``/``auth-token`` still match via # the ``token`` keyword. Quoted values defer to _JSON_FIELD_RE via the lookahead. _YAML_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential)" +# NOTE(perf): possessive quantifiers wherever the successor is disjoint; the +# leading ``[A-Za-z0-9_.\-]*`` stays backtrackable (see _CFG_DOTTED_RE note). _YAML_ASSIGN_RE = re.compile( - rf"(^[ \t]*[A-Za-z0-9_.\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\-]*)(:[ \t]*)(?!['\"])([^\s&]+)", + rf"(^[ \t]*+[A-Za-z0-9_.\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\-]*+)(:[ \t]*+)(?!['\"])([^\s&]++)", re.IGNORECASE | re.MULTILINE, ) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 3481ca072376..96a98d54d24b 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -247,6 +247,14 @@ async def execute_current_async( ) +def _has_running_event_loop() -> bool: + try: + asyncio.get_running_loop() + except RuntimeError: + return False + return True + + def stream_current( request: dict[str, Any], stream_factory: Callable[[dict[str, Any]], Any], @@ -256,12 +264,34 @@ def stream_current( finalizer: Callable[[], Any], metadata: dict[str, Any] | None = None, defer_logical_completion: bool = False, + completed_response_predicate: Callable[[Any], bool] | None = None, ) -> Any: - """Run a provider stream under the inherited Hermes turn when present.""" + """Run a provider stream under the inherited Hermes turn when present. + + When ``completed_response_predicate`` is set and the stream_factory returns + a complete response instead of an iterator (e.g. AnthropicAuxiliaryClient + and other shims that ignore ``stream=True``), unwrap and return the + completed response directly. This mirrors the pre-Relay behavior where + ``call_llm(stream=True)`` returned the raw response and the consumer's + own ``hasattr(stream, "choices")`` check handled it (#11732, #55933) — + without the unwrap the response stays trapped as ``final_response`` on the + inner ManagedLlmStream and the outer consumer sees an empty stream. + """ turn = relay_runtime.active_turn() if turn is None: return stream_factory(request) - return stream( + if _has_running_event_loop(): + # Managed provider callbacks execute on the Relay session's event + # loop. A nested ManagedLlmStream built here would be synchronously + # iterated on that same loop thread, which asyncio forbids + # ("Cannot run the event loop while another loop is running"). + # Return the raw factory result instead: the outer managed stream + # already provides Relay tracking for the enclosing attempt, and its + # own completed_response_predicate traps a completed response (e.g. + # the MoA facade's auxiliary ``call_llm(stream=True)`` returning a + # full response when an adapter ignores ``stream=True``). + return stream_factory(request) + managed = stream( request, stream_factory, session_id=turn.lease.session_id, @@ -270,7 +300,17 @@ def stream_current( finalizer=finalizer, metadata=metadata, defer_logical_completion=defer_logical_completion, + completed_response_predicate=completed_response_predicate, ) + # In the non-managed path the factory already ran eagerly during __init__, + # so a completed response is visible immediately and must surface raw. + # In the managed path the factory runs lazily on first pull, so + # final_response is still None here and the managed stream is returned. + if completed_response_predicate is not None: + completed = getattr(managed, "final_response", None) + if completed is not None: + return completed + return managed def stream( diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index 07b2c2e65e79..f7f003f24abb 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -4,9 +4,10 @@ * ``_is_destructive_command`` — terminal-command heuristic used to gate parallel batch dispatch. -* ``_should_parallelize_tool_batch`` / ``_extract_parallel_scope_path`` / - ``_paths_overlap`` — the rules engine deciding when a multi-tool batch - can run concurrently. +* ``_should_parallelize_tool_batch`` / ``_extract_parallel_scope_paths`` / + ``_extract_parallel_scope_path`` / ``_paths_overlap`` — the rules engine + deciding when a multi-tool batch can run concurrently (V4A patch scope + uses patch-body file headers, not a decoy ``path=``). * ``_is_multimodal_tool_result`` / ``_multimodal_text_summary`` / ``_append_subdir_hint_to_multimodal`` — envelope helpers for the ``{"_multimodal": True, "content": [...], "text_summary": ...}`` dict @@ -57,8 +58,17 @@ "web_search", }) +# Filesystem tools whose parallel admission is decided by path overlap. +# Readers may share a subtree with other readers; a writer conflicts with +# ANY overlapping reservation (reader or writer). This is what keeps a +# batched ``search_files``/``read_file`` from observing pre-mutation file +# state when the model batches it alongside the ``patch``/``write_file`` +# it depends on (the classic same-block write→read race). +_PATH_SCOPED_READERS = frozenset({"read_file", "search_files"}) +_PATH_SCOPED_WRITERS = frozenset({"write_file", "patch"}) + # File tools can run concurrently when they target independent paths. -_PATH_SCOPED_TOOLS = frozenset({"read_file", "write_file", "patch"}) +_PATH_SCOPED_TOOLS = _PATH_SCOPED_READERS | _PATH_SCOPED_WRITERS # Patterns that indicate a terminal command may modify/delete files. _DESTRUCTIVE_PATTERNS = re.compile( @@ -116,10 +126,18 @@ def _plan_tool_batch_segments(tool_calls, *, execution_cwd: Optional[Path] = Non * ``_NEVER_PARALLEL_TOOLS`` (interactive tools) → barrier. * Unparseable / non-dict arguments → barrier. - * Path-scoped tools (``read_file``/``write_file``/``patch``) join a - parallel run only when their target path does not overlap another - path already reserved in the same run; an overlap closes the run so - the conflicting call starts a NEW run after the first completes. + * Path-scoped tools (``read_file``/``search_files``/``write_file``/ + ``patch``) join a parallel run only when their target path(s) do not + CONFLICT with a path already reserved in the same run. Reservations + carry a reader/writer role: reader↔reader overlap is harmless (two + reads of the same file commute) and stays parallel; any overlap + involving a writer closes the run so the conflicting call starts a + NEW run after the first completes. ``search_files`` reserves its + search root (default ``.``) as a reader — a search batched after a + write into the searched subtree is ordered behind that write instead + of racing it. For V4A ``patch(mode="patch")`` the reserved paths are + the file headers in the patch body, not a possibly-stale ``path=`` + argument. * Anything not in ``_PARALLEL_SAFE_TOOLS`` and not an opted-in MCP tool → barrier. @@ -129,7 +147,8 @@ def _plan_tool_batch_segments(tool_calls, *, execution_cwd: Optional[Path] = Non """ segments: list[list] = [] # [kind, calls] pairs, normalized to tuples on return current: list = [] - reserved_paths: list[Path] = [] + # (canonical_path, is_writer) reservations for the current parallel run. + reserved_paths: list[tuple[Path, bool]] = [] def _close_parallel() -> None: nonlocal current, reserved_paths @@ -173,15 +192,25 @@ def _add_sequential(tc) -> None: continue if tool_name in _PATH_SCOPED_TOOLS: - scoped_path = _extract_parallel_scope_path(tool_name, function_args, execution_cwd=execution_cwd) - if scoped_path is None: + scoped_paths = _extract_parallel_scope_paths( + tool_name, function_args, execution_cwd=execution_cwd + ) + if not scoped_paths: _add_sequential(tool_call) continue - if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths): + is_writer = tool_name in _PATH_SCOPED_WRITERS + if any( + (is_writer or existing_is_writer) + and _paths_overlap(scoped_path, existing) + for scoped_path in scoped_paths + for existing, existing_is_writer in reserved_paths + ): # Same-subtree conflict inside this run: close it so this # call starts a fresh run AFTER the conflicting one lands. + # Reader↔reader overlap never conflicts — concurrent reads + # of the same subtree commute. _close_parallel() - reserved_paths.append(scoped_path) + reserved_paths.extend((p, is_writer) for p in scoped_paths) current.append(tool_call) continue @@ -233,33 +262,77 @@ def _canonical_path(raw_path: str, execution_cwd: Optional[Path] = None) -> Path return Path(resolved) -def _extract_parallel_scope_path( +def _extract_parallel_scope_paths( tool_name: str, function_args: dict, execution_cwd: Optional[Path] = None, -) -> Optional[Path]: - """Return the canonical file target for path-scoped tools. +) -> List[Path]: + """Return every canonical path this call reserves for overlap checks. *execution_cwd* should be the working directory that the tool will actually use at runtime. When omitted the process cwd is used, which may differ from the tool execution environment on some platforms (e.g. WSL, sandboxed sub-processes). + + For ``patch`` in V4A ``mode=patch``, scope comes from patch-body + ``*** Update/Add/Delete/Move File:`` headers (not a possibly-decoy + ``path=``). An empty result means the planner cannot determine the + scope and must treat the call as a sequential barrier. """ if tool_name not in _PATH_SCOPED_TOOLS: - return None + return [] - raw_path = function_args.get("path") - if not isinstance(raw_path, str) or not raw_path.strip(): - return None + raw_paths: List[str] = [] + if tool_name == "patch" and (function_args.get("mode") or "replace") == "patch": + raw_paths.extend(_extract_file_mutation_targets(tool_name, function_args)) + else: + raw_path = function_args.get("path") + if isinstance(raw_path, str) and raw_path.strip(): + raw_paths.append(raw_path) + elif tool_name == "search_files": + # ``search_files`` defaults its search root to the cwd when + # ``path`` is omitted — reserve that root rather than falling + # back to a sequential barrier (an empty result here would + # demote every bare search to a barrier and destroy read + # parallelism). + raw_paths.append(".") + + scoped: List[Path] = [] + seen: set[str] = set() + for raw in raw_paths: + if not isinstance(raw, str) or not raw.strip(): + continue + canonical = _canonical_path(raw, execution_cwd) + key = str(canonical) + if key in seen: + continue + seen.add(key) + scoped.append(canonical) + return scoped + + +def _extract_parallel_scope_path( + tool_name: str, + function_args: dict, + execution_cwd: Optional[Path] = None, +) -> Optional[Path]: + """Return the primary canonical file target for path-scoped tools. - return _canonical_path(raw_path, execution_cwd) + Thin view over ``_extract_parallel_scope_paths`` kept for callers/tests + that only need a single representative path. For multi-file V4A + patches this is the first header target. + """ + scoped = _extract_parallel_scope_paths( + tool_name, function_args, execution_cwd=execution_cwd + ) + return scoped[0] if scoped else None def _paths_overlap(left: Path, right: Path) -> bool: """Return True when two paths may refer to the same subtree. Both *left* and *right* must already be canonical (as returned by - ``_extract_parallel_scope_path`` / ``_canonical_path``) so that + ``_extract_parallel_scope_paths`` / ``_canonical_path``) so that symlink aliases and case differences are already normalised. """ left_parts = left.parts @@ -354,8 +427,10 @@ def _extract_file_mutation_targets(tool_name: str, args: Dict[str, Any]) -> List if not isinstance(body, str) or not body: return [] paths: List[str] = [] + # ``\s*`` (not ``\s+``) after ``***`` matches patch_parser / file_tools: + # they accept ``***Update File:`` with no space after the asterisks. for _m in re.finditer( - r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', + r'^\*\*\*\s*(?:Update|Add|Delete)\s+File:\s*(.+)$', body, re.MULTILINE, ): @@ -363,7 +438,7 @@ def _extract_file_mutation_targets(tool_name: str, args: Dict[str, Any]) -> List if p: paths.append(p) for _m in re.finditer( - r'^\*\*\*\s+Move\s+File:\s*(.+?)\s*->\s*(.+)$', + r'^\*\*\*\s*Move\s+File:\s*(.+?)\s*->\s*(.+)$', body, re.MULTILINE, ): @@ -634,6 +709,8 @@ def _maybe_wrap_untrusted(name: str, content: Any) -> Any: "_NEVER_PARALLEL_TOOLS", "_PARALLEL_SAFE_TOOLS", "_PATH_SCOPED_TOOLS", + "_PATH_SCOPED_READERS", + "_PATH_SCOPED_WRITERS", "_DESTRUCTIVE_PATTERNS", "_REDIRECT_OVERWRITE", "_is_destructive_command", @@ -641,6 +718,7 @@ def _maybe_wrap_untrusted(name: str, content: Any) -> Any: "_should_parallelize_tool_batch", "_canonical_path", "_extract_parallel_scope_path", + "_extract_parallel_scope_paths", "_paths_overlap", "_is_multimodal_tool_result", "_multimodal_text_summary", diff --git a/agent/tool_executor.py b/agent/tool_executor.py index f8bf5e6b2e54..6428d1ea19b8 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1294,12 +1294,10 @@ def _execute(next_args: dict[str, Any]) -> Any: except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - # ── Per-tool /steer drain ─────────────────────────────────── - # Same as the sequential path: drain between each collected - # result so the steer lands as early as possible. - agent._apply_pending_steer_to_tool_results(messages, 1) - # ── Per-turn aggregate budget enforcement ───────────────────────── + # Keep /steer pending until the final post-budget drain below. The model + # cannot observe a partial batch, while an early drain can be discarded + # when aggregate budget enforcement replaces that tool result. num_tools = len(parsed_calls) if finalize and num_tools > 0: turn_tool_msgs = messages[-num_tools:] @@ -1314,6 +1312,26 @@ def _execute(next_args: dict[str, Any]) -> Any: +def _append_cancelled_tool_results(messages: list, tool_calls, *, reason: str) -> None: + """Append a cancelled ``tool`` result for each call in ``tool_calls``. + + Used when a hard interrupt (KeyboardInterrupt / BaseException) aborts the + sequential executor mid-batch. Without this, the loop re-raises leaving the + assistant tool-call turn with no matching tool results — a message-role + alternation violation that malforms the next provider request. Mirrors the + cooperative-interrupt skip block and the concurrent path, both of which + already emit a result for every call_id. + """ + for tc in tool_calls: + name = getattr(getattr(tc, "function", None), "name", "") or "tool" + messages.append(make_tool_result_message( + name, + f"[Tool execution cancelled — {name} was skipped due to {reason}]", + getattr(tc, "id", "") or "", + effect_disposition="none", + )) + + def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None: """Execute tool calls sequentially (original behavior). Used for single calls or interactive tools. @@ -1368,7 +1386,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe stage=f"invalid tool arguments {function_name}", ): return - agent._apply_pending_steer_to_tool_results(messages, 1) continue # Tool Search unwrap — see execute_tool_calls_concurrent for full @@ -1726,6 +1743,14 @@ def _execute(next_args: dict) -> Any: agent.interrupt("keyboard interrupt") except Exception: pass + # Emit a tool result for THIS call and every remaining call in + # the batch before re-raising, so the assistant tool-call turn + # is never left without matching tool results (alternation). + _append_cancelled_tool_results( + messages, + assistant_message.tool_calls[i - 1:], + reason="keyboard interrupt", + ) raise except Exception as tool_error: function_result = f"Error executing tool '{function_name}': {tool_error}" @@ -1794,6 +1819,13 @@ def _execute(next_args: dict) -> Any: agent.interrupt("keyboard interrupt") except Exception: pass + # Emit a tool result for THIS call and every remaining call in + # the batch before re-raising (see interactive branch above). + _append_cancelled_tool_results( + messages, + assistant_message.tool_calls[i - 1:], + reason="keyboard interrupt", + ) raise except Exception as tool_error: function_result = f"Error executing tool '{function_name}': {tool_error}" @@ -1945,12 +1977,6 @@ def _execute(next_args: dict) -> Any: except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - # ── Per-tool /steer drain ─────────────────────────────────── - # Drain pending steer BETWEEN individual tool calls so the - # injection lands as soon as a tool finishes — not after the - # entire batch. The model sees it on the next API iteration. - agent._apply_pending_steer_to_tool_results(messages, 1) - if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": if agent.verbose_logging: print(f" ✅ Tool {i} completed in {tool_duration:.2f}s") @@ -1980,6 +2006,9 @@ def _execute(next_args: dict) -> Any: break # ── Per-turn aggregate budget enforcement ───────────────────────── + # Keep /steer pending until the final post-budget drain below. The model + # only receives this batch after all calls finish, and an early drain can + # be discarded when aggregate budget enforcement replaces a tool result. num_tools_seq = len(assistant_message.tool_calls) if finalize and num_tools_seq > 0: enforce_turn_budget(messages[-num_tools_seq:], env=get_active_env(effective_task_id), config=_tool_budget) diff --git a/agent/transcription_registry.py b/agent/transcription_registry.py index b04a8593a576..68bc19c06062 100644 --- a/agent/transcription_registry.py +++ b/agent/transcription_registry.py @@ -20,10 +20,10 @@ from __future__ import annotations import logging -import threading -from typing import Dict, List, Optional +from typing import List, Optional from agent.transcription_provider import TranscriptionProvider +from agent.plugin_profile_scope import ProfileKeyLike, ProfileProviderRegistry logger = logging.getLogger(__name__) @@ -49,11 +49,16 @@ }) -_providers: Dict[str, TranscriptionProvider] = {} -_lock = threading.Lock() +_registry: ProfileProviderRegistry[TranscriptionProvider] = ProfileProviderRegistry( + normalize_name=lambda name: name.strip().lower() +) +_lock = _registry.lock +_providers = _registry.compatibility_mapping() -def register_provider(provider: TranscriptionProvider) -> None: +def register_provider( + provider: TranscriptionProvider, *, profile_key: Optional[ProfileKeyLike] = None +) -> None: """Register a transcription provider. Rejects: @@ -84,9 +89,7 @@ def register_provider(provider: TranscriptionProvider) -> None: key, ", ".join(sorted(_BUILTIN_NAMES)), ) return - with _lock: - existing = _providers.get(key) - _providers[key] = provider + existing = _registry.register(key, provider, profile_key=profile_key) if existing is not None: logger.debug( "Transcription provider '%s' re-registered (was %r)", @@ -99,14 +102,17 @@ def register_provider(provider: TranscriptionProvider) -> None: ) -def list_providers() -> List[TranscriptionProvider]: +def list_providers( + *, profile_key: Optional[ProfileKeyLike] = None +) -> List[TranscriptionProvider]: """Return all registered providers, sorted by name.""" - with _lock: - items = list(_providers.values()) + items = _registry.list(profile_key=profile_key) return sorted(items, key=lambda p: p.name) -def get_provider(name: str) -> Optional[TranscriptionProvider]: +def get_provider( + name: str, *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[TranscriptionProvider]: """Return the provider registered under *name*, or None. Name matching is case-insensitive and whitespace-tolerant — mirrors @@ -115,10 +121,9 @@ def get_provider(name: str) -> Optional[TranscriptionProvider]: """ if not isinstance(name, str): return None - return _providers.get(name.strip().lower()) + return _registry.get(name, profile_key=profile_key) def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" - with _lock: - _providers.clear() + _registry.reset_for_tests() diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 15dd3409e3fc..de71c2b50006 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -544,10 +544,13 @@ def preflight_kwargs( *, allow_stream: bool = False, is_github_responses: bool = False, + sanitize_harmony_tokens: bool = False, ) -> dict: """Validate and sanitize Codex API kwargs before the call. Normalizes input items, strips unsupported fields, validates structure. + ``sanitize_harmony_tokens`` is enabled only for the ChatGPT Codex + backend, which rejects literal reserved Harmony wire tokens in text. """ from agent.codex_responses_adapter import _preflight_codex_api_kwargs @@ -555,6 +558,7 @@ def preflight_kwargs( api_kwargs, allow_stream=allow_stream, is_github_responses=is_github_responses, + sanitize_harmony_tokens=sanitize_harmony_tokens, ) if "prompt_cache_key" in normalized: bounded = _bounded_prompt_cache_key(normalized["prompt_cache_key"]) diff --git a/agent/tts_registry.py b/agent/tts_registry.py index a43359ec5958..3bd9d30a20d6 100644 --- a/agent/tts_registry.py +++ b/agent/tts_registry.py @@ -29,10 +29,10 @@ from __future__ import annotations import logging -import threading -from typing import Dict, List, Optional +from typing import List, Optional from agent.tts_provider import TTSProvider +from agent.plugin_profile_scope import ProfileKeyLike, ProfileProviderRegistry logger = logging.getLogger(__name__) @@ -60,11 +60,16 @@ }) -_providers: Dict[str, TTSProvider] = {} -_lock = threading.Lock() +_registry: ProfileProviderRegistry[TTSProvider] = ProfileProviderRegistry( + normalize_name=lambda name: name.strip().lower() +) +_lock = _registry.lock +_providers = _registry.compatibility_mapping() -def register_provider(provider: TTSProvider) -> None: +def register_provider( + provider: TTSProvider, *, profile_key: Optional[ProfileKeyLike] = None +) -> None: """Register a TTS provider. Rejects: @@ -94,9 +99,7 @@ def register_provider(provider: TTSProvider) -> None: key, ", ".join(sorted(_BUILTIN_NAMES)), ) return - with _lock: - existing = _providers.get(key) - _providers[key] = provider + existing = _registry.register(key, provider, profile_key=profile_key) if existing is not None: logger.debug( "TTS provider '%s' re-registered (was %r)", @@ -109,14 +112,17 @@ def register_provider(provider: TTSProvider) -> None: ) -def list_providers() -> List[TTSProvider]: +def list_providers( + *, profile_key: Optional[ProfileKeyLike] = None +) -> List[TTSProvider]: """Return all registered providers, sorted by name.""" - with _lock: - items = list(_providers.values()) + items = _registry.list(profile_key=profile_key) return sorted(items, key=lambda p: p.name) -def get_provider(name: str) -> Optional[TTSProvider]: +def get_provider( + name: str, *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[TTSProvider]: """Return the provider registered under *name*, or None. Name matching is case-insensitive and whitespace-tolerant — mirrors @@ -125,10 +131,9 @@ def get_provider(name: str) -> Optional[TTSProvider]: """ if not isinstance(name, str): return None - return _providers.get(name.strip().lower()) + return _registry.get(name, profile_key=profile_key) def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" - with _lock: - _providers.clear() + _registry.reset_for_tests() diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index 86f3f5099262..d4d6a23e7865 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -339,6 +339,12 @@ def finalize_turn( # otherwise ``/resume`` reloads ``content=""`` and the bug # resurfaces cross-session. _tail.pop("_db_persisted", None) + # The bounded flush-scan cursor (run_agent.py) skips the + # identity-matched prefix of its previous snapshot on the + # assumption that no live dict loses the marker in place — + # this pop is the one place that does. Invalidate it so the + # filled row is re-examined instead of skipped. + agent._db_flush_scan_prefix = None # The model has completed its request, so replace API-local # voice/model/skill guidance with the clean user input before writing the @@ -378,6 +384,18 @@ def finalize_turn( ): _before = len(messages) _compacted = _compressor._micro_compact(messages) + # Micro-compaction defrag rewrites the newest MICRO + # marker's content and pops _db_persisted from the live + # dict in place — the sibling of the pop site above. The + # compressor has no agent reference, so it raises a flag + # for us to invalidate the bounded flush-scan cursor; + # otherwise the rewritten marker row is identity-skipped + # and the stale summary persists to state.db. + if getattr( + _compressor, "_flush_scan_cursor_invalidated", False + ): + _compressor._flush_scan_cursor_invalidated = False + agent._db_flush_scan_prefix = None if isinstance(_compacted, list) and _compacted: messages[:] = _compacted _after = len(messages) @@ -647,6 +665,13 @@ def finalize_turn( } if agent._tool_guardrail_halt_decision is not None: result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata() + # Persistence failures already set failed=True + an explanation in + # final_response; also stamp `error` so gateway surfaces status="error" + # (and desktop can toast disk-full) instead of a quiet complete frame. + if failed and str(_turn_exit_reason) == "session_persistence_failed": + result["error"] = final_response or ( + "session storage could not be written — free disk space and try again" + ) # Surface any post-loop cleanup failures so the caller can distinguish a # clean turn from one whose trajectory/session/resource teardown raised # (the response is still returned either way — #8049). diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index 59e343bfeda3..d73fe5b6bfc8 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -45,6 +45,14 @@ class TurnRetryState: nous_auth_retry_attempted: bool = False nous_paid_entitlement_refresh_attempted: bool = False copilot_auth_retry_attempted: bool = False + # Copilot surfaces a stale/degraded credential as a 400 + # ``model_not_available_for_integrator`` / ``model_not_supported`` instead + # of a clean 401 (e.g. a raw OAuth token seeded when the token exchange + # degraded at startup, routing the request to the restricted + # ``copilot-language-server`` integrator). Guard a single-shot forced + # re-exchange + client rebuild for that case, separate from the 401 guard + # so both can fire within one attempt if needed. + copilot_stale_cred_retry_attempted: bool = False vertex_auth_retry_attempted: bool = False # ── Format / payload recovery guards ───────────────────────────────── diff --git a/agent/video_gen_registry.py b/agent/video_gen_registry.py index d78babfc9bb8..c17130c9089b 100644 --- a/agent/video_gen_registry.py +++ b/agent/video_gen_registry.py @@ -25,19 +25,27 @@ from __future__ import annotations import logging -import threading -from typing import Dict, List, Optional +from typing import List, Optional from agent.video_gen_provider import VideoGenProvider +from agent.plugin_profile_scope import ( + bind_profile_key, + ProfileKeyLike, + ProfileProviderRegistry, + selected_profile_key, +) logger = logging.getLogger(__name__) -_providers: Dict[str, VideoGenProvider] = {} -_lock = threading.Lock() +_registry: ProfileProviderRegistry[VideoGenProvider] = ProfileProviderRegistry() +_lock = _registry.lock +_providers = _registry.compatibility_mapping() -def register_provider(provider: VideoGenProvider) -> None: +def register_provider( + provider: VideoGenProvider, *, profile_key: Optional[ProfileKeyLike] = None +) -> None: """Register a video generation provider. Re-registration (same ``name``) overwrites the previous entry and logs @@ -52,41 +60,45 @@ def register_provider(provider: VideoGenProvider) -> None: name = provider.name if not isinstance(name, str) or not name.strip(): raise ValueError("Video gen provider .name must be a non-empty string") - with _lock: - existing = _providers.get(name) - _providers[name] = provider + existing = _registry.register(name, provider, profile_key=profile_key) if existing is not None: logger.debug("Video gen provider '%s' re-registered (was %r)", name, type(existing).__name__) else: logger.debug("Registered video gen provider '%s' (%s)", name, type(provider).__name__) -def list_providers() -> List[VideoGenProvider]: +def list_providers( + *, profile_key: Optional[ProfileKeyLike] = None +) -> List[VideoGenProvider]: """Return all registered providers, sorted by name.""" - with _lock: - items = list(_providers.values()) + items = _registry.list(profile_key=profile_key) return sorted(items, key=lambda p: p.name) -def get_provider(name: str) -> Optional[VideoGenProvider]: +def get_provider( + name: str, *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[VideoGenProvider]: """Return the provider registered under *name*, or None.""" if not isinstance(name, str): return None - with _lock: - return _providers.get(name.strip()) + return _registry.get(name, profile_key=profile_key) -def get_active_provider() -> Optional[VideoGenProvider]: +def get_active_provider( + *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[VideoGenProvider]: """Resolve the currently-active provider. Reads ``video_gen.provider`` from config.yaml; falls back per the module docstring. """ + key = selected_profile_key(profile_key) configured: Optional[str] = None try: from hermes_cli.config import load_config_readonly - cfg = load_config_readonly() + with bind_profile_key(key): + cfg = load_config_readonly() section = cfg.get("video_gen") if isinstance(cfg, dict) else None if isinstance(section, dict): raw = section.get("provider") @@ -95,8 +107,7 @@ def get_active_provider() -> Optional[VideoGenProvider]: except Exception as exc: logger.debug("Could not read video_gen.provider from config: %s", exc) - with _lock: - snapshot = dict(_providers) + snapshot = _registry.snapshot(profile_key=key) if configured: provider = snapshot.get(configured) @@ -111,7 +122,8 @@ def get_active_provider() -> Optional[VideoGenProvider]: def _is_available_safe(p: VideoGenProvider) -> bool: """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" try: - return bool(p.is_available()) + with bind_profile_key(key): + return bool(p.is_available()) except Exception as exc: # noqa: BLE001 logger.debug("video_gen provider %s.is_available() raised %s", p.name, exc) return False @@ -129,5 +141,4 @@ def _is_available_safe(p: VideoGenProvider) -> bool: def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" - with _lock: - _providers.clear() + _registry.reset_for_tests() diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py index dd46eb681186..002edaf084ea 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -33,19 +33,27 @@ from __future__ import annotations import logging -import threading -from typing import Dict, List, Optional +from typing import List, Optional from agent.web_search_provider import WebSearchProvider +from agent.plugin_profile_scope import ( + bind_profile_key, + ProfileKeyLike, + ProfileProviderRegistry, + selected_profile_key, +) logger = logging.getLogger(__name__) -_providers: Dict[str, WebSearchProvider] = {} -_lock = threading.Lock() +_registry: ProfileProviderRegistry[WebSearchProvider] = ProfileProviderRegistry() +_lock = _registry.lock +_providers = _registry.compatibility_mapping() -def register_provider(provider: WebSearchProvider) -> None: +def register_provider( + provider: WebSearchProvider, *, profile_key: Optional[ProfileKeyLike] = None +) -> None: """Register a web search/extract provider. Re-registration (same ``name``) overwrites the previous entry and logs @@ -60,9 +68,7 @@ def register_provider(provider: WebSearchProvider) -> None: name = provider.name if not isinstance(name, str) or not name.strip(): raise ValueError("Web provider .name must be a non-empty string") - with _lock: - existing = _providers.get(name) - _providers[name] = provider + existing = _registry.register(name, provider, profile_key=profile_key) if existing is not None: logger.debug( "Web provider '%s' re-registered (was %r)", @@ -75,19 +81,21 @@ def register_provider(provider: WebSearchProvider) -> None: ) -def list_providers() -> List[WebSearchProvider]: +def list_providers( + *, profile_key: Optional[ProfileKeyLike] = None +) -> List[WebSearchProvider]: """Return all registered providers, sorted by name.""" - with _lock: - items = list(_providers.values()) + items = _registry.list(profile_key=profile_key) return sorted(items, key=lambda p: p.name) -def get_provider(name: str) -> Optional[WebSearchProvider]: +def get_provider( + name: str, *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[WebSearchProvider]: """Return the provider registered under *name*, or None.""" if not isinstance(name, str): return None - with _lock: - return _providers.get(name.strip()) + return _registry.get(name, profile_key=profile_key) # --------------------------------------------------------------------------- @@ -130,7 +138,12 @@ def _read_config_key(*path: str) -> Optional[str]: ) -def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]: +def _resolve( + configured: Optional[str], + *, + capability: str, + profile_key: Optional[ProfileKeyLike] = None, +) -> Optional[WebSearchProvider]: """Resolve the active provider for a capability ("search" | "extract"). Resolution rules (in order): @@ -160,20 +173,22 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc matches the legacy preference; the dispatcher then returns a "set up a provider" error to the user. """ - with _lock: - snapshot = dict(_providers) + key = selected_profile_key(profile_key) + snapshot = _registry.snapshot(profile_key=key) def _capable(p: WebSearchProvider) -> bool: - if capability == "search": - return bool(p.supports_search()) - if capability == "extract": - return bool(p.supports_extract()) - return False + with bind_profile_key(key): + if capability == "search": + return bool(p.supports_search()) + if capability == "extract": + return bool(p.supports_extract()) + return False def _is_available_safe(p: WebSearchProvider) -> bool: """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" try: - return bool(p.is_available()) + with bind_profile_key(key): + return bool(p.is_available()) except Exception as exc: # noqa: BLE001 logger.debug("provider %s.is_available() raised %s", p.name, exc) return False @@ -278,27 +293,38 @@ def _norm(s: str) -> str: return None -def get_active_search_provider() -> Optional[WebSearchProvider]: +def get_active_search_provider( + *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[WebSearchProvider]: """Resolve the currently-active web search provider. Reads ``web.search_backend`` (preferred) or ``web.backend`` (shared fallback) from config.yaml; falls back per the module docstring. """ - explicit = _read_config_key("web", "search_backend") or _read_config_key("web", "backend") - return _resolve(explicit, capability="search") + key = selected_profile_key(profile_key) + with bind_profile_key(key): + explicit = _read_config_key("web", "search_backend") or _read_config_key( + "web", "backend" + ) + return _resolve(explicit, capability="search", profile_key=key) -def get_active_extract_provider() -> Optional[WebSearchProvider]: +def get_active_extract_provider( + *, profile_key: Optional[ProfileKeyLike] = None +) -> Optional[WebSearchProvider]: """Resolve the currently-active web extract provider. Reads ``web.extract_backend`` (preferred) or ``web.backend`` (shared fallback) from config.yaml; falls back per the module docstring. """ - explicit = _read_config_key("web", "extract_backend") or _read_config_key("web", "backend") - return _resolve(explicit, capability="extract") + key = selected_profile_key(profile_key) + with bind_profile_key(key): + explicit = _read_config_key("web", "extract_backend") or _read_config_key( + "web", "backend" + ) + return _resolve(explicit, capability="extract", profile_key=key) def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" - with _lock: - _providers.clear() + _registry.reset_for_tests() diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json index c2acdd40584b..5e72fb283f1a 100644 --- a/apps/bootstrap-installer/package.json +++ b/apps/bootstrap-installer/package.json @@ -19,7 +19,7 @@ "fix": "npm run lint:fix" }, "dependencies": { - "@nous-research/ui": "0.16.0", + "@nous-research/ui": "0.18.2", "@tailwindcss/typography": "0.5.20", "@tailwindcss/vite": "4.3.3", "@tauri-apps/api": "2.11.1", diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 0eec8ccd319d..3a7b1b0dbf5f 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -98,6 +98,12 @@ pub fn update_in_progress_marker() -> PathBuf { /// that path), where copying onto ourselves would be a Windows sharing /// violation. Best-effort: a failure here must not fail the install, so the /// caller logs and continues. +/// +/// NOTE: because of that no-op, a user's staged installer is only ever written +/// by a full install/repair. Every later `--update` runs the ORIGINAL binary, +/// so an installer-protocol change can strand the whole installed base on a +/// binary that predates it (see `restage_from_checkout`, which repairs this +/// from the freshly-updated checkout). pub fn copy_self_to_hermes_home() -> std::io::Result<()> { let src = std::env::current_exe()?; let dest = installer_dest(); diff --git a/apps/desktop/assets/icon.ico b/apps/desktop/assets/icon.ico index 1dcf20d8c381..b9de6754c219 100644 Binary files a/apps/desktop/assets/icon.ico and b/apps/desktop/assets/icon.ico differ diff --git a/apps/desktop/electron/backend-env.test.ts b/apps/desktop/electron/backend-env.test.ts index a92ce6e062e7..986f164f9e1d 100644 --- a/apps/desktop/electron/backend-env.test.ts +++ b/apps/desktop/electron/backend-env.test.ts @@ -7,6 +7,7 @@ import { appendUniquePathEntries, buildDesktopBackendEnv, buildDesktopBackendPath, + hermesManagedNodePathEntries, normalizeHermesHomeRoot, pathEnvKey, POSIX_SANE_PATH_ENTRIES @@ -22,8 +23,12 @@ test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entri }) const entries = result.split(':') - assert.equal(entries[0], '/Users/test/.hermes/node/bin') - assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin') + // Both managed-Node layouts lead, POSIX-native shape first, then the venv. + assert.deepEqual(entries.slice(0, 3), [ + '/Users/test/.hermes/node/bin', + '/Users/test/.hermes/node', + '/Users/test/.hermes/hermes-agent/venv/bin' + ]) assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added') assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added') assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added') @@ -33,6 +38,56 @@ test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entri } }) +test('managed Node dirs lead with the platform-native layout but always offer both', () => { + const posix = hermesManagedNodePathEntries('/Users/test/.hermes', { + platform: 'darwin', + pathModule: path.posix + }) + + const windows = hermesManagedNodePathEntries('C:\\Users\\test\\AppData\\Local\\hermes', { + platform: 'win32', + pathModule: path.win32 + }) + + // install.sh uses node/bin; install.ps1 unpacks node.exe into node\ itself. + // Both shapes are always emitted so migrated installs keep resolving. + assert.deepEqual(posix, ['/Users/test/.hermes/node/bin', '/Users/test/.hermes/node']) + assert.deepEqual(windows, [ + 'C:\\Users\\test\\AppData\\Local\\hermes\\node', + 'C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin' + ]) +}) + +test('managed Node dirs are empty without a Hermes home', () => { + assert.deepEqual(hermesManagedNodePathEntries(undefined, { platform: 'darwin', pathModule: path.posix }), []) + assert.deepEqual(hermesManagedNodePathEntries('', { platform: 'win32', pathModule: path.win32 }), []) +}) + +test('every managed Node dir outranks the inherited PATH on both platforms', () => { + for (const [platform, pathModule, home, inherited, delimiter] of [ + ['darwin', path.posix, '/Users/test/.hermes', '/usr/local/bin:/usr/bin', ':'], + ['win32', path.win32, 'C:\\hermes', 'C:\\Program Files\\nodejs;C:\\Windows\\System32', ';'] + ] as const) { + const entries = buildDesktopBackendPath({ + hermesHome: home, + venvRoot: null, + currentPath: inherited, + platform, + pathModule + }).split(delimiter) + + const managed = hermesManagedNodePathEntries(home, { platform, pathModule }) + const firstInherited = Math.min(...inherited.split(delimiter).map(entry => entries.indexOf(entry))) + + for (const dir of managed) { + assert.ok( + entries.indexOf(dir) >= 0 && entries.indexOf(dir) < firstInherited, + `${dir} must precede the inherited PATH on ${platform}` + ) + } + } +}) + test('desktop backend PATH preserves first occurrence and avoids duplicates', () => { const result = buildDesktopBackendPath({ hermesHome: '/Users/test/.hermes', @@ -64,7 +119,11 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () = }) assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath') - assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:')) + assert.ok( + env.PATH.startsWith( + '/Users/test/.hermes/node/bin:/Users/test/.hermes/node:/Users/test/.hermes/hermes-agent/venv/bin:' + ) + ) assert.ok(env.PATH.includes('/opt/homebrew/bin')) }) @@ -115,7 +174,13 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path') assert.equal(env.PATH, undefined) - assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;')) + // Windows leads with the portable layout (install.ps1 unpacks node.exe + // straight into node\, no bin\), then the POSIX shape for migrated installs. + assert.ok( + env.Path.startsWith( + 'C:\\Users\\test\\AppData\\Local\\hermes\\node;C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;' + ) + ) assert.ok(env.Path.includes('\\venv\\Scripts;')) assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows')) assert.equal(env.Path.includes('/opt/homebrew/bin'), false) diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index 3db4a19d0341..24d6928bad09 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -60,6 +60,34 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { return ordered.join(delimiter) } +/** + * Hermes-managed Node.js directories, in preferred lookup order. + * + * There are two on-disk layouts. `scripts/install.ps1` unpacks portable Node + * straight into `%LOCALAPPDATA%\hermes\node` (node.exe at the root, no `bin\`); + * `scripts/install.sh` and the node-bootstrap helper use the POSIX + * `$HERMES_HOME/node/bin`. Emit BOTH on every platform so mixed and migrated + * installs resolve, leading with the layout native to the current platform. + * + * This is the single source of truth for the ordering rule on the Node side — + * `main.ts` imports it rather than keeping its own copy. Mirrors + * `iter_hermes_node_dirs()` in hermes_constants.py, which the Electron main + * process cannot import. + */ +function hermesManagedNodePathEntries( + hermesHome, + { platform = process.platform, pathModule = pathModuleForPlatform(platform) }: any = {} +) { + if (!hermesHome) { + return [] + } + + const root = pathModule.join(hermesHome, 'node') + const bin = pathModule.join(root, 'bin') + + return platform === 'win32' ? [root, bin] : [bin, root] +} + function buildDesktopBackendPath({ hermesHome, venvRoot, @@ -68,11 +96,11 @@ function buildDesktopBackendPath({ pathModule = pathModuleForPlatform(platform) }: any = {}) { const delimiter = delimiterForPlatform(platform) - const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null + const hermesNodeDirs = hermesManagedNodePathEntries(hermesHome, { platform, pathModule }) const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES - return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter }) + return appendUniquePathEntries([hermesNodeDirs, venvBin, currentPath, saneEntries], { delimiter }) } function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) { @@ -126,6 +154,7 @@ export { buildDesktopBackendEnv, buildDesktopBackendPath, delimiterForPlatform, + hermesManagedNodePathEntries, normalizeHermesHomeRoot, pathEnvKey, POSIX_SANE_PATH_ENTRIES diff --git a/apps/desktop/electron/bootstrap-repair-guard.test.ts b/apps/desktop/electron/bootstrap-repair-guard.test.ts new file mode 100644 index 000000000000..e1a7a1ecf3d9 --- /dev/null +++ b/apps/desktop/electron/bootstrap-repair-guard.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { decideBootstrapRepair } from './bootstrap-repair-guard' + +test('first soft attempt with alive backend returns soft restart', () => { + const decision = decideBootstrapRepair({ + attempt: 1, + primaryBackendAlive: true + }) + + assert.equal(decision.hardReinstall, false) + assert.equal(decision.attempt, 1) + assert.match(decision.reason, /still alive/) + assert.match(decision.reason, /1\/3/) +}) + +test('first attempt with dead backend still returns soft restart', () => { + const decision = decideBootstrapRepair({ + attempt: 1, + primaryBackendAlive: false + }) + + assert.equal(decision.hardReinstall, false) + assert.match(decision.reason, /has exited/) +}) + +test('soft restart budget exhausts at maxSoftAttempts+1 and escalates', () => { + const decision = decideBootstrapRepair({ + attempt: 4, + maxSoftAttempts: 3, + primaryBackendAlive: true + }) + + assert.equal(decision.hardReinstall, true) + assert.equal(decision.attempt, 4) + assert.match(decision.reason, /exceeds soft-restart budget/) +}) + +test('attempt exactly at maxSoftAttempts is still soft', () => { + const decision = decideBootstrapRepair({ + attempt: 3, + maxSoftAttempts: 3, + primaryBackendAlive: true + }) + + assert.equal(decision.hardReinstall, false) + assert.equal(decision.attempt, 3) +}) + +test('custom maxSoftAttempts is honored', () => { + const soft = decideBootstrapRepair({ + attempt: 5, + maxSoftAttempts: 10, + primaryBackendAlive: true + }) + + assert.equal(soft.hardReinstall, false) + + const hard = decideBootstrapRepair({ + attempt: 11, + maxSoftAttempts: 10, + primaryBackendAlive: false + }) + + assert.equal(hard.hardReinstall, true) +}) + +test('default maxSoftAttempts is 3', () => { + // Probe the default indirectly: attempt 4 with no override must escalate. + const decision = decideBootstrapRepair({ + attempt: 4, + primaryBackendAlive: true + }) + + assert.equal(decision.hardReinstall, true) +}) + +test('fractional or zero attempts are clamped to 1', () => { + const zeroDecision = decideBootstrapRepair({ + attempt: 0, + primaryBackendAlive: true + }) + + assert.equal(zeroDecision.attempt, 1) + assert.equal(zeroDecision.hardReinstall, false) + + const fractionalDecision = decideBootstrapRepair({ + attempt: 2.7, + primaryBackendAlive: true + }) + + assert.equal(fractionalDecision.attempt, 2) + assert.equal(fractionalDecision.hardReinstall, false) +}) + +test('alive=false on a high attempt number still escalates (defense in depth)', () => { + // A dead backend should normally be handled by the renderer before it + // reaches the repair path, but if it does reach us with a high attempt + // count we still escalate — never silently keep soft-restarting. + const decision = decideBootstrapRepair({ + attempt: 5, + maxSoftAttempts: 3, + primaryBackendAlive: false + }) + + assert.equal(decision.hardReinstall, true) +}) diff --git a/apps/desktop/electron/bootstrap-repair-guard.ts b/apps/desktop/electron/bootstrap-repair-guard.ts new file mode 100644 index 000000000000..f971bcccfe28 --- /dev/null +++ b/apps/desktop/electron/bootstrap-repair-guard.ts @@ -0,0 +1,121 @@ +/** + * Repair-loop guard for the desktop bootstrap. + * + * Why this exists + * ─────────────── + * Hermes desktop can request a "repair" of its bundled backend when the + * renderer observes a transient backend failure (see issue #74874). The + * classic failure fingerprint: + * + * 1. Backend Python process hits a transient GIL stall (e.g. heavy + * import, MCP discovery, a long-running agent turn). + * 2. The renderer's WebSocket can't deliver the `gateway.ready` frame + * in time and treats the socket as dead. + * 3. Renderer calls `hermes:bootstrap:repair`. + * 4. Bootstrap unconditionally force-reinstalls the venv, restarting + * the backend — which stalls again for the same reason. + * 5. Renderer reports dead backend → another repair → infinite loop. + * + * The desktop should distinguish: + * - "the venv/install is genuinely broken" → hard reinstall is correct + * - "the runtime is healthy but temporarily stalled" → restart only, + * NOT a destructive reinstall that drops the venv + * + * What this module does + * ───────────────────── + * A pure decision helper. Given the current repair attempt count and a + * hint about whether the live backend process still looks alive, return + * whether the next repair should: + * - `hardReinstall: true` → run the installer, recreate the venv + * - `hardReinstall: false` → restart the existing backend, keep the venv + * + * Cap on soft restarts is bounded so an actually-corrupted install still + * eventually escalates to a hard reinstall after repeated stalls — the + * guard prevents the *unbounded* reinstall loop, not all reinstalls. + * + * The module is intentionally pure (no I/O, no logging, no global state) + * so it is unit-testable in isolation. Wiring into `main.ts` lives there. + */ + +export type RepairDecision = + | { + /** Run the installer (recreate venv). Caller bypasses the active runtime. */ + hardReinstall: true + /** Human-readable rationale for the desktop log. */ + reason: string + /** 1-indexed repair attempt number for diagnostics. */ + attempt: number + } + | { + /** Skip the installer; restart the existing backend only. */ + hardReinstall: false + reason: string + attempt: number + } + +export type RepairDecisionInput = { + /** + * 1-indexed count of how many repair attempts have happened in this + * failure episode. The first repair is `attempt === 1`; a successful + * boot resets the counter (see `main.ts`'s bootstrap completion path). + */ + attempt: number + /** + * Soft-restart budget before escalation to a hard reinstall. Defaults + * to 3: three "just restart" attempts, then a real reinstall. Bounded + * so a corrupt install still gets fixed; high enough that a GIL + * stall no longer loops the user into a 30-minute reinstall cycle. + */ + maxSoftAttempts?: number + /** + * Whether the live backend process (the one we are about to tear down + * to honour the repair request) still looks alive. A process whose + * `exitCode !== null` or `signalCode !== null` has actually exited; + * a process with both null is either still running or stalled — and a + * stall is exactly the case the soft-restart path is for. + */ + primaryBackendAlive: boolean +} + +/** + * Decide the next repair action. + * + * Decision matrix: + * attempt ≤ maxSoftAttempts AND alive → soft restart (don't reinstall) + * attempt ≤ maxSoftAttempts AND dead → soft restart (process exited, + * but we don't yet trust that + * the install is corrupt; restart + * once to confirm) + * attempt > maxSoftAttempts → hard reinstall (give up on the + * current install) + * + * "Alive" being true does NOT force a soft restart on every call: the + * attempt counter still increments, so an actually-broken install that + * keeps respawning a child but never announces READY still escalates + * after `maxSoftAttempts` cycles. + */ +export function decideBootstrapRepair(input: RepairDecisionInput): RepairDecision { + const maxSoftAttempts = input.maxSoftAttempts ?? 3 + const attempt = Math.max(1, Math.floor(input.attempt)) + const alive = Boolean(input.primaryBackendAlive) + + if (attempt > maxSoftAttempts) { + return { + hardReinstall: true, + attempt, + reason: + `repair attempt ${attempt} exceeds soft-restart budget ` + `(${maxSoftAttempts}); escalating to hard reinstall` + } + } + + return { + hardReinstall: false, + attempt, + reason: alive + ? `repair attempt ${attempt}/${maxSoftAttempts}: primary backend process ` + + `still alive (likely transient stall, see #74874); restarting only, ` + + `skipping installer` + : `repair attempt ${attempt}/${maxSoftAttempts}: primary backend process ` + + `has exited; restarting before escalating to reinstall` + } +} diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 5bae2e8e708a..a306bec5ca1a 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -133,16 +133,47 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => { assert.equal(profileRemoteOverride(null, 'coder'), null) }) -test('SSH remains separate from URL-shaped remote modes', () => { +test('SSH remains separate from URL-shaped remote modes and preserves an explicit remote profile', () => { assert.equal(modeIsRemoteLike('ssh'), false) - const config = { profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key' } } } + + const config = { + profiles: { coder: { mode: 'ssh', host: 'alice@box:2222', keyPath: '/key', remoteProfile: 'default' } } + } + assert.equal(profileRemoteOverride(config, 'coder'), null) + assert.deepEqual(profileSshOverride(config, 'coder'), { mode: 'ssh', host: 'box', user: 'alice', port: 2222, - keyPath: '/key' + keyPath: '/key', + remoteProfile: 'default' + }) +}) + +test('normalizeSshConfig rejects unsafe remote profile mappings', () => { + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: 'writer_2' }), { + mode: 'ssh', + host: 'box', + remoteProfile: 'writer_2' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: 'bad profile' }), { + mode: 'ssh', + host: 'box' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: '' }), { + mode: 'ssh', + host: 'box' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: 'root' }), { + mode: 'ssh', + host: 'box' + }) + assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', remoteProfile: 'default' }), { + mode: 'ssh', + host: 'box', + remoteProfile: 'default' }) }) @@ -350,6 +381,19 @@ test('normalizeRemoteBaseUrl rejects garbage', () => { assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/) }) +test('normalizeRemoteBaseUrl auto-prepends http:// for scheme-less host:port input', () => { + assert.equal(normalizeRemoteBaseUrl('100.64.0.1:9119'), 'http://100.64.0.1:9119') + assert.equal(normalizeRemoteBaseUrl('mini.tailnet-1234.ts.net:9119'), 'http://mini.tailnet-1234.ts.net:9119') + assert.equal(normalizeRemoteBaseUrl('localhost:9119'), 'http://localhost:9119') + assert.equal(normalizeRemoteBaseUrl('gw.example.com'), 'http://gw.example.com') + assert.equal(normalizeRemoteBaseUrl('gw.example.com/hermes/'), 'http://gw.example.com/hermes') +}) + +test('normalizeRemoteBaseUrl still rejects explicit non-http(s) schemes after scheme-less handling', () => { + assert.throws(() => normalizeRemoteBaseUrl('ws://host:9119'), /http:\/\/ or https:\/\//) + assert.throws(() => normalizeRemoteBaseUrl('ftp://host:21'), /http:\/\/ or https:\/\//) +}) + // --- buildGatewayWsUrl (token) --- test('buildGatewayWsUrl uses wss for https and bakes the token', () => { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index c15644b112a0..4644008d4876 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -45,14 +45,27 @@ const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session // cookies above. `privy-token` is the access token (the required signal); // variants cover the secured-prefix forms and the older `privy-session` name. const PRIVY_SESSION_COOKIE_VARIANTS = ['__Host-privy-token', '__Secure-privy-token', 'privy-token', 'privy-session'] +// Keep this aligned with hermes_cli.profiles.validate_profile_name(). `default` +// is the built-in root alias; these names cannot be created as profiles. +const RESERVED_REMOTE_PROFILES = new Set(['hermes', 'test', 'tmp', 'root', 'sudo']) function normalizeRemoteBaseUrl(rawUrl) { - const value = String(rawUrl || '').trim() + let value = String(rawUrl || '').trim() if (!value) { throw new Error('Remote gateway URL is required.') } + // Users routinely paste scheme-less "host:port" (a Tailscale IP, a LAN + // hostname). Without this, `new URL('100.64.0.1:9119')` either throws or — + // worse — parses `host:` as the protocol and produces a baffling + // "must be http:// or https://, got myhost:" error. Only a real + // `scheme://` prefix opts out, so explicit non-http schemes (ftp://, + // file://) still reach the protocol check below and get rejected. + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + value = `http://${value}` + } + let parsed try { @@ -271,6 +284,16 @@ function normalizeSshConfig(entry) { out.remoteHermesPath = remoteHermesPath } + // A Desktop profile can be a local routing label rather than the profile + // name used by the remote Hermes installation. Preserve an explicit mapping + // when it is a valid Hermes profile identifier; otherwise fall back to the + // historical same-name behavior in the caller. + const remoteProfile = String(entry.remoteProfile || '').trim() + + if (/^[a-z0-9][a-z0-9_-]{0,63}$/.test(remoteProfile) && !RESERVED_REMOTE_PROFILES.has(remoteProfile)) { + out.remoteProfile = remoteProfile + } + return out } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9df9c4be7982..834a67ece366 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -35,7 +35,7 @@ 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 { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' import { isReauthRequiredError, waitForHermesReady } from './backend-health' import { canImportHermesCli, @@ -47,6 +47,7 @@ import { import { waitForDashboardPortAnnouncement } from './backend-ready' import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' +import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' import { @@ -188,7 +189,7 @@ import { createStreamThrottle } from './stream-throttle' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { waitForUpdateClearance } from './update-gate' -import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker' +import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' import { runRebuildWithRetry } from './update-rebuild' import { buildRelaunchScript, @@ -200,9 +201,14 @@ import { sandboxPreflight } from './update-relaunch' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' -import { spawnUpdaterProcess } from './updater-process' +import { + resolveStagedUpdaterBinary, + spawnUpdaterProcess, + stagedUpdaterSupportsPrewrittenMarker +} from './updater-process' import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' +import { createWakeIndicatorWindowController } from './wake-indicator-window' import { computeWindowOptions, debounce, @@ -572,19 +578,10 @@ function resolveHermesHome() { const HERMES_HOME = resolveHermesHome() -function hermesManagedNodePathEntries() { - // NOTE: keep this ordering in sync with iter_hermes_node_dirs() in - // hermes_constants.py — this Node main process cannot import the Python - // module, so the platform-ordering rule is mirrored here. - const root = path.join(HERMES_HOME, 'node') - const bin = path.join(root, 'bin') - const entries = IS_WINDOWS ? [root, bin] : [bin, root] - - return entries.filter(directoryExists) -} - function pathWithHermesManagedNode(...entries) { - return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) + const managed = hermesManagedNodePathEntries(HERMES_HOME).filter(directoryExists) + + return [...managed, ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) } // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path @@ -680,7 +677,15 @@ const WINDOW_BUTTON_POSITION = { // (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. // It's only the pre-layout fallback — the renderer measures the exact overlay // width live via the Window Controls Overlay API. +// The apple-touch PNG bakes in the macOS-style ~10% margin, which is correct +// for the dock but renders visibly smaller than neighboring taskbar icons on +// Windows, where icons are full-bleed. Windows prefers the full-bleed +// assets/icon.ico (shipped to resources/ via extraResources) and only falls +// back to the padded PNG if the ico is missing. const APP_ICON_PATHS = [ + ...(IS_WINDOWS + ? [path.join(process.resourcesPath ?? '', 'icon.ico'), path.join(APP_ROOT, 'assets', 'icon.ico')] + : []), path.join(APP_ROOT, 'public', 'apple-touch-icon.png'), path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'), path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png') @@ -1099,6 +1104,14 @@ let bootstrapAbortController = null // repair can force the installer without destroying provenance about how the // install was created. Cleared once the reinstall is under way. let bootstrapRepairRequested = false +// Counter for in-flight repair attempts. Reset on a clean boot completion +// (see runBootstrap -> ensureRuntime resolve path). Each successive repair +// in the same failure episode increments this; once it crosses +// MAX_BOOTSTRAP_REPAIR_SOFT_ATTEMPTS the guard escalates from "soft restart" +// to "hard reinstall" so a transient backend stall (issue #74874) stops +// looping the user through a destructive venv reinstall. +let bootstrapRepairAttempt = 0 +const MAX_BOOTSTRAP_REPAIR_SOFT_ATTEMPTS = 3 let connectionConfigCache = null let connectionConfigCacheMtime = null const hermesLog = [] @@ -2602,18 +2615,14 @@ let isQuittingForHandoff = false let quitPromptOpen = false let quitConfirmedWithActiveWork = false -// Resolve the staged updater binary. The Tauri installer copies itself to -// HERMES_HOME/hermes-setup.exe on a successful install (see -// apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns -// ALL repo mutation — running `hermes update` + rebuilding the desktop — so -// the desktop never touches its own bits while running. Returns null when the -// updater isn't staged (e.g. a dev/source run that never went through the -// installer); callers degrade gracefully. +// Resolve the staged updater binary the desktop may hand an update to. On +// Windows that binary owns ALL repo mutation — running `hermes update` + +// rebuilding the desktop — so the desktop never touches its own bits while +// running. macOS/Linux stage the same binary but deliberately do not use it; +// see resolveStagedUpdaterBinary for the policy and for #74836. Returns null +// whenever no hand-off applies; callers degrade gracefully. function resolveUpdaterBinary() { - const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup' - const candidate = path.join(HERMES_HOME, name) - - return fileExists(candidate) ? candidate : null + return resolveStagedUpdaterBinary(HERMES_HOME, { fileExists, isWindows: IS_WINDOWS }) } function repairMacUpdaterHelper(updater) { @@ -2841,12 +2850,13 @@ async function applyUpdates(opts = {}) { const updater = resolveUpdaterBinary() if (!updater && !IS_WINDOWS) { - // macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows - // (where a venv-shim file lock forces the quit→hand-off→rebuild dance), - // there's no mandatory file locking here, so the desktop can drive the - // whole update itself: `hermes update` (backend) + `hermes desktop - // --build-only` (OS-aware GUI rebuild), then swap the running .app bundle - // with the freshly built one and relaunch. + // macOS/Linux: never hand off, staged hermes-setup or not — the resolver + // returns null there by policy. Unlike Windows (where a venv-shim file + // lock forces the quit→hand-off→rebuild dance), there's no mandatory file + // locking here, so the desktop can drive the whole update itself: + // `hermes update` (backend) + `hermes desktop --build-only` (OS-aware GUI + // rebuild), then swap the running .app bundle with the freshly built one + // and relaunch. return await applyUpdatesPosixInApp(opts) } @@ -2884,6 +2894,19 @@ async function applyUpdates(opts = {}) { return { ok: true, manual: true, command, hermesRoot: updateRoot } } + const handoffConflict = updateHandoffConflict(HERMES_HOME) + + if (handoffConflict) { + // A different updater already owns the marker — most often a previous + // "Update" click whose updater is still alive and parked mid-run. + // Spawning another here would overwrite its claim and let two updaters + // mutate the checkout at once (#75778); refuse instead. + rememberLog(`[updates] refusing hand-off: ${handoffConflict.message}`) + emitUpdateProgress({ stage: 'error', message: handoffConflict.message, percent: null }) + + return { ok: false, error: 'update-already-running', message: handoffConflict.message } + } + emitUpdateProgress({ stage: 'restart', message: @@ -2984,8 +3007,20 @@ async function applyUpdates(opts = {}) { // the venv. By writing the marker ourselves the renderer's // waitForUpdateToFinish() gate sees a live update and parks instead. // The updater overwrites this with its own PID later; same format. - if (Number.isInteger(child.pid)) { + // + // SKIPPED for pre-#74782 staged updaters: those have no self-PID + // exclusion, so they read this very marker as a foreign live owner and + // abort with "Another Hermes update is already running (PID )" — + // an unbreakable loop, because the update that would replace the stale + // binary is the one being refused. Losing the anti-respawn hardening is + // strictly better than never updating again, and the updater still writes + // its own marker moments later. + if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) { writeUpdateMarker(HERMES_HOME, child.pid) + } else if (Number.isInteger(child.pid)) { + rememberLog( + `[updates] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim` + ) } rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`) @@ -3017,6 +3052,24 @@ async function handOffWindowsBootstrapRecovery(reason) { return false } + const handoffConflict = updateHandoffConflict(HERMES_HOME) + + if (handoffConflict) { + // Same hazard as applyUpdates (#75778): a live foreign updater already + // owns the marker. Spawning another here would overwrite its claim and + // race a second updater over the same install tree. The live updater + // is already working on this exact install and will restart us when + // it finishes, so treat this the same as a successful hand-off instead + // of clobbering it with our own. + rememberLog(`[bootstrap] refusing recovery hand-off: ${handoffConflict.message}`) + isQuittingForHandoff = true + setTimeout(() => { + app.quit() + }, UPDATE_HANDOFF_DWELL_MS) + + return true + } + const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() @@ -3054,9 +3107,15 @@ async function handOffWindowsBootstrapRecovery(reason) { // Same marker pre-write as applyUpdates — see comment there. The recovery // hand-off has the same window where the renderer can respawn a backend - // before the updater writes its own marker. - if (Number.isInteger(child.pid)) { + // before the updater writes its own marker, and the same stale-updater + // exclusion: a pre-#74782 binary would refuse its own pre-written claim and + // strand the very recovery meant to heal the install. + if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) { writeUpdateMarker(HERMES_HOME, child.pid) + } else if (Number.isInteger(child.pid)) { + rememberLog( + `[bootstrap] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim` + ) } rememberLog( @@ -4040,6 +4099,7 @@ async function ensureRuntime(backend) { // The repair request has been honoured by reaching the installer; clear it // so a later boot isn't forced through bootstrap again. bootstrapRepairRequested = false + bootstrapRepairAttempt = 0 const bootstrapResult = await runBootstrap({ installStamp: backend.installStamp, @@ -6949,6 +7009,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon sshPort: (ssh || savedSsh)?.port || null, sshKeyPath: (ssh || savedSsh)?.keyPath || '', sshRemoteHermesPath: (ssh || savedSsh)?.remoteHermesPath || '', + sshRemoteProfile: (ssh || savedSsh)?.remoteProfile || '', // The env override only forces the global/primary connection; a per-profile // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. envOverride @@ -7081,7 +7142,8 @@ function buildSshBlock(input: any, existingBlock: any = {}) { user: input.sshUser ?? existingBlock.user, port: input.sshPort ?? existingBlock.port, keyPath: input.sshKeyPath ?? existingBlock.keyPath, - remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath + remoteHermesPath: input.sshRemoteHermesPath ?? existingBlock.remoteHermesPath, + remoteProfile: input.sshRemoteProfile ?? existingBlock.remoteProfile }) if (!merged) { @@ -7370,7 +7432,7 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc const lifecycle = platform.os === 'Windows' ? connectWindowsRemote : remoteLifecycle.connect result = await lifecycle({ ssh, - profile: connectionScopeKey(profile) || '', + profile: sshConfig.remoteProfile || connectionScopeKey(profile) || '', remoteHermesPath: sshConfig.remoteHermesPath || '', ownershipId: sshOwnershipKey(profile), reuseToken: reuseToken || '', @@ -8550,6 +8612,13 @@ async function startHermes() { error: null }) + // A successful boot (including a soft restart that the repair-guard + // chose over a hard reinstall, see #74874) means any in-flight repair + // attempt counter has been honoured — reset it so the next genuine + // failure starts fresh from attempt 1 instead of inheriting the + // accumulated count of the resolved episode. + bootstrapRepairAttempt = 0 + return { baseUrl, mode: 'local', @@ -8804,6 +8873,17 @@ function createInstanceWindow() { return win } +// A macOS-only ambient wake cue. It is deliberately a gateway-less helper +// window: the active renderer owns voice state and sends only the visual phase. +const wakeIndicatorController = createWakeIndicatorWindowController({ + devServer: DEV_SERVER, + isMac: IS_MAC, + loadWindowUrl, + preloadPath: PRELOAD_PATH, + rendererIndex: resolveRendererIndex, + wireWindow: window => wireCommonWindowHandlers(window, zoomWiringForWindowKind('wakeIndicator')) +}) + // The pet overlay: a single transparent, frameless, always-on-top window that // hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out" // here so it can leave the app's bounds and stay visible while Hermes is @@ -9264,6 +9344,7 @@ function createWindow() { const createdMainWindow = mainWindow mainWindow.on('closed', () => { closePetOverlay() + wakeIndicatorController.close() if (mainWindow === createdMainWindow) { mainWindow = null @@ -9464,6 +9545,10 @@ ipcMain.handle('hermes:window:openInstance', async () => { return { ok: true } }) +ipcMain.handle('hermes:wake-indicator:get', () => wakeIndicatorController.getState()) +ipcMain.on('hermes:wake-indicator:set', (_event, state) => { + wakeIndicatorController.setState(state) +}) // --- Text size (zoom) ------------------------------------------------------- // The settings UI drives the same clamped zoom scale as the Ctrl/Cmd @@ -9631,9 +9716,41 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { // 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') + bootstrapRepairAttempt += 1 + + // Probe the live backend process so the guard can distinguish "venv is + // genuinely broken" (force reinstall) from "backend is just transiently + // stalled under GIL pressure" (#74874 — `event loop stalled` followed by + // `ws ready frame send failed`, then renderer keeps reporting dead). + const primaryProc = backendConnectionState.getProcess() + + const primaryBackendAlive = Boolean( + primaryProc && + (primaryProc as { exitCode?: number | null }).exitCode === null && + (primaryProc as { signalCode?: string | null }).signalCode === null + ) + + const repairDecision = decideBootstrapRepair({ + attempt: bootstrapRepairAttempt, + maxSoftAttempts: MAX_BOOTSTRAP_REPAIR_SOFT_ATTEMPTS, + primaryBackendAlive + }) - bootstrapRepairRequested = true + rememberLog( + `[bootstrap] repair requested by renderer; forcing reinstall + clearing latched failure ` + + `(attempt=${repairDecision.attempt}/${MAX_BOOTSTRAP_REPAIR_SOFT_ATTEMPTS}, ` + + `primaryBackendAlive=${primaryBackendAlive}, ` + + `hardReinstall=${repairDecision.hardReinstall}): ${repairDecision.reason}` + ) + + // The guard may decide the install is healthy enough that a restart + // (without touching the venv) is the right answer. Translate that into + // the existing flag: if the guard said "soft restart", we skip the + // "bypass active runtime" path inside startHermes() and fall through + // to the normal restart branch, which just kills the current child + // and respawns it against the same venv. See #74874 — this is what + // breaks the infinite reinstall loop the user hit. + bootstrapRepairRequested = repairDecision.hardReinstall bootstrapFailure = null backendStartFailure = null remoteReauthFailure = null @@ -11711,6 +11828,17 @@ app.whenReady().then(() => { // it without the renderer visiting Settings. A failed registration is logged // here and surfaced in Settings via the IPC state (never silent). applyQuickEntrySettings(readQuickEntrySettings()) + + if (IS_MAC) { + const reposition = () => wakeIndicatorController.reposition() + + screen.on('display-added', reposition) + + screen.on('display-metrics-changed', reposition) + + screen.on('display-removed', reposition) + } + createWindow() // Win/Linux cold start: the launching hermes:// URL is in our own argv. @@ -11839,6 +11967,7 @@ app.on('before-quit', event => { // The always-on-top overlay isn't a "real" app window; close it so a stray // pet can't keep the process alive or float over a quit app. closePetOverlay() + wakeIndicatorController.close() // Same for the Quick Entry composer — and release its global accelerator so a // quitting Hermes never keeps another app's chord hostage. diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8fb6d97c3fad..11483d9ce826 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -8,6 +8,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', { openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key), + wakeIndicator: { + getState: () => ipcRenderer.invoke('hermes:wake-indicator:get'), + setState: state => ipcRenderer.send('hermes:wake-indicator:set', state), + onState: callback => { + const listener = (_event, state) => callback(state) + ipcRenderer.on('hermes:wake-indicator:state', listener) + + return () => ipcRenderer.removeListener('hermes:wake-indicator:state', listener) + } + }, petOverlay: { // Main renderer → main process: window lifecycle + drag. `request` is // `{ bounds, screen }`; resolves with the screen bounds it actually used. diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts index b421b0ef0c31..3855cdab2273 100644 --- a/apps/desktop/electron/remote-lifecycle.test.ts +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' +import { profileSshOverride } from './connection-config' import { buildSpawnCommand, cleanupStale, @@ -420,6 +421,51 @@ test('connect() spawns fresh when there is no lockfile, adopts the served token' assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token')) }) +test('managed SSH maps a local scope to a different non-default remote profile', async () => { + const localScope = 'work' + + const sshConfig = profileSshOverride( + { + profiles: { + [localScope]: { + mode: 'ssh', + host: 'remote-box', + remoteProfile: 'writer_2' + } + } + }, + localScope + ) + + assert.equal(sshConfig?.remoteProfile, 'writer_2') + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid/, '778\n'], + [/kill -0 778/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_BACKEND_READY port=52000\n'] + ]) + + await connect( + connectDeps(ssh, { + profile: sshConfig?.remoteProfile, + adoptServedToken: async () => 'mapped-profile-token' + }) + ) + + const spawn = ssh.calls.find(command => /setsid|nohup/.test(command)) || '' + assert.match(spawn, /--profile\b/) + assert.ok(spawn.includes('writer_2')) + assert.match(spawn, /serve\s+--isolated/) + assert.match(spawn, /\.hermes\/desktop-ssh\/[0-9a-f]{32}\/[0-9a-f]{16}\.token/) + assert.ok(!spawn.includes(' work'), 'the local Desktop scope must not become the remote profile') +}) + test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) @@ -440,6 +486,36 @@ test('connect() reuses a healthy dashboard when fingerprint + probe pass', async assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard') }) +test('connect() respawns when the requested remote profile differs from the lockfile profile', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ profile: 'desktop-work', tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'], + [/kill 333/, ''], + [/--version/, 'Hermes Agent v0.18.2\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '890\n'], + [/kill -0 890/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] + ]) + + const result = await connect( + connectDeps(ssh, { profile: 'default', reuseToken, adoptServedToken: async () => 'fresh' }) + ) + + assert.equal(result.reused, false) + assert.ok( + ssh.calls.some(c => /setsid/.test(c)), + 'profile mismatch must spawn a fresh dashboard' + ) +}) + test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => { const reuseToken = 'stored-token' const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: fingerprintToken(reuseToken) }) diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index 705c80000b92..db4d5b20dda3 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -713,6 +713,7 @@ async function connect(deps) { pidAlive && owned && lock.port > 0 && + lock.profile === profile && Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken) && lock.hermesPath === hermesPath && diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts index cf5c24e97270..d3ffb99e0eee 100644 --- a/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.test.ts @@ -28,6 +28,7 @@ test('sshConfigFingerprint covers scope and every connection field', () => { port: 2222, keyPath: '/other', remoteHermesPath: '/other-hermes', + remoteProfile: 'default', effectiveConfigFingerprint: 'changed-config' })) { assert.notEqual(base, sshConfigFingerprint('', { ...config, [field]: value })) diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.ts index 827e9a4e7a18..c38e0473aae2 100644 --- a/apps/desktop/electron/ssh-bootstrap-coordinator.ts +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.ts @@ -8,6 +8,7 @@ function sshConfigFingerprint(scope, config) { config.port, config.keyPath, config.remoteHermesPath, + config.remoteProfile, config.effectiveConfigFingerprint ] diff --git a/apps/desktop/electron/update-marker.test.ts b/apps/desktop/electron/update-marker.test.ts index 3da49396cc8d..0fb1142b4cbc 100644 --- a/apps/desktop/electron/update-marker.test.ts +++ b/apps/desktop/electron/update-marker.test.ts @@ -24,6 +24,7 @@ import { markerPath, readLiveUpdateMarker, UPDATE_MARKER_MAX_AGE_MS, + updateHandoffConflict, writeUpdateMarker } from './update-marker' @@ -128,3 +129,51 @@ test('writeUpdateMarker + dead pid => self-heals on read', () => { assert.equal(res, null, 'a dead-pid marker from writeUpdateMarker self-heals') assert.ok(!fs.existsSync(markerPath(home)), 'marker file is pruned') }) + +// --------------------------------------------------------------------------- +// updateHandoffConflict (#75778) +// +// A retried "Update" click must not spawn a second updater over a still-live +// one — writeUpdateMarker unconditionally overwrites the marker, so an +// unchecked hand-off clobbers the original updater's claim while it is still +// alive and mutating the checkout. +// --------------------------------------------------------------------------- + +test('no marker => hand-off is not blocked', () => { + const home = tmpHome('conflict-none') + assert.equal(updateHandoffConflict(home, { kill: ALIVE }), null) +}) + +test('a different live updater already owns the marker => hand-off is blocked', () => { + const home = tmpHome('conflict-live') + const now = 1_000_000_000_000 + writeMarker(home, 1010, Math.floor(now / 1000) - 6) // 6s old + const conflict = updateHandoffConflict(home, { kill: ALIVE, now: () => now }) + assert.ok(conflict, 'a live foreign updater must block a new hand-off') + assert.equal(conflict.pid, 1010) + assert.match(conflict.message, /already running/) + assert.match(conflict.message, /PID 1010/) + assert.match(conflict.message, /6s/) +}) + +test('a dead-pid marker does not block a hand-off (self-heals)', () => { + const home = tmpHome('conflict-dead') + writeMarker(home, 999999, Math.floor(Date.now() / 1000)) + assert.equal(updateHandoffConflict(home, { kill: DEAD }), null) +}) + +test('an expired marker does not block a hand-off (self-heals)', () => { + const home = tmpHome('conflict-expired') + const now = 1_000_000_000_000 + writeMarker(home, 1010, Math.floor((now - UPDATE_MARKER_MAX_AGE_MS - 60_000) / 1000)) + assert.equal(updateHandoffConflict(home, { kill: ALIVE, now: () => now }), null) +}) + +test('minutes-scale elapsed time is formatted as "Nm Ss"', () => { + const home = tmpHome('conflict-minutes') + const now = 1_000_000_000_000 + writeMarker(home, 1010, Math.floor(now / 1000) - 125) // 2m 5s old + const conflict = updateHandoffConflict(home, { kill: ALIVE, now: () => now }) + assert.ok(conflict) + assert.match(conflict.message, /2m 5s/) +}) diff --git a/apps/desktop/electron/update-marker.ts b/apps/desktop/electron/update-marker.ts index 543fce0451d0..ee50b52e2114 100644 --- a/apps/desktop/electron/update-marker.ts +++ b/apps/desktop/electron/update-marker.ts @@ -136,3 +136,47 @@ export function writeUpdateMarker(hermesHome, pid, { now = Date.now } = {}) { // updater will write its own when it reaches run_update. } } + +/** + * Whether a NEW updater hand-off must be refused because a different, + * already-alive updater currently owns the marker (#75778). + * + * `writeUpdateMarker` unconditionally overwrites the marker file. Called + * before every hand-off with no conflict check, a user who clicks "Update" + * again while a prior updater is still parked mid-run (e.g. "waiting for + * Hermes to exit…") clobbers that still-running updater's claim: the + * retry's pre-write now names the NEW child, so the OLD process — alive + * and mutating the checkout — is no longer recorded as the owner. A second + * live updater can then run over the same tree unrecorded, the exact + * two-updaters-at-once hazard `UpdateMarkerGuard` in the Rust updater + * exists to prevent (apps/bootstrap-installer/src-tauri/src/update.rs). + * + * Returns the live foreign owner (with a ready-to-show message) when the + * hand-off must be refused, or `null` when it's safe to spawn — no marker, + * or the existing one is stale/dead and self-heals via + * `readLiveUpdateMarker`. + */ +export function updateHandoffConflict( + hermesHome, + opts: { + now?: () => number + maxAgeMs?: number + kill?: typeof process.kill + } = {} +) { + const owner = readLiveUpdateMarker(hermesHome, opts) + + if (!owner) { + return null + } + + const mins = Math.floor(owner.ageMs / 60_000) + const secs = Math.floor((owner.ageMs % 60_000) / 1000) + const elapsed = mins > 0 ? `${mins}m ${secs}s` : `${secs}s` + + return { + pid: owner.pid, + ageMs: owner.ageMs, + message: `An update is already running (PID ${owner.pid}, started ${elapsed} ago). Wait for it to finish, then try again.` + } +} diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index ce61855dff52..e781fe3efa3e 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -1,9 +1,68 @@ import assert from 'node:assert/strict' import type { SpawnOptions } from 'node:child_process' +import path from 'node:path' import { test } from 'vitest' -import { spawnUpdaterProcess } from './updater-process' +import { + MARKER_SELF_ADOPT_EPOCH_MS, + resolveStagedUpdaterBinary, + spawnUpdaterProcess, + stagedUpdaterSupportsPrewrittenMarker +} from './updater-process' + +const DAY_MS = 24 * 60 * 60 * 1000 + +test('stagedUpdaterSupportsPrewrittenMarker rejects installers predating the self-adopt fix', () => { + // The real-world trap: an installer staged at first install months ago, never + // refreshed because copy_self_to_hermes_home no-ops during --update. + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS + }), + false + ) +}) + +test('stagedUpdaterSupportsPrewrittenMarker accepts installers from the fix onward', () => { + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS + }), + true + ) + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS + 30 * DAY_MS + }), + true + ) +}) + +test('stagedUpdaterSupportsPrewrittenMarker treats an unreadable mtime as unsupported', () => { + // Bias toward the path that can always make progress: a skipped pre-write + // loses anti-respawn hardening, a wedged updater can never update again. + assert.equal( + stagedUpdaterSupportsPrewrittenMarker('C:\\Hermes\\hermes-setup.exe', { + stagedMtimeMs: () => null + }), + false + ) +}) + +test('resolveStagedUpdaterBinary still returns a stale staged updater on Windows', () => { + // Staleness gates only the marker PRE-WRITE, never the hand-off itself: + // the stale binary is the only updater these users have, and it works fine + // once it is allowed to write its own claim. + assert.equal( + resolveStagedUpdaterBinary('C:\\Hermes', { + fileExists: () => true, + isWindows: true, + stagedMtimeMs: () => MARKER_SELF_ADOPT_EPOCH_MS - 60 * DAY_MS + }), + path.join('C:\\Hermes', 'hermes-setup.exe') + ) +}) test('spawnUpdaterProcess hides the updater console and detaches the child on Windows', () => { const calls: Array<{ args: string[]; command: string; options: SpawnOptions }> = [] @@ -60,3 +119,49 @@ test('spawnUpdaterProcess preserves updater options off Windows', () => { assert.deepEqual(capturedOptions, { detached: true, stdio: 'ignore' }) }) + +test('resolveStagedUpdaterBinary hands Windows the staged installer it finds', () => { + const home = 'C:\\Users\\hermes\\AppData\\Local\\hermes' + const staged = path.join(home, 'hermes-setup.exe') + const probed: string[] = [] + + const resolved = resolveStagedUpdaterBinary(home, { + fileExists: candidate => { + probed.push(candidate) + + return candidate === staged + }, + isWindows: true + }) + + assert.equal(resolved, staged) + assert.deepEqual(probed, [staged]) +}) + +test('resolveStagedUpdaterBinary returns null off Windows even when hermes-setup is staged (#74836)', () => { + const home = '/Users/hermes/.hermes' + let probes = 0 + + const resolved = resolveStagedUpdaterBinary(home, { + // The installer stages hermes-setup on macOS/Linux too, so "it exists" is + // the normal case — and precisely the one that must not win. + fileExists: () => { + probes += 1 + + return true + }, + isWindows: false + }) + + assert.equal(resolved, null) + assert.equal(probes, 0) +}) + +test('resolveStagedUpdaterBinary returns null on Windows when nothing is staged', () => { + const resolved = resolveStagedUpdaterBinary('C:\\Users\\hermes\\AppData\\Local\\hermes', { + fileExists: () => false, + isWindows: true + }) + + assert.equal(resolved, null) +}) diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index d0706a6e5140..97b1d9651afa 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -1,4 +1,6 @@ import { spawn, type SpawnOptions } from 'node:child_process' +import { statSync } from 'node:fs' +import path from 'node:path' import { hiddenWindowsChildOptions } from './windows-child-options' @@ -7,6 +9,109 @@ export interface UpdaterChild { unref: () => void } +export interface ResolveStagedUpdaterBinaryDeps { + isWindows?: boolean + fileExists?: (candidate: string) => boolean + stagedMtimeMs?: (candidate: string) => number | null +} + +/** + * Staged installers older than this have no self-PID exclusion in + * `UpdateMarkerGuard::acquire` and will refuse an update whose marker was + * pre-written on their behalf. + * + * The self-adopt fix landed in #74782 / 160586ff8 (2026-07-30 17:57 +0700). + * We compare against the start of 2026-07-31 UTC so the boundary is + * unambiguous for binaries staged that same day. + */ +export const MARKER_SELF_ADOPT_EPOCH_MS = Date.UTC(2026, 6, 31) + +function stagedFileExists(candidate: string): boolean { + try { + return statSync(candidate).isFile() + } catch { + return false + } +} + +function stagedFileMtimeMs(candidate: string): number | null { + try { + return statSync(candidate).mtimeMs + } catch { + return null + } +} + +/** + * Decide which staged installer binary — if any — may be handed an update. + * + * The Tauri installer self-copies into HERMES_HOME on *every* platform + * (`hermes-setup.exe` on Windows, `hermes-setup` elsewhere — see + * apps/bootstrap-installer `paths::installer_dest` and + * `bootstrap::copy_self_to_hermes_home`), so finding that binary on macOS or + * Linux is expected, not leftover junk. + * + * Handing an update to it is nonetheless a Windows-only policy. Windows needs + * the quit -> hand-off -> rebuild dance because a venv shim file lock keeps the + * running desktop from rewriting its own bits; macOS and Linux have no such + * lock and update in place through applyUpdatesPosixInApp(). Off Windows the + * hand-off therefore buys nothing and costs a great deal: a staged binary older + * than the hand-off protocol holds the update marker, spawns `hermes update`, + * and that child refuses its own parent — wedging the in-app Update button for + * good, with no route (update, re-download, reinstall) to a newer binary + * (#74836). Returning null off Windows is what routes those platforms to the + * in-app updater. + * + * Null on Windows too when nothing is staged (a dev/source run, or a CLI + * install that never went through the installer); callers degrade gracefully. + */ +export function resolveStagedUpdaterBinary( + hermesHome: string, + deps: ResolveStagedUpdaterBinaryDeps = {} +): string | null { + const isWindows = deps.isWindows ?? process.platform === 'win32' + + if (!isWindows) { + return null + } + + const fileExists = deps.fileExists ?? stagedFileExists + const candidate = path.join(hermesHome, 'hermes-setup.exe') + + return fileExists(candidate) ? candidate : null +} + +/** + * True when the staged installer is new enough to survive a pre-written marker. + * + * `copy_self_to_hermes_home` deliberately no-ops during `--update` + * (apps/bootstrap-installer/src-tauri/src/paths.rs), so the binary staged by a + * user's ORIGINAL install orchestrates every later update — forever. Installers + * predating #74782 have no self-PID exclusion in `UpdateMarkerGuard::acquire`, + * so when the desktop pre-writes the marker naming that very updater, the + * updater reads its own claim as a foreign live owner and aborts with + * "Another Hermes update is already running (PID , started 1s ago)" — + * the observed infinite "Install didn't finish" loop. Skipping the pre-write + * for those binaries lets them acquire cleanly and run `hermes update`, which + * pulls the permanent fixes. See shouldPrewriteUpdateMarker. + * + * We cannot ask the binary its version without executing it, so use its mtime: + * the installer is written to HERMES_HOME at install/repair time, making mtime + * a faithful stamp of which installer generation produced it. + * + * Unreadable mtime counts as UNSUPPORTED — the pre-write is a best-effort + * hardening, while a wedged updater is unrecoverable, so we bias toward the + * path that can always make progress. + */ +export function stagedUpdaterSupportsPrewrittenMarker( + candidate: string, + deps: ResolveStagedUpdaterBinaryDeps = {} +): boolean { + const mtimeMs = (deps.stagedMtimeMs ?? stagedFileMtimeMs)(candidate) + + return typeof mtimeMs === 'number' && Number.isFinite(mtimeMs) && mtimeMs >= MARKER_SELF_ADOPT_EPOCH_MS +} + export interface SpawnUpdaterProcessDeps { isWindows?: boolean spawnProcess?: (command: string, args: string[], options: SpawnOptions) => UpdaterChild diff --git a/apps/desktop/electron/wake-indicator-window.ts b/apps/desktop/electron/wake-indicator-window.ts new file mode 100644 index 000000000000..7ef676a43f4a --- /dev/null +++ b/apps/desktop/electron/wake-indicator-window.ts @@ -0,0 +1,174 @@ +import { pathToFileURL } from 'node:url' + +import { BrowserWindow, screen } from 'electron' + +import { + normalizeWakeIndicatorState, + selectWakeIndicatorDisplay, + WAKE_INDICATOR_FADE_MS, + type WakeIndicatorState, + wakeIndicatorWindowBounds +} from './wake-indicator' + +interface WakeIndicatorWindowOptions { + devServer?: string + isMac: boolean + loadWindowUrl: (window: BrowserWindow, url: string, label: string) => void + preloadPath: string + rendererIndex: () => string + wireWindow: (window: BrowserWindow) => void +} + +export function createWakeIndicatorWindowController({ + devServer, + isMac, + loadWindowUrl, + preloadPath, + rendererIndex, + wireWindow +}: WakeIndicatorWindowOptions) { + let hideTimer: NodeJS.Timeout | null = null + let state: WakeIndicatorState = 'hidden' + let window: BrowserWindow | null = null + + const url = () => { + if (devServer) { + return `${devServer.endsWith('/') ? devServer.slice(0, -1) : devServer}/?win=wake#/` + } + + return `${pathToFileURL(rendererIndex()).toString()}?win=wake#/` + } + + const selectedDisplay = () => selectWakeIndicatorDisplay(screen.getAllDisplays(), screen.getPrimaryDisplay()) + + const reposition = () => { + if (!window || window.isDestroyed()) { + return + } + + window.setBounds(wakeIndicatorWindowBounds(selectedDisplay())) + } + + const sendState = () => { + if (!window || window.isDestroyed()) { + return + } + + window.webContents.send('hermes:wake-indicator:state', state) + } + + const spawn = () => { + const next = new BrowserWindow({ + ...wakeIndicatorWindowBounds(selectedDisplay()), + alwaysOnTop: true, + backgroundColor: '#00000000', + focusable: false, + frame: false, + fullscreenable: false, + hasShadow: false, + hiddenInMissionControl: true, + maximizable: false, + minimizable: false, + movable: false, + resizable: false, + show: false, + skipTaskbar: false, + transparent: true, + type: 'panel', + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + devTools: true, + nodeIntegration: false, + preload: preloadPath, + sandbox: true + } + }) + + next.setAlwaysOnTop(true, 'floating') + next.setHiddenInMissionControl?.(true) + next.setIgnoreMouseEvents(true, { forward: true }) + + try { + next.setVisibleOnAllWorkspaces(true, { + skipTransformProcessType: true, + visibleOnFullScreen: true + }) + } catch { + // Best effort on older Electron/macOS combinations. + } + + wireWindow(next) + + next.webContents.on('did-finish-load', sendState) + next.once('ready-to-show', () => { + if (!next.isDestroyed() && state !== 'hidden') { + next.showInactive() + } + }) + next.on('closed', () => { + if (window === next) { + window = null + } + }) + + loadWindowUrl(next, url(), 'Wake indicator') + + return next + } + + const setState = (value: unknown) => { + if (!isMac) { + return + } + + state = normalizeWakeIndicatorState(value) + + if (hideTimer) { + clearTimeout(hideTimer) + hideTimer = null + } + + if (state === 'hidden') { + sendState() + hideTimer = setTimeout(() => { + hideTimer = null + + if (state === 'hidden' && window && !window.isDestroyed()) { + window.hide() + } + }, WAKE_INDICATOR_FADE_MS) + + return + } + + if (!window || window.isDestroyed()) { + window = spawn() + } else { + reposition() + sendState() + window.showInactive() + } + } + + const close = () => { + if (hideTimer) { + clearTimeout(hideTimer) + hideTimer = null + } + + if (window && !window.isDestroyed()) { + window.close() + } + + window = null + state = 'hidden' + } + + return { + close, + getState: () => state, + reposition, + setState + } +} diff --git a/apps/desktop/electron/wake-indicator.test.ts b/apps/desktop/electron/wake-indicator.test.ts new file mode 100644 index 000000000000..433a9ca2351f --- /dev/null +++ b/apps/desktop/electron/wake-indicator.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { normalizeWakeIndicatorState, selectWakeIndicatorDisplay, wakeIndicatorWindowBounds } from './wake-indicator' +import { createWakeIndicatorWindowController } from './wake-indicator-window' + +const electronMock = vi.hoisted(() => { + type Listener = () => void + + class FakeBrowserWindow { + private destroyed = false + private readonly listeners = new Map() + + readonly close = vi.fn(() => { + this.destroyed = true + this.emit('closed') + }) + readonly hide = vi.fn() + readonly setAlwaysOnTop = vi.fn() + readonly setBounds = vi.fn() + readonly setHiddenInMissionControl = vi.fn() + readonly setIgnoreMouseEvents = vi.fn() + readonly setVisibleOnAllWorkspaces = vi.fn() + readonly showInactive = vi.fn() + readonly webContents = { + on: vi.fn(), + send: vi.fn() + } + + constructor(readonly options: unknown) { + windows.push(this) + } + + isDestroyed() { + return this.destroyed + } + + on(event: string, listener: Listener) { + const listeners = this.listeners.get(event) ?? [] + listeners.push(listener) + this.listeners.set(event, listeners) + + return this + } + + once(event: string, listener: Listener) { + return this.on(event, listener) + } + + private emit(event: string) { + for (const listener of this.listeners.get(event) ?? []) { + listener() + } + } + } + + const windows: FakeBrowserWindow[] = [] + + const display = { + bounds: { height: 982, width: 1512, x: 0, y: 0 }, + id: 'internal', + internal: true + } + + return { + BrowserWindow: FakeBrowserWindow, + screen: { + getAllDisplays: () => [display], + getPrimaryDisplay: () => display + }, + windows + } +}) + +vi.mock('electron', () => ({ + BrowserWindow: electronMock.BrowserWindow, + screen: electronMock.screen +})) + +beforeEach(() => { + electronMock.windows.length = 0 +}) + +describe('wake indicator window', () => { + it('centers the helper window at the top of the selected display', () => { + expect( + wakeIndicatorWindowBounds({ + bounds: { height: 982, width: 1512, x: -120, y: 40 } + }) + ).toEqual({ + height: 52, + width: 176, + x: 548, + y: 40 + }) + }) + + it('prefers the internal display and falls back to the primary display', () => { + const primary = { + bounds: { height: 1080, width: 1920, x: 0, y: 0 }, + id: 'primary', + internal: false + } + + const internal = { + bounds: { height: 982, width: 1512, x: 1920, y: 0 }, + id: 'internal', + internal: true + } + + expect(selectWakeIndicatorDisplay([primary, internal], primary)).toBe(internal) + + expect(selectWakeIndicatorDisplay([primary], primary)).toBe(primary) + }) + + it('rejects unknown renderer states', () => { + expect(normalizeWakeIndicatorState('capturing')).toBe('capturing') + expect(normalizeWakeIndicatorState('other')).toBe('hidden') + expect(normalizeWakeIndicatorState(null)).toBe('hidden') + }) +}) + +describe('wake indicator window controller', () => { + it('closes an active helper window and resets its state', () => { + const controller = createWakeIndicatorWindowController({ + isMac: true, + loadWindowUrl: vi.fn(), + preloadPath: '/tmp/preload.cjs', + rendererIndex: () => '/tmp/index.html', + wireWindow: vi.fn() + }) + + controller.setState('detected') + + expect(electronMock.windows).toHaveLength(1) + expect(controller.getState()).toBe('detected') + + const [window] = electronMock.windows + controller.close() + + expect(window.close).toHaveBeenCalledOnce() + expect(controller.getState()).toBe('hidden') + }) +}) diff --git a/apps/desktop/electron/wake-indicator.ts b/apps/desktop/electron/wake-indicator.ts new file mode 100644 index 000000000000..bf95672645e3 --- /dev/null +++ b/apps/desktop/electron/wake-indicator.ts @@ -0,0 +1,34 @@ +export const WAKE_INDICATOR_WINDOW_WIDTH = 176 +export const WAKE_INDICATOR_WINDOW_HEIGHT = 52 +export const WAKE_INDICATOR_FADE_MS = 500 + +export const WAKE_INDICATOR_STATES = ['hidden', 'detected', 'capturing'] as const + +export type WakeIndicatorState = (typeof WAKE_INDICATOR_STATES)[number] + +interface DisplayLike { + bounds: { + height: number + width: number + x: number + y: number + } + internal?: boolean +} + +export function normalizeWakeIndicatorState(value: unknown): WakeIndicatorState { + return WAKE_INDICATOR_STATES.includes(value as WakeIndicatorState) ? (value as WakeIndicatorState) : 'hidden' +} + +export function selectWakeIndicatorDisplay(displays: T[], primary: T): T { + return displays.find(display => display.internal === true) ?? primary +} + +export function wakeIndicatorWindowBounds(display: DisplayLike) { + return { + height: WAKE_INDICATOR_WINDOW_HEIGHT, + width: WAKE_INDICATOR_WINDOW_WIDTH, + x: Math.round(display.bounds.x + (display.bounds.width - WAKE_INDICATOR_WINDOW_WIDTH) / 2), + y: Math.round(display.bounds.y) + } +} diff --git a/apps/desktop/electron/windows-remote-lifecycle.test.ts b/apps/desktop/electron/windows-remote-lifecycle.test.ts index 56cfb4d45d28..b63d9c69bff9 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.test.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import crypto from 'node:crypto' import { test } from 'vitest' @@ -9,6 +10,7 @@ import { helperCommand, powerShellCommand, psLiteral, + reusableWindowsLock, validLock } from './windows-remote-lifecycle' @@ -114,6 +116,31 @@ test('Windows lock validation is scoped and exact', () => { assert.equal(validLock({ ...lock, port: -1 }, ownershipId), false) }) +test('Windows SSH reuse requires the requested remote profile to match the lock', () => { + const token = 'stored-token' + + const lock = { + schemaVersion: 2, + protocolVersion: 1, + ownershipId, + spawnNonce: '0123456789abcdef', + pid: 10, + creationTimeNs: '1784219690452757504', + port: 1234, + profile: 'default', + tokenFingerprint: crypto.createHash('sha256').update(token).digest('hex').slice(0, 32), + hermesPath: 'C:\\h\\hermes.exe', + hermesHome: 'C:\\h' + } + + const state = { alive: true, owned: true } + const runtime = { hermesPath: lock.hermesPath, hermesHome: lock.hermesHome } + + assert.equal(reusableWindowsLock(lock, state, 'default', token, runtime), true) + assert.equal(reusableWindowsLock(lock, state, 'desktop-work', token, runtime), false) + assert.equal(reusableWindowsLock({ ...lock, profile: '' }, state, '', token, runtime), true) +}) + test('Windows integrated terminal uses encoded PowerShell and preserves cwd as literal data', () => { const command = buildWindowsInteractiveCommand("C:\\Users\\O'Brien\\repo") const script = Buffer.from(command.split(' ').pop()!, 'base64').toString('utf16le') diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index ff03d6a8c791..5d96853a1a10 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -149,6 +149,19 @@ function validLock(lock, ownershipId) { ) } +function reusableWindowsLock(lock, state, profile, reuseToken, runtime) { + return Boolean( + state.alive && + state.owned && + lock.port > 0 && + lock.profile === profile && + reuseToken && + lock.tokenFingerprint === fingerprintToken(reuseToken) && + lock.hermesPath === runtime.hermesPath && + lock.hermesHome === runtime.hermesHome + ) +} + function assertCurrent(signal) { if (signal?.aborted) { const error: any = new Error('SSH bootstrap was cancelled.') @@ -299,14 +312,7 @@ async function connectWindowsRemote(deps) { throw error } - const reusable = - state.alive && - state.owned && - lock.port > 0 && - Boolean(reuseToken) && - lock.tokenFingerprint === fingerprintToken(reuseToken) && - lock.hermesPath === runtime.hermesPath && - lock.hermesHome === runtime.hermesHome + const reusable = reusableWindowsLock(lock, state, profile, reuseToken, runtime) if (reusable) { const localPort = await pickLocalPort() @@ -451,5 +457,6 @@ export { powerShellCommand, probeWindowsRemote, psLiteral, + reusableWindowsLock, validLock } diff --git a/apps/desktop/electron/zoom.test.ts b/apps/desktop/electron/zoom.test.ts index 7fbb291a24bd..c288018979c0 100644 --- a/apps/desktop/electron/zoom.test.ts +++ b/apps/desktop/electron/zoom.test.ts @@ -162,8 +162,8 @@ test('installZoomReassertOnWindowEvents skips destroyed windows', () => { assert.equal(calls, 0) }) -// Zoom-wiring contract: chat windows keep global UI zoom, the pet overlay -// opts out. Tested via the extracted config — no source-text regex. +// Zoom-wiring contract: chat windows keep global UI zoom while fixed-size +// helper windows opt out. Tested via the extracted config — no source-text regex. test('chat windows opt into zoom', () => { assert.deepEqual(zoomWiringForWindowKind('chat'), { zoom: true }) }) @@ -172,6 +172,10 @@ test('pet overlay opts out of zoom', () => { assert.deepEqual(zoomWiringForWindowKind('petOverlay'), { zoom: false }) }) +test('wake indicator opts out of zoom', () => { + assert.deepEqual(zoomWiringForWindowKind('wakeIndicator'), { zoom: false }) +}) + test('unknown window kinds default to chat (zoom enabled)', () => { assert.deepEqual(zoomWiringForWindowKind('unknown'), { zoom: true }) assert.deepEqual(zoomWiringForWindowKind(undefined), { zoom: true }) diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts index ee3d57648896..4a6d0db6bbf6 100644 --- a/apps/desktop/electron/zoom.ts +++ b/apps/desktop/electron/zoom.ts @@ -108,7 +108,8 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc export const ZOOM_WINDOW_CONFIG = { chat: { zoom: true }, petOverlay: { zoom: false }, - quickEntry: { zoom: false } + quickEntry: { zoom: false }, + wakeIndicator: { zoom: false } } as const export function zoomWiringForWindowKind(kind) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2779b3e36a58..1a0f100075b9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,7 @@ "type": "module", "main": "dist/electron-main.mjs", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=22.22.0" }, "scripts": { "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", @@ -64,8 +64,9 @@ "test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots" }, "dependencies": { - "@assistant-ui/react": "0.15.1", - "@assistant-ui/react-streamdown": "0.3.8", + "@assistant-ui/core": "0.2.23", + "@assistant-ui/react": "0.14.24", + "@assistant-ui/react-streamdown": "0.3.5", "@audiowave/react": "0.6.2", "@chenglou/pretext": "0.0.6", "@codemirror/commands": "6.10.4", @@ -80,7 +81,7 @@ "@icons-pack/react-simple-icons": "13.11.1", "@lezer/highlight": "1.2.3", "@nanostores/react": "1.1.0", - "@nous-research/ui": "0.16.0", + "@nous-research/ui": "0.18.2", "@streamdown/code": "1.1.1", "@tabler/icons-react": "3.44.0", "@tailwindcss/typography": "0.5.20", diff --git a/apps/desktop/src/app/chat/composer/directive-actions.test.tsx b/apps/desktop/src/app/chat/composer/directive-actions.test.tsx new file mode 100644 index 000000000000..4c827fe9a164 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/directive-actions.test.tsx @@ -0,0 +1,150 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { I18nProvider } from '@/i18n' + +import { ComposerDirectiveActions } from './directive-actions' +import { refChipElement } from './rich-editor' + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } + +const openSession = vi.fn() + +vi.mock('@/app/open-session', () => ({ openSession: (...args: unknown[]) => openSession(...args) })) + +/** A live contenteditable holding real chips, with the watcher bound to it — + * the same pair both composers mount. */ +function mountEditor(chips: { kind: string; value: string }[]) { + const editor = document.createElement('div') + + editor.contentEditable = 'true' + editor.append(...chips.map(chip => refChipElement(chip.kind, `\`${chip.value}\``))) + document.body.append(editor) + + render( + + + + ) + + return editor +} + +function chips(editor: HTMLElement, kind: string) { + return Array.from(editor.querySelectorAll(`[data-ref-kind="${kind}"]`)) +} + +function hover(node: Element) { + fireEvent.pointerOver(node, { bubbles: true }) +} + +/** The reference the visible action pill points at, or null when there is none. */ +function pillValue() { + return document.querySelector('[data-slot="composer-directive-action"]')?.getAttribute('data-value') ?? null +} + +afterEach(() => { + cleanup() + document.body.replaceChildren() + delete desktopWindow.hermesDesktop + openSession.mockReset() + vi.useRealTimers() +}) + +describe('ComposerDirectiveActions', () => { + it('offers an action for a hovered actionable chip', () => { + const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }]) + + expect(pillValue()).toBeNull() + + hover(chips(editor, 'url')[0]!) + + expect(pillValue()).toBe('https://example.com/docs') + }) + + it('opens a url externally rather than navigating the app', () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + + desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop'] + + const editor = mountEditor([{ kind: 'url', value: 'https://example.com/docs' }]) + + hover(chips(editor, 'url')[0]!) + fireEvent.click(screen.getByRole('button')) + + expect(openExternal).toHaveBeenCalledWith('https://example.com/docs') + expect(pillValue()).toBeNull() + }) + + it('runs the kind-specific action — a session chip opens the session', async () => { + const editor = mountEditor([{ kind: 'session', value: 'default/20260722_204335_d62c16' }]) + + hover(chips(editor, 'session')[0]!) + fireEvent.click(screen.getByRole('button')) + // openSessionRef lazy-imports the navigator, so the call lands a tick later. + await vi.waitFor(() => + expect(openSession).toHaveBeenCalledWith('20260722_204335_d62c16', expect.any(Function), 'tab') + ) + }) + + it('leaves kinds with no action alone', () => { + const editor = mountEditor([{ kind: 'file', value: 'src/main.tsx' }]) + + hover(chips(editor, 'file')[0]!) + + expect(pillValue()).toBeNull() + }) + + it('follows the pointer from one chip to the next', () => { + const editor = mountEditor([ + { kind: 'url', value: 'https://one.example' }, + { kind: 'url', value: 'https://two.example' } + ]) + + const [first, second] = chips(editor, 'url') + + hover(first!) + + expect(pillValue()).toBe('https://one.example') + + hover(second!) + + expect(pillValue()).toBe('https://two.example') + }) + + it('keeps the pill up while the pointer crosses onto it', () => { + vi.useFakeTimers() + + const editor = mountEditor([{ kind: 'url', value: 'https://example.com' }]) + const chip = chips(editor, 'url')[0]! + + hover(chip) + fireEvent.pointerOut(chip, { relatedTarget: document.body }) + fireEvent.mouseEnter(screen.getByRole('button').parentElement!) + vi.advanceTimersByTime(500) + + expect(pillValue()).toBe('https://example.com') + }) + + it('binds to the document so a late-attached editor still gets the affordance', () => { + // The edit composer's editor isn't reliably in the DOM when the effect + // first runs; a document listener that reads the editor lazily works + // regardless — this is the whole reason it binds to document, not editor. + const editor = document.createElement('div') + + editor.contentEditable = 'true' + editor.append(refChipElement('url', '`https://late.example`')) + + render( + + + + ) + + // Editor attached AFTER mount. + document.body.append(editor) + hover(editor.querySelector('[data-ref-kind="url"]')!) + + expect(pillValue()).toBe('https://late.example') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/directive-actions.tsx b/apps/desktop/src/app/chat/composer/directive-actions.tsx new file mode 100644 index 000000000000..b18ade2aac03 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/directive-actions.tsx @@ -0,0 +1,154 @@ +/** + * Hover actions for directive chips in a composer. + * + * A directive chip (`@url:`, `@session:`, …) reads as the thing it points at + * and is coloured like one, but a composer is an editor — a click inside the + * contenteditable only places the caret, so there's no way to *act* on the + * reference. Instead, hovering a chip whose kind has an action floats a small + * pill above it that runs it. + * + * The kind → action table (`DIRECTIVE_ACTIONS`) lives in `directive-text`, so + * it is shared with the sent-message chip: one entry lights up both surfaces. + */ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +import { DIRECTIVE_ACTIONS, type DirectiveAction } from '@/components/assistant-ui/directive-text' +import { composerFloatingPill } from '@/components/chat/composer-dock' +import { Codicon } from '@/components/ui/codicon' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +/** Moving between the chip and the pill crosses a gap where neither is hovered. + * Short enough that it still reads as instant on the way out. */ +const HIDE_DELAY_MS = 120 + +/** The actionable directive chip under `target` that also belongs to `editor`, + * if there is one. */ +function actionableChipAt(target: EventTarget | null, editor: HTMLElement): HTMLElement | null { + const chip = target instanceof Element ? target.closest('[data-ref-kind]') : null + const kind = chip?.dataset.refKind + + return chip && kind && chip.dataset.refId && editor.contains(chip) && DIRECTIVE_ACTIONS[kind] ? chip : null +} + +interface Anchor { + action: DirectiveAction + chip: HTMLElement + left: number + top: number + value: string +} + +function anchorFor(chip: HTMLElement): Anchor | null { + const value = chip.dataset.refId + const action = chip.dataset.refKind ? DIRECTIVE_ACTIONS[chip.dataset.refKind] : undefined + + if (!value || !action || !chip.isConnected) { + return null + } + + const rect = chip.getBoundingClientRect() + + return { action, chip, left: rect.left, top: rect.top, value } +} + +/** + * Renders the action pill for whichever actionable chip in `editorRef` is + * hovered. + * + * Listeners bind to `document`, not the editor, so mount timing can't strand + * them: the edit composer's contenteditable isn't reliably attached when this + * effect first runs, and a document listener that reads the editor lazily works + * regardless. Each instance filters to its own editor, so the docked and edit + * composers never show two pills for one chip. + */ +export function ComposerDirectiveActions({ editorRef }: { editorRef: RefObject }) { + const { t } = useI18n() + const [anchor, setAnchor] = useState(null) + const hideTimerRef = useRef(undefined) + + const cancelHide = useCallback(() => { + window.clearTimeout(hideTimerRef.current) + }, []) + + const hideSoon = useCallback(() => { + cancelHide() + hideTimerRef.current = window.setTimeout(() => setAnchor(null), HIDE_DELAY_MS) + }, [cancelHide]) + + useEffect(() => { + const onPointerOver = (event: PointerEvent) => { + const editor = editorRef.current + const chip = editor && actionableChipAt(event.target, editor) + + if (!chip) { + return + } + + cancelHide() + setAnchor(current => (current?.chip === chip ? current : anchorFor(chip))) + } + + const onPointerOut = (event: PointerEvent) => { + const editor = editorRef.current + const chip = editor && actionableChipAt(event.target, editor) + + // A move within the same chip (its icon → its label) is not a leave. + if (chip && editor && chip === actionableChipAt(event.relatedTarget, editor)) { + return + } + + hideSoon() + } + + // The chip can move or vanish under a parked pointer: the editor scrolls, + // the window resizes, or the user deletes the reference the pill points at. + const reanchor = () => setAnchor(current => (current ? anchorFor(current.chip) : null)) + + document.addEventListener('pointerover', onPointerOver) + document.addEventListener('pointerout', onPointerOut) + window.addEventListener('scroll', reanchor, true) + window.addEventListener('resize', reanchor) + + return () => { + document.removeEventListener('pointerover', onPointerOver) + document.removeEventListener('pointerout', onPointerOut) + window.removeEventListener('scroll', reanchor, true) + window.removeEventListener('resize', reanchor) + window.clearTimeout(hideTimerRef.current) + } + }, [cancelHide, editorRef, hideSoon]) + + if (!anchor) { + return null + } + + return createPortal( +
+ +
, + document.body + ) +} diff --git a/apps/desktop/src/app/chat/composer/empty-composer.test.ts b/apps/desktop/src/app/chat/composer/empty-composer.test.ts index 60498efcd0b1..dcf3398e6bcb 100644 --- a/apps/desktop/src/app/chat/composer/empty-composer.test.ts +++ b/apps/desktop/src/app/chat/composer/empty-composer.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { composerPlainText, normalizeComposerEditorDom, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' +import { + beginComposerComposition, + composerPlainText, + deleteChipBeforeCaret, + normalizeComposerEditorDom, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' function editor(): HTMLDivElement { const el = document.createElement('div') @@ -131,4 +138,133 @@ describe('an emptied composer shows its placeholder again', () => { expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true) }) + + // Chromium leaves zero-length text nodes behind whenever an edit lands next + // to a contenteditable=false chip. They render as nothing, so an editor + // holding only those is empty to the user — counting them as contents left + // the placeholder hidden under a composer that looked blank. + it('advertises emptiness for an editor holding only zero-length text nodes', () => { + const el = editor() + + el.append(document.createTextNode(''), document.createTextNode('')) + normalizeComposerEditorDom(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true) + }) + + it('does not advertise emptiness while real text sits beside that litter', () => { + const el = editor() + + el.append(document.createTextNode(''), document.createTextNode('one'), document.createTextNode('')) + normalizeComposerEditorDom(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) + }) + + // Input events are skipped for the duration of an IME composition, so nothing + // else clears the marker until it ends — the hint would sit behind the + // hiragana the user is composing (#75960). + it('hides the placeholder before IME preedit text starts', () => { + const el = emptied() + + beginComposerComposition(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false) + }) + + it('brings the placeholder back when composition ends with nothing committed', () => { + const el = emptied() + + beginComposerComposition(el) + normalizeComposerEditorDom(el) + + expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true) + }) +}) + +/** A directive chip, as `refChipElement` builds it. */ +function chip(): HTMLSpanElement { + const el = document.createElement('span') + + el.contentEditable = 'false' + el.dataset.refText = '@folder:`apps/desktop/`' + el.append(document.createTextNode('apps/desktop/')) + + return el +} + +function caretAt(node: Node, offset: number) { + const range = document.createRange() + + range.setStart(node, offset) + range.collapse(true) + + const selection = window.getSelection() + + selection?.removeAllRanges() + selection?.addRange(range) +} + +/** Committing a completion empties the typed token's text node instead of + * removing it, and `Range.insertNode` splits the line around the caret — so a + * freshly-chipped directive sits between zero-length text nodes. Backspace has + * to see past them or the chip can't be deleted at all. */ +describe('backspace deletes a chip surrounded by Chromium litter', () => { + it('deletes the chip when the caret sits in a zero-length text node after it', () => { + const el = editor() + + el.append(document.createTextNode(''), chip(), document.createTextNode('')) + caretAt(el.childNodes[2] as Node, 0) + + expect(deleteChipBeforeCaret(el)).toBe(true) + expect(el.querySelector('[data-ref-text]')).toBeNull() + }) + + it('deletes the chip when the caret is past a zero-length text node at editor level', () => { + const el = editor() + + el.append(chip(), document.createTextNode('')) + caretAt(el, 2) + + expect(deleteChipBeforeCaret(el)).toBe(true) + expect(el.querySelector('[data-ref-text]')).toBeNull() + }) + + it('still swallows the auto-inserted trailing space through that litter', () => { + const el = editor() + + el.append(chip(), document.createTextNode(''), document.createTextNode(' ')) + caretAt(el, 2) + + expect(deleteChipBeforeCaret(el)).toBe(true) + expect(composerPlainText(el)).toBe('') + }) + + it('keeps real following text when it deletes the chip', () => { + const el = editor() + + el.append(chip(), document.createTextNode(''), document.createTextNode(' and this')) + caretAt(el, 2) + + expect(deleteChipBeforeCaret(el)).toBe(true) + expect(composerPlainText(el)).toBe('and this') + }) + + it('leaves plain text to the native backspace', () => { + const el = editor() + + el.append(document.createTextNode('hello')) + caretAt(el.firstChild as Node, 5) + + expect(deleteChipBeforeCaret(el)).toBe(false) + }) + + it('sweeps the litter out of the editor when it normalizes', () => { + const el = editor() + + el.append(document.createTextNode(''), chip(), document.createTextNode('')) + normalizeComposerEditorDom(el) + + expect(Array.from(el.childNodes).map(node => node.nodeName)).toEqual(['SPAN']) + }) }) 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 index 50ea8d09ecea..7707c8ae7537 100644 --- 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 @@ -10,11 +10,15 @@ import { type ComposerScope, ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from ' import { useComposerDraft } from './use-composer-draft' -const mockComposerApi = { setText: vi.fn(), getState: () => ({ text: '' }) } +const mockComposerApi = { setText: vi.fn() } vi.mock('@assistant-ui/react', () => ({ - useAui: () => ({ composer: () => mockComposerApi, subscribe: () => () => undefined }), - useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }) + useAui: () => ({ composer: () => mockComposerApi }), + useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }), + useComposerRuntime: () => ({ + getState: () => ({ text: '' }), + subscribe: () => () => undefined + }) })) interface ProbeHarnessProps { 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 034f180c423c..b5bf87dc4660 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,4 +1,4 @@ -import { useAui, useAuiState } from '@assistant-ui/react' +import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' @@ -58,6 +58,7 @@ export function useComposerDraft({ sessionId }: UseComposerDraftArgs) { const aui = useAui() + const composerRuntime = useComposerRuntime() // Which composer this is on the focus bus + which attachment set it owns. const { attachments: attachmentScope, target } = useComposerScope() @@ -78,7 +79,7 @@ export function useComposerDraft({ const setComposerText = useCallback( (value: string) => { try { - aui.composer.setText(value) + aui.composer().setText(value) } catch { // Composer core not bound yet — DOM/draftRef carry the text. } @@ -271,7 +272,7 @@ export function useComposerDraft({ // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const sync = () => { - const text = aui.composer.getState().text + const text = composerRuntime.getState().text draftRef.current = text const editor = editorRef.current @@ -303,13 +304,13 @@ export function useComposerDraft({ }, DRAFT_PERSIST_DEBOUNCE_MS) } - const unsubscribe = aui.subscribe(sync) + const unsubscribe = composerRuntime.subscribe(sync) return () => { unsubscribe() window.clearTimeout(draftPersistTimerRef.current) } - }, [aui, queueEditRef]) + }, [composerRuntime, queueEditRef]) const insertText = (text: string) => { const base = draftRef.current diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 49e0ba5ce7fe..449471dc075c 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' +import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator' import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { $gateway } from '@/store/gateway' @@ -62,6 +63,7 @@ export function useComposerVoice({ const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) + const ownsWakeIndicatorRef = useRef(false) const voiceStartRequest = useStore($voiceConversationStartRequest) const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ @@ -150,6 +152,26 @@ export function useComposerVoice({ beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined }) + // eslint-disable-next-line no-restricted-syntax -- ownership token used only by unmount cleanup + useEffect(() => { + if (target !== 'main') { + return + } + + if (syncWakeIndicatorWithVoice(voiceConversationActive, conversation.status)) { + ownsWakeIndicatorRef.current = voiceConversationActive + } + }, [conversation.status, target, voiceConversationActive]) + + useEffect( + () => () => { + if (ownsWakeIndicatorRef.current) { + clearWakeIndicator() + } + }, + [] + ) + // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting // with STT unconfigured lets the conversation surface its own "configure // speech-to-text" notice rather than silently no-opping. diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 747177592288..3767a4ad4e4e 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -32,6 +32,7 @@ import { import { ContextMenu } from './context-menu' import { COMPOSER_AREAS, runComposerMiddleware } from './contrib' import { ComposerControls } from './controls' +import { ComposerDirectiveActions } from './directive-actions' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' import { markActiveComposer } from './focus' import { HelpHint } from './help-hint' @@ -57,7 +58,7 @@ import { ActionBadges } from './micro-actions' import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' import { - COMPOSER_PLACEHOLDER_CLASS, + beginComposerComposition, composerPlainText, deleteChipBeforeCaret, deleteSelectionInEditor, @@ -947,7 +948,6 @@ export function ChatBar({ autoCorrect="off" className={cn( 'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', - COMPOSER_PLACEHOLDER_CLASS, '**:data-ref-text:cursor-default', stacked && 'pl-3', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1' @@ -969,8 +969,13 @@ export function ChatBar({ // until an unrelated edit forces a sync (#39614). flushEditorToDraft(event.currentTarget) }} - onCompositionStart={() => { + onCompositionStart={event => { composingRef.current = true + + // Input events are skipped for the rest of the composition, so + // nothing else would clear the empty marker until it ends — and the + // hint would sit behind the preedit text the whole time (#75960). + beginComposerComposition(event.currentTarget) }} onDragOver={handleInputDragOver} onDrop={handleInputDrop} @@ -985,6 +990,7 @@ export function ChatBar({ spellCheck={false} suppressContentEditableWarning /> + {/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree so the composer-state binding (text + IME + paste + form-submit hookup) wires up. We render the real input UI ourselves above via the diff --git a/apps/desktop/src/app/chat/composer/micro-actions.tsx b/apps/desktop/src/app/chat/composer/micro-actions.tsx index 6f8cc77ad566..65e3729eadad 100644 --- a/apps/desktop/src/app/chat/composer/micro-actions.tsx +++ b/apps/desktop/src/app/chat/composer/micro-actions.tsx @@ -1,5 +1,6 @@ import { memo, useState } from 'react' +import { composerFloatingPill } from '@/components/chat/composer-dock' import { Codicon } from '@/components/ui/codicon' import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' @@ -7,11 +8,9 @@ import { $composerActionsBySession, type ComposerAction } from '@/store/composer import { notifyError } from '@/store/notifications' /** - * Floating pill — the treatment the thread's jump/approval button uses for a - * control that sits over scrolling content: full radius, hairline border, the - * shared composer fill behind a blur so thread text never bleeds through. - * Sized against the composer's own control height so a row of pills lines up - * with the chrome it floats above. + * Floating pill — the shared treatment for a control that sits over the + * composer (`composerFloatingPill`), plus this strip's own width cap and + * disabled state. * * NEVER `pointer-events-none`, not even when disabled. The pop-out drag region * is an `absolute` sibling behind these pills, so a pill that stops taking @@ -19,10 +18,8 @@ import { notifyError } from '@/store/notifications' * becomes a grab handle that floats the composer. */ const PILL = cn( - 'inline-flex h-(--composer-control-size) max-w-56 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5', - 'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]', - 'text-xs font-normal text-(--ui-text-secondary) transition-colors', - 'hover:bg-(--chrome-action-hover) hover:text-foreground', + composerFloatingPill, + 'max-w-56', 'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)', 'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50' ) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 0c9c7098870c..a7d8e0406759 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -21,28 +21,67 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs' export const RICH_INPUT_SLOT = 'composer-rich-input' -/** Paints `data-placeholder` while the editor is empty. +/** Chromium's litter: editing beside a `contenteditable=false` chip splits the + * line and leaves zero-length text nodes behind. They render as nothing and + * serialize as nothing, so no reader of the editor should count them. */ +function isEmptyTextNode(node: ChildNode | null): boolean { + return node?.nodeType === Node.TEXT_NODE && !node.textContent +} + +/** The node before `node`, stepping over that litter. */ +function meaningfulPreviousSibling(node: ChildNode | null): ChildNode | null { + let prev = node?.previousSibling ?? null + + while (isEmptyTextNode(prev)) { + prev = prev?.previousSibling ?? null + } + + return prev +} + +/** The node after `node`, stepping over that litter. */ +function meaningfulNextSibling(node: ChildNode | null): ChildNode | null { + let next = node?.nextSibling ?? null + + while (isEmptyTextNode(next)) { + next = next?.nextSibling ?? null + } + + return next +} + +/** Keep the `data-empty` marker the placeholder paints on in step with the + * editor root's contents. * * `:empty` can't be the whole test: a cleared editor keeps a scaffolding
* so the contenteditable doesn't collapse, and that break makes `:empty` * false. Nor can CSS infer it on its own — a text node is invisible to * selectors, so `one
` and a lone `
` are the same shape, and - * `:has(> br:only-child)` would paint the placeholder straight over the - * user's text. The code that empties the editor is what knows, so it marks it. + * `:has(> br:only-child)` would paint the placeholder over the user's text. + * The code that empties the editor is what knows, so it marks it. * - * @see markEditorEmptiness */ -export const COMPOSER_PLACEHOLDER_CLASS = - '[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60' - -/** Keep that marker in step with the editor root's contents. */ + * Zero-length text nodes don't count as contents. Chromium leaves them behind + * whenever an edit lands next to a `contenteditable=false` chip, and counting + * them left an editor the user had emptied looking occupied. */ export function markEditorEmptiness(editor: HTMLElement) { - if (editor.childNodes.length === 0) { + if (Array.from(editor.childNodes).every(isEmptyTextNode)) { editor.dataset.empty = '' } else { delete editor.dataset.empty } } +/** Drop the marker as IME composition starts, before any preedit text lands. + * + * Input events during composition are deliberately skipped (they carry + * uncommitted preedit text), so nothing else clears the marker until + * `compositionend` — and the hint would otherwise sit behind the hiragana the + * user is composing. `normalizeComposerEditorDom` restores it if composition + * ends with nothing committed. */ +export function beginComposerComposition(editor: HTMLElement) { + delete editor.dataset.empty +} + /** @see referenceRe — the shared pattern every surface recognises a reference * with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */ export const REF_RE = referenceRe() @@ -407,7 +446,14 @@ export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment /** 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 - * was unreliable). Returns whether it ran. */ + * was unreliable). Returns whether it ran. + * + * "Immediately after" has to be read through Chromium's litter. Committing a + * completion empties the typed token's text node rather than removing it, and + * `Range.insertNode` splits around the caret, so the chip routinely sits + * between zero-length text nodes. Reading those as content made the caret look + * like it was after plain text; the delete declined and Chromium's own + * backspace bounced between the leftovers instead of removing the chip. */ export function deleteChipBeforeCaret(editor: HTMLElement): boolean { const hit = composerSelectionRange(editor) @@ -419,16 +465,20 @@ export function deleteChipBeforeCaret(editor: HTMLElement): boolean { let chip: ChildNode | null = null if (startContainer === editor) { - chip = startOffset > 0 ? editor.childNodes[startOffset - 1] : null + chip = startOffset > 0 ? (editor.childNodes[startOffset - 1] ?? null) : null + + if (isEmptyTextNode(chip)) { + chip = meaningfulPreviousSibling(chip) + } } else if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) { - chip = startContainer.previousSibling + chip = meaningfulPreviousSibling(startContainer as ChildNode) } if (chip?.nodeType !== Node.ELEMENT_NODE || !(chip as HTMLElement).dataset.refText) { return false } - const after = chip.nextSibling + const after = meaningfulNextSibling(chip) chip.remove() // Drop the auto-inserted trailing space; keep any real following text. @@ -666,6 +716,14 @@ function isBlankNode(node: ChildNode | null): boolean { * rendering emits (we use text nodes +
+ chips). Real
line breaks * (Shift+Enter, which sit after actual text) are preserved. */ export function normalizeComposerEditorDom(editor: HTMLElement) { + // Chromium's zero-length text nodes first: every check below reads siblings, + // and litter between them makes a chip look like it has text either side. + for (const child of Array.from(editor.childNodes)) { + if (isEmptyTextNode(child)) { + child.remove() + } + } + // A trailing block wrapper holding only a break/whitespace is the phantom // "new line" Chromium adds after a chip on backspace — drop it. const tailBlock = editor.lastChild as HTMLElement | null diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.test.tsx new file mode 100644 index 000000000000..326cdfcfd6fb --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.test.tsx @@ -0,0 +1,93 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { atom } from 'nanostores' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $notifications, clearNotifications } from '@/store/notifications' + +vi.mock('@/store/coding-status', () => ({ + registerRepoStatusCwd: () => undefined, + repoStatusForCwd: () => + atom({ + added: 12, + ahead: 0, + behind: 0, + branch: 'bb/hitbox', + defaultBranch: 'main', + detached: false, + removed: 3, + untracked: 0 + }), + repoWorktreesForCwd: () => atom([]) +})) + +const { CodingStatusRow } = await import('./coding-row') + +describe('CodingStatusRow', () => { + afterEach(() => { + cleanup() + }) + + it('opens the review pane from the branch and the diff counts, never the bar itself', () => { + const onOpen = vi.fn() + + const { container } = render() + + const bar = container.querySelector('.coding-status-bar') + + expect(bar).not.toBeNull() + + fireEvent.click(bar!) + expect(onOpen).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('bb/hitbox')) + expect(onOpen).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByText('12')) + expect(onOpen).toHaveBeenCalledTimes(2) + }) + + it('wraps the click targets without adding a layout box', () => { + const { container } = render( undefined} repoPath="/repo" />) + + // `display: contents` is what keeps the branch label and the counts direct + // flex children of the row — the hit areas cost nothing visually. + expect(screen.getByText('bb/hitbox').parentElement?.classList.contains('contents')).toBe(true) + expect(screen.getByText('12').closest('button')?.classList.contains('contents')).toBe(true) + // The glyph button fills the row's existing 3.5 leading slot exactly. + expect(container.querySelector('button[class~="size-3.5"]')).not.toBeNull() + }) + + it('parks the copy glyph against the end of the path, not the end of the row', () => { + render( undefined} repoPath="/Users/someone/www/repo" />) + + const path = screen.getByText('~/www/repo') + + // The path sizes to its content and the glyph is its immediate sibling, so + // the pair reads as one unit. `flex-1` belongs to the wrapper (which holds + // the row's slack open) — on the label it stretched the text and pushed the + // glyph out to the kebab. + expect(path.classList.contains('flex-1')).toBe(false) + expect(path.parentElement?.classList.contains('flex-1')).toBe(true) + expect(path.nextElementSibling?.tagName).toBe('BUTTON') + }) + + it('copies the absolute cwd inline — checkmark feedback, no toast', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + clearNotifications() + + render( undefined} repoPath="/Users/someone/www/repo" />) + + // Painted tildified, copied raw. + expect(screen.getByText('~/www/repo')).toBeTruthy() + + const copy = screen.getByRole('button', { name: 'Copy Path' }) + + fireEvent.click(copy) + + await waitFor(() => expect(writeText).toHaveBeenCalledWith('/Users/someone/www/repo')) + // Confirmation is the button turning into a checkmark, not a notification. + await waitFor(() => expect(screen.getByRole('button', { name: 'Copied' })).toBeTruthy()) + expect($notifications.get()).toHaveLength(0) + }) +}) 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 c4b1a56956cf..842b1ac37ec3 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 @@ -12,9 +12,11 @@ import { } from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { CopyButton } from '@/components/ui/copy-button' import { DiffCount } from '@/components/ui/diff-count' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' +import { displayPath } from '@/lib/display-path' import { registerRepoStatusCwd, repoStatusForCwd, repoWorktreesForCwd } from '@/store/coding-status' import { notifyError } from '@/store/notifications' import { $newWorktreeRequest } from '@/store/projects' @@ -62,6 +64,7 @@ export const CodingStatusRow = memo(function CodingStatusRow({ const { t } = useI18n() const s = t.statusStack.coding const p = t.sidebar.projects + const fileMenu = t.fileMenu const resolvedRepoPath = repoPath?.trim() || undefined // This surface's OWN worktree, always — never the primary's. The row used to // fall back to the global `$repoStatus` for a blank repoPath, which painted @@ -219,16 +222,52 @@ export const CodingStatusRow = memo(function CodingStatusRow({ // once `status` exists, so a spinner here only ever fired on *refreshes* // of an already-loaded repo (window focus, turn settle), reading as an // annoying icon "blip" with no first-load value. Refreshes are silent. - leading={} - onActivate={onOpen} + // It's a button (not the whole row) so the glyph opens the review pane + // while the strip around it stays inert; size-3.5 fills the slot exactly. + leading={ + + } >
- - {branchLabel} - + {/* Branch name — the other half of the review-pane target. `contents` + so the button lays out nothing of its own: the label stays the + same flex child it always was, and the hit area is the text. */} + + + {/* Worktree path + copy — plain muted text, not a chip. Always in the + flex so hover doesn't reflow the row; opacity alone reveals the + pair. The path sizes to its content (the `flex-1` lives on the + wrapper) so the glyph sits against the end of the text instead of + drifting to the far edge of the row. `displayPath` collapses + home → ~; the copy still takes the real absolute path, and it's + the shared `CopyButton` so it confirms with the same inline + checkmark as every other copy in the app. */} + {resolvedRepoPath && ( +
+ + {displayPath(resolvedRepoPath)} + + +
+ )} {/* Branch actions kebab — same pattern as the session/worktree rows. ALWAYS laid out; only its opacity flips on hover/focus/open, so @@ -246,14 +285,6 @@ export const CodingStatusRow = memo(function CodingStatusRow({
- {(status.ahead > 0 || status.behind > 0) && ( - - {status.ahead > 0 && ( - - - {status.ahead} + {/* The counts describe what's in the review pane, so clicking them + opens it. `contents` again: the two spans stay direct flex children + of the row, keeping their gap and `ml-auto` behaviour untouched. */} + {(status.ahead > 0 || status.behind > 0 || hasLineDelta || untrackedOnly) && ( + )} - - {hasLineDelta ? ( - - ) : untrackedOnly ? ( - - {s.changed(status.untracked)} - - ) : null} diff --git a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx index 9d3225931637..6b09788157b7 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx @@ -15,6 +15,7 @@ import { import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' +import { displayPath } from '@/lib/display-path' import { $dismissedWorktreeIds, dismissWorktree, setWorkspaceNodeOpen } from '@/store/layout' import { notifyError } from '@/store/notifications' import { removeWorktreePath } from '@/store/projects' @@ -277,7 +278,7 @@ function RepoFlatSection({ label={repo.label} onToggle={toggleOpen} open={open} - title={repo.path ?? undefined} + title={repo.path ? displayPath(repo.path) : undefined} /> {open && {body}} {removeDialog} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx index dd38e84a2b9c..5c05869b5a70 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Codicon } from '@/components/ui/codicon' import type { SessionInfo } from '@/hermes' import { useI18n } from '@/i18n' +import { displayPath } from '@/lib/display-path' import { setWorkspaceNodeOpen } from '@/store/layout' import { notifyError } from '@/store/notifications' import { newSessionInProfile } from '@/store/profile' @@ -132,7 +133,7 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov label={group.label} onToggle={toggleOpen} open={open} - title={group.path ?? undefined} + title={group.path ? displayPath(group.path) : undefined} /> {open && ( diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index df8f2e9717f5..f9a640000aba 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -13,7 +13,6 @@ import { HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' -import { setTerminalTakeover } from '@/app/right-sidebar/store' import { codiconIcon } from '@/components/ui/codicon' import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { HighlightMatches } from '@/components/ui/highlight-matches' @@ -52,7 +51,6 @@ import { SlidersHorizontal, Starmap, Sun, - Terminal, Users, Wrench, Zap @@ -385,7 +383,15 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({ }) type NonConfigSettingsLabel = - 'about' | 'archivedChats' | 'gateway' | 'keysSettings' | 'keysTools' | 'mcp' | 'providerAccounts' | 'providerApiKeys' + | 'about' + | 'archivedChats' + | 'gateway' + | 'keysSettings' + | 'keysTools' + | 'mcp' + | 'plugins' + | 'providerAccounts' + | 'providerApiKeys' const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent @@ -418,6 +424,12 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ labelKey: 'keysSettings', tab: 'keys&kview=settings' }, + { + icon: Package, + keywords: ['plugins', 'extensions', 'desktop plugins', 'addon', 'add-on'], + labelKey: 'plugins', + tab: 'plugins' + }, { icon: Archive, keywords: ['history', 'archived'], labelKey: 'archivedChats', tab: 'sessions' }, { icon: Info, keywords: ['version', 'about'], labelKey: 'about', tab: 'about' } ] @@ -757,14 +769,6 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) { } ] : []), - { - action: 'view.showTerminal', - icon: Terminal, - id: 'nav-terminal', - keywords: ['terminal', 'shell', 'console'], - label: t.keybinds.actions['view.showTerminal'], - run: () => setTerminalTakeover(true) - }, { action: 'nav.settings', icon: Settings, diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 9e01cde324ab..da4ff828cb85 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { computed } from 'nanostores' +import { atom, computed } from 'nanostores' import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react' import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail' @@ -13,20 +13,23 @@ import { LayoutTreeRoot } from '@/components/pane-shell/tree/renderer' import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session' import { $layoutTree, + bindPaneVisibility, + bindToolPaneCollapse, bindTreeSideVisibility, declareDefaultTree, dismissTreePane, dockPaneBeside, + isPaneVisible, markCollapsePane, mirrorLayoutTree, paneRootSide, registerLayoutResetHandler, registerPaneCloser, registerPaneOpener, + removeTreePane, resetLayoutTree, revealTreePane, - setPaneCollapsed, - setTreePaneHidden, + togglePaneVisible, watchContributedPanes } from '@/components/pane-shell/tree/store' import { SidebarProvider } from '@/components/ui/sidebar' @@ -36,9 +39,8 @@ import { useContributions } from '@/contrib/react/use-contributions' import { registry } from '@/contrib/registry' import { discoverRuntimePlugins } from '@/contrib/runtime-loader' import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime' -import { FileText, LayoutDashboard, PanelBottom, Zap } from '@/lib/icons' +import { FileText, LayoutDashboard, PanelBottom, Terminal, Zap } from '@/lib/icons' import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' -import { Codecs, persistentAtom } from '@/lib/persisted' import { setYoloEnabled } from '@/lib/yolo-session' import { pruneComposerPopoutZones } from '@/store/composer-popout' import { @@ -54,7 +56,7 @@ import { SIDEBAR_MAX_WIDTH } from '@/store/layout' import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/preview' -import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review' +import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' import { $statusbarVisible } from '@/store/statusbar-prefs' @@ -175,7 +177,11 @@ registry.registerMany([ // staying collapsed behind the ⌃` toggle. height sizes the fixed track (a // single-pane zone declaring a height is a fixed track — the preset weight // is moot): a short deck, not a third of the window. - data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, + // + // NO minHeight: a tool panel drags all the way down to its collapsed + // header (the sash floors it at COLLAPSED_ZONE_PX and folds the zone to + // its rail there). A real floor left a sliver of unusable terminal. + data: { placement: 'bottom', height: '20vh', maxHeight: '80vh', revealOnPreset: true }, render: () => }, { @@ -227,17 +233,6 @@ registry.registerMany([ maxWidth: FILE_BROWSER_MAX_WIDTH }, render: () => idle() - }, - { - // Optional chrome — in NO default layout. Adoption stacks it with the - // terminal; $logsOpen (default off, ⌘K "Toggle logs") reveals it. - id: 'logs', - area: 'panes', - title: 'logs', - // revealOnPreset: the Quad layout places logs, so applying it turns the - // logs pane on (like a ⌘K "Toggle logs") instead of leaving it collapsed. - data: { placement: 'bottom', height: '20vh', minHeight: '7.5rem', maxHeight: '80vh', revealOnPreset: true }, - render: () => idle() } ]) @@ -381,7 +376,7 @@ const QUAD_TREE = split( 'column', [ split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]), - split('row', [group(['terminal']), group(['preview', 'review', 'logs'])], [1.4, 1]) + split('row', [group(['terminal']), group(['preview', 'review'])], [1.4, 1]) ], [3, 1] ) @@ -466,49 +461,13 @@ registerLayoutResetHandler(stackSessionTilesIntoMain) // toggle mirrors the root row. // --------------------------------------------------------------------------- -function bindPaneVisibility( - paneId: string, - $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, - close?: () => void, - open?: () => void -) { - setTreePaneHidden(paneId, !$open.get()) - $open.listen(isOpen => setTreePaneHidden(paneId, !isOpen)) - - // The tab menu's Close routes through the owning store (never dismissal), - // so the pane's toggle buttons stay truthful. - if (close) { - registerPaneCloser(paneId, close) - } +// HIDE-STYLE PANES (files, review, preview): the binding lives in the tree +// store — bindPaneVisibility — alongside bindToolPaneCollapse, so both are +// testable against the real function instead of a copy. - // The opener is the mirror: preset application (revealOnPreset) shows the - // pane through the same store, so the toggle stays truthful. - if (open) { - registerPaneOpener(paneId, open) - } -} - -// TOOL PANELS (terminal, logs): like bindPaneVisibility but the toggle COLLAPSES -// the zone to a persistent rail (tab stays) instead of hiding it — the -// IntelliJ/VS-Code tool-window model. Restore routes back through `open` (rail -// click / chevron) so ⌃`/the button stay truthful; Close removes the tab. -// -// OPEN goes through revealTreePane, not setPaneCollapsed: Close DISMISSES the -// pane, and setPaneCollapsed can't act on a pane that has left the tree — the -// toggle would flip its store with nothing coming back. revealTreePane -// un-dismisses and re-adopts. -function bindPaneCollapse( - paneId: string, - $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, - close: () => void, - open: () => void -) { - markCollapsePane(paneId) - setPaneCollapsed(paneId, !$open.get()) - $open.listen(isOpen => (isOpen ? revealTreePane(paneId) : setPaneCollapsed(paneId, true))) - registerPaneCloser(paneId, close) - registerPaneOpener(paneId, open) -} +// TOOL PANELS (terminal, logs): the binding lives in the tree store — +// bindToolPaneCollapse — so the boot rule it encodes is testable against the +// real function instead of a copy. See its docblock for the semantics. // SIDES have one source of truth: the TREE. The legacy $panesFlipped flag is // DERIVED from where the sessions zone actually sits (TitlebarControls maps @@ -561,24 +520,50 @@ const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim())) // The tree pane's own presence tracks ⌘J directly, not just the column's // collapse — otherwise revealing a preview (which opens that shared column) // would drag the tree along with it. See revealPreview. +// +// Both get a CLOSER and an OPENER. The closer keeps ⌘J/⌘G truthful when the +// pane is closed from the tab menu; the opener is its mirror, so bringing the +// pane back through the tree (the toggle's reveal path, the rail, a preset) +// writes the store too. Without the opener the boolean went stale the moment +// anything but the toggle showed the pane — the divergence this whole change +// is about. bindPaneVisibility( 'files', - computed([$hasWorkspace, $fileBrowserOpen], (workspace, open) => workspace && open) + computed([$hasWorkspace, $fileBrowserOpen], (workspace, open) => workspace && open), + () => setFileBrowserOpen(false), + () => setFileBrowserOpen(true) ) // ⌘G — the review sidebar appears/disappears (and comes to the front). bindPaneVisibility( 'review', computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace), - closeReview + closeReview, + openReview ) // ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not // hides; PTYs stay alive while collapsed (see PersistentTerminal). -bindPaneCollapse( +bindToolPaneCollapse( 'terminal', $terminalTakeover, () => setTerminalTakeover(false), () => setTerminalTakeover(true) ) +// ⌘K door onto the same pane the keybind and statusbar pill flip — was a +// one-way "open" row under Go to, so it never showed on/off and couldn't hide. +// Reads the TREE like every other pane toggle: `$terminalTakeover` stays true +// behind a stacked sibling tab or a minimized zone, which would light the row +// "on" for a terminal that isn't on screen. +registry.register( + paletteToggle({ + id: 'view.showTerminal', + label: 'Toggle terminal', + action: 'view.showTerminal', + icon: Terminal, + keywords: ['terminal', 'shell', 'console', 'pty'], + get: () => isPaneVisible('terminal'), + set: () => togglePaneVisible('terminal') + }) +) // Preview EXISTS only while something is previewed (old-shell semantics: // closing the last preview tab closes the pane; a new target opens + fronts @@ -588,23 +573,74 @@ const $previewVisible = computed($previewTabs, tabs => tabs.length > 0) bindPaneVisibility('preview', $previewVisible, closeRightRail) -// Logs are optional chrome: off by default, toggled from ⌘K, persisted. -const $logsOpen = persistentAtom('hermes.desktop.logsOpen', false, Codecs.bool) +// Logs are ⌘K-ONLY chrome: the pane contribution EXISTS only while $logsOpen +// is on. Off (the default) keeps logs out of the registry and the tree +// entirely — no secondary tab riding the terminal strip, no preset or +// adoption path that resurrects it. Session-only on purpose (not persisted): +// a fresh boot never re-opens logs automatically. The palette toggle is the +// single door in; tab ✕ / ⌘W / the toggle itself remove it again. +const $logsOpen = atom(false) + +let unregisterLogsPane: (() => void) | null = null + +const syncLogsPane = (open: boolean) => { + if (open) { + unregisterLogsPane ??= registry.register({ + id: 'logs', + area: 'panes', + title: 'logs', + // Same tool-panel sizing rule as the terminal above — no minHeight, so + // the sash floors it at COLLAPSED_ZONE_PX and folds the zone to its rail + // rather than leaving a sliver. dock: its OWN zone beside the terminal — + // never a tab in the terminal's strip. + data: { + placement: 'bottom', + dock: { pane: 'terminal', pos: 'right' }, + height: '20vh', + maxHeight: '80vh' + }, + render: () => idle() + }) + // Summoning logs is explicit intent — front it (un-dismisses if a ✕ close + // left a dismissal record behind). + revealTreePane('logs') + } else { + unregisterLogsPane?.() + unregisterLogsPane = null + + // No dismissal record — the next toggle-on must re-adopt cleanly. Also + // sweeps 'logs' out of persisted trees from before it was summon-only. + // Guarded: removePane rebuilds the tree even for an absent pane, and a + // no-op boot sweep would commit (and persist) a fresh identical tree. + const tree = $layoutTree.get() + + if (tree && allPaneIds(tree).includes('logs')) { + removeTreePane('logs') + } + } +} + +// Tool-panel tab semantics (✕ / ⌘W route through the store) so the palette +// toggle stays truthful either way. +markCollapsePane('logs') +registerPaneCloser('logs', () => $logsOpen.set(false)) +registerPaneOpener('logs', () => $logsOpen.set(true)) +syncLogsPane($logsOpen.get()) +$logsOpen.listen(syncLogsPane) -bindPaneCollapse( - 'logs', - $logsOpen, - () => $logsOpen.set(false), - () => $logsOpen.set(true) -) registry.register( paletteToggle({ id: 'logs.toggle', label: 'Toggle logs', icon: FileText, keywords: ['logs', 'agent log', 'tail', 'debug'], - get: () => $logsOpen.get(), - set: enabled => $logsOpen.set(enabled) + // On-screen, not the store's boolean. Summon-only keeps the two in step + // while logs sits in its own zone, but the user can still drag it into the + // terminal's strip or minimize its zone — and then `$logsOpen` reads true + // with nothing visible, so the row would show "on" and its press would + // spend itself re-asserting a value it already held. + get: () => isPaneVisible('logs'), + set: () => togglePaneVisible('logs') }) ) diff --git a/apps/desktop/src/app/contrib/panes.tsx b/apps/desktop/src/app/contrib/panes.tsx index 0f4d01315120..36782638abb2 100644 --- a/apps/desktop/src/app/contrib/panes.tsx +++ b/apps/desktop/src/app/contrib/panes.tsx @@ -31,8 +31,9 @@ import { $previewTarget, openPreview } from '@/store/preview' import { $currentCwd } from '@/store/session' // --------------------------------------------------------------------------- -// Logs — live agent-log tail. OPTIONAL chrome: not in any default layout, -// hidden until the ⌘K "Toggle logs" command opens it ($logsOpen). +// Logs — live agent-log tail. ⌘K-only chrome: the pane contribution exists +// only while the "Toggle logs" palette command has it summoned ($logsOpen in +// the controller) — never in a default layout, never a standing tab. // --------------------------------------------------------------------------- export function LogsPane() { diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index 2e40092ffce2..750248fbfebf 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -56,7 +56,7 @@ export const SidebarSurface = memo(function SidebarSurface({ export const TerminalSurface = memo(function TerminalSurface() { return ( -
+
) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index e5802d051799..a39a16dd8615 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -29,6 +29,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' +import { activateWakeIndicator } from '@/lib/wake-indicator' import { playWakeSound } from '@/lib/wake-sound' import { $billingSettingsRequest } from '@/store/billing-block' import { requestVoiceConversationStart } from '@/store/composer' @@ -688,6 +689,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Audible confirmation that the wake registered, before voice capture // starts. Gated by the shared sound-mute toggle. playWakeSound() + activateWakeIndicator() // Multi-profile routing: a wake phrase enrolled by another profile // re-homes the gateway to that profile first (live swap — same path diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index dcd97cf29c30..32d9caaba22e 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -2,9 +2,15 @@ import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router' import { closeActiveTab } from '@/app/chat/close-tab' -import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' +import { setTerminalTakeover } from '@/app/right-sidebar/store' import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals' -import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store' +import { + activateTreeTabSlot, + cycleTreeTabInFocusedZone, + isPaneVisible, + layoutHasRootSide, + togglePaneVisible +} from '@/components/pane-shell/tree/store' import { onReleaseTypingFocus } from '@/components/ui/keyboard-first' import { findBarClaimsCombo } from '@/lib/find-in-page' import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' @@ -184,22 +190,23 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { // terminal-on-bottom) would leave it a dead key, so it falls back to the // terminal there. The single "secondary panel" toggle. 'view.toggleRightSidebar': () => - layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()), + layoutHasRootSide('right') ? toggleFileBrowserOpen() : togglePaneVisible('terminal'), 'view.toggleReview': toggleReview, 'view.toggleStatusbar': toggleStatusbarVisible, 'view.showFiles': showFiles, - 'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()), + 'view.showTerminal': () => togglePaneVisible('terminal'), // Create first so the pane's open-effect ensure sees a non-empty set and // doesn't also spawn one — net effect is exactly one fresh terminal. 'view.newTerminal': () => { createTerminal() setTerminalTakeover(true) }, - // Switch / close only act while the pane is open (no focus-scoping here, so - // this stands in for "terminal is showing"). - 'view.nextTerminal': () => $terminalTakeover.get() && cycleTerminal(1), - 'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1), - 'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(), + // Switch / close only act while the terminal is actually ON SCREEN — ask + // the tree, not the toggle store (which stays true behind a stacked + // sibling tab or a minimized zone). + 'view.nextTerminal': () => isPaneVisible('terminal') && cycleTerminal(1), + 'view.prevTerminal': () => isPaneVisible('terminal') && cycleTerminal(-1), + 'view.closeTerminal': () => isPaneVisible('terminal') && closeActiveTerminal(), 'view.flipPanes': togglePanesFlipped, // ⌘W: close the focused tab (terminal / preview target / zone tree tab). // On the main tab with session tabs stacked, it shifts the next one in — diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index 1db67b8778e3..0ec999344109 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { getProfileSoul, type ProfileInfo, updateProfileSoul } from '@/hermes' import { useI18n } from '@/i18n' +import { displayPath } from '@/lib/display-path' import { AlertTriangle, Save } from '@/lib/icons' import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color' import { normalize } from '@/lib/text' @@ -258,8 +259,11 @@ function ProfileDetail({ profile }: { profile: ProfileInfo }) { {profile.is_default && {p.defaultBadge}} {profile.has_env && .env}
-

- {profile.path} +

+ {displayPath(profile.path)}

diff --git a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx index b502c014c7dd..3374348e20fb 100644 --- a/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx +++ b/apps/desktop/src/app/right-sidebar/files/remote-picker.tsx @@ -5,6 +5,7 @@ import { Codicon } from '@/components/ui/codicon' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { useI18n } from '@/i18n' import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs' +import { displayPath, pathLeaf } from '@/lib/display-path' import { cn } from '@/lib/utils' function clean(path: string) { @@ -24,7 +25,7 @@ function parentDir(path: string) { } function pathName(path: string) { - return path.split('/').filter(Boolean).pop() || path + return pathLeaf(path) || path } interface PendingSelection { @@ -167,7 +168,7 @@ export function RemoteFolderPicker() {
-
{currentPath}
+
{displayPath(currentPath)}
+
+ + {TERMINAL_FONT_SUGGESTIONS.map(font => ( + +
+ + {copy.terminalFontPreview} + + ~/project git:main ❯ +
+
+ } + description={copy.terminalFontDesc} + title={copy.terminalFontTitle} + wide + /> + ) +} diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 0aa3c69fe3b6..a4081e9ac77d 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -2,13 +2,14 @@ import { useStore } from '@nanostores/react' import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' -import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' import { useApprovalModeStatusbarItem } from '@/app/shell/approval-mode-menu' import { ContextUsagePanel } from '@/app/shell/context-usage-panel' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' +import { $paneVisible, togglePaneVisible } from '@/components/pane-shell/tree/store' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' +import { displayPath, pathLeaf } from '@/lib/display-path' import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' @@ -29,6 +30,7 @@ import { $sessions, $sessionStartedAt, $turnStartedAt, + idsShareLineage, sessionMatchesStoredId, setCurrentUsage } from '@/store/session' @@ -50,13 +52,6 @@ import type { StatusbarItem } from '../statusbar-controls' const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const -function workspaceLabel(cwd: string): string { - const normalized = cwd.replace(/[\\/]+$/, '') - const leaf = normalized.split(/[\\/]/).filter(Boolean).pop() - - return leaf || cwd -} - interface StatusbarItemsOptions { agentsOpen: boolean chatOpen: boolean @@ -92,15 +87,15 @@ export function useStatusbarItems({ const fileMenu = t.fileMenu const primaryActiveSessionId = useStore($activeSessionId) const activeGatewayProfile = useStore($activeGatewayProfile) - const terminalTakeover = useStore($terminalTakeover) + // What the button paints and flips is whether the terminal is ON SCREEN — + // the takeover store alone stays true behind a stacked sibling tab or a + // minimized zone, which lit the button for a pane the user couldn't see. + const terminalShowing = useStore($paneVisible('terminal')) const primaryBusy = useStore($busy) - const currentCwd = useStore($currentCwd) - // Derive the workspace's project name from the already-cached project tree - // (backend truth via projects.*), so the status item labels by project without - // a second per-session copy of the same fact. Re-derives whenever the cwd or - // the tree changes; null (no named project) falls back to the cwd leaf below. - const projectTree = useStore($projectTree) - const projectName = useMemo(() => projectNameForCwd(currentCwd), [currentCwd, projectTree]) + // Draft / primary composer atom — used only while the focused surface is the + // primary (or a draft with no runtime slice yet). A focused TILE keeps its + // own cwd in `$sessionStates` and must not paint the primary's workspace. + const primaryCwd = useStore($currentCwd) const primaryUsage = useStore($currentUsage) const gatewayRestarting = useStore($gatewayRestarting) const primarySessionStartedAt = useStore($sessionStartedAt) @@ -128,15 +123,14 @@ export function useStatusbarItems({ // The FOCUSED session (interacted tile, else the primary — the same // derivation the titlebar title follows): every session-scoped readout - // below (context count, timers, busy pulse) tracks it, so clicking into a - // tile makes the statusbar describe THAT session. + // below (workspace cwd, context count, timers, busy pulse) tracks it, so + // clicking into a tile makes the statusbar describe THAT session. const focusedStoredSessionId = useStore($focusedStoredSessionId) const focusedRuntimeId = useStore($focusedRuntimeId) // `$focusedSessionState` is a projection of `$sessionStates`, which is // republished on EVERY message delta — tens of times a second during a turn. - // Only three fields are read off it here, so subscribing to the whole object - // re-ran this hook (and re-created all ~9 statusbar items) per token. Select - // each field individually so an unchanged readout bails out instead. + // Only the fields read here are selected, so an unchanged readout bails out + // instead of rebuilding all ~9 statusbar items per token. const focusedBusy = useStoreSelector($focusedSessionState, state => Boolean(state?.busy)) const focusedTurnStartedAt = useStoreSelector($focusedSessionState, state => state?.turnStartedAt ?? null) // `usage` is an object, so it can't be compared as a scalar. It IS however @@ -144,6 +138,14 @@ export function useStatusbarItems({ // reports new usage — far rarer than a delta — so its reference is a valid // bail-out key on its own. const focusedUsage = useStoreSelector($focusedSessionState, state => state?.usage ?? null) + const focusedStateCwd = useStoreSelector($focusedSessionState, state => state?.cwd?.trim() || '') + + // Runtime slices carry the stored id they were bound for. During a primary + // tab switch the runtime id can lag a frame behind the new selection — the + // slice still describes the PREVIOUS chat. Gate live cwd on ownership so we + // never paint session A's workspace while the tab already shows session B. + const focusedStateStoredId = useStoreSelector($focusedSessionState, state => state?.storedSessionId?.trim() || null) + const selectedStoredSessionId = useStore($selectedStoredSessionId) const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId @@ -156,16 +158,65 @@ export function useStatusbarItems({ const turnStartedAt = primaryFocused ? primaryTurnStartedAt : focusedTurnStartedAt - // A tile's session-start comes from its stored row (the cache only knows - // runtime state); seconds → ms. Only this ONE scalar is read off - // `$sessions`, so select it — a whole-list `useStore` re-ran the hook on - // every session-list write (title updates, poll refreshes, archives). + // A tile's session-start + cold cwd come from its stored row (the cache only + // knows runtime state). Only these scalars are read off `$sessions`, so + // select them — a whole-list `useStore` re-ran the hook on every session-list + // write (title updates, poll refreshes, archives). const focusedRowStartedAt = useStoreSelector($sessions, sessions => focusedStoredSessionId ? (sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))?.started_at ?? null) : null ) + const focusedRowCwd = useStoreSelector($sessions, sessions => { + if (!focusedStoredSessionId) { + return '' + } + + const row = sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId)) + + return row?.cwd?.trim() || '' + }) + + // Live runtime cwd is authoritative once it belongs to the focused chat + // (agent can relocate mid-turn). Until then — cold tabs, mid-switch lag — + // the stored session row is the selection's project. Primary drafts fall + // through to `$currentCwd`. A focused TILE must never inherit the primary's + // workspace — an empty tile cwd stays empty rather than lying about another + // project's path. + // + // Lineage match is a pure derivation of ($sessions + the two ids). Select it + // so a session-list write only re-renders when the answer actually flips — + // not on every title/archive refresh of an unrelated row. + const liveCwdSharesFocusLineage = useStoreSelector($sessions, sessions => { + if (!focusedStoredSessionId || !focusedStateStoredId) { + return false + } + + return idsShareLineage(focusedStoredSessionId, focusedStateStoredId, sessions) + }) + + const liveCwdBelongsToFocus = + Boolean(focusedStateCwd) && + (!focusedStoredSessionId || + !focusedStateStoredId || + focusedStateStoredId === focusedStoredSessionId || + liveCwdSharesFocusLineage) + + const currentCwd = ( + (liveCwdBelongsToFocus ? focusedStateCwd : '') || + focusedRowCwd || + (primaryFocused ? primaryCwd : '') || + '' + ).trim() + + // Derive the workspace's project name from the already-cached project tree + // (backend truth via projects.*), so the status item labels by project without + // a second per-session copy of the same fact. Re-derives whenever the cwd or + // the tree changes; null (no named project) falls back to the cwd leaf below. + const projectTree = useStore($projectTree) + const projectName = useMemo(() => projectNameForCwd(currentCwd), [currentCwd, projectTree]) + const sessionStartedAt = primaryFocused ? primarySessionStartedAt : focusedRowStartedAt @@ -366,33 +417,33 @@ export function useStatusbarItems({ hidden: !currentCwd, icon: , id: 'workspace-cwd', - // Prefer the named project; fall back to the cwd leaf. The full cwd is - // always in the tooltip (`title` below), so hovering reveals where the - // session actually sits — the worktree/subfolder, not just the project. - label: projectName || (currentCwd ? workspaceLabel(currentCwd) : undefined), + // Prefer the named project; fall back to the cwd leaf. Hover tip uses + // the shared display formatter (home → ~) so statusbar and branch bar + // agree on how a path looks. + label: projectName || (currentCwd ? pathLeaf(currentCwd) : undefined), menuItems: currentCwd ? [ { id: 'copy-workspace-path', label: fileMenu.copyPath, onSelect: () => void copyFilePath(currentCwd), - title: currentCwd + title: displayPath(currentCwd) }, { id: 'reveal-workspace-finder', label: fileMenu.revealFileManager, onSelect: () => void revealFile(currentCwd), - title: currentCwd + title: displayPath(currentCwd) }, { id: 'reveal-workspace-sidebar', label: fileMenu.revealInSidebar, onSelect: () => revealFileInTree(currentCwd), - title: currentCwd + title: displayPath(currentCwd) } ] : undefined, - title: currentCwd || undefined, + title: currentCwd ? displayPath(currentCwd) : undefined, toggleLabel: copy.toggleWorkspace, variant: 'menu' }, @@ -506,12 +557,12 @@ export function useStatusbarItems({ }, { actionId: 'view.showTerminal', - className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, + className: `w-7 justify-center px-0${terminalShowing ? ' bg-accent/55 text-foreground' : ''}`, hidden: !chatOpen, icon: , id: 'terminal', - onSelect: () => setTerminalTakeover(!$terminalTakeover.get()), - title: terminalTakeover ? copy.hideTerminal : copy.showTerminal, + onSelect: () => togglePaneVisible('terminal'), + title: terminalShowing ? copy.hideTerminal : copy.showTerminal, toggleLabel: copy.toggleTerminal, variant: 'action' }, @@ -533,7 +584,7 @@ export function useStatusbarItems({ requestGateway, sessionStartedAt, gatewayState, - terminalTakeover, + terminalShowing, turnStartedAt ] ) diff --git a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx new file mode 100644 index 000000000000..27c6c1880705 --- /dev/null +++ b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx @@ -0,0 +1,108 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu' +import { + $modelVisibilityOpen, + $visibleModels, + modelVisibilityKey, + setModelVisibilityOpen, + setVisibleModels +} from '@/store/model-visibility' + +import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu' + +// Radix calls these on open; jsdom doesn't implement them. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) + +const getGlobalModelOptions = vi.fn() + +vi.mock('@/hermes', () => ({ + getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args), + setApiRequestProfile: vi.fn() +})) + +beforeEach(() => { + $visibleModels.set(null) + setModelVisibilityOpen(false) + getGlobalModelOptions.mockResolvedValue({ + providers: [{ models: ['gemini-3.1-pro', 'gemini-2.5-flash'], name: 'Google', slug: 'google' }] + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +// A minimal controller — these tests are about the CATALOG's own behaviour +// (what it lists, what it offers), not about what any host does with a pick. +function renderMenu() { + const select = vi.fn() + + const controller: ModelMenuController = { + applyPreset: vi.fn(), + current: { effort: '', fast: false, model: '', provider: '' }, + presetFor: () => ({}), + select, + setOptions: vi.fn() + } + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + + + + + + ) + + return select +} + +// Curation is ONE global preference, so it belongs to the catalog rather than +// to whichever surface mounted it. If a host had to opt in, the composer and +// the kanban board would end up disagreeing about what "my models" means — +// which is exactly the drift extracting this component was meant to prevent. +describe('the catalog owns model curation', () => { + it('honours the stored Edit Models shortlist', async () => { + setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')])) + + renderMenu() + + await screen.findByText(/Gemini 2\.5 Flash/i) + expect(screen.queryByText(/Gemini 3\.1 Pro/i)).toBeNull() + }) + + it('still finds a hidden model by search — curation narrows the default view, not the catalog', async () => { + setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')])) + + renderMenu() + await screen.findByText(/Gemini 2\.5 Flash/i) + + const input = screen.getByRole('textbox', { name: 'Search models' }) + + fireEvent.change(input, { target: { value: 'gemini-3.1' } }) + + await vi.waitFor(() => { + expect(screen.queryByText(/Gemini 3\.1 Pro/i)).not.toBeNull() + }) + }) + + it('offers Edit Models without the host wiring it up', async () => { + renderMenu() + await screen.findByText(/Gemini 3\.1 Pro/i) + + fireEvent.click(screen.getByText('Edit Models…')) + + expect($modelVisibilityOpen.get()).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx new file mode 100644 index 000000000000..782bf579d80d --- /dev/null +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -0,0 +1,589 @@ +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + dropdownMenuRow, + DropdownMenuSearch, + dropdownMenuSectionLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { HighlightMatches } from '@/components/ui/highlight-matches' +import { usePointerQuiet } from '@/components/ui/keyboard-first' +import { Skeleton } from '@/components/ui/skeleton' +import type { HermesGateway } from '@/hermes' +import { useI18n } from '@/i18n' +import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' +import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' +import { normalize } from '@/lib/text' +import { cn } from '@/lib/utils' +import { + $visibleModels, + collapseModelFamilies, + DEFAULT_VISIBLE_PER_PROVIDER, + effectiveVisibleKeys, + type ModelFamily, + modelVisibilityKey, + setModelVisibilityOpen +} from '@/store/model-visibility' +import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' +import { $defaultReasoningEffort } from '@/store/session' +import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' + +import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' + +// Lets the host dropdown (model-pill, a kanban field trigger, …) hand the panel +// a way to dismiss itself so clicking a model row commits + closes, while the +// hover-revealed edit submenu (reasoning/fast) stays open to play with (its +// items preventDefault on select). +export const ModelMenuCloseContext = createContext<() => void>(() => {}) + +/** One model choice, everything a caller needs to act on a selection. + * `effort` is '' for "inherit the default" and 'none' for thinking off. */ +export interface ModelChoice { + effort: string + fast: boolean + model: string + provider: string +} + +/** + * What a surface DOES with the catalog. The menu renders and navigates; the + * controller owns meaning — the composer writes through to a live session, + * the kanban override just holds a value in dialog state. + * + * `presetFor` supplies the remembered settings shown on a non-active row. + * Returning `{}` is fine — the row then shows Hermes' defaults. + */ +export interface ModelMenuController { + /** Restore a model's remembered settings after it is selected. Separate from + * `setOptions` because it is one atomic "apply this model's preset" write, + * not a user editing one control — surfaces that write through to a session + * need to batch it. Values are already capability-gated by the menu. */ + applyPreset: (preset: { effort?: string; fast?: boolean }, row: { model: string; provider: string }) => void + current: ModelChoice + presetFor: (provider: string, model: string) => { effort?: string; fast?: boolean } + /** Commit a model row. Return false to abort (a failed session switch). */ + select: (model: string, provider: string) => Promise | void + /** Edit ONE option on a row. `isActive` says whether it's the current model. */ + setOptions: ( + patch: { effort?: string; fast?: boolean }, + row: { isActive: boolean; model: string; provider: string } + ) => void +} + +interface ModelCatalogMenuProps { + controller: ModelMenuController + /** Rows appended under the catalog (Refresh Models, Edit Models, …). */ + footer?: ReactNode + gateway?: HermesGateway + /** Render the virtual `moa` provider's presets as a selectable section. + * Off for override surfaces, where a MoA preset isn't a worker model. */ + includeMoa?: boolean + profile?: string + /** Session whose catalog to fetch. A live session's catalog can differ from + * the profile-global one, and the app invalidates the SESSION-scoped query + * key on model changes — a surface bound to a session must pass it or its + * menu goes stale. Detached surfaces (per-task overrides) omit it. */ + sessionId?: null | string +} + +interface ProviderGroup { + families: ModelFamily[] + provider: ModelOptionProvider +} + +/** + * THE model catalog menu: searchable, provider-grouped, `-fast` families + * collapsed to one row, per-row hover submenu for thinking/effort/fast, full + * keyboard selection. Shared verbatim by the composer's model pill and by + * plugin surfaces that pick a model without a session behind it — so the two + * can never drift apart. + */ +export function ModelCatalogMenu({ + controller, + footer, + gateway, + includeMoa = false, + profile = 'default', + sessionId = null +}: ModelCatalogMenuProps) { + const { t } = useI18n() + const copy = t.shell.modelMenu + const closeMenu = useContext(ModelMenuCloseContext) + const [search, setSearch] = useState('') + const collapsedProviders = useStoreCollapsed() + const defaultEffort = useDefaultEffort() + // Which models the user curated in Edit Models. Read HERE rather than taken + // as a prop: it's one global preference, so every surface that shows a + // catalog must show the same shortlist. A per-caller opt-in is how the board + // and the composer would end up disagreeing about what "my models" means. + const visibleModels = useStore($visibleModels) + + const modelOptions = useQuery({ + queryKey: modelOptionsQueryKey(profile, sessionId), + // Gateway-first even with no session: a connected (possibly remote) + // gateway owns the model catalog, including virtual providers the local + // REST fallback can't know about (#53817). + queryFn: (): Promise => requestModelOptions({ gateway, sessionId }) + }) + + const loading = modelOptions.isPending && !modelOptions.data + + const error = modelOptions.error + ? modelOptions.error instanceof Error + ? modelOptions.error.message + : String(modelOptions.error) + : null + + const providers = modelOptions.data?.providers + + // The catalog carries MoA presets as a virtual `moa` provider row. Keep it + // out of the main groups so presets never show up twice. + const moaPresets = useMemo( + () => (includeMoa ? (providers?.find(p => p.slug.toLowerCase() === 'moa')?.models ?? []) : []), + [providers, includeMoa] + ) + + const pickerProviders = useMemo( + () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [], + [providers] + ) + + const current = controller.current + + // Resolve visibility HERE, against the catalog we actually fetched: an empty + // provider list would otherwise resolve to an empty key set that reads as + // "user hid everything" and blanks the menu on first open. + const shownKeys = useMemo( + () => effectiveVisibleKeys(visibleModels, pickerProviders), + [visibleModels, pickerProviders] + ) + + const groups = useMemo( + () => groupModels(pickerProviders, search, { model: current.model, provider: current.provider }, shownKeys), + [pickerProviders, search, current.model, current.provider, shownKeys] + ) + + const q = normalize(search) + + // Presets are searchable rows like everything else — an unfiltered preset + // sitting under zero model matches would otherwise become the "first match" + // Enter commits. + const shownMoaPresets = useMemo( + () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets), + [moaPresets, q] + ) + + const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { + const caps = provider.capabilities?.[family.id] + const preset = controller.presetFor(provider.slug, family.id) + + // Variant-fast models (no speed param) express "fast" as a separate `-fast` + // id, so honor the remembered preset by selecting that sibling. Param-fast + // is applied through setOptions below instead. + const variantFast = !(caps?.fast ?? false) && !!family.fastId + const targetId = variantFast && preset.fast === true ? family.fastId! : family.id + + if ((await controller.select(targetId, provider.slug)) === false) { + return + } + + controller.applyPreset( + { + effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, + fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined + }, + { model: family.id, provider: provider.slug } + ) + } + + const selectMoaPreset = async (preset: string) => { + if ((await controller.select(preset, 'moa')) === false) { + return + } + + closeMenu() + } + + // ── Keyboard selection (cmdk semantics on a Radix menu) ─────────────────── + // One flat list mirroring EXACTLY what's rendered (collapse, filter, presets), + // so the selection can never sit on a hidden row. + type KbRow = + | { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider } + | { key: string; kind: 'moa'; preset: string } + + const kbRows = useMemo( + () => [ + ...groups.flatMap(group => + collapsedProviders.includes(group.provider.slug) && !search + ? [] + : group.families.map((family): KbRow => ({ + family, + key: `${group.provider.slug}:${family.id}`, + kind: 'family', + provider: group.provider + })) + ), + ...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset })) + ], + [groups, collapsedProviders, search, shownMoaPresets] + ) + + const [kbOverride, setKbOverride] = useState(null) + // A parked cursor is not a cursor in use: until the mouse actually moves, + // hover can't take rows out from under the keyboard. + const pointerQuiet = usePointerQuiet() + + const currentKey = current.provider === 'moa' ? `moa:${current.model}` : `${current.provider}:${current.model}` + + const autoIndex = q + ? kbRows.length > 0 + ? 0 + : -1 + : kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === current.model)) + + const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex + const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null + + const stepKb = (delta: -1 | 1) => { + if (kbRows.length === 0) { + return + } + + const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0 + + setKbOverride((from + delta + kbRows.length) % kbRows.length) + } + + const commitKbRow = () => { + const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined + + if (!row) { + return + } + + if (row.kind === 'moa') { + void selectMoaPreset(row.preset) + + return + } + + if (row.key !== currentKey && row.family.fastId !== current.model) { + void selectFamily(row.family, row.provider) + } + + closeMenu() + } + + // Keep the selected row in view while arrowing through the scrollable list. + const listRef = useRef(null) + + useEffect(() => { + listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' }) + }, [kbActiveKey]) + + const kbRowProps = (key: string) => { + const active = kbActiveKey === key + + return { + className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'), + ...(active ? { 'data-kb-active': '' } : {}) + } + } + + // Rows are hover-selectable, so they go inert with the pointer. + const quietRows = pointerQuiet && 'pointer-events-none' + + return ( + <> + { + // Claim arrows and Enter from Radix so DOM focus stays in the input + // and Enter commits the highlighted row without a DownArrow first. + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + stepKb(event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + commitKbRow() + } + }} + onValueChange={value => { + setSearch(value) + setKbOverride(null) + }} + placeholder={copy.search} + value={search} + /> + + + + {loading ? ( + + {Array.from({ length: 4 }, (_, index) => ( + event.preventDefault()} + > + + + ))} + + ) : error ? ( + + {error} + + ) : groups.length === 0 && moaPresets.length === 0 ? ( + + {copy.noModels} + + ) : ( +
+ {groups.map(group => { + const slug = group.provider.slug + + // Collapsed when the user stored it (and not while searching, which + // spans every model regardless of collapse state). + const collapsed = collapsedProviders.includes(slug) && !search + + return ( + + { + event.preventDefault() + toggleCollapsedProvider(slug) + }} + textValue="" + > + + + + + + {!collapsed && + group.families.map(family => { + // The active id may be the base or its -fast sibling; either + // way this one family row represents both. + const activeId = + group.provider.slug === current.provider && + (current.model === family.id || current.model === family.fastId) + ? current.model + : null + + const isCurrent = activeId !== null + const name = modelDisplayParts(family.id).name + const caps = group.provider.capabilities?.[family.id] + + // Effective settings for this row: the live choice when it's + // the active model, otherwise its remembered preset. Row + // label AND submenu read from these so they never disagree. + const preset = controller.presetFor(group.provider.slug, family.id) + const effEffort = isCurrent ? current.effort : (preset.effort ?? '') + const effFast = isCurrent ? current.fast : (preset.fast ?? false) + + const fastControl: FastControl = resolveFastControl( + activeId ?? family.id, + group.provider.models ?? [], + caps?.fast ?? false, + effFast + ) + + const meta = [ + fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, + (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null + ] + .filter(Boolean) + .join(' ') + + // Clicking the row commits the model and closes; the edit + // submenu (reasoning/fast) is reached by HOVER, so you can + // tweak those without the click dismissing everything. + const activate = () => { + if (!isCurrent) { + void selectFamily(family, group.provider) + } + + closeMenu() + } + + return ( + + { + if (event.key === 'Enter' || event.key === ' ') { + activate() + } + }} + {...kbRowProps(`${group.provider.slug}:${family.id}`)} + > + + + {meta ? {meta} : null} + + {isCurrent ? ( + + ) : null} + + controller.select(nextModel, group.provider.slug)} + onSetOptions={patch => + controller.setOptions(patch, { + isActive: isCurrent, + model: family.id, + provider: group.provider.slug + }) + } + provider={group.provider.slug} + reasoning={caps?.reasoning ?? true} + /> + + ) + })} + + ) + })} +
+ )} + + {shownMoaPresets.length > 0 ? ( +
+ + MoA presets + {shownMoaPresets.map(preset => { + const isCurrentMoa = current.provider === 'moa' && current.model === preset + + return ( + { + event.preventDefault() + void selectMoaPreset(preset) + }} + {...kbRowProps(`moa:${preset}`)} + > + + MoA: + + {isCurrentMoa ? : null} + + ) + })} +
+ ) : null} + + {/* Curation belongs to the catalog, not to one host: wherever you can + pick a model you can say which models you want, and the shortlist is + the same everywhere because it's one stored preference. It shares the + host footer's group rather than opening a second one, so a host that + contributes rows (the composer's Refresh Models) keeps the single + trailing block it has always rendered. */} + + {footer} + setModelVisibilityOpen(true)} + > + + {copy.editModels} + + + ) +} + +/** Re-exported so callers building a footer row match the catalog's rows. */ +export { dropdownMenuRow } + +// Collapsed we show the user's chosen models (or the curated default); typing +// spans every available model so anything is reachable past the cut. A search +// is itself a narrowing action, so we do NOT cap per-provider matches. +function groupModels( + providers: ModelOptionProvider[], + search: string, + current: { model: string; provider: string }, + visible: Set | null +): ProviderGroup[] { + const q = normalize(search) + const groups: ProviderGroup[] = [] + + for (const provider of providers) { + const allFamilies = collapseModelFamilies(provider.models ?? []) + + if (allFamilies.length === 0) { + continue + } + + const matches = (family: ModelFamily) => + `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` + .toLowerCase() + .includes(q) + + let shown: Set + + if (q) { + // Search spans every family, regardless of visibility. + shown = new Set(allFamilies.filter(matches).map(family => family.id)) + } else if (visible) { + // User has customized which models show — honor their selection exactly. + shown = new Set( + allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) + ) + } else { + shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) + } + + // Always include the active model — but keep every row in the provider's + // stable curated order, so selecting a model can't shuffle the list. While + // SEARCHING the pin is skipped: a query means "show me matches". + const activeId = + !q && provider.slug === current.provider && current.model + ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id + : undefined + + const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) + + if (families.length > 0) { + groups.push({ families, provider }) + } + } + + // Stable, logical group order: alphabetical by provider name. (The backend + // floats the current provider first, which would reshuffle on every switch.) + groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) + + return groups +} + +// Small hooks kept at the bottom so the component reads top-down. +function useStoreCollapsed(): string[] { + return useStore($collapsedProviders) +} + +function useDefaultEffort(): string { + return useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT +} diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx index 4e552303b572..3685f8e44aaa 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -1,5 +1,5 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { DropdownMenu, @@ -7,26 +7,9 @@ import { DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' -import type * as HermesApi from '@/hermes' -import { $modelPresets, getModelPreset } from '@/store/model-presets' -import { - $activeSessionId, - $currentFastMode, - $currentReasoningEffort, - getCurrentModelSource, - setCurrentFastMode, - setCurrentModelSource, - setCurrentReasoningEffort -} from '@/store/session' import { type FastControl, ModelEditSubmenu } from './model-edit-submenu' -vi.mock('@/hermes', async importOriginal => { - const actual = await importOriginal() - - return { ...actual, setApiRequestProfile: vi.fn() } -}) - // Radix calls these on open; jsdom doesn't implement them. beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() @@ -34,35 +17,36 @@ beforeAll(() => { Element.prototype.releasePointerCapture = vi.fn() }) -beforeEach(() => { - $modelPresets.set({}) - $activeSessionId.set(null) - setCurrentFastMode(false) - setCurrentModelSource('') - setCurrentReasoningEffort('') -}) - afterEach(() => { cleanup() vi.clearAllMocks() }) // Render the submenu inside an open menu/sub so its content (switches) mounts. -function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise }) { +function renderSubmenu(opts: { + defaultEffort?: string + effort?: string + fastControl: FastControl + isActive?: boolean + onSelectModel?: (model: string) => void + onSetOptions: (patch: { effort?: string; fast?: boolean }) => void + reasoning: boolean +}) { return render( edit @@ -70,43 +54,78 @@ function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; req ) } -// Regression: editing the active row before a live session exists must stay -// preset-only — the gateway's config.set falls back to global config when no -// session matches, so it must not be called. (Caught in the second review.) -describe('ModelEditSubmenu no-session guard', () => { - it('param fast: records explicit off in the draft but skips the gateway without a session', () => { - const requestGateway = vi.fn().mockResolvedValue({}) - setCurrentFastMode(true) - renderSubmenu({ fastControl: { kind: 'param', on: true }, reasoning: false, requestGateway }) +// The submenu is PURE: it reports edits and never writes to a session, a +// preset store, or the gateway. That's the invariant that lets the same +// component drive a live chat session AND a detached per-task override — if it +// ever writes directly again, picking an effort for a kanban card would reach +// over and change the user's live chat. +describe('ModelEditSubmenu reports edits without performing them', () => { + it('param fast: reports the toggle', () => { + const onSetOptions = vi.fn() + renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onSetOptions).toHaveBeenCalledWith({ fast: false }) + }) + + it('thinking: toggling off reports the none level', () => { + const onSetOptions = vi.fn() + renderSubmenu({ fastControl: { kind: 'none' }, onSetOptions, reasoning: true }) + // Thinking starts on (medium); toggling it off reports 'none'. fireEvent.click(screen.getByRole('switch')) - expect(getModelPreset('p1', 'm1').fast).toBe(false) - expect($currentFastMode.get()).toBe(false) - expect(getCurrentModelSource()).toBe('manual') - expect(requestGateway).not.toHaveBeenCalled() + expect(onSetOptions).toHaveBeenCalledWith({ effort: 'none' }) }) - it('reasoning: records the preset but skips the gateway without a session', () => { - const requestGateway = vi.fn().mockResolvedValue({}) - renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway }) + it('thinking: toggling back on restores the row level, not the hardcoded default', () => { + const onSetOptions = vi.fn() + renderSubmenu({ + defaultEffort: 'high', + effort: 'none', + fastControl: { kind: 'none' }, + onSetOptions, + reasoning: true + }) - // Thinking starts on (medium); toggling it off routes through patchReasoning. fireEvent.click(screen.getByRole('switch')) - expect(getModelPreset('p1', 'm1').effort).toBe('none') - expect($currentReasoningEffort.get()).toBe('none') - expect(getCurrentModelSource()).toBe('manual') - expect(requestGateway).not.toHaveBeenCalled() + expect(onSetOptions).toHaveBeenCalledWith({ effort: 'high' }) }) - it('param fast: pushes to the gateway once a session is active', async () => { - const requestGateway = vi.fn().mockResolvedValue({}) - $activeSessionId.set('sess1') - renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway }) + it('variant fast: swaps the model only when the row is active', () => { + const onSelectModel = vi.fn() + const onSetOptions = vi.fn() + + renderSubmenu({ + fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false }, + isActive: false, + onSelectModel, + onSetOptions, + reasoning: false + }) + + fireEvent.click(screen.getByRole('switch')) + + // Inactive rows stay preference-only — no model switch. + expect(onSetOptions).toHaveBeenCalledWith({ fast: true }) + expect(onSelectModel).not.toHaveBeenCalled() + }) + + it('variant fast: active row swaps to the -fast sibling', () => { + const onSelectModel = vi.fn() + const onSetOptions = vi.fn() + + renderSubmenu({ + fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false }, + onSelectModel, + onSetOptions, + reasoning: false + }) fireEvent.click(screen.getByRole('switch')) - expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' }) + expect(onSelectModel).toHaveBeenCalledWith('m1-fast') }) }) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 94c241bdf04e..1d5b5dc598dd 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -1,6 +1,3 @@ -import { useStore } from '@nanostores/react' - -import { useSessionView } from '@/app/chat/session-view' import { DropdownMenuItem, DropdownMenuLabel, @@ -13,21 +10,7 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' -import { - DEFAULT_REASONING_EFFORT, - isThinkingEnabled, - REASONING_EFFORTS, - resolveReasoningEffort -} from '@/lib/reasoning-effort' -import { setModelPreset } from '@/store/model-presets' -import { notifyError } from '@/store/notifications' -import { - $defaultReasoningEffort, - markComposerSelectionManual, - setCurrentFastMode, - setCurrentReasoningEffort -} from '@/store/session' -import { sessionTileDelegate } from '@/store/session-states' +import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort' // Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned // by the Thinking toggle, not the radio. @@ -76,6 +59,9 @@ export function resolveFastControl( } interface ModelEditSubmenuProps { + /** The profile's configured default effort — what an unset row inherits. + * Passed in (not read from a store) so this submenu stays pure. */ + defaultEffort: string /** This row's effective reasoning effort (live for the active model, else its * preset) — the submenu shows and edits from this, never the raw session. */ effort: string @@ -83,15 +69,19 @@ interface ModelEditSubmenuProps { fastControl: FastControl /** Whether this row's model is the active one. */ isActive: boolean - /** This row's model id — edits persist as its global preset. */ + /** This row's model id. */ model: string /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ - onSelectModel: (model: string) => Promise | void - /** This row's provider slug — edits persist as its global preset. */ + onSelectModel: (model: string) => Promise | void + /** Report an option change. This submenu is PURE: it never writes to a + * session, a preset store, or the gateway itself — the owning surface's + * controller decides what an edit means. That's what lets the same submenu + * drive a live chat session and a detached per-task override. */ + onSetOptions: (patch: { effort?: string; fast?: boolean }) => void + /** This row's provider slug. */ provider: string /** Whether this model supports reasoning effort. */ reasoning: boolean - requestGateway: (method: string, params?: Record) => Promise } export function ModelEditSubmenu(props: ModelEditSubmenuProps) { @@ -108,72 +98,26 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) { } function ModelEditSubmenuBody({ + defaultEffort, effort, fastControl, isActive, - model, onSelectModel, - provider, - reasoning, - requestGateway + onSetOptions, + reasoning }: ModelEditSubmenuProps) { const { t } = useI18n() const copy = t.shell.modelOptions - const view = useSessionView() - const activeSessionId = useStore(view.$runtimeId) - const touchesPrimary = view.kind === 'primary' - const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const effortValue = resolveReasoningEffort(effort, defaultEffort) const thinkingOn = isThinkingEnabled(effort, defaultEffort) - // Editing always records the model's global preset (keyed by provider::model, - // not per-surface — a tile edit re-applies to that model everywhere); the - // active model also gets it pushed onto its OWN session (primary → globals, - // tile → its slice). Non-active edits stay preset-only — no model switch. - const patchReasoning = async (next: string) => { - setModelPreset(provider, model, { effort: next }) - - if (!isActive) { - return - } - - if (touchesPrimary) { - markComposerSelectionManual() - setCurrentReasoningEffort(next) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next })) - } - - // Preset-only without a session: `isActive` holds for the global/default - // row pre-session, and the gateway's `config.set` falls back to global - // config when none matches — so don't reach it (preset + optimistic store - // are the whole effect). Same guard in applyModelPreset / setFast. - if (!activeSessionId) { - return - } - - try { - await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) - } catch (err) { - if (touchesPrimary) { - setCurrentReasoningEffort(effort) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: effort })) - } - - setModelPreset(provider, model, { effort }) - notifyError(err, copy.updateFailed) - } - } - const setFast = (enabled: boolean) => { if (fastControl.kind === 'variant') { - // Fast is a separate model id. Record the choice on the base model's - // preset (selectFamily picks the `-fast` sibling later when set), and - // only swap models now if this is the active row — inactive edits must - // stay preset-only, same as the param path below. - setModelPreset(provider, fastControl.baseId, { fast: enabled }) + // Fast is a separate model id. Report the choice so the controller can + // record it against the base model, and only swap models now if this is + // the active row — inactive edits stay preference-only. + onSetOptions({ fast: enabled }) if (isActive) { void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) @@ -183,41 +127,7 @@ function ModelEditSubmenuBody({ } if (fastControl.kind === 'param') { - setModelPreset(provider, model, { fast: enabled }) - - if (!isActive) { - return - } - - if (touchesPrimary) { - markComposerSelectionManual() - setCurrentFastMode(enabled) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled })) - } - - // Preset-only without a session (see patchReasoning). - if (!activeSessionId) { - return - } - void (async () => { - try { - await requestGateway('config.set', { - key: 'fast', - session_id: activeSessionId, - value: enabled ? 'fast' : 'normal' - }) - } catch (err) { - if (touchesPrimary) { - setCurrentFastMode(!enabled) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled })) - } - - setModelPreset(provider, model, { fast: !enabled }) - notifyError(err, copy.fastFailed) - } - })() + onSetOptions({ fast: enabled }) } } @@ -235,7 +145,7 @@ function ModelEditSubmenuBody({ void patchReasoning(checked ? effortValue || defaultEffort : 'none')} + onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })} size="xs" /> @@ -250,7 +160,7 @@ function ModelEditSubmenuBody({ <> {copy.effort} - void patchReasoning(value)} value={effortValue}> + onSetOptions({ effort: value })} value={effortValue}> {REASONING_EFFORTS.map(value => ( void>(() => {}) +export { ModelMenuCloseContext } from './model-catalog-menu' export interface ModelSelection { model: string @@ -62,16 +42,15 @@ interface ModelMenuPanelProps { requestGateway: (method: string, params?: Record) => Promise } -interface ProviderGroup { - families: ModelFamily[] - provider: ModelOptionProvider -} - +/** + * The composer's model menu: `ModelCatalogMenu` (the shared renderer) plus the + * controller that gives a selection its meaning HERE — write through to this + * surface's session, remember the pick as a global preset, keep the optimistic + * stores honest, and roll back on a failed gateway write. + */ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', requestGateway }: ModelMenuPanelProps) { const { t } = useI18n() const copy = t.shell.modelMenu - const closeMenu = useContext(ModelMenuCloseContext) - const [search, setSearch] = useState('') const [refreshing, setRefreshing] = useState(false) const queryClient = useQueryClient() // Bind to THIS surface's SessionView (primary or tile) so each pane's menu @@ -85,13 +64,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re const modelPresets = useStore($modelPresets) const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const visibleModels = useStore($visibleModels) - const collapsedProviders = useStore($collapsedProviders) + const touchesPrimary = view.kind === 'primary' + // Subscribe to the SAME query the menu runs (identical key ⇒ React Query + // dedupes, no second fetch). It must be a live subscription, not a cache + // peek: with no model in the session store yet, currentPickerSelection falls + // back to the catalog's reported current, and a non-reactive read would + // never repaint that fallback once the catalog resolved. const modelOptions = useQuery({ queryKey: modelOptionsQueryKey(profile, activeSessionId), - // Gateway-first even with no session yet: a connected (possibly remote) - // gateway owns the model catalog, including virtual providers like `moa` - // that the local REST fallback can't know about (#53817). queryFn: (): Promise => requestModelOptions({ gateway, sessionId: activeSessionId }) }) @@ -100,42 +81,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re modelOptions.data ) - const loading = modelOptions.isPending && !modelOptions.data - - const error = modelOptions.error - ? modelOptions.error instanceof Error - ? modelOptions.error.message - : String(modelOptions.error) - : null - - const providers = modelOptions.data?.providers - - // The catalog carries MoA presets as a virtual `moa` provider row. Render - // them in their dedicated section below and keep the row out of the main - // provider groups so presets don't show up twice. - const moaPresets = useMemo( - () => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [], - [providers] - ) - - const pickerProviders = useMemo( - () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [], - [providers] - ) - - const effectiveVisibleModels = useMemo( - () => effectiveVisibleKeys(visibleModels, pickerProviders), - [visibleModels, pickerProviders] - ) - - // The composer picker never persists the profile default. With a session it - // scopes the switch to that session; with none it's UI state shipped on the - // next session.create (see selectModel). The default lives in Settings → Model. - // Always stamp sessionId from this surface so a tile switch never hits the - // primary (busy) session by accident. - const switchTo = (model: string, provider: string) => - onSelectModel({ model, provider, sessionId: activeSessionId || null }) - // Explicit "Refresh Models": re-fetch the catalog with refresh:true so the // backend busts its 1h provider-model disk cache and re-pulls each provider's // live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing @@ -162,448 +107,138 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re } } - // Selecting a model row restores that model's remembered preset onto the - // session (effort/fast), gated by capability. Unset → Hermes defaults. - const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { - const caps = provider.capabilities?.[family.id] - const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {} - - // Variant-fast models (no speed param) express "fast" as a separate `-fast` - // id, so honor the saved preset by selecting that sibling. Param-fast is - // applied via applyModelPreset below instead. - const variantFast = !(caps?.fast ?? false) && !!family.fastId - const targetId = variantFast && preset.fast === true ? family.fastId! : family.id + // Push a reasoning change onto the session that owns it, with rollback. + const patchReasoning = async (next: string, previous: string, provider: string, model: string) => { + if (touchesPrimary) { + markComposerSelectionManual() + setCurrentReasoningEffort(next) + } else if (activeSessionId) { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next })) + } - if ((await switchTo(targetId, provider.slug)) === false) { + // Preset-only without a session: the gateway's `config.set` falls back to + // global config when none matches — so don't reach it (preset + optimistic + // store are the whole effect). + if (!activeSessionId) { return } - await applyModelPreset( - { - effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, - fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined - }, - { - failMessage: t.shell.modelOptions.updateFailed, - primary: view.kind === 'primary', - request: requestGateway, - sessionId: activeSessionId + try { + await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) + } catch (err) { + if (touchesPrimary) { + setCurrentReasoningEffort(previous) + } else { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: previous })) } - ) - } - // Selecting a MoA preset switches the session to it PERSISTENTLY, using the - // same path real provider selections use (onSelectModel → config.set with - // --session for live sessions → the gateway's persistent switch_model). - // Previously this dispatched the one-shot `/moa` command, which ran a single - // turn through MoA and then silently reverted to the prior model (#54670) — - // the dropdown presented presets like persistent selections but they weren't. - // No session gate: like regular model rows, a pre-session pick is UI state - // shipped on the next session.create. - const selectMoaPreset = async (preset: string) => { - if ((await switchTo(preset, 'moa')) === false) { - return + setModelPreset(provider, model, { effort: previous }) + notifyError(err, t.shell.modelOptions.updateFailed) } - - closeMenu() } - const groups = useMemo( - () => - groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), - [pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels] - ) - - const q = normalize(search) - - // Presets are searchable rows like everything else — an unfiltered preset - // sitting under zero model matches would otherwise become the "first match" - // Enter commits. - const shownMoaPresets = useMemo( - () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets), - [moaPresets, q] - ) - - // ── Keyboard selection (cmdk semantics on a Radix menu) ─────────────────── - // One flat list mirroring EXACTLY what's rendered (collapse, filter, presets), - // so the selection can never sit on a hidden row. The selected index is - // derived — current model with no query (Enter = close), first match while - // typing — with an arrow-key override that resets on every keystroke. Focus - // stays in the search input throughout: ⌘⇧M → type → ↑/↓ → Enter. - type KbRow = - | { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider } - | { key: string; kind: 'moa'; preset: string } - - const kbRows = useMemo( - () => [ - ...groups.flatMap(group => - collapsedProviders.includes(group.provider.slug) && !search - ? [] - : group.families.map((family): KbRow => ({ - family, - key: `${group.provider.slug}:${family.id}`, - kind: 'family', - provider: group.provider - })) - ), - ...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset })) - ], - [groups, collapsedProviders, search, shownMoaPresets] - ) - - const [kbOverride, setKbOverride] = useState(null) - // A parked cursor is not a cursor in use: until the mouse actually moves, - // hover can't take rows out from under the keyboard (rows re-flow beneath it - // as the filter narrows). One real movement hands hover back. - const pointerQuiet = usePointerQuiet() - - const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}` - - const autoIndex = q - ? kbRows.length > 0 - ? 0 - : -1 - : kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === optionsModel)) - - const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex - const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null - - const stepKb = (delta: -1 | 1) => { - if (kbRows.length === 0) { - return + const patchFast = async (enabled: boolean, provider: string, model: string) => { + if (touchesPrimary) { + markComposerSelectionManual() + setCurrentFastMode(enabled) + } else if (activeSessionId) { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled })) } - const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0 - - setKbOverride((from + delta + kbRows.length) % kbRows.length) - } - - const commitKbRow = () => { - const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined - - if (!row) { + if (!activeSessionId) { return } - if (row.kind === 'moa') { - void selectMoaPreset(row.preset) - - return - } + try { + await requestGateway('config.set', { + key: 'fast', + session_id: activeSessionId, + value: enabled ? 'fast' : 'normal' + }) + } catch (err) { + if (touchesPrimary) { + setCurrentFastMode(!enabled) + } else { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled })) + } - if (row.key !== currentKey && row.family.fastId !== optionsModel) { - void selectFamily(row.family, row.provider) + setModelPreset(provider, model, { fast: !enabled }) + notifyError(err, t.shell.modelOptions.fastFailed) } - - closeMenu() } - // Keep the selected row in view while arrowing through the scrollable list. - const listRef = useRef(null) + const controller: ModelMenuController = { + // Selecting a model row restores that model's remembered preset onto the + // session (effort/fast). applyModelPreset owns the batched gateway write. + applyPreset: (preset, row) => { + setModelPreset(row.provider, row.model, preset) - useEffect(() => { - listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' }) - }, [kbActiveKey]) + void applyModelPreset(preset, { + failMessage: t.shell.modelOptions.updateFailed, + primary: touchesPrimary, + request: requestGateway, + sessionId: activeSessionId + }) + }, + + current: { + effort: currentReasoningEffort, + fast: currentFastMode, + model: optionsModel, + provider: optionsProvider + }, + + presetFor: (provider, model) => modelPresets[modelPresetKey(provider, model)] ?? {}, + + // The composer picker never persists the profile default. With a session it + // scopes the switch to that session; with none it's UI state shipped on the + // next session.create. Always stamp sessionId from this surface so a tile + // switch never hits the primary (busy) session by accident. + select: (model, provider) => onSelectModel({ model, provider, sessionId: activeSessionId || null }), + + setOptions: (patch, row) => { + // Editing always records the model's global preset (keyed by + // provider::model, not per-surface — a tile edit re-applies to that model + // everywhere); the active model also gets it pushed onto its OWN session. + // Non-active edits stay preset-only — no model switch, no session write. + if (patch.effort !== undefined || patch.fast !== undefined) { + setModelPreset(row.provider, row.model, patch) + } + + if (!row.isActive) { + return + } - // The keyboard-selected row, styled + tagged for scrollIntoView. Pointer - // suppression is NOT here — it belongs on the containers (below), so one - // class covers every row inside them. - const kbRowProps = (key: string) => { - const active = kbActiveKey === key + if (patch.effort !== undefined) { + void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model) + } - return { - className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'), - ...(active ? { 'data-kb-active': '' } : {}) + if (patch.fast !== undefined) { + void patchFast(patch.fast, row.provider, row.model) + } } } - // Rows are hover-selectable, so they go inert with the pointer (usePointerQuiet). - const quietRows = pointerQuiet && 'pointer-events-none' - return ( - <> - { - // Claim arrows and Enter from Radix so DOM focus stays in the input - // and Enter commits the highlighted row without a DownArrow first - // (VS Code's checked-or-first pattern). - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + { event.preventDefault() - event.stopPropagation() - stepKb(event.key === 'ArrowDown' ? 1 : -1) - } else if (event.key === 'Enter') { - event.preventDefault() - event.stopPropagation() - commitKbRow() - } - }} - onValueChange={value => { - setSearch(value) - setKbOverride(null) - }} - placeholder={copy.search} - value={search} - /> - - - - {loading ? ( - - {Array.from({ length: 4 }, (_, index) => ( - event.preventDefault()} - > - - - ))} - - ) : error ? ( - - {error} + void refreshModels() + }} + > + + {copy.refreshModels} - ) : groups.length === 0 && moaPresets.length === 0 ? ( - - {copy.noModels} - - ) : ( -
- {groups.map(group => { - const slug = group.provider.slug - - // Collapsed when the user stored it (and not while searching, which - // spans every model regardless of collapse state). - const collapsed = collapsedProviders.includes(slug) && !search - - return ( - - { - event.preventDefault() - toggleCollapsedProvider(slug) - }} - textValue="" - > - - - - - - {!collapsed && - group.families.map(family => { - // The active id may be the base or its -fast sibling; either - // way this one family row represents both. - const activeId = - group.provider.slug === optionsProvider && - (optionsModel === family.id || optionsModel === family.fastId) - ? optionsModel - : null - - const isCurrent = activeId !== null - const name = modelDisplayParts(family.id).name - // Capabilities are looked up against the active/base id; the - // -fast variant carries the same param support as its base. - const caps = group.provider.capabilities?.[family.id] - - // Effective settings for this row: live session state when it's - // the active model, otherwise its remembered preset (Hermes - // defaults when unset). Row label AND submenu read from these so - // they never disagree. - const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {} - const effEffort = isCurrent ? currentReasoningEffort : (preset.effort ?? '') - const effFast = isCurrent ? currentFastMode : (preset.fast ?? false) - - const fastControl = resolveFastControl( - activeId ?? family.id, - group.provider.models ?? [], - caps?.fast ?? false, - effFast - ) - - const meta = [ - fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null - ] - .filter(Boolean) - .join(' ') - - // Every row is a hover-Edit submenu trigger. Activating it - // (pointer or keyboard) switches to the family's base model and - // restores its preset; the Fast toggle inside swaps to the -fast - // sibling (or flips the speed param). The sub-trigger has no - // `onSelect`, so wire both click and Enter/Space for keyboard parity. - // Clicking the row commits the model and closes the picker; the - // edit submenu (reasoning/fast) is reached by HOVER, so you can - // still tweak those without the click dismissing everything. - const activate = () => { - if (!isCurrent) { - void selectFamily(family, group.provider) - } - - closeMenu() - } - - return ( - - { - if (event.key === 'Enter' || event.key === ' ') { - activate() - } - }} - {...kbRowProps(`${group.provider.slug}:${family.id}`)} - > - - - {meta ? {meta} : null} - - {isCurrent ? ( - - ) : null} - - switchTo(nextModel, group.provider.slug)} - provider={group.provider.slug} - reasoning={caps?.reasoning ?? true} - requestGateway={requestGateway} - /> - - ) - })} - - ) - })} -
- )} - - - - {shownMoaPresets.length > 0 ? ( -
- MoA presets - {shownMoaPresets.map(preset => { - const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset - - return ( - { - event.preventDefault() - void selectMoaPreset(preset) - }} - {...kbRowProps(`moa:${preset}`)} - > - - MoA: - - {isCurrentMoa ? : null} - - ) - })} - -
- ) : null} - - { - event.preventDefault() - void refreshModels() - }} - > - - {copy.refreshModels} - - - setModelVisibilityOpen(true)} - > - - {copy.editModels} - - + } + gateway={gateway} + includeMoa + profile={profile} + sessionId={activeSessionId} + /> ) } - -// Collapsed we show the user's chosen models (or the curated default); typing -// spans every available model so anything is reachable past the cut. A search -// is itself a narrowing action, so we do NOT cap per-provider matches — a -// provider serving 19 models (e.g. opencode-go) must show all 19 when the user -// searches for it, not a truncated subset. (#47077 follow-up) - -function groupModels( - providers: ModelOptionProvider[], - search: string, - current: { model: string; provider: string }, - visible: Set | null -): ProviderGroup[] { - const q = normalize(search) - const groups: ProviderGroup[] = [] - - for (const provider of providers) { - const allFamilies = collapseModelFamilies(provider.models ?? []) - - if (allFamilies.length === 0) { - continue - } - - const matches = (family: ModelFamily) => - `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` - .toLowerCase() - .includes(q) - - // Which model ids to show (the active one is always added on top of this). - let shown: Set - - if (q) { - // Search spans every family, regardless of visibility. - shown = new Set(allFamilies.filter(matches).map(family => family.id)) - } else if (visible) { - // User has customized which models show — honor their selection exactly. - shown = new Set( - allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) - ) - } else { - // Default: curated top-N families per provider. - shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) - } - - // Always include the active model — but keep every row in the provider's - // stable curated order (filter `allFamilies`, never reorder), so selecting - // a model can't shuffle the list. While SEARCHING, the pin is skipped: a - // query means "show me matches", and a pinned non-match sitting above them - // reads like the top result (type "grok", see the current Fable first). - const activeId = - !q && provider.slug === current.provider && current.model - ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id - : undefined - - const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) - - if (families.length > 0) { - groups.push({ families, provider }) - } - } - - // Stable, logical group order: alphabetical by provider name. (The backend - // floats the current provider first, which would reshuffle on every switch.) - groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) - - return groups -} diff --git a/apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx b/apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx new file mode 100644 index 000000000000..d90b9a8958e9 --- /dev/null +++ b/apps/desktop/src/app/wake-indicator/wake-indicator-app.tsx @@ -0,0 +1,42 @@ +import './wake-indicator.css' + +import { useEffect, useState } from 'react' + +import type { WakeIndicatorState } from '@/lib/wake-indicator' + +export function WakeIndicatorApp() { + const [state, setState] = useState('hidden') + + useEffect(() => { + const api = window.hermesDesktop?.wakeIndicator + let mounted = true + let receivedLiveState = false + + const unsubscribe = api?.onState(next => { + if (mounted) { + receivedLiveState = true + setState(next) + } + }) + + void api + ?.getState() + .then(next => { + if (mounted && !receivedLiveState) { + setState(next) + } + }) + .catch(() => undefined) + + return () => { + mounted = false + unsubscribe?.() + } + }, []) + + return ( +
+
+
+ ) +} diff --git a/apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx b/apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx new file mode 100644 index 000000000000..fc4a99288ca2 --- /dev/null +++ b/apps/desktop/src/app/wake-indicator/wake-indicator-root.tsx @@ -0,0 +1,26 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { ErrorBoundary } from '@/components/error-boundary' + +import { WakeIndicatorApp } from './wake-indicator-app' + +export function mountWakeIndicator(): void { + const style = document.createElement('style') + style.textContent = 'html,body,#root{background:transparent !important;overflow:hidden;}' + document.head.appendChild(style) + + const root = document.getElementById('root') + + if (!root) { + return + } + + createRoot(root).render( + + + + + + ) +} diff --git a/apps/desktop/src/app/wake-indicator/wake-indicator.css b/apps/desktop/src/app/wake-indicator/wake-indicator.css new file mode 100644 index 000000000000..c9d92d509d9f --- /dev/null +++ b/apps/desktop/src/app/wake-indicator/wake-indicator.css @@ -0,0 +1,61 @@ +.wake-indicator-surface { + align-items: flex-start; + background: transparent; + display: flex; + height: 100vh; + justify-content: center; + padding-top: 5px; + pointer-events: none; + width: 100vw; +} + +.wake-indicator-light { + backdrop-filter: blur(20px) saturate(1.2); + background: rgba(124, 58, 237, 0.62); + border: 1px solid rgba(216, 180, 254, 0.82); + border-radius: 999px; + box-shadow: + 0 0 10px rgba(124, 58, 237, 0.7), + 0 0 24px rgba(168, 85, 247, 0.48); + height: 28px; + opacity: 0; + transform: scale(0.96); + transition: + opacity 500ms ease-out, + transform 500ms ease-out; + width: 120px; +} + +.wake-indicator-surface[data-state='detected'] .wake-indicator-light { + animation: wake-indicator-breathe 2.5s ease-in-out infinite; +} + +.wake-indicator-surface[data-state='capturing'] .wake-indicator-light { + opacity: 1; + transform: scale(1); +} + +@keyframes wake-indicator-breathe { + 0%, + 100% { + opacity: 0.34; + transform: scale(0.96); + } + + 50% { + opacity: 0.94; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .wake-indicator-light { + transition: opacity 200ms ease-out; + } + + .wake-indicator-surface[data-state='detected'] .wake-indicator-light { + animation: none; + opacity: 0.72; + transform: scale(1); + } +} diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 6d149e2dd2d1..538f7770d734 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -6,7 +6,9 @@ import type { FC } from 'react' import { Fragment, useEffect, useMemo, useState } from 'react' import { ZoomableImage } from '@/components/chat/zoomable-image' +import type { I18nContextValue } from '@/i18n' import { extractEmbeddedImages } from '@/lib/embedded-images' +import { openExternalLink } from '@/lib/external-link' import { triggerHaptic } from '@/lib/haptics' import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media' import { useSessionLinkTitle } from '@/lib/session-link-title' @@ -442,7 +444,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => { * it's already a tile/main, otherwise open a stacked tab (never steals main * from under the chat you're reading). Lazy-imports so the composer's rich * editor can pull this module in without booting the profile/REST stack. */ -function openSessionRef(value: string) { +export function openSessionRef(value: string) { const { sessionId } = parseSessionRefValue(value) if (!sessionId) { @@ -454,6 +456,33 @@ function openSessionRef(value: string) { void import('@/app/open-session').then(({ openSession }) => openSession(sessionId, () => undefined, 'tab')) } +/** What activating a directive of a given kind does. The single source of truth + * for "you can act on this reference," shared by every surface that renders a + * chip: the composer's hover pill (`ComposerDirectiveActions`) and the sent + * message's clickable chip below. A kind with no entry is inert everywhere. + * + * Add a kind here and both surfaces light up — that's the whole point of one + * table. `icon`/`label` are for the pill; the transcript chip carries its own + * glyph and only reads `run`. */ +export interface DirectiveAction { + icon: string + label: (t: I18nContextValue['t']) => string + run: (value: string) => void +} + +export const DIRECTIVE_ACTIONS: Record = { + session: { + icon: 'link-external', + label: t => t.composer.openDirective, + run: openSessionRef + }, + url: { + icon: 'link-external', + label: t => t.composer.openDirective, + run: openExternalLink + } +} + /** A `@session:/` reference in the user transcript (directive * segments), rendered as a chip like the other composer refs. Clicking it * opens the session as a tab. */ @@ -501,14 +530,18 @@ const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({
) -/** Inert by default; `onClick` promotes the chip to a real button (session - * refs, which open the session they name). */ +/** A directive reference in a sent message. A kind with a `DIRECTIVE_ACTIONS` + * entry (a url, …) renders as a real button that runs it on click; everything + * else is inert text. `onClick` overrides for chips that resolve their target + * themselves (session, which needs the async navigator). */ const DirectiveChip: FC<{ type: string label: string id: string onClick?: () => void }> = ({ type, label, id, onClick }) => { + const activate = onClick ?? (DIRECTIVE_ACTIONS[type] ? () => DIRECTIVE_ACTIONS[type]!.run(id) : undefined) + const body = ( <> @@ -517,14 +550,14 @@ const DirectiveChip: FC<{ ) const props = { - ...refAttrs(type, cn('wrap-anywhere', onClick && 'cursor-pointer')), + ...refAttrs(type, cn('wrap-anywhere', activate && 'cursor-pointer')), 'data-directive-id': id, 'data-slot': 'aui_directive-chip', title: id } - return onClick ? ( - ) : ( diff --git a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx index d8fe7136aacc..f10d729d88f6 100644 --- a/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx +++ b/apps/desktop/src/components/assistant-ui/session-ref-open.test.tsx @@ -12,9 +12,12 @@ vi.mock('@/app/open-session', () => ({ openSession: (...args: unknown[]) => openSession(...args) })) +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } + afterEach(() => { cleanup() openSession.mockClear() + delete desktopWindow.hermesDesktop __resetSessionLinkTitleCache() }) @@ -41,3 +44,23 @@ describe('session refs open the session', () => { await vi.waitFor(() => expect(openSession).toHaveBeenCalledWith('20260101_abc123', expect.any(Function), 'tab')) }) }) + +// A url the user sent renders as a chip too, and it opens in the browser — the +// same door the composer's hover pill uses, so a link behaves the same before +// and after send. +describe('url refs open externally', () => { + it('opens a url chip in the user transcript', () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + + desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop'] + + render() + + const chip = screen.getByTitle('https://example.com/docs') + + expect(chip.tagName).toBe('BUTTON') + fireEvent.click(chip) + + expect(openExternal).toHaveBeenCalledWith('https://example.com/docs') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 3c9f7b0fe73f..043a60401c2c 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -3,8 +3,8 @@ import { BranchPickerPrimitive, ErrorPrimitive, MessagePrimitive, - useAui, - useAuiState + useAuiState, + useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type FC, useCallback, useMemo, useState } from 'react' @@ -54,7 +54,7 @@ export const AssistantMessage: FC<{ onDismissError?: (messageId: string) => void }> = ({ onBranchInNewChat, onDismissError }) => { const messageId = useAuiState(s => s.message.id) - const messageRuntime = useAui().message + const messageRuntime = useMessageRuntime() const { t } = useI18n() // PERF: this component must NOT subscribe to the streaming text. Every @@ -71,6 +71,13 @@ export const AssistantMessage: FC<{ // ChatMessage.interim). const isInterim = useAuiState(s => s.message.metadata?.custom?.interim === true) + // The thinking/stall indicator belongs to the TAIL of the thread, period. A + // stale pending bubble mid-transcript (a turn that ended without its settle + // event, a steer race) must never show one — a spinner above a later user + // message reads as the agent answering out of order. Booleans are stable + // across token flushes, so this selector adds no streaming re-renders. + const isLastMessage = useAuiState(s => s.thread.messages[s.thread.messages.length - 1]?.id === s.message.id) + // Preview targets only materialize once the turn completes — while running // the selector returns '' (stable), so per-token flushes skip the regex // scan and the re-render it would cause. @@ -124,7 +131,7 @@ export const AssistantMessage: FC<{ > {/* Todos render in the composer status stack now, not inline. */} - {isPlaceholder ? : isRunning && } + {isLastMessage && (isPlaceholder ? : isRunning && )} {previewTargets.length > 0 && (
{previewTargets.map(target => ( diff --git a/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx index 8a29c55de9ca..021036440d61 100644 --- a/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx @@ -5,11 +5,17 @@ import { useSessionView } from '@/app/chat/session-view' import { deriveChangedFiles } from '@/components/assistant-ui/thread/changed-files' import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell' import { DiffCount } from '@/components/ui/diff-count' +import { FadeScroll } from '@/components/ui/fade-scroll' import { FileTypeIcon } from '@/components/ui/file-type-icon' import { useI18n } from '@/i18n' +import { displayPath } from '@/lib/display-path' import { cn } from '@/lib/utils' import { openReviewForPath, revealReview } from '@/store/review' +// ~5 rows. A turn that rewrites twenty files should still read as one card in +// the transcript, not a wall the user has to scroll past to reach the composer. +const MAX_ROWS_HEIGHT = '9.375rem' + /** * Cursor-style "N files changed" summary closing out the newest assistant turn: * one row per file it edited with that file's +/-, and a Review action opening @@ -47,13 +53,13 @@ export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) = {copy.reviewChanges}
-
+ {files.map(file => ( ))} -
+ ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx new file mode 100644 index 000000000000..d48ff5a095e8 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx @@ -0,0 +1,107 @@ +// The thinking indicator (dither block) may only ever render at the TAIL of +// the thread. A message stuck status:running mid-transcript — however it got +// there (missed settle event, steer race, upstream state bug) — must render +// its content with no spinner: a live indicator above a later user message +// reads as the agent answering out of order. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Thread } from '.' + +const createdAt = new Date('2026-05-01T00:00:00.000Z') + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) + +Element.prototype.scrollTo = function scrollTo() {} + +// Enter animation fires for running messages; jsdom has no WAAPI. +Element.prototype.animate = function animate() { + return { cancel() {}, finished: Promise.resolve() } as unknown as Animation +} + +afterEach(() => { + cleanup() +}) + +const assistantMetadata = { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} +} + +function user(id: string, text: string): ThreadMessage { + return { + id, + role: 'user', + content: [{ type: 'text', text }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistant(id: string, text: string, running: boolean): ThreadMessage { + return { + id, + role: 'assistant', + content: text ? [{ type: 'text', text }] : [], + status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' }, + createdAt, + metadata: assistantMetadata + } as ThreadMessage +} + +function Harness({ messages }: { messages: ThreadMessage[] }) { + const runtime = useExternalStoreRuntime({ + messages, + isRunning: messages.at(-1)?.status?.type === 'running', + onNew: async () => {} + }) + + return ( + + + + ) +} + +describe('thinking indicator is tail-only', () => { + it('shows the loading indicator on a running placeholder at the tail', async () => { + const { container } = render() + + expect(await screen.findByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy() + expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeTruthy() + }) + + it('never shows an indicator on a stale running message mid-transcript', async () => { + // A stranded pending bubble from an earlier turn, then a newer exchange. + const { container } = render( + + ) + + await screen.findByText('answered') + + expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull() + expect(container.querySelector('[data-slot="aui_stream-stall"]')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts index 9120dbb491ac..75b011c55550 100644 --- a/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts +++ b/apps/desktop/src/components/assistant-ui/thread/use-message-reactions.ts @@ -1,4 +1,4 @@ -import { useAui, useAuiState } from '@assistant-ui/react' +import { useAuiState, useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type MouseEvent, useCallback } from 'react' @@ -106,7 +106,7 @@ export function useTapbackDoubleClick( role: ChatMessage['role'] ): ((event: MouseEvent) => void) | undefined { const enabled = useStore($reactionsEnabled) - const messageRuntime = useAui().message + const messageRuntime = useMessageRuntime() const onDoubleClick = useCallback( (event: MouseEvent) => { diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 3af7ca53c838..dbb90459ccee 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -13,6 +13,7 @@ import { useState } from 'react' +import { ComposerDirectiveActions } from '@/app/chat/composer/directive-actions' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance' import { type ComposerInsertMode, @@ -35,7 +36,6 @@ import { } from '@/app/chat/composer/inline-refs' import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs' import { - COMPOSER_PLACEHOLDER_CLASS, composerPlainText, insertComposerContentsAtCaret, placeCaretEnd, @@ -165,7 +165,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const next = `${base}${sep}${value}` draftRef.current = next - aui.composer.setText(next) + aui.composer().setText(next) const editor = editorRef.current @@ -230,7 +230,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess if (nextDraft !== draftRef.current) { draftRef.current = nextDraft - aui.composer.setText(nextDraft) + aui.composer().setText(nextDraft) } return nextDraft @@ -338,7 +338,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const finish = () => { draftRef.current = composerPlainText(editor) - aui.composer.setText(draftRef.current) + aui.composer().setText(draftRef.current) requestEditFocus() starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger() } @@ -381,7 +381,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess rememberInitialDraft() const nextDraft = composerPlainText(editor) draftRef.current = nextDraft - aui.composer.setText(nextDraft) + aui.composer().setText(nextDraft) requestEditFocus() return true @@ -581,7 +581,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess // and leave revert as the only way out (#49903 is the same unguarded-core // hazard on the main composer). try { - aui.composer.send() + aui.composer().send() } catch { setSubmitting(false) } @@ -624,7 +624,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess // down (a send/cancel raced this timer), cancel() throws "Composer is // not available" as an uncaught renderer error. Nothing to cancel then. try { - aui.composer.cancel() + aui.composer().cancel() } catch { // Composer core already gone — the edit is closing anyway. } @@ -690,7 +690,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess if (event.key === 'Escape') { event.preventDefault() - aui.composer.cancel() + aui.composer().cancel() return } @@ -773,7 +773,6 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess autoCorrect="off" className={cn( 'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none', - COMPOSER_PLACEHOLDER_CLASS, '**:data-ref-text:cursor-default', expanded ? 'min-h-16' : 'min-h-[1.25rem]' )} @@ -795,6 +794,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess spellCheck={false} suppressContentEditableWarning /> + { + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() +}) + +describe('hasTextSelection', () => { + it('is false with nothing highlighted', () => { + expect(hasTextSelection()).toBe(false) + }) + + it('is true once the user has a live range', () => { + const node = document.createElement('span') + node.textContent = 'copy me' + document.body.appendChild(node) + + const range = document.createRange() + range.selectNodeContents(node) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + + expect(hasTextSelection()).toBe(true) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx index 16c6963e248c..2ea43309df9b 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message.tsx @@ -16,6 +16,13 @@ import { cn } from '@/lib/utils' import { notifyThreadEditOpen } from '@/store/thread-scroll' import { isWatchWindow } from '@/store/windows' +/** True when the user has a live text highlight (drag-select / triple-click). */ +export function hasTextSelection(): boolean { + const selection = window.getSelection() + + return Boolean(selection && !selection.isCollapsed && selection.toString().length > 0) +} + export function StickyHumanMessageContainer({ attachments, children, @@ -279,10 +286,16 @@ export const UserMessage: FC<{
{ + if (hasTextSelection()) { + return + } + event.preventDefault() setPickerOpen(true) } @@ -295,7 +308,9 @@ export const UserMessage: FC<{ aria-expanded={bodyClamped ? expanded : undefined} className={cn(bubbleClassName, !bodyClamped && 'cursor-default')} onClick={() => { - if (!bodyClamped) { + // Drag-select ends on mouseup→click; don't collapse the + // clamp just because the highlight finished. + if (hasTextSelection() || !bodyClamped) { return } @@ -310,12 +325,29 @@ export const UserMessage: FC<{ ) : ( // Always editable — clicking opens the edit composer even while a // turn streams; sending the edit reverts (interrupt + rewind). + // A live text highlight wins: finishing a drag-select must not + // open the editor and throw the selection away.