diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ded0ff541..3441d7bb6db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,11 @@ jobs: needs: detect uses: ./.github/workflows/uv-lockfile-check.yml + infographic-check: + name: Check no committed infographics + needs: detect + uses: ./.github/workflows/infographic-check.yml + lockfile-diff: name: package-lock.json diff needs: detect diff --git a/.github/workflows/infographic-check.yml b/.github/workflows/infographic-check.yml new file mode 100644 index 00000000000..288f6f493a0 --- /dev/null +++ b/.github/workflows/infographic-check.yml @@ -0,0 +1,78 @@ +name: Infographic Check + +# Rejects PRs that commit PR-infographic images into the repo. +# +# PR infographics are rendered to an image-provider URL (fal.media) and +# embedded in the PR *description*. The PR body is the archive; the binary +# never belongs in git history. +# +# This has now leaked twice. PR #48261 removed the first batch, PR #54564 +# removed a second batch and added `infographic/` to `.gitignore` — but +# `.gitignore` only stops *accidental* `git add`. It does nothing against +# `git add -f`, and it does nothing for a path that does not literally match +# the ignore pattern. Nine more PNGs (~14MB) were committed in the four +# weeks AFTER that rule landed, plus PR #70552 caught an `infograficos/` +# spelling that sidestepped the pattern entirely. +# +# A passive ignore rule cannot enforce a policy. This check can. + +on: + workflow_call: + outputs: + review_status: + description: "JSON array of review_status objects for the synthesizer." + value: ${{ jobs.check-no-committed-infographics.outputs.review_status }} + +permissions: + contents: read + +jobs: + check-no-committed-infographics: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + review_status: ${{ steps.infographic-check.outputs.review_status }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - id: infographic-check + name: Reject committed PR-infographic images + run: | + # Match on the IMAGE, not on a directory name. Keying this to + # `infographic/` is what let `infograficos/` through in #70552 — + # any localized or typo'd directory would sidestep it again. + # Instead: find tracked raster images whose path contains an + # infographic-ish segment, in any spelling, at any depth. + # + # `docs/assets` and `website/` legitimately hold product imagery + # and are excluded; those are referenced from shipped docs pages. + OFFENDERS=$(git ls-files -z \ + | tr '\0' '\n' \ + | grep -iE '(^|/)(infograph|infograf)[^/]*/' \ + | grep -iE '\.(png|jpe?g|webp|gif)$' \ + || true) + + if [ -n "$OFFENDERS" ]; then + COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ') + STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached \n```\nThen put it in the PR description:\n```\n## Infographic\n\n![slug](https://)\n```\n"}]}]' + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" + echo "" + echo "::error::${COUNT} PR-infographic image(s) are tracked in git." + echo "" + printf '%s\n' "$OFFENDERS" | sed 's/^/ /' + echo "" + echo "PR infographics are rendered to an image-provider URL and" + echo "embedded in the PR DESCRIPTION. The PR body is the archive —" + echo "the binary never enters git history." + echo "" + echo "This rule has been re-established twice already (#48261," + echo "#54564) and leaked both times, because .gitignore cannot stop" + echo "'git add -f' or a differently-spelled directory (#70552)." + echo "" + echo "To fix:" + echo " git rm --cached # keeps your local copy" + echo " # then embed the provider URL in the PR description" + exit 1 + fi + echo "::notice::No committed PR-infographic images." + echo "review_status=[]" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 8c0ce9c23d5..cd05306af00 100644 --- a/.gitignore +++ b/.gitignore @@ -152,6 +152,11 @@ docs/superpowers/* .update-incomplete .update-incomplete.lock +# Checkout fingerprint the __pycache__ tree was last validated against +# (launch-time stale-bytecode sweep). Runtime state, never a code change. +.bytecode-fingerprint +.bytecode-fingerprint.tmp + # Installer-written method stamp in the managed checkout root (scripts/install.sh). # Runtime metadata only — never a code change. Ignore so `git status` stays clean # and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855). @@ -175,5 +180,13 @@ apps/desktop/demo/ # image-provider (fal.media) URL — they are NEVER committed to the repo. The # PR body is the archive. See the hermes-agent-dev skill's # pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1). +# +# Spelling variants are listed because a single `infographic/` pattern was +# sidestepped by an `infograficos/` directory (#70552). .gitignore is only +# the first line of defence and cannot stop `git add -f` at all — the +# infographic-check CI job is what actually enforces this. infographic/ +infographics/ +infograficos/ +infografico/ native/fts5_cjk/*.so diff --git a/AGENTS.md b/AGENTS.md index cb53e95eb0b..d623ba59bbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -325,7 +325,7 @@ class AIAgent: provider: str = None, api_mode: str = None, # "chat_completions" | "codex_responses" | ... model: str = "", # empty → resolved from config/provider later - max_iterations: int = 90, # tool-calling iterations (shared with subagents) + max_iterations: int = 500, # tool-calling iterations (shared with subagents) enabled_toolsets: list = None, disabled_toolsets: list = None, quiet_mode: bool = False, diff --git a/Dockerfile b/Dockerfile index 388056faacd..42870d73776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,45 @@ +# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset +# corruption bug. Build a pinned shared library for the runtime image instead +# of relying on a distro backport that trixie does not currently provide. +# See #70480 and https://sqlite.org/wal.html#walresetbug. +FROM debian:13.4 AS sqlite_build +ARG SQLITE_AUTOCONF_VERSION=3530400 +ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c +RUN apt-get -o Acquire::Retries=3 update && \ + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ + build-essential ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* && \ + (curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \ + -o /tmp/sqlite.tar.gz \ + "https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \ + curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \ + -o /tmp/sqlite.tar.gz \ + "https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \ + printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \ + sha256sum -c /tmp/sqlite.sha256 && \ + tar -xzf /tmp/sqlite.tar.gz -C /tmp && \ + cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \ + CFLAGS="-O2 \ + -DSQLITE_ENABLE_FTS3 \ + -DSQLITE_ENABLE_FTS3_PARENTHESIS \ + -DSQLITE_ENABLE_FTS4 \ + -DSQLITE_ENABLE_FTS5 \ + -DSQLITE_ENABLE_RTREE \ + -DSQLITE_ENABLE_GEOPOLY \ + -DSQLITE_ENABLE_COLUMN_METADATA \ + -DSQLITE_ENABLE_UNLOCK_NOTIFY \ + -DSQLITE_ENABLE_DBSTAT_VTAB \ + -DSQLITE_ENABLE_DBPAGE_VTAB \ + -DSQLITE_ENABLE_MATH_FUNCTIONS \ + -DSQLITE_ENABLE_PREUPDATE_HOOK \ + -DSQLITE_ENABLE_SESSION \ + -DSQLITE_SECURE_DELETE \ + -DSQLITE_THREADSAFE=1 \ + -DSQLITE_MAX_VARIABLE_NUMBER=250000" \ + ./configure --prefix=/opt/sqlite-fixed --disable-static && \ + make -j"$(nproc)" && \ + make install + FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source # Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x # which reached EOL in April 2026 — we copy node + npm + corepack from the @@ -31,6 +73,23 @@ RUN apt-get -o Acquire::Retries=3 update && \ ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \ rm -rf /var/lib/apt/lists/* +# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the +# public library name stable so both the system interpreter and the uv-created +# venv resolve the replacement without changing Python import paths. +COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/ +RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \ + ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \ + printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \ + ldconfig && \ + python3 -c "import sqlite3, sys; \ +v = sqlite3.sqlite_version_info; \ +sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \ +db = sqlite3.connect(':memory:'); \ +db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \ +db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \ +sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \ +db.close()" + # ---------- s6-overlay install ---------- # s6-overlay provides supervision for the main hermes process, the dashboard, # and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT. diff --git a/agent/agent_init.py b/agent/agent_init.py index 955ca11cff8..a99a0de8991 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -455,7 +455,7 @@ def init_agent( command: str = None, args: list[str] | None = None, model: str = "", - max_iterations: int = 90, # Default tool-calling iterations (shared with subagents) + max_iterations: int = 500, # Default tool-calling iterations (shared with subagents) tool_delay: float = 1.0, enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None, @@ -529,7 +529,7 @@ def init_agent( requested_provider (str): Original provider identity before runtime canonicalization api_mode (str): API mode override: "chat_completions" or "codex_responses" model (str): Model name to use (default: "anthropic/claude-opus-4.6") - max_iterations (int): Maximum number of tool calling iterations (default: 90) + max_iterations (int): Maximum number of tool calling iterations (default: 500) tool_delay (float): Delay between tool calls in seconds (default: 1.0) enabled_toolsets (List[str]): Only enable tools from these toolsets (optional) disabled_toolsets (List[str]): Disable tools from these toolsets (optional) @@ -645,6 +645,13 @@ def init_agent( # AWS Bedrock — auto-detect from provider name or base URL # (bedrock-runtime..amazonaws.com). agent.api_mode = "bedrock_converse" + elif agent.provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* → Messages, everything else → + # chat_completions. Callers that already pass api_mode win above; + # this covers direct AIAgent construction without a resolved runtime. + from hermes_cli.providers import nous_api_mode + + agent.api_mode = nous_api_mode(agent.model) else: agent.api_mode = "chat_completions" @@ -951,12 +958,6 @@ def init_agent( agent._stream_writer_tls = threading.local() agent._stream_writer_dropped = 0 - # Displayed reasoning text streamed during the current model response, - # captured only when a surface consumed it via a reasoning callback. Used - # by active-turn redirect to checkpoint what the user actually saw without - # ever persisting hidden provider reasoning. - agent._current_streamed_reasoning_text = "" - # Optional current-turn user-message override used when the API-facing # user message intentionally differs from the persisted transcript # (e.g. CLI voice mode adds a temporary prefix for the live call only). @@ -1155,6 +1156,10 @@ def init_agent( elif base_url_host_matches(effective_base, "chatgpt.com"): from agent.auxiliary_client import _codex_cloudflare_headers client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key) + elif base_url_host_matches(effective_base, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + client_kwargs["default_headers"] = hermes_xai_default_headers() elif "default_headers" not in client_kwargs: # Fall back to profile.default_headers for providers that # declare custom headers (e.g. Kimi User-Agent on non-kimi.com @@ -1952,8 +1957,12 @@ def init_agent( # parent_session_id chain, no `name #N` renumber). See #38763 and # agent/conversation_compression.py. Consumed by compress_context(), not the # compressor, so it rides on the agent. + # Default True must match DEFAULT_CONFIG["compression"]["in_place"] + # (#38763). default=False here previously flipped agents into rotation + # mode whenever the merged config omitted the key (partial configs, + # load_config failure → {}), re-arming the pre-lease drift abort. compression_in_place = is_truthy_value( - _compression_cfg.get("in_place"), default=False + _compression_cfg.get("in_place"), default=True ) codex_app_server_auto_compaction = str( _compression_cfg.get("codex_app_server_auto", "native") or "native" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 6458ad88fbe..b5706cadfec 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1221,7 +1221,15 @@ def try_recover_primary_transport( if agent._is_openrouter_url(): return False provider_lower = (agent.provider or "").strip().lower() - if provider_lower in {"nous", "nous-research"}: + # Portal OpenAI-wire traffic still rides aggregator retry infra, so one + # more rebuilt OpenAI client won't help. Portal Claude on the native + # Messages route holds a local Anthropic SDK client whose connection + # pool *does* need the rebuild every other anthropic_messages provider + # already gets — don't blanket-skip the dual-wire path. + if ( + provider_lower in {"nous", "nous-portal", "nousresearch"} + and getattr(agent, "api_mode", None) != "anthropic_messages" + ): return False try: @@ -1895,7 +1903,15 @@ def anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if (is_openrouter or is_nous_portal) and (is_claude or is_kimi): + # Envelope layout is an OpenAI-wire construct. Portal Claude on the native + # Messages route must fall through to the third-party anthropic_messages + # branch below, which emits inner-block cache_control breakpoints; the + # envelope form would be dropped and serve 0% cache hits. + if ( + (is_openrouter or is_nous_portal) + and (is_claude or is_kimi) + and not is_anthropic_wire + ): return True, False # Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout # cache_control path as Portal Claude. Portal proxies to OpenRouter @@ -2059,8 +2075,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo from hermes_cli.providers import determine_api_mode # ── Determine api_mode if not provided ── + # Pass model so dual-wire providers (Nous Portal anthropic/* → Messages) + # resolve correctly; without it determine_api_mode falls back to the + # openai_chat overlay default. if not api_mode: - api_mode = determine_api_mode(new_provider, base_url) + api_mode = determine_api_mode(new_provider, base_url, model=new_model) # Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing # /v1 into the anthropic_messages client, which would cause the SDK to @@ -2614,6 +2633,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i _clarify_tool( question=next_args.get("question", ""), choices=next_args.get("choices"), + multi_select=next_args.get("multi_select", False), callback=agent.clarify_callback, ), next_args, @@ -2759,6 +2779,129 @@ def repair_tool_call(agent, tool_name: str) -> str | None: +# Placeholder substituted for an empty non-final message that would otherwise +# make the provider reject the whole request. Kept identical to the stub- +# creation placeholder in chat_completion_helpers so a healed transcript reads +# consistently whether the empty turn was caught at write time or send time. +_INTERRUPTED_PLACEHOLDER = "[response interrupted]" + + +def _msg_has_payload(msg: Dict[str, Any]) -> bool: + """True if ``msg`` carries anything the API treats as non-empty content. + + Covers string content, non-empty multimodal content lists, tool_calls, + tool_call_id linkage (tool results), and reasoning payloads. Mirrors the + emptiness checks used by ``AIAgent._is_thinking_only_assistant`` but is + role-agnostic so it can vet user/assistant/tool turns uniformly. + """ + content = msg.get("content") + if isinstance(content, str): + if content.strip(): + return True + elif isinstance(content, list): + for block in content: + if isinstance(block, dict): + # any typed block (text/image/tool_use/document/...) counts, + # as long as a text block is not itself blank + if block.get("type") == "text": + if isinstance(block.get("text"), str) and block["text"].strip(): + return True + continue + return True + elif block: + return True + elif content not in (None, ""): + return True + # Structural payloads that make an "empty-content" message still valid. + if msg.get("tool_calls"): + return True + if isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip(): + return True + if msg.get("reasoning") or msg.get("reasoning_details"): + return True + # Codex Responses item carriers: a commentary-phase assistant turn + # persists with content:"" by DESIGN — its text lives in + # ``codex_message_items`` (delivered via the interim callback) and the + # structured items are replayed for prefix-cache hits. Same for + # ``codex_reasoning_items``. These turns are never wire-empty on any + # api_mode: the codex transport replays the items, and the + # chat-completions transport strips the carriers only after this repair + # pass has already run. Treat them as payload so the repair never + # rewrites a designed-empty codex turn (July 2026: a write-time pad that + # ignored this broke codex commentary replay in CI). + if msg.get("codex_message_items") or msg.get("codex_reasoning_items"): + return True + return False + + +def repair_empty_non_final_messages( + messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Heal empty-content non-final messages before they reach the provider. + + Root-cause context: a stream that dies with 0 recovered characters (peer + reset, stall-kill) could persist an assistant turn with ``content=None`` + and no tool_calls. The Anthropic message schema — and the litellm/Bedrock + proxies in front of it — reject ANY request whose transcript contains an + empty non-final message: + + "all messages must have non-empty content except for the optional + final assistant message" (HTTP 400 INVALID_REQUEST_BODY) + + Once such a message lands mid-transcript it poisons EVERY subsequent turn + of that session until it scrolls out of context. The write-time guard in + ``chat_completion_helpers`` stops NEW stubs, but sessions already carrying + one (persisted before the guard, or fed in from a host history) stay stuck + and previously needed a manual DB edit + gateway restart to recover. + + This pass is the self-healing counterpart: it runs unconditionally on the + per-call ``api_messages`` copy, so a poisoned transcript repairs itself + IN MEMORY on the very next send — no restart, no DB surgery. The final + message is left untouched (an empty final assistant turn is legal). The + stored conversation history is never mutated; only the wire copy is + repaired, so the UI/session trace stays faithful. + + Repair strategy is substitution, not deletion: dropping a mid-transcript + turn can break role alternation and tool-call pairing, whereas an honest + minimal placeholder keeps the sequence intact and reads correctly as an + interrupted turn on replay. + """ + if not messages or len(messages) < 2: + return messages + + repaired: List[Dict[str, Any]] = [] + healed = 0 + last_idx = len(messages) - 1 + for idx, msg in enumerate(messages): + if ( + idx != last_idx + and isinstance(msg, dict) + # tool results are validated by their own orphan/pairing pass; an + # empty tool result is a separate (and rarer) concern. + and msg.get("role") in ("assistant", "user") + and not _msg_has_payload(msg) + ): + # Shallow-copy so stored history / prompt caching stays byte-stable. + fixed = dict(msg) + fixed["content"] = _INTERRUPTED_PLACEHOLDER + repaired.append(fixed) + healed += 1 + else: + repaired.append(msg) + + if healed: + _ra().logger.warning( + "Pre-call sanitizer: healed %d empty non-final message(s) by " + "substituting placeholder content — an empty-content turn was in " + "the transcript and would 400 the request ('messages must have " + "non-empty content' / INVALID_REQUEST_BODY). Self-recovering the " + "poisoned transcript in memory; no restart needed.", + healed, + ) + return repaired + return messages + + def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Fix orphaned tool_call / tool_result pairs before every LLM call. @@ -2779,6 +2922,15 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] filtered.append(msg) messages = filtered + # --- Heal empty-content non-final messages (self-recovery) --- + # A dead stream can leave an empty assistant stub (or an empty user turn) + # mid-transcript; the provider then 400s EVERY subsequent request until it + # scrolls out. Repair it here, on the per-call copy, so a poisoned session + # recovers itself in memory on the next send — no restart, no DB edit. + # Done first so a substituted turn participates normally in the tool-pair + # and dedup passes below. + messages = repair_empty_non_final_messages(messages) + # --- Drop empty / malformed tool_calls arrays on assistant messages --- # An assistant message carrying ``tool_calls: []`` (an empty array) — or a # non-list value under the key — is semantically identical to an assistant diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 7fa3ba391fd..0d59d94c9c3 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -23,7 +23,7 @@ from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple -from utils import base_url_host_matches, normalize_proxy_env_vars +from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars # NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls # ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.) @@ -546,15 +546,49 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: return "/anthropic" in normalized.rstrip("/").lower() +def _is_nous_portal_endpoint(base_url: str | None) -> bool: + """Return True for Nous Portal's Anthropic Messages route. + + Portal serves its ``anthropic/*`` catalog natively at + ``https://inference-api.nousresearch.com/v1/messages``. Portal-specific + behaviours key off this: Bearer JWT auth, verbatim catalog model ids, + and native thinking-signature replay. + + Trusted hosts only: + + 1. Prod hostname ``inference-api.nousresearch.com`` + 2. The operator-set ``NOUS_INFERENCE_BASE_URL`` hostname (staging/preview) + + Lookalikes such as ``inference-api.nousresearch.com.attacker.test`` are + rejected (hostname match, not substring). + """ + if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"): + return True + try: + from hermes_cli.auth import _nous_inference_env_override + + override = _nous_inference_env_override() + except Exception: + return False + if not override: + return False + # Exact host equality (not subdomain) so the env override can't broaden + # into sibling hosts the operator did not set. + override_host = base_url_hostname(override) + return bool(override_host) and base_url_hostname(base_url or "") == override_host + + def _requires_bearer_auth(base_url: str | None) -> bool: """Return True for Anthropic-compatible providers that require Bearer auth. Some third-party /anthropic endpoints implement Anthropic's Messages API but require Authorization: Bearer instead of Anthropic's native x-api-key header. MiniMax's global and China Anthropic-compatible endpoints, Azure AI - Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy - follow this pattern. + Foundry's Anthropic-style endpoint, Palantir Foundry's LLM proxy, and Nous + Portal's Messages route follow this pattern. """ + if _is_nous_portal_endpoint(base_url): + return True normalized = _normalize_base_url_text(base_url) if not normalized: return False @@ -721,7 +755,11 @@ def _build_anthropic_client_with_bearer_hook( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Same env-inference trap as build_anthropic_client: auth_token-only + # construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key. + client.api_key = None + return client def build_anthropic_client( @@ -850,7 +888,16 @@ def build_anthropic_client( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Bearer-only construction leaves ``api_key`` unset, so the SDK fills it + # from ``ANTHROPIC_API_KEY`` (Hermes loads that into the process env from + # ``~/.hermes/.env``). The result is dual auth — + # ``X-Api-Key: sk-ant-…`` *and* ``Authorization: Bearer `` — + # on every Portal / MiniMax / OAuth Messages request. Clear the env-filled + # key whenever we intentionally authenticated via auth_token alone. + if "auth_token" in kwargs and "api_key" not in kwargs: + client.api_key = None + return client def build_anthropic_bedrock_client(region: str): @@ -2060,6 +2107,28 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: _apply_assistant_cache_control_to_last_cacheable_block( replayed, m.get("cache_control") ) + # apply_anthropic_cache_control marks an assistant turn with + # non-empty text by writing cache_control INTO ``content`` (see + # _apply_cache_marker's list branch), not at the top level. This + # branch rebuilds the message from ordered_blocks and never reads + # ``content``, so that marker would be dropped -- and because + # _can_carry_marker already counted this message as a carrier, the + # breakpoint is burned rather than relocated. #56195 covered the + # complementary shape (blank content -> top-level marker); this is + # the interleaved thinking + preamble-text + tool_use shape. + _inline_cc = None + _msg_content = m.get("content") + if isinstance(_msg_content, list): + for _blk in _msg_content: + if isinstance(_blk, dict) and isinstance( + _blk.get("cache_control"), dict + ): + _inline_cc = _blk["cache_control"] + break + if _inline_cc is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + replayed, _inline_cc + ) return {"role": "assistant", "content": replayed} blocks = _extract_preserved_thinking_blocks(m) @@ -2394,10 +2463,22 @@ def _manage_thinking_signatures( replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and hermes-agent#16748 (DeepSeek). + Nous Portal's ``/v1/messages`` route is the exception among third-party + hosts: it proxies Claude to Anthropic/Vertex/Bedrock and validates the + same signed thinking blocks. Sticky ``session_id`` keeps a conversation + on one upstream instance so those signatures stay warm — stripping them + here would 400 the first tool-loop turn ("thinking must be passed back"). + Portal therefore takes the native Anthropic replay path below. + Mutates ``result`` in place. """ _THINKING_TYPES = frozenset(("thinking", "redacted_thinking")) - _is_third_party = _is_third_party_anthropic_endpoint(base_url) + # Portal speaks Anthropic's thinking contract end-to-end; do not treat it + # as a signature-blind proxy even though the host is not anthropic.com. + _is_third_party = ( + _is_third_party_anthropic_endpoint(base_url) + and not _is_nous_portal_endpoint(base_url) + ) last_assistant_idx = None for i in range(len(result) - 1, -1, -1): @@ -2654,7 +2735,12 @@ def build_anthropic_kwargs( ) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] - model = normalize_model_name(model, preserve_dots=preserve_dots) + # Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``); + # normalizing to the bare Anthropic slug would make the model unresolvable + # there. Skipping the call preserves the prefix AND the dots, so + # ``preserve_dots`` stays irrelevant for Portal. + if not _is_nous_portal_endpoint(base_url): + model = normalize_model_name(model, preserve_dots=preserve_dots) # effective_max_tokens = output cap for this call (≠ total context window) # Use the resolver helper so non-positive values (negative ints, # fractional floats, NaN, non-numeric) fail locally with a clear error @@ -2894,6 +2980,7 @@ def create_anthropic_message( log_prefix: str = "", prefer_stream: bool = True, on_stream_event=None, + on_response=None, ) -> Any: """Create an Anthropic message, aggregating via stream when available. @@ -2910,6 +2997,13 @@ def create_anthropic_message( progress hook so a slow-but-generating summary model isn't treated as hung. Only fires on the streaming path; the ``create()`` fallback has no events to report. + + ``on_response``: optional callable invoked once with the underlying httpx + response before the message is aggregated (best-effort, exceptions + swallowed). Response *headers* carry out-of-band provider state that the + parsed ``Message`` drops — Nous Portal's ``x-nous-credits-*`` balance family + in particular. Only fires on the streaming path, which is the one the main + turn loop takes. """ sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) @@ -2920,6 +3014,14 @@ def create_anthropic_message( stream_kwargs.pop("stream", None) try: with stream_fn(**stream_kwargs) as stream: + if callable(on_response): + try: + on_response(getattr(stream, "response", None)) + except Exception: + logger.debug( + "%son_response callback failed", + log_prefix, exc_info=True, + ) if callable(on_stream_event): # Consume the event stream manually so each event can # tick the caller's progress callback; get_final_message diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 74ccd578129..fa5bc6b80d7 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1362,10 +1362,32 @@ class AsyncCodexAuxiliaryClient: class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" - def __init__(self, real_client: Any, model: str, is_oauth: bool = False): + def __init__( + self, + real_client: Any, + model: str, + is_oauth: bool = False, + base_url: str | None = None, + ): self._client = real_client self._model = model self._is_oauth = is_oauth + # Prefer the caller-supplied URL (AnthropicAuxiliaryClient keeps the + # pre-strip Portal ``.../v1`` form). Only fall back to the SDK + # client's host for Nous Portal — a blanket fallback would flip + # MiniMax/Zhipu/etc. aux adapters from "unknown host = native + # Anthropic" to third-party (stripping thinking signatures). + self._base_url = base_url or None + if not self._base_url: + candidate = str(getattr(real_client, "base_url", "") or "") or None + if candidate: + try: + from agent.anthropic_adapter import _is_nous_portal_endpoint + + if _is_nous_portal_endpoint(candidate): + self._base_url = candidate + except Exception: + pass def create(self, **kwargs) -> Any: from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message @@ -1417,6 +1439,11 @@ class _AnthropicCompletionsAdapter: reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, + # Portal routes on ``anthropic/`` catalog ids and replays + # signed thinking like native Anthropic; both carve-outs key off + # base_url. Omitting it normalizes the id to a bare Anthropic + # slug and the Portal Messages route cannot resolve it. + base_url=self._base_url, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -1510,7 +1537,9 @@ class AnthropicAuxiliaryClient: def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False): self._real_client = real_client - adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth) + adapter = _AnthropicCompletionsAdapter( + real_client, model, is_oauth=is_oauth, base_url=base_url, + ) self.chat = _AnthropicChatShim(adapter) self.api_key = api_key self.base_url = base_url @@ -2765,7 +2794,13 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str return None, None api_key, base_url = resolved logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model) - real_client = _create_openai_client(api_key=api_key, base_url=base_url) + from tools.xai_http import hermes_xai_default_headers + + real_client = _create_openai_client( + api_key=api_key, + base_url=base_url, + default_headers=hermes_xai_default_headers(), + ) return CodexAuxiliaryClient(real_client, model), model @@ -4188,6 +4223,7 @@ def _try_main_agent_model_fallback( failed_provider: str, task: str = None, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Last-resort fallback to the user's main agent provider + model. @@ -4196,8 +4232,23 @@ def _try_main_agent_model_fallback( layer: if nothing the user asked for can serve the request, try the main chat model before giving up. - Skips when the failed provider already IS the main provider (no point - retrying the same backend that just failed). + ``failed_model`` narrows the same-provider skip to the exact + (provider, model) pair that just failed, mirroring + :func:`_try_configured_fallback_chain`. This matters for self-hosted / + custom endpoints serving several models behind one provider label: the + aux compression model timing out says nothing about the health of the + main agent model deployed on the same URL (real incident: aux + ``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the + identical endpoint was serving 448K-token turns fine — the + provider-label skip discarded the one fallback that would have worked). + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model``: skip the + main model only when it IS the exact model that failed. + - Provider-wide failures (auth 401, payment 402) and legacy callers + leave ``failed_model`` as None, keeping the whole-provider skip — + the shared credentials/account are broken, so the main model on the + same provider cannot help either. Returns: (client, model, provider_label) or (None, None, "") if no fallback. @@ -4214,9 +4265,23 @@ def _try_main_agent_model_fallback( if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: return None, None, "" - skip = (failed_provider or "").lower().strip() - if main_provider.lower() == skip: - # The thing that failed IS the main model — nothing to fall back to. + # Identity + scope semantics owned by agent.backend_identity (#72468): + # model-scoped failures skip only the exact deployment that failed; + # provider-wide failures (no failed_model) skip the credential surface. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + skip_model = (failed_model or "").strip().lower() or None + if should_skip_candidate( + BackendIdentity.build(provider=main_provider, model=main_model), + BackendIdentity.build(provider=failed_provider, model=skip_model), + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL, + ): + # The thing that failed IS the main model (or the failure was + # provider-wide) — nothing to fall back to. return None, None, "" if _is_provider_unhealthy(main_provider): _log_skip_unhealthy(main_provider, task) @@ -4326,6 +4391,7 @@ def _try_configured_fallback_chain( task: str, failed_provider: str, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Try user-configured fallback_chain for a specific auxiliary task. @@ -4333,6 +4399,25 @@ def _try_configured_fallback_chain( entry in order. Each entry must have at least ``provider``; ``model``, ``base_url``, and ``api_key`` are optional. + ``failed_model`` narrows the skip check to the exact (provider, model) + pair that just failed, rather than the whole provider. Without it every + entry sharing the failed provider is skipped (the original behaviour). + Callers pass it only when a sibling model on the same provider could + plausibly recover: + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model`` so a + chain that intentionally lists several models under the same provider + — e.g. two more NVIDIA NIM models after the primary NIM model times + out — is not skipped wholesale. Only the exact model that failed is + skipped; the siblings still run instead of jumping straight to the + main-agent-model safety net. + - Provider-wide failures (auth 401, payment 402) and "no client could + be built" callers leave ``failed_model`` as None, keeping the whole + provider skipped — the shared credentials/account behind every model + on that provider are broken, so a sibling can't help and the + main-agent-model safety net should be reached instead. + Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ @@ -4344,7 +4429,24 @@ def _try_configured_fallback_chain( if not chain or not isinstance(chain, list): return None, None, "" - skip = failed_provider.lower().strip() + skip_model = (failed_model or "").strip().lower() or None + # Identity + scope semantics owned by agent.backend_identity (#59561, + # #72468): a failed_model means the failure was model-scoped (timeout / + # connection / rate limit) — only the exact deployment is skipped; no + # failed_model means provider-wide (auth/payment) — the whole credential + # surface is skipped. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + failed_ident = BackendIdentity.build( + provider=failed_provider, model=skip_model, + ) + failure_scope = ( + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL + ) tried = [] min_ctx = _task_minimum_context_length(task) @@ -4352,9 +4454,20 @@ def _try_configured_fallback_chain( if not isinstance(entry, dict): continue fb_provider = str(entry.get("provider", "")).strip() - if not fb_provider or fb_provider.lower() == skip: + if not fb_provider: continue - fb_model = str(entry.get("model", "")).strip() or None + fb_model_raw = str(entry.get("model", "")).strip() + if should_skip_candidate( + BackendIdentity.build( + provider=fb_provider, + model=fb_model_raw, + base_url=str(entry.get("base_url") or ""), + ), + failed_ident, + failure_scope, + ): + continue + fb_model = fb_model_raw or None label = f"fallback_chain[{i}]({fb_provider})" @@ -4785,6 +4898,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"): async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url) + elif base_url_host_matches(sync_base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + async_kwargs["default_headers"] = hermes_xai_default_headers() else: # Fall back to profile.default_headers for providers that declare # client-level headers on their ProviderProfile (e.g. attribution @@ -5021,10 +5138,11 @@ def resolve_provider_client( # ── Nous Portal (OAuth) ────────────────────────────────────────── if provider == "nous": - # Detect vision tasks: either explicit model override from - # _PROVIDER_VISION_MODELS, or caller passed a known vision model. + # Detect vision tasks: caller flag (strict vision backend), explicit + # model override from _PROVIDER_VISION_MODELS, or a known vision id. _is_vision = ( - model in _PROVIDER_VISION_MODELS.values() + is_vision + or model in _PROVIDER_VISION_MODELS.values() or (model or "").strip().lower() == "mimo-v2-omni" ) client, default = _try_nous(vision=_is_vision) @@ -5033,6 +5151,17 @@ def resolve_provider_client( "but Nous Portal not configured (run: hermes auth)") return None, None final_model = _normalize_resolved_model(model or default, provider) + # Dual-wire: anthropic/* → /v1/messages, everything else stays on + # /chat/completions. Derive from the catalog id (not a stale + # api_mode=chat_completions) so aux matches the main agent. + from hermes_cli.providers import nous_api_mode + + portal_mode = nous_api_mode(final_model) + api_key_str = str(getattr(client, "api_key", "") or "") + base_url_str = str(getattr(client, "base_url", "") or "") + client = _maybe_wrap_anthropic( + client, final_model, api_key_str, base_url_str, portal_mode, + ) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -5396,6 +5525,10 @@ def resolve_provider_client( )) elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): headers.update(build_nvidia_nim_headers(base_url)) + elif base_url_host_matches(base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + headers.update(hermes_xai_default_headers()) else: # Fall back to profile.default_headers for providers that declare # client-level attribution headers on their profile (e.g. GMI @@ -5689,7 +5822,10 @@ def _resolve_strict_vision_backend( if provider == "openrouter": return _try_openrouter(model=model) if provider == "nous": - return _try_nous(vision=True) + # Must go through resolve_provider_client so anthropic/* vision + # recommendations wrap onto /v1/messages — _try_nous alone returns + # a bare OpenAI client and the call 404s. + return resolve_provider_client("nous", model, is_vision=True) if provider == "openai-codex": # Route through resolve_provider_client so the caller's explicit # model is used. There is no safe default Codex model (shifting @@ -6950,8 +7086,14 @@ def _build_call_kwargs( _is_gemini_native = is_native_gemini_base_url(_effective_base) except Exception: pass + _nous_on_messages = False + if _provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( _is_anthropic_compat_endpoint(provider, _effective_base) + or _nous_on_messages or _is_nvidia_nim or _is_moa or _is_gemini_native @@ -7046,21 +7188,43 @@ def _build_call_kwargs( else: effort = reasoning_config.get("effort") or "medium" merged_extra["reasoning"] = {"enabled": True, "effort": effort} - if provider == "nous" and "tags" not in merged_extra: - merged_extra["tags"] = _nous_portal_tags() + # Portal product tags + sticky session_id. The provider profile usually + # supplies both; this fallback covers profile-load failures and alias + # spellings the profile lookup might miss. session_id keeps aux + # compression/title/vision calls on the same upstream instance as the + # main turn (cache warmth) — tags alone are not enough on /v1/messages. + _provider_for_portal = str(provider or "").strip().lower() + if _provider_for_portal in {"nous", "nous-portal", "nousresearch"}: + if "tags" not in merged_extra: + merged_extra["tags"] = _nous_portal_tags() + if "session_id" not in merged_extra: + try: + from agent.portal_tags import get_conversation_context + + sticky_key = get_conversation_context() + except Exception: + sticky_key = None + if sticky_key: + merged_extra["session_id"] = sticky_key if merged_extra: kwargs["extra_body"] = merged_extra - # Native Anthropic Messages adapters do not consume ``extra_body``. Carry - # the normalized Hermes reasoning config through a private kwarg so the - # adapter can pass it into build_anthropic_kwargs(), where provider-aware - # thinking/output_config projection lives. Do not expose this private kwarg - # to ordinary OpenAI-compatible SDK clients, which would reject it. + # Anthropic Messages adapters translate Hermes reasoning into native + # ``thinking`` via a private kwarg (and strip OpenAI-shaped + # ``extra_body.reasoning``). Do not expose this private kwarg to ordinary + # OpenAI-compatible SDK clients, which would reject it. Portal Claude is + # dual-wire — include it when the catalog id selects /v1/messages. if reasoning_config and isinstance(reasoning_config, dict): provider_norm = str(provider or "").strip().lower() effective_base = base_url or "" + _nous_on_messages = False + if provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( provider_norm == "anthropic" + or _nous_on_messages or _endpoint_speaks_anthropic_messages(effective_base) or _is_anthropic_compat_endpoint(provider_norm, effective_base) ): @@ -8078,6 +8242,15 @@ def call_llm( logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -8086,7 +8259,8 @@ def call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8095,10 +8269,12 @@ def call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: fb_resp = _call_fallback_candidate_sync( @@ -8612,6 +8788,15 @@ async def async_call_llm( logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -8620,7 +8805,8 @@ async def async_call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8629,10 +8815,12 @@ async def async_call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: # Convert sync fallback client to async diff --git a/agent/backend_identity.py b/agent/backend_identity.py new file mode 100644 index 00000000000..7a7e9efb6bf --- /dev/null +++ b/agent/backend_identity.py @@ -0,0 +1,204 @@ +"""Single owner for backend identity and failure-scoped skip decisions. + +Every fallback / dedup / skip / quarantine decision in Hermes ultimately asks +one question: **"is this candidate the same backend as the one that failed, +along the axis that failure invalidated?"** Before this module, that +question was re-implemented inline at six call sites across four subsystems, +each comparing whatever string was locally convenient (provider label, +provider+model, base_url+model, ...). Each incident fixed one site while the +others kept the bug: #22548 (same-shim aliases), #70893 (xai-oauth vs xai — +same host, distinct credential), #59561 (aux chain skipped sibling models), +#72468 (aux main-model safety net, same bug three weeks later), #62984 / +#54250 / #57584 (dedup ignoring base_url strands multi-endpoint pools). + +The root insight: "provider" conflates three independent identity axes, and +each failure class invalidates a different one: + +* **credential surface** — auth 401 / payment 402 kill everything sharing the + credential (every model, every host reached with that key/token). +* **endpoint** — DNS failure / connection refused kill everything behind the + URL, regardless of model or credential. +* **model deployment** — timeout / overload / rate limit / model-incompatible + kill ONE model's deployment. A sibling model behind the same URL is an + independent deployment (real incident: aux ``glm-5.2`` hung and timed out + while main ``macaron-v1-venti`` on the identical endpoint was serving + 448K-token turns). + +Call sites should build :class:`BackendIdentity` values, classify the failure +with :func:`classify_failure_scope`, and ask :func:`should_skip_candidate`. +Do not re-implement any comparison inline — extend THIS module instead. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +logger = logging.getLogger(__name__) + + +class FailureScope(Enum): + """Which identity axis a failure invalidates.""" + + #: Timeout, overload/429, connection blip, model-incompatible, invalid + #: response: evidence against ONE model deployment only. + MODEL = "model" + #: Auth 401 / payment 402: evidence against the shared credential — + #: every model reached with it is equally dead. + CREDENTIAL = "credential" + #: DNS / connection-refused / unreachable host: evidence against the + #: endpoint — every model behind the URL is equally dead. + ENDPOINT = "endpoint" + + +#: Reason strings already used by auxiliary_client's except-chain, mapped to +#: scopes. Unknown reasons default to MODEL — the least-invalidating scope — +#: so an unrecognized failure never over-skips viable candidates. +_REASON_SCOPES = { + "auth error": FailureScope.CREDENTIAL, + "payment error": FailureScope.CREDENTIAL, + "rate limit": FailureScope.MODEL, + "model incompatible with route": FailureScope.MODEL, + "invalid provider response": FailureScope.MODEL, + "connection error": FailureScope.MODEL, + "timeout": FailureScope.MODEL, +} + + +def classify_failure_scope(reason: Optional[str]) -> FailureScope: + """Map a human-readable failure reason to the identity axis it kills.""" + return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL) + + +def _norm_provider(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_model(value: Optional[str]) -> str: + return (value or "").strip().lower() + + +def _norm_base_url(value: Optional[str]) -> str: + return (value or "").strip().rstrip("/").lower() + + +@dataclass(frozen=True) +class BackendIdentity: + """Normalized identity of one (provider, model, endpoint) deployment. + + Empty fields mean "unknown" — comparisons treat an unknown axis as + non-distinguishing (it can neither prove sameness nor difference on its + own; the remaining axes decide). + """ + + provider: str = "" + model: str = "" + base_url: str = "" + + @classmethod + def build( + cls, + provider: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> "BackendIdentity": + return cls( + provider=_norm_provider(provider), + model=_norm_model(model), + base_url=_norm_base_url(base_url), + ) + + +def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool: + """True when both providers are distinct registered first-class providers. + + Two different registry providers have distinct credential surfaces even + when they share an inference host (xai-oauth vs xai, openai-codex vs + openai-api) — #70893. Custom/shim aliases are NOT in the registry, so + two aliases pointing at one URL still count as the same backend (#22548). + """ + if not a.provider or not b.provider or a.provider == b.provider: + return False + try: + from hermes_cli.auth import PROVIDER_REGISTRY + + return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY + except Exception: + return False + + +def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities share the credential a 401/402 just invalidated? + + Conservative on purpose: an unprovable axis must answer "different" + (try the candidate — worst case one wasted RTT) rather than "same" + (skip — worst case stranded failover). Two distinct custom labels at + one URL may carry different per-entry api_keys, so a shared URL alone + never proves a shared credential; it is only used as a weak signal + when a provider label is missing entirely. + """ + if a.provider and b.provider: + # Same label = same configured credential. Different labels = + # different credential config (first-class registry providers + # explicitly so — #70893; custom entries can each carry their own + # api_key, so sameness is unprovable and we must not skip). + return a.provider == b.provider + # Provider unknown on a side: same explicit URL is the best signal left. + return bool(a.base_url and a.base_url == b.base_url) + + +def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool: + """Do two identities sit behind the endpoint that just went unreachable?""" + if a.base_url and b.base_url: + return a.base_url == b.base_url + # An unknown base_url inherits the provider default → same provider + # label implies the same default endpoint. + return bool(a.provider and a.provider == b.provider) + + +def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool: + """Are these the exact same model deployment (the thing a timeout kills)? + + Provider+model must match; the base_url axis distinguishes only when BOTH + sides carry an explicit URL (#62984: same provider+model on two different + explicit URLs is two deployments — a pool). A side with an unknown URL + inherits the provider default and cannot prove difference. + """ + if not (a.provider and b.provider and a.provider == b.provider): + # Same-host different-label shims: same URL + same model IS the same + # deployment even when the alias labels differ (#22548) — unless both + # labels are first-class registry providers (#70893). + if ( + a.base_url + and a.base_url == b.base_url + and a.model + and a.model == b.model + and not _both_first_class(a, b) + ): + return True + return False + if not (a.model and b.model and a.model == b.model): + return False + if a.base_url and b.base_url and a.base_url != b.base_url: + return False # distinct explicit endpoints — a pool, not a dup + return True + + +def should_skip_candidate( + candidate: BackendIdentity, + failed: BackendIdentity, + scope: FailureScope = FailureScope.MODEL, +) -> bool: + """THE skip predicate: would trying ``candidate`` just repeat the failure? + + True when the candidate is the same backend as ``failed`` along the axis + ``scope`` says the failure invalidated. Every fallback/dedup/skip site + must call this instead of comparing labels inline. + """ + if scope is FailureScope.CREDENTIAL: + return same_credential_surface(candidate, failed) + if scope is FailureScope.ENDPOINT: + return same_endpoint(candidate, failed) + return same_deployment(candidate, failed) diff --git a/agent/billing_view.py b/agent/billing_view.py index a535aee1f14..29e8068c227 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -107,6 +107,22 @@ class CardInfo: return f"{self.masked} — {label}" if label else self.masked +@dataclass(frozen=True) +class PaymentMethodInfo: + """The payment method on file. `kind` is "card", "link", or "unknown" + — anything else is normalised to "unknown" at parse time, so consumers + only ever see fields that belong to the kind they are looking at.""" + + kind: str + brand: Optional[str] = None + last4: Optional[str] = None + wallet: Optional[str] = None + email: Optional[str] = None + resolved_via: Optional[str] = None + #: What the server called it, when we did not recognise the kind. + raw_kind: Optional[str] = None + + @dataclass(frozen=True) class MonthlyCap: limit_usd: Optional[Decimal] = None @@ -150,6 +166,7 @@ class BillingState: min_usd: Optional[Decimal] = None max_usd: Optional[Decimal] = None card: Optional[CardInfo] = None + payment_method: Optional[PaymentMethodInfo] = None monthly_cap: Optional[MonthlyCap] = None auto_reload: Optional[AutoReload] = None portal_url: Optional[str] = None @@ -201,6 +218,41 @@ def _parse_card(raw: Any) -> Optional[CardInfo]: return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via) +def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]: + if not isinstance(raw, dict): + return None + kind = raw.get("kind") + if not isinstance(kind, str): + return None + + def _optional_string(key: str) -> Optional[str]: + value = raw.get(key) + return value if isinstance(value, str) else None + + resolved_via = _optional_string("resolvedVia") + brand = _optional_string("brand") + last4 = _optional_string("last4") + # Settle the kind here, the way _parse_card settles a card, so nothing + # downstream has to re-check which fields this kind is allowed to have. + if kind == "card" and brand and last4: + return PaymentMethodInfo( + kind="card", + brand=brand, + last4=last4, + wallet=_optional_string("wallet"), + resolved_via=resolved_via, + ) + if kind == "link": + return PaymentMethodInfo( + kind="link", + email=_optional_string("email"), + resolved_via=resolved_via, + ) + return PaymentMethodInfo( + kind="unknown", raw_kind=kind, resolved_via=resolved_via + ) + + def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]: if not isinstance(raw, dict): return None @@ -274,6 +326,7 @@ def billing_state_from_payload( min_usd=parse_money(bounds.get("minUsd")), max_usd=parse_money(bounds.get("maxUsd")), card=_parse_card(payload.get("card")), + payment_method=_parse_payment_method(payload.get("paymentMethod")), monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")), auto_reload=_parse_auto_reload(payload.get("autoReload")), portal_url=portal_url, diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 3689b4d5be8..5fecd0e26cf 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -188,6 +188,31 @@ def _provider_preferences_for_agent(agent) -> Dict[str, Any]: return preferences +def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dict: + """Merge Portal ``tags`` / ``session_id`` onto an Anthropic Messages kwargs dict. + + The Nous provider profile is only consulted by the OpenAI-wire transport; + anthropic_messages callers must merge it themselves. Passes ``session_id`` + only — not ``provider_preferences`` (those become a top-level ``provider`` + routing object on the OpenAI wire). Never blocks a turn on tagging. + """ + if getattr(agent, "provider", None) not in {"nous", "nous-portal", "nousresearch"}: + return anthropic_kwargs + try: + from providers import get_provider_profile + + nous_profile = get_provider_profile("nous") + if nous_profile is not None: + anthropic_kwargs.setdefault("extra_body", {}).update( + nous_profile.build_extra_body( + session_id=getattr(agent, "session_id", None) + ) + ) + except Exception as exc: # noqa: BLE001 — never block a turn on tagging + logger.debug("Nous Portal extra_body merge failed: %s", exc) + return anthropic_kwargs + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -433,26 +458,56 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client): def should_use_direct_api_call(agent) -> bool: - """Whether a cron OpenAI-wire request should skip the interrupt worker. + """Whether an OpenAI-wire request should skip the interrupt worker. - Issue #62151 is specific to OpenRouter's chat-completions path inside the - gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their - established workers: their cancellation and client ownership differ, and - the report provides no evidence that those paths share the pre-HTTP wedge. + Two nested-pool contexts wedge before the socket opens when the request + is pushed onto yet another daemon worker thread: + + - Gateway cron turns (#62151): gateway asyncio loop → cron thread → + interrupt worker. Fixed by running inline. + - Delegated children (#60203): gateway loop → async-delegation executor + (module-lifetime daemon pool) → per-child timeout executor → interrupt + worker. Same fingerprint after multi-day gateway uptime — children hang + at their FIRST API call with zero stale-detector output (the worker + never reaches dispatch), all providers, restart cures it. The cron fix + originally excluded delegation "for lack of evidence"; #60203 is that + evidence. + + Running inline drops the deepest thread layer (whose only job is + interactive-interrupt responsiveness). Interrupts still work: the inline + path registers ``agent._active_request_abort``, which ``interrupt()`` + invokes cross-thread to shut the active sockets — the same mechanism the + async-delegation stall monitor (#72227) relies on. + + Keep native/Codex/Bedrock/MoA transports on their established workers: + their cancellation and client ownership differ. """ - return ( - getattr(agent, "platform", None) == "cron" - and getattr(agent, "api_mode", None) == "chat_completions" - and getattr(agent, "provider", None) != "moa" - ) + if getattr(agent, "api_mode", None) != "chat_completions": + return False + if getattr(agent, "provider", None) == "moa": + return False + if getattr(agent, "platform", None) == "cron": + return True + # Delegated child (delegate_task sync or background) — detected via the + # execution ContextVar set by _run_single_child, with the agent's own + # platform stamp as a fallback for callers that bypass the runner. + try: + from agent.delegation_context import is_delegated_child_context + + if is_delegated_child_context(): + return True + except Exception: + pass + return getattr(agent, "platform", None) == "subagent" def direct_api_call(agent, api_kwargs: dict): """Run a non-streaming LLM call inline on the conversation thread. - Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker - (whose only job is interactive-interrupt responsiveness, which this context - does not have) so the nested-pool deadlock (#62151) cannot occur. Because the + Used when ``should_use_direct_api_call`` is True (cron turns and + delegated children). Skips the interrupt worker (whose only job is + interactive-interrupt responsiveness, which these contexts do not have) + so the nested-pool deadlock (#62151, #60203) cannot occur. Because the request runs in-flight normally, the per-request OpenAI client's own httpx timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds a genuinely hung provider — the same bound interactive calls already rely on. @@ -463,7 +518,7 @@ def direct_api_call(agent, api_kwargs: dict): request_client_lock = threading.Lock() def _abort_active_request(reason: str) -> None: - """Abort the inline request from cron's watchdog/interrupt thread.""" + """Abort the inline request from a watchdog/interrupt thread.""" with request_client_lock: request_client = request_client_holder["client"] if request_client is not None: @@ -993,7 +1048,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None) if ephemeral_out is not None: agent._ephemeral_max_output_tokens = None # consume immediately - return _transport.build_kwargs( + anthropic_kwargs = _transport.build_kwargs( model=agent.model, messages=anthropic_messages, tools=tools_for_api, @@ -1006,6 +1061,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict: fast_mode=(agent.request_overrides or {}).get("speed") == "fast", drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)), ) + # Nous Portal reads ``tags`` and ``session_id`` as top-level body fields + # on its Messages route the same way it does on /chat/completions, but + # the profile hook that produces them is only consulted by the + # OpenAI-wire transport. Merge them here so Messages traffic keeps + # product attribution and sticky routing. + return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs) # AWS Bedrock native Converse API — bypasses the OpenAI client entirely. # The adapter handles message/tool conversion and boto3 calls directly. @@ -1312,6 +1373,17 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic from agent.redact import redact_sensitive_text _san_content = redact_sensitive_text(_san_content) + # NOTE (empty-content class fix): textless assistant turns are NOT padded + # here. The single owner for "never send a turn strict wire validation + # rejects as empty" is ``repair_empty_non_final_messages`` in + # agent_runtime_helpers, which runs inside ``sanitize_api_messages`` — the + # unconditional pre-send chokepoint for both the main loop and the summary + # path. Padding at write time was tried (a single-space pad, later a + # placeholder) and rejected: it forked the concept across three sites, + # broke codex commentary turns (content:'' is a designed state there), and + # a DB-side pad can't survive ``_rows_to_conversation``'s whitespace strip + # anyway. Repair belongs at the send boundary, once. + msg = { "role": "assistant", "content": _san_content, @@ -1514,45 +1586,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]: ) -def _fallback_entry_is_same_backend_by_base_url( - *, - current_provider: str, - fb_provider: str, - current_base_url: str, - fb_base_url: str, - current_model: str, - fb_model: str, -) -> bool: - """True when base_url+model identity means the fallback is the same backend. - - Issue #22548: two ``custom_providers`` aliases that point at the same shim - URL with the same model must be skipped, or failover loops on the dead - backend. First-class providers that share a host while using different - auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are - distinct credential surfaces — skipping them strands configured failover - when primary and fallback reuse the same model slug on that host. - """ - if not ( - fb_base_url - and current_base_url - and fb_base_url == current_base_url - and fb_model == current_model - ): - return False - if fb_provider == current_provider: - return True - try: - from hermes_cli.auth import PROVIDER_REGISTRY - - # Both sides are registered first-class providers → different auth - # identities even when the inference host matches. Allow failover. - if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY: - return False - except Exception: - pass - return True - - def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]: """Return a skip reason for fallback entries known to be unusable locally.""" fb_provider = (fb.get("provider") or "").strip().lower() @@ -1638,33 +1671,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool ) return agent._try_activate_fallback(reason) - # Skip entries that resolve to the current (provider, model) — falling - # back to the same backend that just failed loops the failure. Compare - # base_url too so two distinct custom_providers entries pointing at the - # same shim/proxy URL also dedup. See issue #22548. Do NOT treat - # first-class providers that share a host (xai-oauth vs xai) as the same - # backend — they use different credentials. - current_provider = (getattr(agent, "provider", "") or "").strip().lower() - current_model = (getattr(agent, "model", "") or "").strip() - current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower() - fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower() - if fb_provider == current_provider and fb_model == current_model: + # Skip entries that resolve to the same backend that just failed — + # falling back to it loops the failure. Identity semantics (which axes + # distinguish two backends, shim aliases, first-class credential + # surfaces, multi-endpoint pools) are owned by agent.backend_identity — + # see #22548, #70893, #62984. Do not re-implement comparisons here. + from agent.backend_identity import BackendIdentity, should_skip_candidate + + current_ident = BackendIdentity.build( + provider=getattr(agent, "provider", ""), + model=getattr(agent, "model", ""), + base_url=str(getattr(agent, "base_url", "") or ""), + ) + fb_ident = BackendIdentity.build( + provider=fb_provider, + model=fb_model, + base_url=(fb.get("base_url") or ""), + ) + if should_skip_candidate(fb_ident, current_ident): logger.warning( - "Fallback skip: chain entry %s/%s matches current provider/model", - fb_provider, fb_model, - ) - return agent._try_activate_fallback(reason) - if _fallback_entry_is_same_backend_by_base_url( - current_provider=current_provider, - fb_provider=fb_provider, - current_base_url=current_base_url, - fb_base_url=fb_base_url_for_dedup, - current_model=current_model, - fb_model=fb_model, - ): - logger.warning( - "Fallback skip: chain entry base_url %s matches current backend", - fb_base_url_for_dedup, + "Fallback skip: chain entry %s/%s resolves to the same backend " + "as the current one (%s)", + fb_provider, fb_model, current_ident.base_url or current_ident.provider, ) return agent._try_activate_fallback(reason) @@ -1715,6 +1743,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool _fb_is_azure = agent._is_azure_openai_url(fb_base_url) if fb_provider == "openai-codex": fb_api_mode = "codex_responses" + elif fb_provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* must land on /v1/messages. + # resolve_provider_client still returns an OpenAI client for + # Nous; the anthropic_messages branch below rebuilds the native + # client from that credential + base_url. + from hermes_cli.providers import nous_api_mode + + fb_api_mode = nous_api_mode(fb_model) elif ( fb_provider == "anthropic" or fb_base_url.rstrip("/").lower().endswith("/anthropic") @@ -2141,7 +2177,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, is_oauth=agent._is_anthropic_oauth, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw = _merge_nous_portal_messages_extra_body(agent, _ant_kw) summary_response = agent._anthropic_messages_create(_ant_kw) _summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_summary_result.content or "").strip() @@ -2171,7 +2209,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None, is_oauth=agent._is_anthropic_oauth, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw2 = _merge_nous_portal_messages_extra_body(agent, _ant_kw2) retry_response = agent._anthropic_messages_create(_ant_kw2) _retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_retry_result.content or "").strip() @@ -3889,6 +3929,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= result["error"], ) _stub_finish_reason = FINISH_REASON_LENGTH + # NOTE (empty-content class fix): the stub is deliberately allowed + # to carry empty content here. The conversation loop's truncation + # path detects an EMPTY partial-stream stub (PARTIAL_STREAM_STUB_ID + # + no content) and skips appending it to history entirely — only + # the continuation nudge is sent. Substituting placeholder text at + # this site was tried and reverted: it defeats that guard (the stub + # no longer looks empty), gets appended to history, and the + # placeholder leaks into the stitched final response via + # truncated_response_parts. Transcripts that already carry a + # persisted empty turn are healed at the send boundary by + # ``repair_empty_non_final_messages`` (the single owner). _stub_msg = SimpleNamespace( role="assistant", content=_partial_text, tool_calls=None, reasoning_content=None, diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 59f9bac25a2..da3bc4f9569 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -778,12 +778,27 @@ def run_codex_app_server_turn( # the already-flushed user turn). See gateway/run.py agent_persisted. if getattr(agent, "_session_db", None) is not None: try: - agent._flush_messages_to_session_db(messages) + _codex_flush_ok = agent._flush_messages_to_session_db(messages) except Exception: - logger.debug( + _codex_flush_ok = False + logger.warning( "codex app-server projected-message flush failed", exc_info=True, ) + if _codex_flush_ok is False: + # Unlike the chat-completions loop (which fails closed BEFORE + # projection — see conversation_loop session_persistence_failed), + # codex output has already streamed to the user by the time this + # flush runs, so there is nothing left to withhold. We cannot + # flip agent_persisted=False either: the gateway fallback write + # would re-INSERT the already-flushed user turn (#860/#42039). + # Surface the durability gap loudly instead of a silent debug. + logger.warning( + "codex app-server turn was delivered but could NOT be " + "persisted to the session DB (session=%s) — this turn " + "will be missing after restart/resume", + getattr(agent, "session_id", None), + ) # Counter ticks for the agent-improvement loop. diff --git a/agent/context_breakdown.py b/agent/context_breakdown.py index 0e2eb772f2f..4527c5dca22 100644 --- a/agent/context_breakdown.py +++ b/agent/context_breakdown.py @@ -154,3 +154,207 @@ def compute_session_context_breakdown( "estimated_total": estimated_total, "model": getattr(agent, "model", "") or "", } + + +# ── /context rendering (CLI + gateway) ────────────────────────────────────── +# +# Pure text renderers over the payload above. The CLI shows a glyph block-grid +# plus a category table; the gateway uses the same table without the grid +# (proportional monospace is not guaranteed on messaging platforms). + +_CATEGORY_GLYPHS = { + "system_prompt": "■", + "tool_definitions": "▣", + "rules": "▩", + "skills": "▤", + "mcp": "▥", + "subagent_definitions": "▦", + "memory": "▧", + "conversation": "▨", +} +_FREE_GLYPH = "·" +_GRID_COLUMNS = 20 +_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window + +# Human-readable tables cap the expanded listings; nothing is dropped from +# the underlying data. +_DETAILS_TABLE_LIMIT = 15 + + +def _bytes_to_tokens(size: Optional[int]) -> Optional[int]: + if size is None: + return None + return (int(size) + 3) // 4 + + +def compute_context_details(agent: Any) -> Dict[str, Any]: + """Expanded per-skill / per-toolset cost listing for ``/context all``. + + Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656): + per-skill index-line bytes parsed from the live ```` + block, and per-toolset schema bytes attributed via the tool registry's + canonical tool→toolset map. Byte figures are converted to the same + chars/4 token heuristic the categories above use. + """ + from hermes_cli.prompt_size import ( + _compute_skills_breakdown, + _compute_toolsets_breakdown, + ) + from agent.system_prompt import build_system_prompt_parts + + parts = build_system_prompt_parts(agent) + stable = parts.get("stable", "") or "" + skills_match = _SKILLS_BLOCK_RE.search(stable) + skills_block = skills_match.group(0) if skills_match else "" + + skills: List[Dict[str, Any]] = [] + if skills_block: + for entry in _compute_skills_breakdown(skills_block): + skills.append({ + "name": entry.get("name", ""), + "index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0, + "skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")), + }) + + toolsets: List[Dict[str, Any]] = [] + tools = list(getattr(agent, "tools", None) or []) + if tools: + for group in _compute_toolsets_breakdown(tools): + toolsets.append({ + "toolset": group.get("toolset", ""), + "tool_count": int(group.get("tool_count", 0) or 0), + "schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0, + }) + + return {"skills": skills, "toolsets": toolsets} + + +def render_context_grid(payload: Dict[str, Any]) -> List[str]: + """Render the payload as a Claude Code-style glyph block grid. + + 100 cells (5×20), each one percent of the model context window. Categories + fill in declaration order; the remainder renders as free space. + """ + context_max = int(payload.get("context_max") or 0) + categories = payload.get("categories") or [] + total_cells = _GRID_COLUMNS * _GRID_ROWS + + cells: List[str] = [] + if context_max > 0: + for cat in categories: + tokens = int(cat.get("tokens") or 0) + n = round(tokens / context_max * total_cells) + if tokens > 0 and n == 0: + n = 1 # never render a nonzero category as invisible + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + cells.extend([glyph] * n) + cells = cells[:total_cells] + cells.extend([_FREE_GLYPH] * (total_cells - len(cells))) + + return [ + " ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS]) + for row in range(_GRID_ROWS) + ] + + +def render_context_category_lines(payload: Dict[str, Any]) -> List[str]: + """Render the 'Estimated usage by category' table as plain-text lines.""" + categories = payload.get("categories") or [] + context_max = int(payload.get("context_max") or 0) + estimated_total = int(payload.get("estimated_total") or 0) + denom = context_max or estimated_total + + lines = ["Estimated usage by category"] + if not categories: + lines.append(" (no data yet — send a message first)") + return lines + + width = max(len(str(cat.get("label") or "")) for cat in categories) + width = max(width, len("Free space")) + for cat in categories: + tokens = int(cat.get("tokens") or 0) + glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪") + pct = tokens / denom * 100 if denom else 0.0 + label = str(cat.get("label") or cat.get("id") or "") + lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%") + if context_max > 0: + free = max(0, context_max - estimated_total) + pct = free / context_max * 100 + lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%") + return lines + + +def render_context_details_lines(details: Dict[str, Any]) -> List[str]: + """Render the expanded ``/context all`` per-skill / per-toolset tables.""" + lines: List[str] = [] + + toolsets = details.get("toolsets") or [] + if toolsets: + lines.append("Toolsets by schema cost (largest first)") + for group in toolsets[:_DETAILS_TABLE_LIMIT]: + lines.append( + f" {group['toolset']:<24} {group['tool_count']:>3} tools" + f" {group['schema_tokens']:>8,} tokens" + ) + remaining = len(toolsets) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + skills = details.get("skills") or [] + if skills: + if lines: + lines.append("") + lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)") + for entry in skills[:_DETAILS_TABLE_LIMIT]: + name = str(entry.get("name") or "") + if len(name) > 28: + name = name[:27] + "…" + md = entry.get("skill_md_tokens") + md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}" + lines.append( + f" {name:<28} index {entry['index_tokens']:>6,}" + f" SKILL.md {md_str} tokens" + ) + remaining = len(skills) - _DETAILS_TABLE_LIMIT + if remaining > 0: + lines.append(f" … and {remaining} more") + + return lines + + +def render_context_breakdown_lines( + payload: Dict[str, Any], + *, + details: Optional[Dict[str, Any]] = None, + grid: bool = True, +) -> List[str]: + """Render the full /context view as plain-text lines. + + ``grid=True`` (CLI) prepends the glyph block grid; the gateway passes + ``grid=False`` and keeps its own gauge. ``details`` (from + :func:`compute_context_details`) appends the expanded listings. + """ + lines: List[str] = [] + if grid: + lines.extend(render_context_grid(payload)) + lines.append("") + lines.extend(render_context_category_lines(payload)) + + context_max = int(payload.get("context_max") or 0) + context_used = int(payload.get("context_used") or 0) + if context_max > 0: + pct = int(payload.get("context_percent") or 0) + lines.append("") + lines.append( + f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)" + ) + + if details is not None: + detail_lines = render_context_details_lines(details) + if detail_lines: + lines.append("") + lines.extend(detail_lines) + else: + lines.append("") + lines.append("Use /context all for per-skill and per-toolset costs.") + return lines diff --git a/agent/context_references.py b/agent/context_references.py index 8981aa472f5..ab370a5a592 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -213,8 +213,12 @@ async def preprocess_context_references_async( f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})." ) - stripped = _remove_reference_tokens(message, refs) - final = stripped + # Leave the `@file:`/`@folder:` tokens where the user typed them. The token + # IS the reference, not scaffolding around it: clients render each one as an + # inline chip, so stripping them left a sentence with a hole in it ("review + # and ship") and made the desktop re-derive the refs from the attached block + # to show them as a detached list above the prose. + final = message if warnings: final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings) if blocks: @@ -473,19 +477,6 @@ def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None return _strip_reference_wrappers(value), None, None -def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str: - pieces: list[str] = [] - cursor = 0 - for ref in refs: - pieces.append(message[cursor:ref.start]) - cursor = ref.end - pieces.append(message[cursor:]) - text = "".join(pieces) - text = re.sub(r"\s{2,}", " ", text) - text = re.sub(r"\s+([,.;:!?])", r"\1", text) - return text.strip() - - def _is_binary_file(path: Path) -> bool: mime, _ = mimetypes.guess_type(path.name) if mime and not mime.startswith("text/") and not any( diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index d2df6f25a39..506181f2d39 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1402,7 +1402,10 @@ def compress_context( # `name #N` renumber, no contextvar/env/logging re-sync, no memory/context- # engine session-switch. The conversation keeps one durable id for life, # eliminating the session-rotation bug cluster. Default True (2107b86024). - in_place = bool(getattr(agent, "compression_in_place", False)) + # Default True matches DEFAULT_CONFIG / #38763. A missing attribute must + # NOT fall back to rotation mode — that re-enables the pre-lease drift + # path and can wedge busy sessions that never set the flag. + in_place = bool(getattr(agent, "compression_in_place", True)) # Set True once the in-place DB write actually completes (the DB block can # raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place. compacted_in_place = False @@ -1712,6 +1715,13 @@ def compress_context( # non-destructive — pre-compaction rows are soft-archived (active=0, # compacted=1), stay searchable and recoverable, so snapshot/durable # drift cannot lose data there and must not abort compaction. + # + # When durable DID grow, ADOPT it and continue rather than aborting. + # Aborting returned the stale snapshot unchanged, so busy sessions + # (memory review / shared session_id writers) stayed permanently + # behind the DB: every /compress and auto-compress saw + # "changed before lease acquisition", surfaced as the misleading + # "No changes from compression", and never reclaimed tokens. if not in_place and _lock_db is not None and _lock_sid: durable_loader = getattr( type(_lock_db), "get_messages_as_conversation", None @@ -1719,16 +1729,19 @@ def compress_context( if callable(durable_loader): durable_parent = durable_loader(_lock_db, _lock_sid) if isinstance(durable_parent, list) and len(durable_parent) > len(messages): - logger.warning( - "compression aborted: session=%s changed before lease " - "acquisition; preserving newer durable messages", + logger.info( + "compression: session=%s grew before lease " + "(%d → %d msgs); adopting durable snapshot", _lock_sid, + len(messages), + len(durable_parent), ) - _release_lock() - existing_prompt = getattr(agent, "_cached_system_prompt", None) - if not existing_prompt: - existing_prompt = agent._build_system_prompt(system_message) - return messages, existing_prompt + messages = durable_parent + _pre_msg_count = len(messages) + # Token estimate was for the stale snapshot; clear it so + # the compressor re-derives from the adopted transcript + # instead of under-counting the newly visible rows. + approx_tokens = 0 # Notify external memory provider before compression discards context. # The provider's on_pre_compress() may return a string of insights it @@ -2027,14 +2040,13 @@ def compress_context( # (same startswith gate as the restore path); otherwise the # request layer falls back to the legacy single-breakpoint # layout with the prompt bytes untouched. - try: - from agent.system_prompt import build_system_prompt_parts as _build_parts + from agent.system_prompt import reconstruct_static_prefix - _static = _build_parts(agent, system_message=system_message)["stable"] - if _static and cached_system_prompt.startswith(_static): - agent._cached_system_prompt_static = _static - except Exception: - pass + reconstruct_static_prefix( + agent, + system_message=system_message, + log_label="compression keep-prompt", + ) else: new_system_prompt = agent._build_system_prompt(system_message) agent._cached_system_prompt = new_system_prompt diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a73f08fb24c..7df0e44db8e 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -72,7 +72,10 @@ from agent.model_metadata import ( save_context_length, ) from agent.process_bootstrap import _install_safe_stdio -from agent.prompt_caching import apply_anthropic_cache_control +from agent.prompt_caching import ( + apply_anthropic_cache_control, + strip_anthropic_cache_control, +) from agent.retry_utils import ( adaptive_rate_limit_backoff, is_zai_coding_overload_error, @@ -118,22 +121,44 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text Incomplete provider reasoning blocks are not valid replay items (Anthropic signs them; Responses reasoning items require their following output). - Preserve only what Hermes actually displayed, demoted to ordinary text, - then add the correction as a real user message. This keeps role alternation + Preserve only the *visible* response text, demoted to ordinary text, then + add the correction as a real user message. This keeps role alternation valid and leaves every previously cached message byte-for-byte unchanged. + + INVARIANT — raw chain-of-thought must never be serialized into replayable + message content. Streamed reasoning is display-only state: it may be shown + live, but it does not re-enter the transcript as assistant (or user) text. + An assistant turn whose content inlines its own chain-of-thought reads to + Anthropic's output classifier as reasoning-injection/prefill jailbreak, + and because the poisoned checkpoint is persisted and replayed on every + subsequent call, the session dies permanently with deterministic + "Provider returned an empty response" storms that no retry, nudge, or + empty-recovery branch can escape (July 2026: four sessions bricked this + way; every reasoning-free checkpoint that week was untouched — same + mechanism as the ~/.hermes/prefill.json incident, 20/20 blocked with + assistant-exposed CoT vs 0/20 without). The interrupted reasoning was + incomplete by definition; the model regenerates it on the retried turn. + If a future path needs to preserve interrupted thinking, carry it in a + provider-gated reasoning *field*, never in content. + INVARIANT — the scaffolding is provider-replay text, not transcript text. + ``[This response was interrupted by a user correction.]`` and its + ``Visible response before the interruption:`` header exist so the MODEL + understands its own reply was cut off. They are not prose the user wrote + or the agent said. Persisting them into ``content`` painted the raw + machinery as an assistant bubble on every reload (and merged it into the + preceding tool-call bubble), which is what made a steered transcript + unreadable. Carry the scaffolded form in the ``api_content`` sidecar -- + the exact bytes replayed to the provider -- and keep ``content`` clean. + When nothing was on screen there is no clean form at all, so the row is + marked ``display_kind="hidden"``: still replayed to the model, dropped by + every transcript surface (desktop, TUI, CLI resume), exactly like the + compaction-reference rows. """ - reasoning = str( - getattr(agent, "_current_streamed_reasoning_text", "") or "" - ).strip() visible = agent._strip_think_blocks( getattr(agent, "_current_streamed_assistant_text", "") or "" ).strip() checkpoint_parts = ["[This response was interrupted by a user correction.]"] - if reasoning: - checkpoint_parts.extend( - ["Reasoning shown before the interruption:", reasoning] - ) if visible: checkpoint_parts.extend( ["Visible response before the interruption:", visible] @@ -150,13 +175,25 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text f"{checkpoint}\n\n" f"{text}" ) - messages.append({"role": "user", "content": correction}) + # Transcript shows the user's own words; the provider replays the + # scaffolded form so it still sees the interrupted context. + messages.append( + {"role": "user", "content": text, "api_content": correction} + ) else: - messages.append({"role": "assistant", "content": checkpoint}) + entry: Dict[str, Any] = { + "role": "assistant", + "content": visible or checkpoint, + "api_content": checkpoint, + } + if not visible: + # Nothing reached the screen — this row carries no assistant prose + # at all, only the cut-off notice for the model. + entry["display_kind"] = "hidden" + messages.append(entry) messages.append({"role": "user", "content": text}) agent._current_streamed_assistant_text = "" - agent._current_streamed_reasoning_text = "" agent._stream_needs_break = True @@ -444,30 +481,13 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) # first turn — flip-flopping the wire shape mid-conversation and # silently degrading to the legacy single-breakpoint layout. # - # Safety: the rebuilt stable tier is used ONLY when the restored - # prompt literally starts with it (checked here AND re-checked by - # ``_apply_system_cache_markers``'s ``startswith`` gate). If any - # stable-tier input changed since the prompt was persisted (skills - # edited, identity changed), the prefix mismatches, ``_static`` - # stays None, and the request falls back to the legacy layout with - # the restored prompt bytes untouched — never a rewritten prompt. - # - # Gated on ``_use_prompt_caching`` so non-Anthropic routes skip the - # rebuild entirely (the static prefix is only consumed by - # ``apply_anthropic_cache_control``). - if getattr(agent, "_use_prompt_caching", False): - try: - from agent.system_prompt import build_system_prompt_parts as _build_parts + # ``reconstruct_static_prefix`` gates on ``_use_prompt_caching`` (so + # non-Anthropic routes skip the rebuild), applies the startswith + # safety gate (stored prompt bytes are never rewritten), and + # fails open to the legacy cache layout. + from agent.system_prompt import reconstruct_static_prefix - _static = _build_parts(agent, system_message=system_message)["stable"] - if _static and stored_prompt.startswith(_static): - agent._cached_system_prompt_static = _static - except Exception: - # Fail-open: restore continues with the legacy cache layout. - logger.debug( - "static system-prefix reconstruction failed on restore", - exc_info=True, - ) + reconstruct_static_prefix(agent, system_message=system_message) return if stored_prompt: stored_state = "stale_runtime" @@ -774,6 +794,41 @@ def _compression_deferred_result( } +def _rewrite_system_content_blocks(system_message: dict, effective: str) -> bool: + """Rewrite a cache-decorated system message in place, keeping its blocks. + + ``apply_anthropic_cache_control`` runs once per call block, *before* the + retry loop, and splits the system prompt into ``[static prefix, volatile + tail]`` text blocks carrying the cache_control breakpoints. Assigning a bare + string over that list drops both breakpoints, so the failover retry ships + the whole system prompt uncached and re-bills it in full. + + ``rewrite_prompt_model_identity`` only touches the LAST ``Model:`` / + ``Provider:`` lines, and those live in the volatile tail — so the static + prefix stays byte-identical and its cache entry keeps matching. Returns + False when the shape is not one we can safely patch, so the caller falls + back to the plain-string assignment. + """ + content = system_message.get("content") + if not isinstance(content, list) or not content: + return False + if not all( + isinstance(part, dict) and part.get("type") == "text" for part in content + ): + return False + if len(content) == 1: + content[0]["text"] = effective + return True + if len(content) == 2: + head = content[0].get("text") or "" + if head and effective.startswith(head): + tail = effective[len(head):] + if tail: + content[1]["text"] = tail + return True + return False + + def _sync_failover_system_message(agent, api_messages, active_system_prompt): """Refresh the in-flight system message after a provider failover. @@ -796,10 +851,109 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt): effective = sp if agent.ephemeral_system_prompt: effective = (effective + "\n\n" + agent.ephemeral_system_prompt).strip() - api_messages[0]["content"] = effective + if not _rewrite_system_content_blocks(api_messages[0], effective): + api_messages[0]["content"] = effective return sp +def _ensure_cached_system_prompt_static(agent, system_message=None) -> None: + """Rebuild ``_cached_system_prompt_static`` when caching becomes active. + + Sessions restored under a cache-off primary skip the static-prefix rebuild + (gated on ``_use_prompt_caching`` at restore time). A later failover to a + cache-on provider would otherwise redecorate with ``static_system_prefix= + None`` and silently fall back to the legacy system-plus-3 layout (#72626). + + Thin wrapper over :func:`agent.system_prompt.reconstruct_static_prefix`, + which memoizes failed rebuilds so this stays cheap on the retry-loop hot + path (it runs at the top of every attempt). + """ + from agent.system_prompt import reconstruct_static_prefix + + reconstruct_static_prefix( + agent, system_message=system_message, log_label="failover redecoration" + ) + + +def _peel_moa_guidance( + messages: List[Dict[str, Any]], + guidance: Any, +) -> List[Dict[str, Any]]: + """Remove MoA reference guidance previously attached by ``_attach_reference_guidance``. + + Thin wrapper over :func:`agent.moa_loop.peel_reference_guidance` (kept + adjacent to the attach so the forward/inverse shapes evolve together). + Lazy import mirrors the module's other moa_loop touchpoints. + """ + from agent.moa_loop import peel_reference_guidance + + return peel_reference_guidance(messages, guidance) + + +def _redecorate_prompt_cache_for_provider( + agent, + api_messages: List[Dict[str, Any]], + *, + system_message=None, + moa_prepared: Optional[Dict[str, Any]] = None, +) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Strip and re-apply cache_control for the *current* provider policy. + + Decoration runs once per call block before the retry loop for the primary + provider. ``try_activate_fallback`` refreshes ``_use_prompt_caching`` / + ``_use_native_cache_layout`` but the nine failover ``continue`` paths reused + the old ``api_messages`` (#72626). Mirror ``_reapply_reasoning_echo_for_provider`` + by reshaping at the top of each retry attempt. + + The source list is the mutated in-flight request (image shrink / ASCII / + reasoning_details recoveries already applied) — never a pristine + pre-decoration snapshot. MoA guidance is peeled, the base is redecorated, + then ``rebase_prepared_request`` re-attaches guidance outside the cached + span. + """ + messages: List[Dict[str, Any]] = [ + dict(m) if isinstance(m, dict) else m for m in (api_messages or []) + ] + prepared = moa_prepared + guidance = prepared.get("guidance") if isinstance(prepared, dict) else None + if guidance: + messages = _peel_moa_guidance(messages, guidance) + + strip_anthropic_cache_control(messages) + + # Direct attribute access matches the call-block decoration site — the + # flags are unconditionally initialized on AIAgent, and a getattr + # default here would mask a real init bug as silent cache-off. + if agent._use_prompt_caching: + _ensure_cached_system_prompt_static(agent, system_message=system_message) + static = getattr(agent, "_cached_system_prompt_static", None) + messages = apply_anthropic_cache_control( + messages, + cache_ttl=agent._cache_ttl, + native_anthropic=agent._use_native_cache_layout, + static_system_prefix=static if isinstance(static, str) else None, + ) + + if ( + prepared is not None + and getattr(agent, "provider", None) == "moa" + ): + # No `and guidance` here: guidance=None is a real prepared shape + # (all-references-failed / silent degraded policy builds the + # prepared request without attaching guidance), and the MoA facade + # sends prepared["messages"] — not api_kwargs["messages"] — so the + # rebase must refresh the prepared object even when there is no + # guidance to re-attach. rebase_prepared_request handles falsy + # guidance by copying the messages and skipping the attach. + completions = getattr(getattr(agent.client, "chat", None), "completions", None) + rebase = getattr(completions, "rebase_prepared_request", None) + if callable(rebase): + prepared = rebase(prepared, messages) + messages = prepared["messages"] + + return messages, prepared + + def _apply_context_engine_selection( agent: Any, api_messages: List[Dict[str, Any]], @@ -939,6 +1093,8 @@ def run_conversation( stream_callback: Optional[callable] = None, persist_user_message: Optional[Any] = None, persist_user_timestamp: Optional[float] = None, + persist_user_display_kind: Optional[str] = None, + persist_user_display_metadata: Optional[Dict[str, Any]] = None, moa_config: Optional[dict[str, Any]] = None, ) -> Dict[str, Any]: """ @@ -957,6 +1113,13 @@ def run_conversation( synthetic prefixes. persist_user_timestamp: Optional platform event timestamp to store as metadata on that persisted user message. + persist_user_display_kind: Optional presentation type for a + synthesized user turn (``auto_continue``, ``model_switch``, …). + Display-only: transcript surfaces render the row as a timeline + event instead of a user bubble, while the model still receives + the message unchanged. + persist_user_display_metadata: Optional payload for that event + (e.g. a delegation's task count). or queuing follow-up prefetch work. Returns: @@ -999,6 +1162,8 @@ def run_conversation( stream_callback, persist_user_message, persist_user_timestamp, + persist_user_display_kind=persist_user_display_kind, + persist_user_display_metadata=persist_user_display_metadata, restore_or_build_system_prompt=_restore_or_build_system_prompt, install_safe_stdio=_install_safe_stdio, sanitize_surrogates=_sanitize_surrogates, @@ -1025,6 +1190,9 @@ def run_conversation( # Commentary deduplication spans all provider continuations and tool calls # within one user turn, but must not suppress the same phrase next turn. agent._delivered_interim_texts = set() + # A configured SessionDB append failure halts only the affected turn. A + # cached gateway agent must recover on the next message if storage did. + agent._incremental_persistence_failed = False # Main conversation loop counters (pure locals consumed by the loop below). api_call_count = 0 @@ -1507,6 +1675,15 @@ def run_conversation( # the OpenAI SDK. Sanitizing here prevents the 3-retry cycle. _sanitize_messages_surrogates(api_messages) + # NOTE (empty-content class fix): no send-time pad loop here. The + # single owner for "never send a turn strict wire validation rejects + # as empty" is ``repair_empty_non_final_messages``, which runs inside + # ``_sanitize_api_messages`` above — the unconditional pre-send + # chokepoint shared with the summary path. Its placeholder is + # non-whitespace, so it survives the whitespace-normalization pass + # 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 @@ -1870,6 +2047,18 @@ def run_conversation( # unless the active provider needs it) so the fallback request # isn't sent with stale, primary-shaped reasoning fields. agent._reapply_reasoning_echo_for_provider(api_messages) + # Same story for prompt-cache decoration (#72626): try_activate_ + # fallback refreshes the policy flags, but the decorated list + # still carries the primary's breakpoints (or none). Strip and + # re-render for the current provider before building kwargs. + api_messages, _moa_prepared_request = ( + _redecorate_prompt_cache_for_provider( + agent, + api_messages, + system_message=system_message, + moa_prepared=_moa_prepared_request, + ) + ) api_kwargs = agent._build_api_kwargs(api_messages) if agent._force_ascii_payload: _sanitize_structure_non_ascii(api_kwargs) @@ -2335,6 +2524,17 @@ def run_conversation( _backoff_touch_counter = 0 while time.time() < sleep_end: if agent._interrupt_requested: + # A redirect uses the interrupt machinery to cancel + # only the live request. Aborting the retry here + # with clear_interrupt() would DESTROY the pending + # correction and kill the turn with "Operation + # interrupted" — the exact mid-stream steer loss + # users hit when a redirect lands during provider + # backoff. Rebuild from the correction instead, + # mirroring the InterruptedError handler. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True) _interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})." close_interrupted_tool_sequence(messages, _interrupt_text) @@ -2356,6 +2556,8 @@ def run_conversation( f"retry backoff ({retry_count}/{max_retries}), " f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + break # rebuild this iteration from the correction continue # Retry the API call agent._turn_received_provider_response = True @@ -2661,10 +2863,27 @@ def run_conversation( ) if assistant_message is not None and not _trunc_has_tool_calls: length_continue_retries += 1 - interim_msg = agent._build_assistant_message(assistant_message, finish_reason) - messages.append(interim_msg) - if assistant_message.content: - truncated_response_parts.append(assistant_message.content) + # An EMPTY partial-stream stub (stream dropped + # mid tool-call before any text was delivered) + # must not be appended as an interim assistant + # message: it would serialize as + # {"role": "assistant", "content": ""}, and + # strict providers (Moonshot/Kimi via OpenRouter) + # reject empty assistant content with HTTP 400 + # ("message ... with role 'assistant' must not be + # empty") on the very next replay — permanently + # poisoning the session history. There is no + # partial text to continue from anyway, so only + # the continuation user-message is appended. + _is_empty_partial_stub = ( + getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID + and not getattr(assistant_message, "content", None) + ) + if not _is_empty_partial_stub: + interim_msg = agent._build_assistant_message(assistant_message, finish_reason) + messages.append(interim_msg) + if assistant_message.content: + truncated_response_parts.append(assistant_message.content) if length_continue_retries < 4: _is_partial_stream_stub = ( @@ -3559,7 +3778,7 @@ def run_conversation( agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...") continue if ( - agent.api_mode == "chat_completions" + agent.api_mode in ("chat_completions", "anthropic_messages") and agent.provider == "nous" and status_code == 401 and not _retry.nous_auth_retry_attempted @@ -3831,6 +4050,12 @@ def run_conversation( # Check for interrupt before deciding to retry if agent._interrupt_requested: + # Preserve a pending redirect (mid-stream correction): the + # user is steering, not stopping. Rebuild the turn from the + # correction instead of aborting with a dead-end interrupt. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True) _interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})." close_interrupted_tool_sequence(messages, _interrupt_text) @@ -5067,6 +5292,12 @@ def run_conversation( _backoff_touch_counter = 0 while time.time() < sleep_end: if agent._interrupt_requested: + # Same preserve-redirect rule as the retry-wait above: + # a steering correction must survive backoff, not die + # as "Operation interrupted". + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True) _interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})." close_interrupted_tool_sequence(messages, _interrupt_text) @@ -5088,6 +5319,11 @@ def run_conversation( f"error retry backoff ({retry_count}/{max_retries}), " f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + # Leave the retry loop — the check right below rebuilds this + # iteration from the correction instead of re-firing the + # stale request. + break if _retry.restart_with_redirected_messages: # The cancelled request produced no valid assistant item. Reuse the @@ -5433,6 +5669,14 @@ def run_conversation( args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200] logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview) + # Uniquify duplicate tool-call ids BEFORE any downstream + # consumer (validation error paths, dispatch, history build, + # Responses item-id derivation). Models that reuse one id for + # different calls in a batch otherwise lose the later call's + # result: the pre-API sanitizer keeps only the first + # call/result pair per id. See _uniquify_tool_call_ids. + agent._uniquify_tool_call_ids(assistant_message.tool_calls) + # Validate tool call names - detect model hallucinations # Repair mismatched tool names before validating for tc in assistant_message.tool_calls: @@ -5741,8 +5985,6 @@ def run_conversation( and previous_interim_visible == current_interim_visible ) messages.append(assistant_msg) - if not duplicate_previous_interim: - agent._emit_interim_assistant_message(assistant_msg) # Mixed batch: error-result the invalid calls and strip them # from the execution set. The assistant message above keeps @@ -5764,13 +6006,17 @@ def run_conversation( if tc.function.name in agent.valid_tool_names ] + _tool_turn_persisted = None try: # Persist the assistant tool-call turn before any tool # side effects run. If a destructive tool restarts or # terminates Hermes mid-turn, resume logic still sees the # exact tool-call block that already executed. - agent._flush_messages_to_session_db(messages, conversation_history) + _tool_turn_persisted = agent._flush_messages_to_session_db( + messages, conversation_history + ) except Exception as exc: + _tool_turn_persisted = False logger.warning( "Incremental tool-call persistence failed before execution " "(session=%s): %s", @@ -5778,6 +6024,22 @@ def run_conversation( exc, ) + if _tool_turn_persisted is False: + # The canonical append failed. Do not project the row or + # run side-effecting tools from state that exists only in + # this process. Breaking also avoids retrying the same + # unpersisted turn until the iteration budget is exhausted. + _turn_exit_reason = "session_persistence_failed" + final_response = "" + failed = True + break + + # A UI must never observe an assistant/tool-call row that is + # still only an ephemeral in-memory projection. Emit interim + # commentary only after the canonical SessionDB append above. + if not duplicate_previous_interim: + agent._emit_interim_assistant_message(assistant_msg) + # Close any open streaming display (response box, reasoning # box) before tool execution begins. Intermediate turns may # have streamed early content that opened the response box; @@ -5792,6 +6054,15 @@ def run_conversation( agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count) + if getattr(agent, "_incremental_persistence_failed", False): + # A tool result could not be made canonical. Do not send + # the in-memory result back to the model or project any + # later events from this turn. + _turn_exit_reason = "session_persistence_failed" + final_response = "" + failed = True + break + if agent._tool_guardrail_halt_decision is not None: decision = agent._tool_guardrail_halt_decision _turn_exit_reason = "guardrail_halt" @@ -6264,7 +6535,28 @@ def run_conversation( ". No fallback providers configured.") ) - final_response = "(empty)" + # Deliver a labeled reasoning excerpt instead of a bare + # "(empty)" when the model DID think but never produced + # visible text. This is delivery-only: the persisted + # assistant message above keeps the "(empty)" sentinel + # (its replay semantics prevent empty-response loops), + # and raw chain-of-thought is never promoted to a normal + # answer earlier in the ladder — prefill continuation, + # empty-content retries, and provider fallback all run + # first. Only at this terminal, where the alternative is + # returning nothing, is showing the model's own reasoning + # (clearly labeled as such) strictly more useful. + # Idea credit: PR #48795 (@ligl0325). + if reasoning_text: + final_response = ( + "⚠️ The model produced only internal reasoning and " + "no final answer, despite retries" + + (" and fallback" if agent._fallback_chain else "") + + ". Its last reasoning, which may contain the " + "answer:\n\n" + reasoning_preview + ) + else: + final_response = "(empty)" break # Reset retry counter/signature on successful content diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 33c2f545856..27df08f58ea 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [ "throttlingexception", "too many concurrent requests", "servicequotaexceededexception", + # Generic throttle prefix — Bedrock (and some proxies) surface throttling + # as "Throttling error: Too many tokens, please wait before trying + # again." Without this entry the message falls through to the + # context-overflow list (which contains "too many tokens") and the retry + # loop compresses a healthy session instead of backing off. Matched + # BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the + # throttle wins. (port of anomalyco/opencode#37848's exclusion guard) + "throttling", ] # Patterns that indicate provider-side overload, NOT a per-credential rate @@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [ "request entity too large", "payload too large", "error code: 413", + # Anthropic's structured 413 error type. Normally arrives with an HTTP + # 413 status (handled by the status path), but aggregators/proxies can + # re-wrap it into a plain message with no status attribute — route it to + # the same compression recovery. (port of anomalyco/opencode#37848) + "request_too_large", + "request exceeds the maximum size", ] # Image-size patterns. Matched against 400 bodies (not 413) because most @@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [ "max input token", "input token", "exceeds the maximum number of input tokens", + # Together/Fireworks-style: "Input length 131393 exceeds the maximum + # allowed input length of 131040 tokens." No other pattern in this list + # matches that wording. (port of anomalyco/opencode#37848) + "maximum allowed input length", ] # Model not found patterns @@ -321,6 +339,30 @@ _MODEL_NOT_FOUND_PATTERNS = [ "no endpoints found that support tool use", ] +# Malformed-message-array 400s. Deterministic request-shape rejections that +# describe the *transcript* being invalid, not a parameter. The canonical +# case: a stream dies mid-response and Hermes persists a content-less +# assistant stub; on the next turn the Anthropic message schema (and the +# litellm/Bedrock proxies in front of it) reject the whole request with +# "all messages must have non-empty content except for the optional final +# assistant message" / errorCode INVALID_REQUEST_BODY +# These are NOT context overflow — the input may be tiny — but a large +# session used to mis-route them into the compression loop via the generic +# "400 + large session" heuristic below, ending in "Cannot compress further" +# every retry (the input is unchanged, so compression cannot help). Match +# the message-shape signals explicitly and fail fast as a format_error so the +# loop stops looping. The empty-stub creation is the root cause (fixed in +# chat_completion_helpers); this pattern stops the misclassification symptom +# for transcripts that already contain a poisoned stub. +_INVALID_MESSAGE_BODY_PATTERNS = [ + "must have non-empty content", + "messages must have non-empty", + "invalid_request_body", + "text content blocks must be non-empty", + "content field is required", + "messages: at least one message is required", +] + # Request-validation patterns — the request is malformed and will fail # identically on every retry. Some OpenAI-compatible gateways (notably # codex.nekos.me) return these as 5xx instead of the standard 4xx, which @@ -1271,6 +1313,33 @@ def _classify_400( should_fallback=True, ) + # Malformed message array (empty-content assistant stub, etc.). Must be + # checked BEFORE context_overflow: the input can be tiny, so the generic + # "400 + large session" heuristic would otherwise mis-route it into the + # compression loop and thrash until "Cannot compress further" on every + # retry (the request is unchanged, so compression cannot fix it). This is + # a deterministic request-shape rejection — fail fast as a non-retryable + # format_error and fall back. Checked against the message text AND the + # structured error code, since proxies (litellm/Bedrock) surface the + # signal in errorCode=INVALID_REQUEST_BODY. + if ( + any(p in error_msg for p in _INVALID_MESSAGE_BODY_PATTERNS) + or error_code_lower == "invalid_request_body" + ): + logger.warning( + "Malformed message array 400 (invalid request body) classified as " + "format_error, NOT context overflow — failing fast + falling back " + "instead of entering the compression loop. This usually means an " + "empty-content assistant stub is in the transcript; num_messages=%s " + "approx_tokens=%s. error=%.200s", + num_messages, approx_tokens, error_msg, + ) + return result_fn( + FailoverReason.format_error, + retryable=False, + should_fallback=True, + ) + # Empty-provider-response advisories must not enter compression. They # often mention "max_tokens" as a possible cause and used to match the # bare overflow pattern, then thrash compress until "Cannot compress @@ -1331,6 +1400,18 @@ def _classify_400( # Responses API (and some providers) use flat body: {"message": "..."} if not err_body_msg: err_body_msg = str(body.get("message") or "").strip().lower() + # litellm / Bedrock proxies use a custom shape: {"errorMessage": "...", + # "errorCode": "...", "errorArgs": {"reason": "..."}}. Without these + # keys err_body_msg stays "" and a long, descriptive rejection is + # wrongly treated as a "generic" (bare) error below, which — on a + # large session — mis-routes into the compression loop. Recognize + # them so the is_generic heuristic sees the real message length. + if not err_body_msg: + err_body_msg = str(body.get("errorMessage") or "").strip().lower() + if not err_body_msg: + _args = body.get("errorArgs") + if isinstance(_args, dict): + err_body_msg = str(_args.get("reason") or "").strip().lower() is_generic = len(err_body_msg) < 30 or err_body_msg in {"error", ""} # Absolute token/message-count thresholds are only a proxy for smaller # context windows. Large-context sessions can have many messages while @@ -1629,7 +1710,7 @@ def _extract_error_code(body: dict) -> str: return nested_code # Top-level code - code = body.get("code") or body.get("error_code") or "" + code = body.get("code") or body.get("error_code") or body.get("errorCode") or "" if isinstance(code, (str, int)): text = str(code).strip() if text and text != "400": @@ -1649,6 +1730,16 @@ def _extract_message(error: Exception, body: dict) -> str: msg = body.get("message", "") if isinstance(msg, str) and msg.strip(): return msg.strip()[:500] + # litellm / Bedrock proxy shape: {"errorMessage": "...", + # "errorArgs": {"reason": "..."}}. + msg = body.get("errorMessage", "") + if isinstance(msg, str) and msg.strip(): + return msg.strip()[:500] + args = body.get("errorArgs") + if isinstance(args, dict): + reason = args.get("reason", "") + if isinstance(reason, str) and reason.strip(): + return reason.strip()[:500] # Fallback to str(error) return str(error)[:500] diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index b4f6e6386e7..bb53e32b2ce 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = ( ) +def is_standard_key_auth_error( + status: int, error_message: str, reason: str = "" +) -> bool: + """Return True when a Gemini 401 indicates Google rejected the key TYPE. + + Google began rejecting unrestricted legacy "Standard" Google Cloud API + keys on the Gemini API on June 19, 2026, and ALL Standard keys stop + working in September 2026. The rejection surfaces as a misleading 401 + telling the user to supply an OAuth 2 access token ("Request had invalid + authentication credentials. Expected OAuth 2 access token, login cookie + or other valid authentication credential."), optionally carrying + ``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``. + + Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``, + "API key not valid") keeps its existing message. + """ + if status != 401: + return False + if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED": + return True + return "expected oauth 2 access token" in (error_message or "").lower() + + +_STANDARD_KEY_GUIDANCE = ( + "\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. " + "Google began rejecting legacy 'Standard' Google Cloud keys for the " + "Gemini API on June 19, 2026, and all Standard keys stop working in " + "September 2026. Open https://aistudio.google.com/api-keys, check the " + "key's type and status, and create a replacement Gemini API key (or, as " + "a temporary bridge, restrict the Standard key to " + "generativelanguage.googleapis.com). Then update GEMINI_API_KEY / " + "GOOGLE_API_KEY in ~/.hermes/.env and restart your session. " + "Details: https://ai.google.dev/gemini-api/docs/api-key" +) + + class GeminiAPIError(Exception): """Error shape compatible with Hermes retry/error classification.""" @@ -824,6 +860,12 @@ def gemini_http_error( if status == 429 and is_free_tier_quota_error(err_message or body_text): message = message + _FREE_TIER_GUIDANCE + # Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) -> + # Google's raw 401 misleadingly tells the user to use OAuth. Append the + # actual fix (mint a new Gemini API key in AI Studio). + if is_standard_key_auth_error(status, err_message or body_text, reason): + message = message + _STANDARD_KEY_GUIDANCE + return GeminiAPIError( message, code=code, diff --git a/agent/gemini_schema.py b/agent/gemini_schema.py index b0985422fbb..665fd79a37e 100644 --- a/agent/gemini_schema.py +++ b/agent/gemini_schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from typing import Any, Dict # Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema`` @@ -76,15 +77,31 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]: # Gemini's Schema validator requires every ``enum`` entry to be a string, # even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``. - # OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's - # ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``), - # so we only drop the ``enum`` when it would collide with Gemini's rule. - # Keeping ``type: integer`` plus the human-readable description gives the - # model enough guidance; the tool handler still validates the value. + # Preserve those constraints by stringifying scalar values while keeping + # the declared type intact; Gemini uses the strings as schema metadata and + # still emits typed tool arguments at runtime. enum_val = cleaned.get("enum") type_val = cleaned.get("type") if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}: - if any(not isinstance(item, str) for item in enum_val): + stringified = [] + for item in enum_val: + if isinstance(item, str): + value = item + elif isinstance(item, bool): + value = "true" if item else "false" + elif ( + isinstance(item, (int, float)) + and not isinstance(item, bool) + and math.isfinite(item) + ): + value = str(item) + else: + continue + if value not in stringified: + stringified.append(value) + if stringified: + cleaned["enum"] = stringified + else: cleaned.pop("enum", None) # Gemini validates ``required`` strictly against the same node's diff --git a/agent/iteration_budget.py b/agent/iteration_budget.py index 213b97c0226..7d50026c170 100644 --- a/agent/iteration_budget.py +++ b/agent/iteration_budget.py @@ -2,7 +2,7 @@ Extracted from ``run_agent.py``. Each ``AIAgent`` instance (parent or subagent) holds an :class:`IterationBudget`; the parent's cap comes from -``max_iterations`` (default 90), each subagent's cap comes from +``max_iterations`` (default 500), each subagent's cap comes from ``delegation.max_iterations`` (default 50). ``run_agent`` re-exports ``IterationBudget`` so existing @@ -18,7 +18,7 @@ class IterationBudget: """Thread-safe iteration counter for an agent. Each agent (parent or subagent) gets its own ``IterationBudget``. - The parent's budget is capped at ``max_iterations`` (default 90). + The parent's budget is capped at ``max_iterations`` (default 500). Each subagent gets an independent budget capped at ``delegation.max_iterations`` (default 50) — this means total iterations across parent + subagents can exceed the parent's cap. diff --git a/agent/moa_loop.py b/agent/moa_loop.py index ed0f41a817a..173816c8cbd 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -1337,6 +1337,63 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str agg_messages.append({"role": "user", "content": guidance}) +def peel_reference_guidance( + messages: list[dict[str, Any]], + guidance: Any, +) -> list[dict[str, Any]]: + """Remove reference guidance previously attached by ``_attach_reference_guidance``. + + Exact inverse of the three attach shapes above (string merge, trailing + text part, appended user message) — kept adjacent so the two evolve + together; a drifting separator or shape would make the peel silently + no-op and let a cache breakpoint land on the turn-varying guidance + block (the bug class #72626 fixes). + + Used by the failover redecoration chokepoint: redecoration must run on + the base transcript so the last cache breakpoint does not land on the + guidance; callers then rebase via ``rebase_prepared_request``. + + Returns a new list (input list and its messages are not mutated). + """ + if not guidance or not messages: + return messages + guidance_text = str(guidance) + last = messages[-1] + if not isinstance(last, dict) or last.get("role") != "user": + return messages + content = last.get("content") + if content == guidance_text: + # Attach shape (c): guidance was appended as its own user message. + return list(messages[:-1]) + suffix = "\n\n" + guidance_text + if isinstance(content, str) and content.endswith(suffix): + # Attach shape (a): merged into a trailing string user turn. + peeled = dict(last) + peeled["content"] = content[: -len(suffix)] + return [*messages[:-1], peeled] + if isinstance(content, list) and content: + last_part = content[-1] + if isinstance(last_part, dict) and last_part.get("type", "text") == "text": + text = last_part.get("text") or "" + if text == suffix or text == guidance_text: + # Attach shape (b): guidance rode as its own trailing part. + peeled = dict(last) + peeled["content"] = list(content[:-1]) + if not peeled["content"]: + # The guidance part was the only content — mirror the + # string shape (c) and drop the whole message rather + # than leaving an empty-content user turn behind. + return list(messages[:-1]) + return [*messages[:-1], peeled] + if text.endswith(suffix): + new_part = dict(last_part) + new_part["text"] = text[: -len(suffix)] + peeled = dict(last) + peeled["content"] = [*content[:-1], new_part] + return [*messages[:-1], peeled] + return messages + + class MoAChatCompletions: """OpenAI-chat-compatible facade where the aggregator is the acting model.""" diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index 09b51e61d01..2e606cd377c 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -119,6 +119,59 @@ def _apply_system_cache_markers( return 1 +def strip_anthropic_cache_control( + api_messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Remove ``cache_control`` markers and undo decoration-produced list shapes. + + Used before re-applying decoration after a mid-turn provider failover so + the mutated, undecorated shape (image shrink / ASCII cleanup / etc.) is + preserved while markers match the *new* provider's cache policy (#72626). + + Flattening back to a plain string is restricted to the exact shapes + :func:`apply_anthropic_cache_control` produces from string content — + a single ``{"type": "text"}`` part, or the two-part ``[static, volatile]`` + system split — so the ``""``-join is provably byte-exact. Organic + multi-part text (merged user turns, imported transcripts) and parts + carrying extra keys (``citations`` etc.) keep their structure; only + per-part markers are removed. Marker removal is copy-on-write on the + part dicts: content parts may alias the persistent conversation history + (the per-call copy is shallow), and stripping must never rewrite the + stored transcript. + + Mutates the top-level message dicts of ``api_messages`` in place and + returns the same list. + """ + for msg in api_messages: + if not isinstance(msg, dict): + continue + msg.pop("cache_control", None) + content = msg.get("content") + if not isinstance(content, list): + continue + if any(isinstance(part, dict) and "cache_control" in part for part in content): + content = [ + {k: v for k, v in part.items() if k != "cache_control"} + if isinstance(part, dict) and "cache_control" in part + else part + for part in content + ] + msg["content"] = content + decoration_shape = content and all( + isinstance(part, dict) + and part.get("type", "text") == "text" + and isinstance(part.get("text"), str) + and set(part.keys()) <= {"type", "text"} + for part in content + ) and ( + len(content) == 1 + or (msg.get("role") == "system" and len(content) == 2) + ) + if decoration_shape: + msg["content"] = "".join(part["text"] for part in content) + return api_messages + + def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", diff --git a/agent/reasoning_timeouts.py b/agent/reasoning_timeouts.py index 8af5ab799f4..9c5fc202015 100644 --- a/agent/reasoning_timeouts.py +++ b/agent/reasoning_timeouts.py @@ -106,6 +106,14 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = ( ("claude-sonnet-5", 180), ("claude-sonnet-4.5", 180), ("claude-sonnet-4.6", 180), + # Anthropic Mythos-class named reasoning models (claude-fable-5, …). + # 1M context + 128K output — heavier thinking phase than the + # numbered Claude line, so the floor is in the deep-reasoning tier + # alongside o1 / deepseek-r1 / nemotron-3-ultra. Without this + # entry the stale-stream detector kills fable-5's thinking phase + # at the default 180s (300s with context scaling), tripping the + # cross-turn circuit breaker after 5 consecutive stale kills. + ("claude-fable", 600), # xAI Grok reasoning variants. Explicit reasoning-only keys # plus one for the ``non-reasoning`` variant so users picking # the fast variant don't get the 300s floor. Bare ``grok-3``, @@ -207,6 +215,8 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]: 300.0 >>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6") 240.0 + >>> get_reasoning_stale_timeout_floor("anthropic/claude-fable-5") + 600.0 >>> get_reasoning_stale_timeout_floor("gpt-4o") is None True >>> get_reasoning_stale_timeout_floor("olmo-1") is None diff --git a/agent/redact.py b/agent/redact.py index ebca1ae75f1..bff23934da6 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile( re.IGNORECASE | re.MULTILINE, ) +# Word-boundary validation for the mixed/lowercase key patterns above +# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE). +# +# Those key classes allow arbitrary alphanumeric affixes around the secret +# keyword so real key names like ``client_secret``, ``clientSecret``, and +# ``s3.secret-key`` match. The side effect: ordinary prose/document words that +# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret), +# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling +# legitimate content on the surfaces that run these passes (browser snapshots, +# log lines, kanban summaries, CLI-echoed command output). Ported from +# nearai/ironclaw#6129, where the same substring false positive ("Secretary of +# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool +# results from the replayed transcript and sent the model into a re-fetch +# loop. +# +# A keyword occurrence only counts when it sits at a word boundary within the +# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a +# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A +# trailing plural ``s`` is treated as part of the keyword (``secrets:``, +# ``tokens:``). Common concatenated compounds keep matching via explicit +# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey`` +# minio, ``apikey``). Embedded occurrences inside a larger word +# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer +# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an +# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE. +_KEY_KEYWORD_RE = re.compile( + r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)" + r"|token|secret|passwd|password|credential|auth", + re.IGNORECASE, +) + + +def _is_word_start(s: str, i: int) -> bool: + """True if position ``i`` in ``s`` begins a word (not mid-word).""" + if i == 0: + return True + prev, cur = s[i - 1], s[i] + if not prev.isalpha(): + return True + if cur.isupper() and prev.islower(): + return True # camelCase: clientSecret + # Acronym run ending: APIToken — the 'T' begins a new word when it is + # followed by lowercase while the preceding run is uppercase. + if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower(): + return True + return False + + +def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool: + """True if position ``j`` (exclusive end) in ``s`` ends a word.""" + if j >= len(s): + return True + cur = s[j] + if not cur.isalpha(): + return True + if cur.isupper() and s[j - 1].islower(): + return True # camelCase continuation: secretKey + if allow_plural and cur in "sS": + return _is_word_end(s, j + 1, allow_plural=False) + return False + + +def _key_has_secret_keyword(key: str) -> bool: + """True if ``key`` contains a secret keyword at a word boundary. + + Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE / + _YAML_ASSIGN_RE hits — rejects prose words that merely embed a keyword + (``secretary``, ``tokenizer``, ``authored``). Safe to call with the + _ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy + embedded-match behavior. + """ + letters = [c for c in key if c.isalpha()] + if letters and all(c.isupper() for c in letters): + return True # legacy all-caps behavior (MYTOKEN=…) + for m in _KEY_KEYWORD_RE.finditer(key): + if _is_word_start(key, m.start()) and _is_word_end(key, m.end()): + return True + return False + # JSON field patterns: "apiKey": "value", "token": "value", etc. _JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)" _JSON_FIELD_RE = re.compile( @@ -614,6 +693,13 @@ def redact_sensitive_text( # prose/log contexts (issue #2852): ``KEY=os.getenv('X')``. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``author=Smith`` / ``press.secretary=…`` are prose, not + # credentials (ported from nearai/ironclaw#6129). All-caps + # keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy + # embedded matching inside the helper. + if not _key_has_secret_keyword(name): + return m.group(0) return f"{name}={quote}{_mask_token(value)}{quote}" text = _ENV_ASSIGN_RE.sub(_redact_env, text) # Lowercase/dotted config keys (issue #16413). Skip URLs entirely — @@ -647,6 +733,11 @@ def redact_sensitive_text( # not a leaked secret value. if _ENV_LOOKUP_VALUE_RE.match(value): return m.group(0) + # Keyword must sit at a word boundary within the key — + # ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are + # document text, not credentials (nearai/ironclaw#6129). + if not _key_has_secret_keyword(key): + return m.group(0) return f"{key}{sep}{_mask_token(value)}" text = _YAML_ASSIGN_RE.sub(_redact_yaml, text) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 80453bc3713..5699e4d9eb6 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -100,6 +100,7 @@ emitted by each built-in hook site. child_role – role string of the child agent child_summary – summary of the child's work child_status – exit status string (e.g. "success", "error") + tool_call_history – redacted tool name/input summary/byte counts/status list duration_ms – wall-clock time of the child run in milliseconds """ diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 294ca2b1754..3f1156a8592 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -97,7 +97,7 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: return None -def describe_skill_invocation(content: Any) -> Optional[str]: +def describe_skill_invocation(content: Any, separator: str = " — ") -> Optional[str]: """Render a slash-skill-expanded turn the way the user typed it. The expanded message embeds the whole skill body, so any surface that @@ -109,6 +109,10 @@ def describe_skill_invocation(content: Any) -> Optional[str]: Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare invocation, or ``None`` when *content* is not skill scaffolding (the caller should then summarize it as an ordinary message). + + *separator* joins the command and the instruction. Previews use the + default em dash; pass ``" "`` for the literal invocation the user typed, + which is what chat transcripts render. """ if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX): return None @@ -127,7 +131,7 @@ def describe_skill_invocation(content: Any) -> Optional[str]: instruction = instruction.split(SKILL_EXCERPT_JOINT)[0] instruction = " ".join(instruction.split()) if instruction: - return f"{label} — {instruction}" if name else instruction + return f"{label}{separator}{instruction}" if name else instruction return label if name else None diff --git a/agent/skill_utils.py b/agent/skill_utils.py index df0f933317f..eea78d6a07c 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -50,7 +50,7 @@ EXCLUDED_SKILL_DIRS = frozenset( SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) -def is_excluded_skill_path(path) -> bool: +def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* should be skipped by active skill scanners. Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to @@ -66,11 +66,11 @@ def is_excluded_skill_path(path) -> bool: from pathlib import PurePath parts = PurePath(str(path)).parts return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path( - path + path, root=root ) -def is_skill_support_path(path) -> bool: +def is_skill_support_path(path, *, root: Optional[Path] = None) -> bool: """True if *path* is under a support dir of an actual skill root. ``references/``, ``templates/``, ``assets/``, and ``scripts/`` are @@ -92,6 +92,8 @@ def is_skill_support_path(path) -> bool: if part not in SKILL_SUPPORT_DIRS or idx == 0: continue skill_root = Path(*parts[:idx]) + if root is not None and not path_obj.is_absolute(): + skill_root = root / skill_root if (skill_root / "SKILL.md").exists(): return True return False diff --git a/agent/subagent_lifecycle.py b/agent/subagent_lifecycle.py new file mode 100644 index 00000000000..b85d84d9e19 --- /dev/null +++ b/agent/subagent_lifecycle.py @@ -0,0 +1,533 @@ +"""Public, plugin-safe lifecycle API for delegated Hermes subagents. + +This module deliberately exposes immutable contracts, not ``AIAgent`` objects. +It is the supported boundary for plugins that need to supervise fresh child +sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``. +""" + +from __future__ import annotations + +import contextvars +import dataclasses +import enum +import hashlib +import hmac +import json +import math +import secrets +import threading +import time +from contextlib import contextmanager +from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError +from typing import Any, Callable, Mapping, Optional + + +PUBLIC_CONTRACT_VERSION = 1 +_MAX_GOAL_CHARS = 16_000 +_MAX_CONTEXT_CHARS = 32_000 +_MAX_METADATA_BYTES = 8_192 +_MAX_RESULT_CHARS = 32_000 +_TERMINAL_RETENTION_SECONDS = 3_600 + + +class SubagentLifecycleError(ValueError): + """A request cannot be safely accepted by the public lifecycle API.""" + + +class SubagentState(str, enum.Enum): + PENDING = "PENDING" + STARTING = "STARTING" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + INTERRUPTED = "INTERRUPTED" + CANCEL_REQUESTED = "CANCEL_REQUESTED" + CANCELLED = "CANCELLED" + UNKNOWN = "UNKNOWN" + + +@dataclasses.dataclass(frozen=True) +class SubagentLaunchRequest: + goal: str + context: Optional[str] = None + role: str = "leaf" + model: Optional[str] = None + allowed_toolsets: Optional[tuple[str, ...]] = None + blocked_tools: tuple[str, ...] = () + working_directory: Optional[str] = None + parent_session_id: Optional[str] = None + correlation_id: Optional[str] = None + metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + timeout_seconds: Optional[float] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentHandle: + contract_version: int + subagent_id: str + parent_session_id: Optional[str] + correlation_id: Optional[str] + created_at: float + provider: Optional[str] + model: Optional[str] + role: str + depth: int + capability: str + + def to_dict(self) -> dict[str, Any]: + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle": + try: + return cls(**dict(value)) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("Malformed subagent handle.") from exc + + +@dataclasses.dataclass(frozen=True) +class SubagentStatus: + handle: SubagentHandle + state: SubagentState + updated_at: float + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentTerminalState: + handle: SubagentHandle + state: SubagentState + completed: bool + timed_out: bool = False + diagnostic: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentCancelResult: + accepted: bool + already_terminal: bool = False + unknown_handle: bool = False + unsupported: bool = False + state: SubagentState = SubagentState.UNKNOWN + + +@dataclasses.dataclass(frozen=True) +class SubagentResult: + handle: SubagentHandle + terminal_state: SubagentState + ready: bool + summary: Optional[str] = None + structured_payload: Optional[Mapping[str, Any]] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + error_classification: Optional[str] = None + error_message: Optional[str] = None + usage_metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict) + tool_execution_summary: Mapping[str, Any] = dataclasses.field(default_factory=dict) + result_hash: Optional[str] = None + + +@dataclasses.dataclass(frozen=True) +class SubagentReconnectResult: + connected: bool + state: SubagentState + diagnostic: Optional[str] = None + + +@dataclasses.dataclass +class _Record: + handle: SubagentHandle + state: SubagentState + updated_at: float + agent: Any = None + future: Optional[Future] = None + started_at: Optional[float] = None + completed_at: Optional[float] = None + result: Optional[SubagentResult] = None + + +class _Registry: + """Thread-safe terminal-retention registry; never returns live records.""" + + def __init__(self) -> None: + self.lock = threading.RLock() + self.records: dict[str, _Record] = {} + self.correlations: dict[tuple[Optional[str], str], str] = {} + + +_REGISTRY = _Registry() +# Daemon worker pool: a wedged/abandoned child must never block interpreter +# exit at atexit-join time (same rationale as _run_single_child's timeout +# executor and the async-delegation registry pool). +from tools.daemon_pool import DaemonThreadPoolExecutor as _DaemonExecutor + +_EXECUTOR = _DaemonExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle") +_SECRET = secrets.token_bytes(32) +_ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar( + "hermes_subagent_lifecycle_parent", default=None +) + + +@contextmanager +def bind_subagent_parent(parent_agent: Any): + """Bind the host-owned parent for the current agent turn.""" + token = _ACTIVE_PARENT_AGENT.set(parent_agent) + try: + yield + finally: + _ACTIVE_PARENT_AGENT.reset(token) + + +def get_active_subagent_parent() -> Any: + """Return the parent bound to this execution context, if any.""" + return _ACTIVE_PARENT_AGENT.get() + + +class SubagentLifecycleService: + """Stable public service returned by :attr:`PluginContext.subagent_lifecycle`. + + Running children are in-process only. Completed results remain available + until process exit; ``reconnect`` accurately reports that a serialized + handle cannot reconnect after a restart instead of launching work again. + """ + + def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None: + self._parent_agent_resolver = parent_agent_resolver + + def launch(self, request: SubagentLaunchRequest) -> SubagentHandle: + parent = self._parent_agent_resolver() + if parent is None: + raise SubagentLifecycleError( + "No active Hermes parent session is available." + ) + self._validate_request(request, parent) + parent_session_id = str(getattr(parent, "session_id", "") or "") or None + if request.parent_session_id and request.parent_session_id != parent_session_id: + raise SubagentLifecycleError( + "parent_session_id does not match the active session." + ) + correlation_key = (parent_session_id, request.correlation_id or "") + with _REGISTRY.lock: + self._cleanup_locked() + if request.correlation_id and correlation_key in _REGISTRY.correlations: + raise SubagentLifecycleError( + "Duplicate correlation_id for this parent session." + ) + + # Delegate construction remains internal so plugin code never imports + # private delegation helpers or manipulates the active-child registry. + from tools.delegate_tool import ( + _build_child_preserving_parent_tools, + DEFAULT_MAX_ITERATIONS, + ) + + child = _build_child_preserving_parent_tools( + task_index=0, + goal=request.goal, + context=request.context, + toolsets=list(request.allowed_toolsets) + if request.allowed_toolsets + else None, + model=request.model, + max_iterations=DEFAULT_MAX_ITERATIONS, + task_count=1, + parent_agent=parent, + role=request.role, + ) + subagent_id = str(getattr(child, "_subagent_id", "") or "") + if not subagent_id: + raise SubagentLifecycleError("Hermes failed to assign a child identity.") + created = time.time() + handle = SubagentHandle( + PUBLIC_CONTRACT_VERSION, + subagent_id, + parent_session_id, + request.correlation_id, + created, + getattr(child, "provider", None), + getattr(child, "model", None), + getattr(child, "_delegate_role", request.role), + int(getattr(child, "_delegate_depth", 1) or 1), + self._capability(subagent_id, parent_session_id, created), + ) + record = _Record(handle, SubagentState.PENDING, created, agent=child) + with _REGISTRY.lock: + _REGISTRY.records[subagent_id] = record + if request.correlation_id: + _REGISTRY.correlations[correlation_key] = subagent_id + record.future = _EXECUTOR.submit(self._run, record, request.goal, parent) + return handle + + def status(self, handle: SubagentHandle) -> SubagentStatus: + record = self._record(handle) + if record is None: + return SubagentStatus( + handle, SubagentState.UNKNOWN, time.time(), "UNKNOWN_HANDLE" + ) + with _REGISTRY.lock: + return SubagentStatus(record.handle, record.state, record.updated_at) + + def wait( + self, handle: SubagentHandle, *, timeout_seconds: Optional[float] = None + ) -> SubagentTerminalState: + record = self._record(handle) + if record is None: + return SubagentTerminalState( + handle, SubagentState.UNKNOWN, True, diagnostic="UNKNOWN_HANDLE" + ) + future = record.future + if future is not None: + try: + future.result(timeout=timeout_seconds) + except TimeoutError: + return SubagentTerminalState(record.handle, record.state, False, True) + except Exception: + pass + with _REGISTRY.lock: + return SubagentTerminalState( + record.handle, record.state, record.result is not None + ) + + def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult: + record = self._record(handle) + if record is None: + return SubagentCancelResult(False, unknown_handle=True) + with _REGISTRY.lock: + if record.result is not None: + return SubagentCancelResult( + False, already_terminal=True, state=record.state + ) + agent = record.agent + record.state = SubagentState.CANCEL_REQUESTED + record.updated_at = time.time() + if agent is None or not hasattr(agent, "interrupt"): + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + try: + agent.interrupt(f"Lifecycle cancellation requested: {reason[:500]}") + except Exception: + return SubagentCancelResult( + False, unsupported=True, state=SubagentState.CANCEL_REQUESTED + ) + return SubagentCancelResult(True, state=SubagentState.CANCEL_REQUESTED) + + def result(self, handle: SubagentHandle) -> SubagentResult: + record = self._record(handle) + if record is None: + return SubagentResult( + handle, + SubagentState.UNKNOWN, + False, + error_classification="UNKNOWN_HANDLE", + ) + with _REGISTRY.lock: + if record.result is not None: + return record.result + return SubagentResult( + record.handle, record.state, False, error_classification="NOT_READY" + ) + + def reconnect(self, handle: SubagentHandle) -> SubagentReconnectResult: + record = self._record(handle) + if record is None: + return SubagentReconnectResult( + False, SubagentState.UNKNOWN, "RECONNECT_UNAVAILABLE" + ) + with _REGISTRY.lock: + return SubagentReconnectResult(True, record.state) + + def _record(self, handle: SubagentHandle) -> Optional[_Record]: + if ( + not isinstance(handle, SubagentHandle) + or type(handle.contract_version) is not int + or handle.contract_version != PUBLIC_CONTRACT_VERSION + ): + return None + if ( + not isinstance(handle.subagent_id, str) + or not handle.subagent_id + or ( + handle.parent_session_id is not None + and not isinstance(handle.parent_session_id, str) + ) + or ( + handle.correlation_id is not None + and not isinstance(handle.correlation_id, str) + ) + or isinstance(handle.created_at, bool) + or not isinstance(handle.created_at, (int, float)) + or not math.isfinite(handle.created_at) + or (handle.provider is not None and not isinstance(handle.provider, str)) + or (handle.model is not None and not isinstance(handle.model, str)) + or not isinstance(handle.role, str) + or type(handle.depth) is not int + or not isinstance(handle.capability, str) + ): + return None + if not hmac.compare_digest( + handle.capability, + self._capability( + handle.subagent_id, handle.parent_session_id, handle.created_at + ), + ): + return None + parent = self._parent_agent_resolver() + active_parent_id = str(getattr(parent, "session_id", "") or "") or None + if active_parent_id != handle.parent_session_id: + return None + with _REGISTRY.lock: + return _REGISTRY.records.get(handle.subagent_id) + + @staticmethod + def _cleanup_locked() -> None: + """Retain terminal snapshots for a bounded period, never live work.""" + cutoff = time.time() - _TERMINAL_RETENTION_SECONDS + expired = [ + subagent_id + for subagent_id, record in _REGISTRY.records.items() + if record.result is not None + and record.completed_at is not None + and record.completed_at < cutoff + ] + for subagent_id in expired: + record = _REGISTRY.records.pop(subagent_id) + if record.handle.correlation_id: + _REGISTRY.correlations.pop( + (record.handle.parent_session_id, record.handle.correlation_id), + None, + ) + + def _run(self, record: _Record, goal: str, parent: Any) -> None: + with _REGISTRY.lock: + if record.state is not SubagentState.CANCEL_REQUESTED: + record.state = SubagentState.RUNNING + record.started_at = time.time() + record.updated_at = record.started_at + try: + from tools.delegate_tool import _run_child_lifecycle + + raw = _run_child_lifecycle(0, goal, record.agent, parent) + status = ( + str(raw.get("status", "error")) if isinstance(raw, dict) else "error" + ) + if status == "completed": + state = SubagentState.SUCCEEDED + elif status == "interrupted": + state = ( + SubagentState.CANCELLED + if record.state == SubagentState.CANCEL_REQUESTED + else SubagentState.INTERRUPTED + ) + else: + state = SubagentState.FAILED + summary = raw.get("summary") if isinstance(raw, dict) else None + summary = str(summary)[:_MAX_RESULT_CHARS] if summary is not None else None + error = raw.get("error") if isinstance(raw, dict) else None + result = SubagentResult( + record.handle, + state, + True, + summary=summary, + completed_at=time.time(), + started_at=record.started_at, + error_classification=None + if state == SubagentState.SUCCEEDED + else status.upper(), + error_message=str(error)[:_MAX_RESULT_CHARS] if error else None, + usage_metadata={"api_calls": raw.get("api_calls", 0)} + if isinstance(raw, dict) + else {}, + tool_execution_summary={ + "duration_seconds": raw.get("duration_seconds", 0) + } + if isinstance(raw, dict) + else {}, + ) + except Exception as exc: + result = SubagentResult( + record.handle, + SubagentState.FAILED, + True, + started_at=record.started_at, + completed_at=time.time(), + error_classification=type(exc).__name__, + error_message=str(exc)[:_MAX_RESULT_CHARS], + ) + payload = dataclasses.asdict(result) + payload.pop("result_hash", None) + result = dataclasses.replace( + result, + result_hash=hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode() + ).hexdigest(), + ) + with _REGISTRY.lock: + record.agent = None + record.result = result + record.state = result.terminal_state + record.completed_at = result.completed_at + record.updated_at = result.completed_at or time.time() + + @staticmethod + def _capability( + subagent_id: str, parent_session_id: Optional[str], created_at: float + ) -> str: + value = f"{subagent_id}|{parent_session_id or ''}|{created_at:.6f}".encode() + return hmac.new(_SECRET, value, hashlib.sha256).hexdigest() + + @staticmethod + def _validate_request(request: SubagentLaunchRequest, parent: Any) -> None: + if ( + not isinstance(request, SubagentLaunchRequest) + or not isinstance(request.goal, str) + or not request.goal.strip() + or len(request.goal) > _MAX_GOAL_CHARS + ): + raise SubagentLifecycleError( + "goal must be a non-empty string of at most 16000 characters." + ) + if request.context is not None and ( + not isinstance(request.context, str) + or len(request.context) > _MAX_CONTEXT_CHARS + ): + raise SubagentLifecycleError( + "context must be a string of at most 32000 characters." + ) + if request.role not in {"leaf", "orchestrator"}: + raise SubagentLifecycleError("role must be 'leaf' or 'orchestrator'.") + if request.timeout_seconds is not None: + raise SubagentLifecycleError( + "Per-launch timeout is not supported; configure delegation timeout explicitly." + ) + if request.working_directory is not None: + raise SubagentLifecycleError( + "working_directory is not supported because Hermes delegates use isolated task environments." + ) + if request.blocked_tools: + raise SubagentLifecycleError( + "Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools." + ) + try: + metadata_bytes = len( + json.dumps(dict(request.metadata), sort_keys=True).encode() + ) + except (TypeError, ValueError) as exc: + raise SubagentLifecycleError("metadata must be JSON-serializable.") from exc + if metadata_bytes > _MAX_METADATA_BYTES: + raise SubagentLifecycleError("metadata exceeds 8192 bytes.") + if request.allowed_toolsets: + from toolsets import TOOLSETS + + unknown = set(request.allowed_toolsets) - set(TOOLSETS) + if unknown: + raise SubagentLifecycleError( + f"Unknown toolsets: {', '.join(sorted(unknown))}." + ) + enabled = getattr(parent, "enabled_toolsets", None) + if enabled is not None and not set(request.allowed_toolsets).issubset( + set(enabled) + ): + raise SubagentLifecycleError( + "Requested toolsets would broaden parent permissions." + ) diff --git a/agent/system_prompt.py b/agent/system_prompt.py index d92072d1ac1..8b8832ca280 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -26,6 +26,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders. from __future__ import annotations import json +import logging import os from typing import Any, Dict, List, Optional @@ -51,6 +52,8 @@ from agent.runtime_cwd import resolve_context_cwd from hermes_constants import get_hermes_home from utils import is_truthy_value +logger = logging.getLogger(__name__) + def _ra(): """Lazy reference to the ``run_agent`` module. @@ -582,6 +585,60 @@ def invalidate_system_prompt(agent: Any) -> None: agent._memory_store.load_from_disk() +def reconstruct_static_prefix( + agent: Any, + system_message: Optional[str] = None, + *, + log_label: str = "restore", +) -> None: + """Reconstruct ``_cached_system_prompt_static`` for a stored prompt. + + The static prefix is not persisted (only the full prompt is), so any + path that adopts a stored/kept ``_cached_system_prompt`` — session + restore, the compression keep-prompt path, or a failover to a cache-on + provider mid-turn (#72626) — must rebuild the stable tier to regain the + two-block ``[static, volatile]`` system layout. + + Safety: the rebuilt stable tier is used ONLY when the stored prompt + literally starts with it (checked here AND re-checked by + ``_apply_system_cache_markers``'s ``startswith`` gate). If any + stable-tier input changed since the prompt was persisted (skills + edited, identity changed), the prefix mismatches, the static stays + None, and requests fall back to the legacy layout with the stored + prompt bytes untouched — never a rewritten prompt. + + A failed reconstruction is memoized per stored prompt + (``_static_rebuild_failed_for``): ``build_system_prompt_parts`` does + real file I/O (SOUL.md, context files, memory), and callers on the + retry-loop hot path must not re-run it every attempt when the inputs + haven't changed. A legitimately changed stored prompt retries once. + """ + if not getattr(agent, "_use_prompt_caching", False): + return + stored = getattr(agent, "_cached_system_prompt", None) + if not isinstance(stored, str) or not stored: + return + existing = getattr(agent, "_cached_system_prompt_static", None) + if isinstance(existing, str) and existing and stored.startswith(existing): + return + if getattr(agent, "_static_rebuild_failed_for", None) == stored: + return + try: + static = build_system_prompt_parts(agent, system_message=system_message)["stable"] + if static and stored.startswith(static): + agent._cached_system_prompt_static = static + agent._static_rebuild_failed_for = None + return + except Exception: + logger.debug( + "static system-prefix reconstruction failed on %s", + log_label, + exc_info=True, + ) + agent._cached_system_prompt_static = None + agent._static_rebuild_failed_for = stored + + def format_tools_for_system_message(agent: Any) -> str: """Format tool definitions for the system message in the trajectory format. diff --git a/agent/tool_executor.py b/agent/tool_executor.py index a5729a2e8f5..d32fe99c0c5 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -140,8 +140,8 @@ def _flush_session_db_after_tool_progress( messages: list, *, stage: str, -) -> None: - """Best-effort incremental SessionDB flush for tool-call progress. +) -> bool: + """Flush tool-call progress before projecting it to any UI surface. Tool execution can perform side effects that terminate or restart the current Hermes process before the normal turn-end persistence path runs. @@ -149,9 +149,14 @@ def _flush_session_db_after_tool_progress( transcript survives destructive-but-valid tool calls. """ try: - agent._flush_messages_to_session_db(messages) + persisted = agent._flush_messages_to_session_db(messages) is not False + if not persisted: + agent._incremental_persistence_failed = True + return persisted except Exception as exc: + agent._incremental_persistence_failed = True logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc) + return False def _ra(): @@ -431,8 +436,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = json.dumps({ "error": ( @@ -854,6 +866,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls): r = results[i] blocked = False + is_error = True + progress_function_name = name # A worker can finish and write results[i] in the window between the # deadline snapshot (timed_out_indices, taken from not_done) and this # loop. Prefer that real result over a fabricated timeout message — the @@ -909,6 +923,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tool_duration = 0.0 else: function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r + progress_function_name = function_name if blocked: effect_disposition = "none" @@ -936,44 +951,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe except Exception as _ver_err: logging.debug("file-mutation verifier record failed: %s", _ver_err) - if not blocked and agent.tool_progress_callback: - try: - agent.tool_progress_callback( - "tool.completed", function_name, None, None, - duration=tool_duration, is_error=is_error, - result=function_result, - ) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - if agent.verbose_logging: logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s") logging.debug(f"Tool result ({len(function_result)} chars): {function_result}") - # Print cute message per tool - if agent._should_emit_quiet_tool_messages(): - cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result) - agent._safe_print(f" {cute_msg}") - elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": - _preview_str = _multimodal_text_summary(function_result) - if agent.verbose_logging: - print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") - print(agent._wrap_verbose("Result: ", _preview_str)) - else: - response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str - print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}") - agent._current_tool = None _status_suffix = " (error)" if is_error else "" agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s){_status_suffix}") - if not blocked and agent.tool_complete_callback: - try: - display_args = _redact_tool_args_for_display(name, args) or args - agent.tool_complete_callback(tc.id, name, display_args, function_result) - except Exception as cb_err: - logging.debug(f"Tool complete callback error: {cb_err}") - + display_function_result = function_result function_result = maybe_persist_tool_result( content=function_result, tool_name=name, @@ -1008,6 +994,50 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) messages.append(tool_message) risk_metadata = tool_message.get("_tool_output_risk") + if not _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {name}", + ): + return + + # Every completion surface is downstream of the canonical append. If + # the UI bridge or process dies while projecting one of these events, + # resume can reconstruct the tool result that was already visible. + if not blocked and agent.tool_progress_callback: + try: + agent.tool_progress_callback( + "tool.completed", progress_function_name, None, None, + duration=tool_duration, is_error=is_error, + result=display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool progress callback error: {cb_err}") + + # Print cute message per tool + if agent._should_emit_quiet_tool_messages(): + cute_msg = _get_cute_tool_message_impl( + name, args, tool_duration, result=display_function_result, + ) + agent._safe_print(f" {cute_msg}") + elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": + _preview_str = _multimodal_text_summary(display_function_result) + if agent.verbose_logging: + print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s") + print(agent._wrap_verbose("Result: ", _preview_str)) + else: + response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str + print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}") + + if not blocked and agent.tool_complete_callback: + try: + display_args = _redact_tool_args_for_display(name, args) or args + agent.tool_complete_callback( + tc.id, name, display_args, display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool complete callback error: {cb_err}") + if ( risk_metadata is not None and risk_metadata.get("risk") != "low" @@ -1024,11 +1054,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - _flush_session_db_after_tool_progress( - agent, - messages, - stage=f"tool result {name}", - ) # ── Per-tool /steer drain ─────────────────────────────────── # Same as the sequential path: drain between each collected @@ -1060,6 +1085,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # Resolve the context-scaled tool-output budget once per turn. _tool_budget = _budget_for_agent(agent) for i, tool_call in enumerate(assistant_message.tool_calls, 1): + if getattr(agent, "_incremental_persistence_failed", False): + return # SAFETY: check interrupt BEFORE starting each tool. # If the user sent "stop" during a previous tool's execution, # do NOT start any more tools -- skip them all immediately. @@ -1075,11 +1102,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_tc.id, effect_disposition="none", )) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"cancelled tool result {skipped_name}", - ) + ): + return break function_name = tool_call.function.name @@ -1095,11 +1123,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_call.id, ) ) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"invalid tool arguments {function_name}", - ) + ): + return agent._apply_pending_steer_to_tool_results(messages, 1) continue @@ -1113,8 +1142,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: if _underlying in _tool_search_scoped_names(agent): - function_name = _underlying - function_args = _underlying_args + # Probe-validate before unwrapping (ironclaw#5149): + # missing required args return the parameter schema + # instead of dispatching into an opaque failure. + _probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args) + if _probe_err is not None: + # This path wraps _block_msg in {"error": ...} — + # flatten the probe payload to one plain string. + try: + _probe = json.loads(_probe_err) + _ts_scope_block = ( + f"{_probe.get('error', '')} Parameters schema: " + f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. " + f"{_probe.get('hint', '')}" + ).strip() + except Exception: + _ts_scope_block = _probe_err + else: + function_name = _underlying + function_args = _underlying_args else: _ts_scope_block = ( f"'{_underlying}' is not available in this session. " @@ -1360,6 +1406,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe return _clarify_tool( question=next_args.get("question", ""), choices=next_args.get("choices"), + multi_select=next_args.get("multi_select", False), callback=agent.clarify_callback, ) function_result, function_args = _run_agent_tool_execution_middleware( @@ -1645,16 +1692,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe except Exception as _ver_err: logging.debug("file-mutation verifier record failed: %s", _ver_err) - if not _execution_blocked and agent.tool_progress_callback: - try: - agent.tool_progress_callback( - "tool.completed", function_name, None, None, - duration=tool_duration, is_error=_is_error_result, - result=function_result, - ) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - agent._current_tool = None _status_suffix = " (error)" if _is_error_result else "" agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s){_status_suffix}") @@ -1664,13 +1701,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe _log_result = _multimodal_text_summary(function_result) logging.debug(f"Tool result ({len(_log_result)} chars): {_log_result}") - if not _execution_blocked and agent.tool_complete_callback: - try: - display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result) - except Exception as cb_err: - logging.debug(f"Tool complete callback error: {cb_err}") - + display_function_result = function_result function_result = maybe_persist_tool_result( content=function_result, tool_name=function_name, @@ -1693,6 +1724,40 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id) messages.append(tool_message) risk_metadata = tool_message.get("_tool_output_risk") + if not _flush_session_db_after_tool_progress( + agent, + messages, + stage=f"tool result {function_name}", + ): + return + + # UI completion/progress events are projections of the canonical tool + # row, never a competing in-memory authority. + if not _execution_blocked and agent.tool_progress_callback: + try: + agent.tool_progress_callback( + "tool.completed", function_name, None, None, + duration=tool_duration, is_error=_is_error_result, + result=display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool progress callback error: {cb_err}") + + if not _execution_blocked and agent.tool_complete_callback: + try: + display_args = ( + _redact_tool_args_for_display(function_name, function_args) + or function_args + ) + agent.tool_complete_callback( + tool_call.id, + function_name, + display_args, + display_function_result, + ) + except Exception as cb_err: + logging.debug(f"Tool complete callback error: {cb_err}") + if ( risk_metadata is not None and risk_metadata.get("risk") != "low" @@ -1709,11 +1774,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe ) except Exception as cb_err: logging.debug("Tool output risk callback error: %s", cb_err) - _flush_session_db_after_tool_progress( - agent, - messages, - stage=f"tool result {function_name}", - ) # ── Per-tool /steer drain ─────────────────────────────────── # Drain pending steer BETWEEN individual tool calls so the @@ -1741,11 +1801,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skipped_tc.id, effect_disposition="none", )) - _flush_session_db_after_tool_progress( + if not _flush_session_db_after_tool_progress( agent, messages, stage=f"skipped tool result {skipped_name}", - ) + ): + return break if agent.tool_delay > 0 and i < len(assistant_message.tool_calls): @@ -1796,6 +1857,8 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec segments = _plan_tool_batch_segments(assistant_message.tool_calls, execution_cwd=_exec_cwd) for kind, calls in segments: + if getattr(agent, "_incremental_persistence_failed", False): + return segment_message = SimpleNamespace(tool_calls=list(calls)) if kind == "parallel": execute_tool_calls_concurrent( @@ -1808,6 +1871,9 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec finalize=False, ) + if getattr(agent, "_incremental_persistence_failed", False): + return + # ── Whole-turn finalize (budget + /steer) ───────────────────────── total_tools = len(assistant_message.tool_calls) if total_tools > 0: diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index f08f1b60478..444ce373959 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -79,6 +79,7 @@ class ToolCallGuardrailConfig: no_progress_block_after: int = 5 idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES) mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES) + loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig()) @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig": @@ -121,6 +122,54 @@ class ToolCallGuardrailConfig: hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")), defaults.no_progress_block_after, ), + loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")), + ) + + +# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop +# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at +# the start of every agent loop (reset_for_turn), so the limit is "within a +# single turn" rather than cumulative over the whole session. A single loop +# issuing dozens of web searches or spawning dozens of subagents is already +# pathological, so the defaults are deliberately low. +_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50 +_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50 + + +@dataclass(frozen=True) +class LoopCapConfig: + """Per-turn caps on runaway-prone tool calls. + + Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on + WebSearch calls and subagent spawns to stop runaway search / delegation + loops. Here the caps count *within a single agent loop* (one turn): the + counters reset in ``reset_for_turn`` at the start of every + ``run_conversation``, so a legitimate multi-turn session is never starved, + but a single turn that spirals into an unbounded search / delegation loop + is stopped. + + Semantics differ from the per-turn loop *detector* above (which keys on + repeated identical/failing calls): these caps are a hard ceiling on the + total count of a tool within the turn and fire regardless of + ``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited). + """ + + max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN + max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN + + @classmethod + def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig": + """Build config from the ``tool_loop_guardrails.loop_caps`` section.""" + if not isinstance(data, Mapping): + return cls() + defaults = cls() + return cls( + max_web_searches=_non_negative_int( + data.get("max_web_searches"), defaults.max_web_searches + ), + max_subagents=_non_negative_int( + data.get("max_subagents"), defaults.max_subagents + ), ) @@ -233,6 +282,11 @@ class ToolCallGuardrailController: self._same_tool_failure_counts: dict[str, int] = {} self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {} self._halt_decision: ToolGuardrailDecision | None = None + # Per-turn runaway-loop cap counters. Reset every turn (this method + # runs at the start of each run_conversation), so the caps bound a + # single agent loop rather than accumulating across the session. + self._turn_web_search_count = 0 + self._turn_subagent_count = 0 @property def halt_decision(self) -> ToolGuardrailDecision | None: @@ -240,6 +294,17 @@ class ToolCallGuardrailController: def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision: signature = ToolCallSignature.from_call(tool_name, _coerce_args(args)) + + # ── Per-turn runaway-loop caps ────────────────────────────────── + # These are hard ceilings on how many times a runaway-prone tool may + # be called within a single agent loop (turn). They apply regardless + # of hard_stop_enabled (which only governs the per-turn loop detector). + # We block BEFORE the call runs once the count is already at the cap, + # then increment for an allowed call so the (cap+1)-th is refused. + cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature) + if cap_block is not None: + return cap_block + if not self.config.hard_stop_enabled: return ToolGuardrailDecision(tool_name=tool_name, signature=signature) @@ -379,6 +444,68 @@ class ToolCallGuardrailController: return False return tool_name in self.config.idempotent_tools + def _check_loop_cap( + self, + tool_name: str, + args: Mapping[str, Any], + signature: ToolCallSignature, + ) -> ToolGuardrailDecision | None: + """Enforce and advance the per-turn runaway-loop counters. + + Returns a ``block`` decision when the cap is already reached, otherwise + increments the relevant counter for the allowed call and returns + ``None``. A cap of 0 disables that limit entirely. Counters reset each + turn via ``reset_for_turn``. + """ + caps = self.config.loop_caps + + if tool_name == "web_search": + cap = caps.max_web_searches + if cap and self._turn_web_search_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="loop_web_search_cap", + message=( + f"Blocked web_search: this turn has already made {cap} " + "web searches, the per-turn limit. This looks like a " + "runaway search loop. Work with the results you already " + "have and give the user your answer." + ), + tool_name=tool_name, + count=self._turn_web_search_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._turn_web_search_count += 1 + return None + + if tool_name == "delegate_task": + cap = caps.max_subagents + if not cap: + return None + spawn_count = _subagent_spawn_count(args) + if self._turn_subagent_count >= cap: + decision = ToolGuardrailDecision( + action="block", + code="loop_subagent_cap", + message=( + f"Blocked delegate_task: this turn has already spawned " + f"{self._turn_subagent_count} subagents (limit {cap}). " + "This looks like a runaway delegation loop. Finish the " + "work with the results you have and answer the user." + ), + tool_name=tool_name, + count=self._turn_subagent_count, + signature=signature, + ) + self._halt_decision = decision + return decision + self._turn_subagent_count += spawn_count + return None + + return None + def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str: """Build a synthetic role=tool content string for a blocked tool call.""" @@ -471,6 +598,32 @@ def _positive_int(value: Any, default: int) -> int: return parsed if parsed >= 1 else default +def _non_negative_int(value: Any, default: int) -> int: + """Parse a session-cap value. 0 is a valid (disable) value; negatives and + junk fall back to the default.""" + if value is None: + return default + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 0 else default + + +def _subagent_spawn_count(args: Mapping[str, Any]) -> int: + """How many subagents a single delegate_task call spawns. + + delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty + list, one child per item) or a single task (``goal``). Count the batch size + when present, otherwise 1, so the session subagent cap reflects real spawns + rather than delegate_task invocations. + """ + tasks = args.get("tasks") if isinstance(args, Mapping) else None + if isinstance(tasks, list) and tasks: + return len(tasks) + return 1 + + def _sha256(value: str) -> str: # surrogatepass: tool results scraped from the web can carry unpaired # UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict diff --git a/agent/turn_context.py b/agent/turn_context.py index ac9555d012e..94dc1660d1a 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -336,6 +336,8 @@ def build_turn_context( persist_user_message: Optional[Any], persist_user_timestamp: Optional[float] = None, *, + persist_user_display_kind: Optional[str] = None, + persist_user_display_metadata: Optional[Dict[str, Any]] = None, restore_or_build_system_prompt, install_safe_stdio, sanitize_surrogates, @@ -537,6 +539,19 @@ def build_turn_context( # Add the current user message after the prompt/session setup has made # close persistence safe. The handoff above preserves any marker already # stamped by an earlier close flush. + # + # A synthesized turn (auto-continue recovery note, delegation completion) + # declares how it should READ in a transcript. Stamp that on the live + # message so the crash persist below writes the row already typed. Typing + # it after the turn instead leaves the row untyped for the whole run — and + # forever if the turn crashes — so the raw system note paints as a user + # bubble. The model still receives role/content unchanged; the api_messages + # build strips both fields from every outgoing copy. + if persist_user_display_kind: + user_msg["display_kind"] = persist_user_display_kind + if persist_user_display_metadata: + user_msg["display_metadata"] = persist_user_display_metadata + messages.append(user_msg) current_turn_user_idx = len(messages) - 1 agent._persist_user_message_idx = current_turn_user_idx diff --git a/agent/turn_summary.py b/agent/turn_summary.py new file mode 100644 index 00000000000..f4440afb50a --- /dev/null +++ b/agent/turn_summary.py @@ -0,0 +1,310 @@ +"""Per-turn accounting for the interactive CLI. + +Two display-only pieces live here: + +* :class:`TurnSummaryCollector` — a tiny observer that rides the existing + ``tool_progress_callback`` feed (``tool.completed`` events already carry + the tool name and its raw result) and tallies what a turn actually did. + It holds **no** agent-loop state: the display layer already sees every + tool call, so nothing new is threaded through the conversation loop. +* :func:`format_turn_summary` — a pure formatter that turns a tally plus a + wall-clock duration into one dim line, e.g.:: + + ⋯ 12.4s · edited 2 files +18 -3 · read 4 files · ran 3 commands + + Ported from Claude Code's post-turn accounting line + ("Edited 1 file +6 -2, read 1 file … Worked for 10s"). + +:func:`format_token_flow` is the spinner-side counterpart: a cumulative +token readout appended to the live elapsed timer (``↓ 1.2k tok``). + +Everything in this module is pure/side-effect free apart from the +collector's own counters, which makes it directly unit-testable without a +terminal, an agent, or a network call. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +__all__ = [ + "TurnSummaryCollector", + "TurnTally", + "format_turn_summary", + "format_token_flow", + "format_elapsed", +] + + +# Leading glyph for the summary line. Deliberately not an emoji — the line is +# meant to read as terminal chrome, not as agent speech. +SUMMARY_PREFIX = "⋯" + +# A turn that called no tools and finished this fast has nothing worth +# reporting (plain chat reply). Below the threshold the formatter returns "". +_MIN_TOOLLESS_SECONDS = 2.0 + +# Max number of "verb + count" segments rendered before collapsing the rest +# into a "+N more" tail, so a 12-tool turn cannot blow past one line. +_MAX_SEGMENTS = 4 + + +# Tool name -> (verb, singular noun, plural noun). +# +# Verbs are past tense because the line is printed *after* the turn. Tools not +# listed here fall into a generic "called N tools" bucket rather than inventing +# phrasing for plugin/MCP tools whose semantics we don't know. +_VERB_GROUPS: dict[str, tuple[str, str, str]] = { + "write_file": ("edited", "file", "files"), + "patch": ("edited", "file", "files"), + "read_file": ("read", "file", "files"), + "web_extract": ("read", "page", "pages"), + "terminal": ("ran", "command", "commands"), + "execute_code": ("ran", "script", "scripts"), + "search_files": ("searched", "path", "paths"), + "web_search": ("searched the web", "time", "times"), + "session_search": ("searched sessions", "time", "times"), + "browser_navigate": ("browsed", "page", "pages"), + "skill_view": ("read", "skill", "skills"), + "skill_manage": ("updated", "skill", "skills"), + "skills_list": ("listed skills", "time", "times"), + "todo": ("updated", "task list", "task lists"), + "delegate_task": ("delegated", "task", "tasks"), + "memory": ("updated", "memory", "memories"), +} + +# Verb groups that carry file-edit line deltas (+X -Y) when known. +_EDIT_VERB = "edited" + +# Render order: edits first (the thing users most want confirmed), then reads, +# then commands. Anything else follows in first-seen order. +_VERB_PRIORITY: tuple[str, ...] = ("edited", "read", "ran") + +# Tools whose results may report a unified diff we can count lines from. +_DIFF_RESULT_TOOLS = frozenset({"patch"}) + + +@dataclass +class TurnTally: + """What a single turn did, as observed from the tool-progress feed.""" + + # verb -> {noun_plural: count}; keeps insertion order for stable rendering. + verbs: dict[str, dict[str, int]] = field(default_factory=dict) + # Tools with no curated verb, counted together. + other_tools: int = 0 + # Aggregated unified-diff line deltas across edit tools, when reported. + lines_added: int = 0 + lines_removed: int = 0 + # True once at least one edit tool reported a countable diff, so the + # formatter knows the difference between "+0 -0" and "unknown". + has_line_deltas: bool = False + + @property + def total_tools(self) -> int: + counted = sum(sum(nouns.values()) for nouns in self.verbs.values()) + return counted + self.other_tools + + +def _count_diff_lines(diff: str) -> tuple[int, int]: + """Count added/removed lines in unified-diff text. + + File headers (``+++``/``---``) are excluded so a one-line edit does not + read as three additions. + """ + added = removed = 0 + for line in diff.splitlines(): + if line.startswith("+++") or line.startswith("---"): + continue + if line.startswith("+"): + added += 1 + elif line.startswith("-"): + removed += 1 + return added, removed + + +def _extract_line_deltas(tool_name: str, result: Any) -> tuple[int, int] | None: + """Pull (added, removed) from a tool result, or None when unavailable. + + Only tools that already report a diff in their result payload are + inspected — we never shell out to git and never re-read files to + synthesise a delta. + """ + if tool_name not in _DIFF_RESULT_TOOLS: + return None + payload: Any = result + if isinstance(payload, str): + text = payload.strip() + if not text.startswith("{"): + return None + try: + import json + + # strict=False tolerates literal control characters inside strings + # (raw newlines in an embedded diff), which some tool serialisers + # emit. A tally line is never worth failing over formatting. + payload = json.loads(text, strict=False) + except Exception: + return None + if not isinstance(payload, dict): + return None + diff = payload.get("diff") + if not isinstance(diff, str) or not diff.strip(): + return None + added, removed = _count_diff_lines(diff) + # A diff that carries no +/- content lines (e.g. a bare hunk header) tells + # us nothing — report it as unknown rather than rendering a misleading + # "+0 -0" next to a real edit. + if added == 0 and removed == 0: + return None + return added, removed + + +class TurnSummaryCollector: + """Accumulate per-turn tool tallies from the tool-progress feed. + + Wired into the CLI's existing ``_on_tool_progress`` handler: the display + layer already receives every ``tool.completed`` event with the tool name + and raw result, so no agent-loop bookkeeping is added. + """ + + def __init__(self) -> None: + self._tally = TurnTally() + + def begin(self) -> None: + """Start a fresh turn (drops any prior tally).""" + self._tally = TurnTally() + + def record_tool( + self, + tool_name: str | None, + *, + result: Any = None, + is_error: bool = False, + ) -> None: + """Record one completed tool call. + + Failed calls are skipped: a summary claiming "edited 2 files" when one + write was denied would be exactly the over-claim the file-mutation + verifier exists to catch. + """ + if not tool_name or is_error: + return + # Internal/pseudo tools (``_thinking``) are not user-visible work. + if tool_name.startswith("_"): + return + + group = _VERB_GROUPS.get(tool_name) + if group is None: + self._tally.other_tools += 1 + return + + verb, _singular, plural = group + nouns = self._tally.verbs.setdefault(verb, {}) + nouns[plural] = nouns.get(plural, 0) + 1 + + if verb == _EDIT_VERB: + deltas = _extract_line_deltas(tool_name, result) + if deltas is not None: + added, removed = deltas + self._tally.lines_added += added + self._tally.lines_removed += removed + self._tally.has_line_deltas = True + + @property + def tally(self) -> TurnTally: + return self._tally + + def render(self, elapsed_seconds: float) -> str: + """Render this turn's summary line (see :func:`format_turn_summary`).""" + return format_turn_summary(elapsed_seconds, self._tally) + + +def format_elapsed(seconds: float) -> str: + """Format a wall-clock duration compactly (``12.4s`` / ``2m05s``).""" + if seconds < 0: + seconds = 0.0 + if seconds < 60: + return f"{seconds:.1f}s" + minutes, rest = divmod(int(round(seconds)), 60) + return f"{minutes}m{rest:02d}s" + + +def _pluralize(count: int, plural_noun: str) -> str: + """Return ``"1 file"`` / ``"3 files"`` from a plural noun form.""" + if count == 1: + singular = plural_noun + if plural_noun.endswith("ies"): + singular = plural_noun[:-3] + "y" + elif plural_noun.endswith("ses"): + singular = plural_noun[:-2] + elif plural_noun.endswith("s"): + singular = plural_noun[:-1] + return f"1 {singular}" + return f"{count} {plural_noun}" + + +def _ordered_verbs(tally: TurnTally) -> list[str]: + """Verbs in render order: priority verbs first, then first-seen order.""" + seen = list(tally.verbs.keys()) + ranked = [v for v in _VERB_PRIORITY if v in tally.verbs] + ranked += [v for v in seen if v not in _VERB_PRIORITY] + return ranked + + +def format_turn_summary( + elapsed_seconds: float, + tally: TurnTally | None, + *, + max_segments: int = _MAX_SEGMENTS, +) -> str: + """Render the per-turn accounting line, or ``""`` when there's nothing to say. + + Pure function — no config lookups, no terminal access, no I/O. Gating + (``display.turn_summary``, quiet mode, CLI-only) is the caller's job. + """ + if tally is None: + tally = TurnTally() + + segments: list[str] = [] + for verb in _ordered_verbs(tally): + nouns = tally.verbs[verb] + parts = [_pluralize(count, plural) for plural, count in nouns.items() if count] + if not parts: + continue + segment = f"{verb} {', '.join(parts)}" + if verb == _EDIT_VERB and tally.has_line_deltas: + segment += f" +{tally.lines_added} -{tally.lines_removed}" + segments.append(segment) + + if tally.other_tools: + segments.append(f"called {_pluralize(tally.other_tools, 'tools')}") + + if not segments and tally.total_tools == 0 and elapsed_seconds < _MIN_TOOLLESS_SECONDS: + return "" + + if max_segments > 0 and len(segments) > max_segments: + hidden = len(segments) - max_segments + segments = segments[:max_segments] + [f"+{hidden} more"] + + pieces = [format_elapsed(elapsed_seconds)] + segments + return f"{SUMMARY_PREFIX} " + " · ".join(pieces) + + +def format_token_flow(output_tokens: Any, *, arrow: str = "↓") -> str: + """Render cumulative turn tokens for the live spinner (``↓ 1.2k tok``). + + Returns ``""`` for a non-positive count so the spinner shows nothing + rather than a misleading ``↓ 0 tok`` before the first API response lands. + """ + try: + count = int(output_tokens) + except (TypeError, ValueError): + return "" + if count <= 0: + return "" + if count < 1000: + return f"{arrow} {count} tok" + if count < 1_000_000: + return f"{arrow} {count / 1000:.1f}k tok" + return f"{arrow} {count / 1_000_000:.1f}M tok" diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs index 1d70ec59a61..f78b26134e4 100644 --- a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs +++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs @@ -12,11 +12,11 @@ //! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`. //! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::{mpsc, Mutex}; @@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool { && resolve_hermes_desktop_exe(install_root).is_some() } +fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option { + if let Some(commit) = pin + .commit + .as_ref() + .filter(|commit| !commit.trim().is_empty()) + { + return Some(commit.clone()); + } + + let output = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(install_root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if commit.is_empty() { + None + } else { + Some(commit) + } +} + +fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result { + use std::io::Write; + + let marker_path = crate::paths::likely_bootstrap_marker(install_root); + if let Some(parent) = marker_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "could not create bootstrap marker directory {}", + parent.display() + ) + })?; + } + + let completed_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let marker = serde_json::json!({ + "schemaVersion": 1, + "pinnedCommit": resolve_marker_commit(install_root, pin), + "pinnedBranch": pin.branch.clone(), + "completedAtUnix": completed_at_unix, + }); + let mut body = serde_json::to_vec_pretty(&marker)?; + body.push(b'\n'); + + // Atomic publish (temp sibling + flush + rename), matching Electron's + // writeFileAtomic(). hermes_is_installed() only checks existence, so a + // partial direct write would incorrectly enable the launcher fast path. + let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp"); + { + let mut file = std::fs::File::create(&tmp_path).with_context(|| { + format!( + "could not create temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.write_all(&body).with_context(|| { + format!( + "could not write temp bootstrap marker {}", + tmp_path.display() + ) + })?; + file.sync_all().with_context(|| { + format!( + "could not flush temp bootstrap marker {}", + tmp_path.display() + ) + })?; + } + // Windows rename fails if the destination already exists; drop any prior + // marker first so a re-run can still publish a fresh payload. + if marker_path.exists() { + std::fs::remove_file(&marker_path).with_context(|| { + format!( + "could not replace existing bootstrap marker {}", + marker_path.display() + ) + })?; + } + if let Err(err) = std::fs::rename(&tmp_path, &marker_path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err).with_context(|| { + format!( + "could not publish bootstrap marker {} → {}", + tmp_path.display(), + marker_path.display() + ) + }); + } + + tracing::info!(path = %marker_path.display(), "bootstrap marker written"); + Ok(marker) +} + /// Spawn the already-built desktop app, detached. Returns Err if no built app /// exists or the spawn fails, so the caller can fall back to showing the /// installer UI. @@ -644,6 +745,23 @@ async fn run_bootstrap( .unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned()); let install_root = PathBuf::from(&hermes_home).join("hermes-agent"); + // Marker publish is terminal for this run: a write failure must emit Failed + // so the UI leaves the progress state (it does not poll get_bootstrap_status). + let marker = match write_bootstrap_complete_marker(&install_root, &pin) { + Ok(marker) => marker, + Err(err) => { + let msg = format!("write bootstrap marker failed: {err:#}"); + emit_event( + &app, + BootstrapEvent::Failed { + stage: None, + error: msg.clone(), + }, + ); + return Err(anyhow!(msg)); + } + }; + // Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can // re-invoke us with `--update` and shortcuts have a stable target. This is // a one-shot install concern; an `--update` re-invocation no-ops because @@ -660,10 +778,7 @@ async fn run_bootstrap( &app, BootstrapEvent::Complete { install_root: install_root.to_string_lossy().into_owned(), - marker: Some(serde_json::json!({ - "pinnedCommit": pin.commit, - "pinnedBranch": pin.branch, - })), + marker: Some(marker), }, ); @@ -903,4 +1018,103 @@ mod tests { ); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn bootstrap_complete_marker_uses_desktop_compatible_schema() { + let root = unique_tmp_dir("marker-schema"); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let marker = + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + let marker_path = root.join(".hermes-bootstrap-complete"); + let from_disk: serde_json::Value = + serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap(); + + assert_eq!(marker, from_disk); + assert_eq!(from_disk["schemaVersion"], 1); + assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890"); + assert_eq!(from_disk["pinnedBranch"], "main"); + assert!( + from_disk["completedAtUnix"].as_u64().is_some(), + "marker must carry a completion timestamp" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn bootstrap_complete_marker_is_published_atomically() { + let root = unique_tmp_dir("marker-atomic"); + make_release_tree(&root); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); + + let marker_path = root.join(".hermes-bootstrap-complete"); + let tmp_path = root.join(".hermes-bootstrap-complete.tmp"); + assert!( + marker_path.is_file(), + "final marker must exist after atomic publish" + ); + assert!( + !tmp_path.exists(), + "temp sibling must not remain after atomic publish" + ); + assert!( + hermes_is_installed(&root), + "atomically published marker must enable the installer fast path" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn hermes_is_installed_treats_marker_existence_as_sufficient() { + // Documents why write_bootstrap_complete_marker must publish atomically: + // the launcher predicate only checks existence, so a partial/corrupt + // final marker would still enable the fast path. + let root = unique_tmp_dir("marker-existence-only"); + make_release_tree(&root); + std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap(); + + assert!( + hermes_is_installed(&root), + "empty/partial marker content still counts as installed" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn marker_write_failure_leaves_no_final_marker() { + // install_root is a regular file → create_dir_all on its path fails + // before any marker bytes are published under the final name. + let base = unique_tmp_dir("marker-fail"); + let not_a_dir = base.join("not-a-dir"); + std::fs::write(¬_a_dir, b"not a directory").unwrap(); + let pin = Pin { + commit: Some("abcdef1234567890".to_string()), + branch: Some("main".to_string()), + }; + + let err = write_bootstrap_complete_marker(¬_a_dir, &pin) + .expect_err("marker write against a non-directory root must fail"); + let msg = format!("{err:#}"); + assert!( + msg.contains("bootstrap marker"), + "error should mention the marker path: {msg}" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete").exists(), + "failed write must not leave a final marker that enables the fast path" + ); + assert!( + !not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(), + "failed write must not leave a temp marker sibling either" + ); + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/apps/bootstrap-installer/src-tauri/src/paths.rs b/apps/bootstrap-installer/src-tauri/src/paths.rs index 7c64c91cf6e..0eec8ccd319 100644 --- a/apps/bootstrap-installer/src-tauri/src/paths.rs +++ b/apps/bootstrap-installer/src-tauri/src/paths.rs @@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) { #[cfg(not(target_os = "macos"))] fn repair_macos_installer_helper(_path: &Path) {} -/// Where install.ps1 writes the bootstrap-complete marker (existence-only file -/// the Electron app also checks). Per main.ts: +/// Where the bootstrap-complete marker lives (existence-only for the Rust +/// installer fast path; JSON schema-checked by the Electron app). Per main.ts: /// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') /// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so /// this is a probe helper, not a definitive path. diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 53075c5d49b..dae529dca90 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -117,20 +117,31 @@ that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro` (status-stack/table-footers), and the icon family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`. -**Icon-only buttons must have a tooltip.** Every button with an `icon*` size -carries no visible text label, so it must be wrapped in `` -with a descriptive label (matching the button's `aria-label`). Never use the -native HTML `title=` attribute — it's unstyled, delayed (~500ms OS default), -and visually inconsistent with the instant themed `Tip`. An enforcement test -(`src/components/ui/__tests__/no-native-title.test.ts`) fails on any ` - {onRemove && ( + <> + +
- )} -
-
+ {onRemove && ( + + )} + +
+ {lightboxSrc && ( + + )} + ) } diff --git a/apps/desktop/src/app/chat/composer/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts index 4df8463ba2d..062b2567738 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -2,12 +2,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' import { describe, expect, it } from 'vitest' import { + acceptsTriggerCompletion, isPendingDraftPersistCurrent, type PendingDraftPersist, pickPlaceholder, slashArgStage, slashChipKindForItem, - slashCommandToken + slashCommandToken, + type TriggerAcceptInput } from './composer-utils' const item = (group: string): Unstable_TriggerItem => @@ -39,6 +41,53 @@ describe('slashChipKindForItem', () => { }) }) +describe('acceptsTriggerCompletion', () => { + const press = (key: string, overrides: Partial = {}) => + acceptsTriggerCompletion({ + activeExplicit: false, + freeTextArgStage: false, + key, + kind: '/', + query: 'personality alic', + ...overrides + }) + + it('accepts on Enter / Tab / Space for a finite option list', () => { + expect(press('Enter')).toBe(true) + expect(press('Tab')).toBe(true) + expect(press(' ')).toBe(true) + }) + + it('ignores keys that are neither navigation nor acceptance', () => { + expect(press('a')).toBe(false) + expect(press('Escape')).toBe(false) + }) + + it('lets an `@` mention take a literal space', () => { + expect(press(' ', { kind: '@', query: 'src/comp' })).toBe(false) + expect(press('Enter', { kind: '@', query: 'src/comp' })).toBe(true) + }) + + it('types a space on a bare `/ ` instead of accepting', () => { + expect(press(' ', { query: '' })).toBe(false) + }) + + // The `/goal ` class: the popover may be live over free-form text, so + // the keys that mean something else in prose must keep meaning it. + it('sends the prose rather than the unchosen first row', () => { + expect(press('Enter', { freeTextArgStage: true, query: 'goal ship the redesign' })).toBe(false) + expect(press(' ', { freeTextArgStage: true, query: 'goal ship the' })).toBe(false) + }) + + it('accepts on Enter once the user has arrowed to a row deliberately', () => { + expect(press('Enter', { activeExplicit: true, freeTextArgStage: true, query: 'goal stat' })).toBe(true) + }) + + it('keeps Tab as the explicit accept even over free text', () => { + expect(press('Tab', { freeTextArgStage: true, query: 'goal stat' })).toBe(true) + }) +}) + describe('pickPlaceholder', () => { it('returns a member of the pool', () => { const pool = ['a', 'b', 'c'] as const diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 547a210f06f..21e3c1ac4a1 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -4,6 +4,8 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text' import type { ComposerAttachment } from '@/store/composer' import { setSessionPickerOpen } from '@/store/session' +import type { TriggerState } from './text-utils' + export const COMPOSER_STACK_BREAKPOINT_PX = 320 // Above the stack breakpoint but still cramped: the model pill sheds its label @@ -59,6 +61,48 @@ export const slashArgStage = (query: string) => query.includes(' ') /** The `/command` token of a slash query (`personality x` → `/personality`). */ export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` +export interface TriggerAcceptInput { + /** The user moved the highlight themselves (arrow keys) rather than + * inheriting the list's default first row. */ + activeExplicit: boolean + /** The trigger is a slash command whose argument is arbitrary prose. */ + freeTextArgStage: boolean + key: string + kind: TriggerState['kind'] + query: string +} + +/** + * Whether a keypress accepts the highlighted completion while the popover is + * open. Tab is always an accept — it has no other meaning in the composer. + * + * Enter and Space are conditional, because both mean something else while a + * free-text argument is being written (`/goal ship the redesign`). Space types + * a space, and Enter sends the message; letting either take the popover's + * pre-highlighted row would swap the prose the user is mid-sentence on for a + * subcommand they never chose. Enter still accepts once the user has arrowed + * to a row deliberately, so the highlight never lies about what Enter will do. + */ +export function acceptsTriggerCompletion({ + activeExplicit, + freeTextArgStage, + key, + kind, + query +}: TriggerAcceptInput): boolean { + if (key === 'Tab') { + return true + } + + if (key === 'Enter') { + return !freeTextArgStage || activeExplicit + } + + // Space is slash-only (an `@` mention takes a literal space) and gated to a + // non-empty query so a bare `/ ` still types a space. + return key === ' ' && kind === '/' && Boolean(query.trim()) && !freeTextArgStage +} + export interface QueueEditState { attachments: ComposerAttachment[] draft: string diff --git a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx index ff01bf6fd37..ad35b20faa3 100644 --- a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +++ b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx @@ -29,7 +29,8 @@ function Harness({ onSubmit, onQueue, onCancel, - onDrain + onDrain, + onSendNow }: { busy?: boolean disabled?: boolean @@ -38,6 +39,7 @@ function Harness({ onQueue: (text: string) => void onCancel: () => void onDrain: () => void + onSendNow?: (id: string) => void }) { const editorRef = useRef(null) const draftRef = useRef('') @@ -103,6 +105,12 @@ function Harness({ } if (busy && !hasLivePayload) { + const head = queued[0] + + if (head) { + onSendNow?.(head) + } + return } @@ -167,13 +175,14 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', expect(onCancel).not.toHaveBeenCalled() }) - it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => { + it('treats an empty Enter while busy with nothing queued as a no-op (never an accidental Stop)', async () => { const onCancel = vi.fn() const onSubmit = vi.fn() const onQueue = vi.fn() + const onSendNow = vi.fn() const { getByTestId } = render( - + ) const editor = getByTestId('editor') @@ -186,6 +195,35 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', expect(onCancel).not.toHaveBeenCalled() expect(onSubmit).not.toHaveBeenCalled() expect(onQueue).not.toHaveBeenCalled() + expect(onSendNow).not.toHaveBeenCalled() + }) + + it('double-send: an empty Enter while busy with a queued turn sends that turn now', async () => { + const onCancel = vi.fn() + const onSendNow = vi.fn() + + const { getByTestId } = render( + + ) + + const editor = getByTestId('editor') + + await act(async () => { + editor.textContent = '' + fireEvent.keyDown(editor, { key: 'Enter' }) + }) + + // Head of the queue, and NOT a bare cancel — send-now promotes + interrupts. + expect(onSendNow).toHaveBeenCalledWith('queued-1') + expect(onCancel).not.toHaveBeenCalled() }) it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index 949b8f1020c..aa657ca8291 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react' import { playSpeechText } from '@/lib/voice-playback' import { ownsAmbientCue } from '@/store/ambient' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $voicePlayback } from '@/store/voice-playback' import { $autoSpeakReplies } from '@/store/voice-prefs' +import { useComposerScope } from '../scope' + interface AutoSpeakReply { id: string pending: boolean @@ -40,6 +41,9 @@ export function useAutoSpeakReplies({ sessionId }: UseAutoSpeakReplies) { const enabled = useStore($autoSpeakReplies) + // Wake on THIS composer's transcript: a tile subscribed to the primary's + // would never fire on its own replies (and would fire on someone else's). + const { $messages } = useComposerScope() const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply }) latest.current = { conversationActive, failureLabel, markSpoken, pendingReply } @@ -83,5 +87,5 @@ export function useAutoSpeakReplies({ const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)] return () => stops.forEach(f => f()) - }, [enabled, sessionId]) + }, [$messages, enabled, sessionId]) } 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 11aa35dfaf7..43b8514cc35 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -408,6 +408,7 @@ export function useComposerDraft({ requestMainFocus, sessionIdRef, setComposerText, - stashAt + stashAt, + syncDraftFromEditor } } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts index 228758dd905..771bf2a9dd3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -9,8 +9,6 @@ import { } from '@/app/chat/surface-vars' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' -import { $composerPoppedOut } from '@/store/composer-popout' -import { isSecondaryWindow } from '@/store/windows' import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' @@ -82,6 +80,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, const lastBucketedSurfaceHeightRef = useRef(0) const lastTightRef = useRef(null) const lastCompactPillRef = useRef(null) + // Mirrored into a ref so `syncComposerMetrics` stays referentially stable — + // it's the shared ResizeObserver's handler, and a new identity every render + // would re-register the observation. + const poppedOutRef = useRef(poppedOut) + poppedOutRef.current = poppedOut const syncComposerMetrics = useCallback(() => { const composer = composerRef.current @@ -92,9 +95,10 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, // Floating composer is out of the thread's flow — it must not reserve any // bottom clearance. Zero the measured vars so the thread reclaims the space. - // (Read globals here so the callback stays stable; mirror the popoutAllowed - // gate since secondary windows are forced docked.) - if ($composerPoppedOut.get() && !isSecondaryWindow()) { + // Read through a ref so the callback stays stable, and read THIS surface's + // own state: pop-out is per layout zone, so a float in the left split must + // not zero the right split's clearance. + if (poppedOutRef.current) { lastBucketedHeightRef.current = 0 lastBucketedSurfaceHeightRef.current = 0 setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px') diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts index 518aa3658a5..b326025bb95 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts @@ -1,18 +1,20 @@ import { useStore } from '@nanostores/react' -import { type RefObject, useCallback, useEffect } from 'react' +import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visibility' +import { useResizeObserver } from '@/hooks/use-resize-observer' import { triggerHaptic } from '@/lib/haptics' import { - $composerPopoutPosition, - $composerPoppedOut, + $composerPopoutZone, + clampPopoutPosition, + getComposerPopoutZone, + popoutBoundsElement, + type PopoutPosition, readPopoutBounds, - setComposerPopoutPosition, setComposerPoppedOut } from '@/store/composer-popout' import { isSecondaryWindow } from '@/store/windows' -import { useComposerScope } from '../scope' - import { useComposerPopoutGestures } from './use-popout-drag' interface UseComposerPopoutOptions { @@ -20,32 +22,122 @@ interface UseComposerPopoutOptions { } /** - * Pop-out engine: the docked↔floating state (a shared, persisted atom), the - * dock/float/toggle actions, the drag gestures, and the on-screen re-clamp. - * Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't - * pop out — a floating composer makes no sense there and would yank the main - * window's composer out via the shared atom. + * This surface's on-screen placement, derived from its zone's drag intent. + * + * A zone stores one intent for its whole tab stack — drag the box in any tab and + * it moves in all of them — but each surface owns a different rect, so the + * intent is clamped per surface. Clamping into the store instead would have + * every keep-alive-mounted tab overwrite the others with a position bounded by + * ITS geometry, last writer winning: that's how a drag in one tab used to get + * lost in another. + * + * Re-placing is skipped while this surface drags (the gesture already clamped + * against this rect) and while it's an inactive tab (still mounted, so a live + * drag would otherwise force a reflow per background tab per frame). + */ +function usePopoutPlacement( + composerRef: RefObject, + groupId: string, + intent: PopoutPosition, + dragging: boolean, + poppedOut: boolean +): PopoutPosition { + const [placement, setPlacement] = useState(intent) + const visible = usePaneVisible() + // Re-place while this surface is the visible tab and isn't itself dragging. + const live = poppedOut && visible && !dragging + + // Resolved before the shared ResizeObserver below registers (hook order puts + // this layout effect first), so the observer always has this surface's own + // bounds element rather than a document-wide first match. + const boundsRef = useRef(null) + + useLayoutEffect(() => { + boundsRef.current = popoutBoundsElement(composerRef.current) + }) + + const reclamp = useCallback(() => { + const el = composerRef.current + + if (!el) { + return + } + + const size = { height: el.offsetHeight, width: el.offsetWidth } + const next = clampPopoutPosition(getComposerPopoutZone(groupId).position, size, readPopoutBounds(el)) + + // Bail on an unchanged placement: a sash drag resizes the surface every + // frame, and a fresh object each time re-renders the whole composer. + setPlacement(prev => (prev.bottom === next.bottom && prev.right === next.right ? prev : next)) + }, [composerRef, groupId]) + + // The surface resizing (sash drag, sidebar open, tab split) re-places the box + // against its new rect; the composer resizing (a growing draft) re-places it + // against its new height. + useResizeObserver( + useCallback(() => { + if (live) { + reclamp() + } + }, [live, reclamp]), + composerRef, + boundsRef + ) + + // useLayoutEffect, not useEffect: a tab revealed after the box was dragged in + // another one must not paint a frame at its stale placement before catching + // up. Runs before paint, and no-ops for hidden tabs (`live`). + useLayoutEffect(() => { + if (!live) { + return undefined + } + + reclamp() + // A second pass after layout settles (sidebar widths, fonts): anyone + // restored out of bounds is pulled back even if the first measure was + // premature. + const raf = requestAnimationFrame(reclamp) + window.addEventListener('resize', reclamp) + + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', reclamp) + } + }, [intent, live, reclamp]) + + return dragging ? intent : placement +} + +/** + * Pop-out engine: the docked↔floating state, the dock/float/toggle actions, the + * drag gestures, and this surface's placement. + * + * State is scoped to the surface's layout ZONE (its tab stack): tabs in the same + * zone share one float, so switching tabs keeps the box exactly where you put + * it, while a split zone beside them keeps its own — popping out on the left + * doesn't fling a composer out of the right. + * + * Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) stay + * docked: a floating composer makes no sense in a scratch window. */ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) { - // The floating composer is a window-level singleton: only the main scope - // (not tiles) in a primary window may pop out. - const scope = useComposerScope() - const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed - const poppedOut = useStore($composerPoppedOut) && popoutAllowed - const popoutPosition = useStore($composerPopoutPosition) + const popoutAllowed = !isSecondaryWindow() + const groupId = usePaneGroup() + const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId])) + const poppedOut = zone.poppedOut && popoutAllowed const handleComposerPopOut = useCallback(() => { triggerHaptic('open') - setComposerPoppedOut(true) - }, []) + setComposerPoppedOut(groupId, true) + }, [groupId]) const handleComposerDock = useCallback(() => { triggerHaptic('success') - setComposerPoppedOut(false) - }, []) + setComposerPoppedOut(groupId, false) + }, [groupId]) // Double-click the grab area toggles dock/float. Undocking restores the last - // position (the persisted atom is never cleared on dock). + // position (a zone's stored position is never cleared on dock). const handleComposerToggle = useCallback(() => { poppedOut ? handleComposerDock() : handleComposerPopOut() }, [handleComposerDock, handleComposerPopOut, poppedOut]) @@ -56,39 +148,14 @@ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) { onPointerDown: onComposerGesturePointerDown } = useComposerPopoutGestures({ composerRef, + groupId, onDock: handleComposerDock, onPopOut: handleComposerPopOut, poppedOut, - position: popoutPosition + position: zone.position }) - // Keep the floating box on-screen: re-clamp (with the real measured size + - // thread bounds) when it pops out and on every window resize — so a position - // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar - // can never strand it. The rAF pass re-clamps after layout settles (sidebar - // widths, fonts), so anyone loading in out of bounds is pulled back + saved - // even if the first measure was premature. - useEffect(() => { - if (!poppedOut) { - return undefined - } - - const reclamp = (persist: boolean) => { - const el = composerRef.current - const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined - setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size }) - } - - reclamp(true) - const raf = requestAnimationFrame(() => reclamp(true)) - const onResize = () => reclamp(false) - window.addEventListener('resize', onResize) - - return () => { - cancelAnimationFrame(raf) - window.removeEventListener('resize', onResize) - } - }, [composerRef, poppedOut]) + const popoutPosition = usePopoutPlacement(composerRef, groupId, zone.position, dragging, poppedOut) return { dockProximity, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index dff3804bb69..e1bb5442fef 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -106,7 +106,9 @@ export function useComposerQueue({ entryId: entry.id, sessionKey: activeQueueSessionKey }) - loadIntoComposer(entry.text, entry.attachments) + // Edit what the panel SHOWS. A queued `/skill` entry's text is the + // expanded skill body — never drop that into the composer. + loadIntoComposer(entry.displayText ?? entry.text, entry.attachments) triggerHaptic('selection') focusInput() } @@ -135,7 +137,7 @@ export function useComposerQueue({ if (next) { setQueueEditSnapshot({ ...queueEdit, entryId: next.id }) - loadIntoComposer(next.text, next.attachments) + loadIntoComposer(next.displayText ?? next.text, next.attachments) } else { setQueueEditSnapshot(null) loadIntoComposer(queueEdit.draft, queueEdit.attachments) @@ -213,6 +215,7 @@ export function useComposerQueue({ const accepted = await Promise.resolve( onSubmit(entry.text, { attachments: entry.attachments, + ...(entry.displayText ? { displayText: entry.displayText } : {}), fromQueue: true, sessionId: drainRuntimeSessionId, storedSessionId: drainQueueSessionKey diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index b3bcaaa463e..dd83d04f0c0 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -1,7 +1,9 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { $clarifyRequests } from '@/store/clarify' import type { ComposerAttachment } from '@/store/composer' +import { $gateway } from '@/store/gateway' import { useComposerSubmit } from './use-composer-submit' @@ -184,3 +186,80 @@ describe('useComposerSubmit busy-turn routing', () => { ) }) }) + +describe('useComposerSubmit with a clarify parked on the session', () => { + const gatewayRequest = vi.fn(async () => ({ ok: true })) + + const parkClarify = (sessionId: string) => { + $clarifyRequests.set({ + [sessionId]: { requestId: `req-${sessionId}`, question: 'which one?', choices: ['a', 'b'], sessionId } + }) + $gateway.set({ request: gatewayRequest } as unknown as ReturnType) + } + + afterEach(() => { + cleanup() + gatewayRequest.mockClear() + $clarifyRequests.set({}) + $gateway.set(null) + vi.restoreAllMocks() + }) + + it('skips the question and still sends the typed message on an idle session', async () => { + parkClarify('runtime-session') + const { hook, onSubmit } = renderSubmitHook({ text: 'actually do this instead' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => + expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { + request_id: 'req-runtime-session', + answer: '' + }) + ) + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] })) + ) + expect($clarifyRequests.get()['runtime-session']).toBeUndefined() + }) + + it('skips the question before steering a busy turn', async () => { + parkClarify('runtime-session') + const { hook, onSteer } = renderSubmitHook({ busy: true, text: 'change course' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course')) + expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' }) + }) + + it('leaves the question alone for an empty Enter (Stop, not an answer)', () => { + parkClarify('runtime-session') + const { hook, onCancel } = renderSubmitHook({ busy: true }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(gatewayRequest).not.toHaveBeenCalled() + expect($clarifyRequests.get()['runtime-session']).toBeDefined() + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it("leaves another session's question alone", async () => { + parkClarify('other-session') + const { hook, onSubmit } = renderSubmitHook({ text: 'unrelated message' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()) + expect(gatewayRequest).not.toHaveBeenCalled() + expect($clarifyRequests.get()['other-session']).toBeDefined() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index 315157a96e5..9d34ddfca3f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -2,12 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' +import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify' import { clearSessionDraft, type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' import { cloneAttachments, type QueueEditState } from '../composer-utils' import { onComposerSubmitRequest } from '../focus' +import { pathifyRefs } from '../path-refs' import { composerPlainText } from '../rich-editor' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -138,9 +140,27 @@ export function useComposerSubmit({ } } - const text = draftRef.current + // A path that never got its committing space (`@apps/desktop/` left by a Tab + // descend, then Enter) is still the reference the user picked — promote it + // on the way out so it attaches instead of submitting as inert text. + const text = pathifyRefs(draftRef.current) const payloadPresent = text.trim().length > 0 || attachments.length > 0 + // A clarify card parked on this session owns the turn: the agent is blocked + // inside its tool batch waiting on `clarify.respond`, so a follow-up routed + // through steer/queue sits undelivered until the clarify's own timeout + // (default 5 min) — the message looks sent and nothing happens. Typing a + // real message instead of picking an option IS the answer "none of these": + // skip the question so the tool returns, then route the words normally. + // + // Fire-and-forget, not awaited: the skip clears the card synchronously and + // both RPCs ride the same socket in call order, so the gateway resolves the + // clarify before it sees the follow-up. Awaiting first would leave the draft + // live for a tick — long enough for a second Enter to send it twice. + if (payloadPresent && !queueEdit && hasClarifyRequest(sessionId)) { + void skipClarifyRequest(sessionId) + } + if (queueEdit) { exitQueuedEdit('save') } else if (busy) { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts index c356fae3af8..a778262d370 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts @@ -120,4 +120,106 @@ describe('useComposerTrigger — slash anywhere in the prompt', () => { expect(hook.result.current.trigger).toBeNull() }) + + it('opens the list for a second slash after a leading command', () => { + // `/work /cle`: the command regex's argument tail would otherwise swallow + // `/cle` as an argument to `/work`, and a no-arg command suppresses the + // popover — so every slash after the first went dead. + const editor = mountEditor('/work /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' }) + expect(hook.result.current.triggerItems).toHaveLength(1) + }) + + it('inserts the second command without disturbing the first', () => { + const editor = mountEditor('/work rewrite the composer /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(item('/clean'))) + + expect(composerPlainText(editor)).toBe('/work rewrite the composer /clean ') + }) +}) + +describe('useComposerTrigger — free-text slash arguments', () => { + it('keeps a picked /goal command as editable text while retaining subcommand completion', () => { + const editor = mountEditor('/go') + const goal = item('/goal', 'Commands') + const { hook } = mountTrigger(editor, [goal]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(goal)) + + expect(composerPlainText(editor)).toBe('/goal ') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + expect(hook.result.current.trigger).not.toBeNull() + }) + + it('does not seal a multi-word /goal into a chip when the option list runs empty', () => { + const editor = mountEditor('/goal finish the full prompt') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(true) + expect(hook.result.current.commitTypedSlashDirective()).toBe(false) + expect(composerPlainText(editor)).toBe('/goal finish the full prompt') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + }) + + it('treats the default highlight as a suggestion until the user arrows to a row', () => { + const editor = mountEditor('/goal stat') + const { hook } = mountTrigger(editor, [item('/goal status', 'Options')]) + + act(() => hook.result.current.refreshTrigger()) + expect(hook.result.current.triggerActiveExplicit).toBe(false) + + act(() => hook.result.current.moveTriggerActive(1)) + expect(hook.result.current.triggerActiveExplicit).toBe(true) + }) + + it('drops a deliberate selection once the query moves on', () => { + const editor = mountEditor('/goal stat') + const { hook } = mountTrigger(editor, [item('/goal status', 'Options')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.moveTriggerActive(1)) + + renderComposerContents(editor, '/goal start the migration') + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerActiveExplicit).toBe(false) + }) + + it('keeps a multi-word /resume search typeable instead of firing the picker action', () => { + // The session list always ends in a "Browse all sessions…" action row, so + // an accept here doesn't insert a chip — it empties the composer and opens + // the overlay, taking the half-typed query with it. + const editor = mountEditor('/resume my new') + const { hook } = mountTrigger(editor, [item('/resume', 'Sessions')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(true) + expect(hook.result.current.commitTypedSlashDirective()).toBe(false) + expect(composerPlainText(editor)).toBe('/resume my new') + }) + + it('still commits a fully typed finite option as one directive chip', () => { + const editor = mountEditor('/personality creative') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(false) + act(() => { + expect(hook.result.current.commitTypedSlashDirective()).toBe(true) + }) + expect(composerPlainText(editor)).toBe('/personality creative ') + expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative') + }) }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 3c22fddb634..ea968fb3643 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -2,7 +2,7 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' -import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' +import { desktopSlashCommandArgumentMode } from '@/lib/desktop-slash-commands' import { COMPLETION_ACTIONS, @@ -53,6 +53,11 @@ export function useComposerTrigger({ }: UseComposerTriggerOptions) { const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) + // The list highlights its first row on open, which is a suggestion rather + // than a choice. This records that the user moved the highlight themselves, + // which is what lets Enter accept a completion in a free-text argument stage + // without stealing prose from everyone who never touched the arrows. + const [triggerActiveExplicit, setTriggerActiveExplicit] = useState(false) const [triggerItems, setTriggerItems] = useState([]) // Set synchronously in keydown when the open trigger popover consumes a // navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must @@ -63,6 +68,11 @@ export function useComposerTrigger({ // re-rendered and the handler closure sees the post-keydown state. const triggerKeyConsumedRef = useRef(false) + const resetTriggerActive = useCallback(() => { + setTriggerActive(0) + setTriggerActiveExplicit(false) + }, []) + const refreshTrigger = useCallback(() => { const editor = editorRef.current @@ -80,7 +90,7 @@ export function useComposerTrigger({ if (!rawText.includes('@') && !rawText.includes('/')) { if (trigger) { setTrigger(null) - setTriggerActive(0) + resetTriggerActive() } return @@ -89,11 +99,16 @@ export function useComposerTrigger({ const before = textBeforeCaret(editor) const found = detectTrigger(before ?? composerPlainText(editor)) - // The arg-stage popover is only useful for commands with an options screen. - // For a no-arg command it would dead-end on "No matches", so drop it — the - // directive is already complete. + // A text-only command has no completion screen once its prose begins. Mixed + // commands such as /goal stay live so their finite subcommands can still be + // suggested, while arbitrary goal text remains valid. + const argumentMode = + found?.kind === '/' && slashArgStage(found.query) + ? desktopSlashCommandArgumentMode(slashCommandToken(found.query)) + : null + const detected = - found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query)) + found?.kind === '/' && slashArgStage(found.query) && argumentMode !== 'options' && argumentMode !== 'mixed' ? null : found @@ -104,9 +119,9 @@ export function useComposerTrigger({ // caret move (mouseup) or a stray refresh — must preserve the user's // current selection instead of snapping back to the first item. if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) { - setTriggerActive(0) + resetTriggerActive() } - }, [editorRef, trigger]) + }, [editorRef, resetTriggerActive, trigger]) const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null @@ -134,10 +149,23 @@ export function useComposerTrigger({ // Space/Tab — neither should dead-end on a popover. const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length + const slashArgumentMode = + trigger?.kind === '/' && slashArgStage(trigger.query) + ? desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) + : null + + const slashFreeTextArgStage = slashArgumentMode === 'mixed' || slashArgumentMode === 'text' + const closeTrigger = () => { setTrigger(null) setTriggerItems([]) - setTriggerActive(0) + resetTriggerActive() + } + + /** Step the highlight, marking it as the user's own deliberate pick. */ + const moveTriggerActive = (delta: number) => { + setTriggerActiveExplicit(true) + setTriggerActive(idx => (idx + delta + triggerItems.length) % triggerItems.length) } useEffect(() => { @@ -148,9 +176,16 @@ export function useComposerTrigger({ // the completion list is empty because the arg is already fully typed (the // backend completer drops exact matches). Reuses the chip path via a // synthetic item whose serialized form is the verbatim text. - const commitTypedSlashDirective = () => { + const commitTypedSlashDirective = (): boolean => { if (trigger?.kind !== '/') { - return + return false + } + + // Free prose must stay ordinary contentEditable text. This guard also + // protects against a stale completion result reaching the keydown path + // before refreshTrigger has caught up with the latest DOM input. + if (desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) !== 'options') { + return false } const text = `/${trigger.query.trimEnd()}` @@ -168,9 +203,11 @@ export function useComposerTrigger({ rawText: text } }) + + return true } - const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { + const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => { const editor = editorRef.current if (!editor || !trigger) { @@ -200,6 +237,31 @@ export function useComposerTrigger({ const serialized = hermesDirectiveFormatter.serialize(item) const starter = serialized.endsWith(':') + // Tab on a folder walks INTO it instead of committing it: re-type the + // token as the bare path so the next `complete.path` lists that folder's + // children, exactly as typing the path by hand would. Enter still commits + // the folder itself — the two intents are distinct, so the keys are too. + // Only `@` folders descend; a slash command's arg list has no hierarchy. + const descendInto = + options?.descend && trigger.kind === '@' && item.type === 'folder' + ? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '') + : '' + + if (descendInto) { + const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/` + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${path}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + requestMainFocus() + window.setTimeout(refreshTrigger, 0) + + return + } + // Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit // it — expand to its options step so the popover shows the inline list, just // as typing `/personality ` by hand would. A serialized value with a space is @@ -208,15 +270,15 @@ export function useComposerTrigger({ // there's no command invocation for the args to belong to. const command = (item.metadata as { command?: string } | undefined)?.command ?? '' - const expandsToArgs = - trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) + const argumentMode = desktopSlashCommandArgumentMode(command) + const expandsToArgs = trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && argumentMode !== null const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) // No pill while expanding — the bare command stays plain text until an arg // is picked, at which point a single pill is emitted for the full command. const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null - const keepTriggerOpen = starter || expandsToArgs + const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text') const finish = () => { draftRef.current = composerPlainText(editor) @@ -281,15 +343,47 @@ export function useComposerTrigger({ finish() } + /** Backspace inside an `@` path drops the last segment (`a/b/` → `a/`) + * instead of one character. Descending is one Tab per level, so climbing + * back out should cost one key too rather than a held delete. Returns + * false when the caret isn't in a path, so keydown falls through. */ + const ascendTriggerPath = () => { + const editor = editorRef.current + + if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) { + return false + } + + // Trailing slash means we're listing a folder's children: drop that + // folder. Otherwise a partial segment is typed — drop just that. + const trimmed = trigger.query.replace(/\/$/, '') + const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1) + + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, `${prefix}@${parent}`) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + setComposerText(draftRef.current) + window.setTimeout(refreshTrigger, 0) + + return true + } + return { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, + moveTriggerActive, refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, + triggerActiveExplicit, triggerItems, triggerKeyConsumedRef, triggerLoading diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx new file mode 100644 index 00000000000..bbffc396ecb --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx @@ -0,0 +1,193 @@ +import { render } from '@testing-library/react' +import { createRef, type RefObject } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerUndo } from './use-composer-undo' + +/** Mount the hook against a real contentEditable, exposing its API. */ +function mountUndo(editorRef: RefObject, onSync: () => string) { + const api: { current: ReturnType | null } = { current: null } + + const Harness = () => { + // Assigned during render on purpose: the tests drive the API imperatively + // right after mount, and this is a harness, not app state. + api.current = useComposerUndo({ editorRef, syncDraftFromEditor: onSync }) + + return null + } + + const view = render() + + return { api, view } +} + +function makeEditor(text: string) { + const editor = document.createElement('div') + editor.contentEditable = 'true' + // jsdom only focuses a contentEditable div when it's explicitly focusable; + // the real editor is reachable via the composer's focus bus. + editor.tabIndex = 0 + editor.append(document.createTextNode(text)) + document.body.append(editor) + + const ref = createRef() as RefObject + ref.current = editor + + return { editor, ref } +} + +const caretAtEnd = (editor: HTMLElement) => { + const range = document.createRange() + const selection = window.getSelection()! + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) +} + +describe('useComposerUndo', () => { + it('restores the pre-edit text, which is what a paste destroyed', () => { + const { editor, ref } = makeEditor('before') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + // Bank, then simulate the Range-based paste that Chromium never records. + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' PASTED')) + expect(editor.textContent).toBe('before PASTED') + + api.current!.undo() + expect(editor.textContent).toBe('before') + + api.current!.redo() + expect(editor.textContent).toBe('before PASTED') + + view.unmount() + editor.remove() + }) + + it('withUndoPoint banks only when the edit actually ran', () => { + const { editor, ref } = makeEditor('text') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + // A guard that declines must not consume an undo slot. + expect(api.current!.withUndoPoint(() => false)).toBe(false) + expect(api.current!.undo()).toBe(false) + + expect( + api.current!.withUndoPoint(() => { + editor.append(document.createTextNode('!')) + + return true + }) + ).toBe(true) + + api.current!.undo() + expect(editor.textContent).toBe('text') + + view.unmount() + editor.remove() + }) + + it('claims a native historyUndo aimed at the focused editor', () => { + const { editor, ref } = makeEditor('kept') + editor.focus() + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' extra')) + + // What Electron's Edit menu `{ role: 'undo' }` produces. + const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' }) + editor.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(editor.textContent).toBe('kept') + + view.unmount() + editor.remove() + }) + + it('ignores a historyUndo while another editor holds focus', () => { + const { editor, ref } = makeEditor('mine') + const { editor: other } = makeEditor('theirs') + + other.focus() + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' changed')) + + const event = new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'historyUndo' }) + other.dispatchEvent(event) + + // Not ours to claim — the other surface keeps its native behavior. + expect(event.defaultPrevented).toBe(false) + expect(editor.textContent).toBe('mine changed') + + view.unmount() + editor.remove() + other.remove() + }) + + it('keeps two mounted composers independent', () => { + const { editor: main, ref: mainRef } = makeEditor('main') + const { editor: edit, ref: editRef } = makeEditor('edit') + + const mainUndo = mountUndo(mainRef, () => main.textContent || '') + const editUndo = mountUndo(editRef, () => edit.textContent || '') + + mainUndo.api.current!.recordUndoPoint() + main.append(document.createTextNode(' typed')) + + // Undoing in the edit composer must not touch the main composer's text. + editUndo.api.current!.undo() + expect(main.textContent).toBe('main typed') + + mainUndo.api.current!.undo() + expect(main.textContent).toBe('main') + expect(edit.textContent).toBe('edit') + + mainUndo.view.unmount() + editUndo.view.unmount() + main.remove() + edit.remove() + }) + + it('reset drops history so undo cannot cross a draft swap', () => { + const { editor, ref } = makeEditor('session A') + caretAtEnd(editor) + + const { api, view } = mountUndo(ref, () => editor.textContent || '') + + api.current!.recordUndoPoint() + editor.append(document.createTextNode(' edited')) + api.current!.resetUndoHistory() + + expect(api.current!.undo()).toBe(false) + expect(editor.textContent).toBe('session A edited') + + view.unmount() + editor.remove() + }) + + it('is inert when the editor ref is empty', () => { + const ref = createRef() as RefObject + const sync = vi.fn(() => '') + + const { api, view } = mountUndo(ref, sync) + + api.current!.recordUndoPoint() + + expect(api.current!.undo()).toBe(false) + expect(sync).not.toHaveBeenCalled() + + view.unmount() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts new file mode 100644 index 00000000000..72f891f6dac --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.ts @@ -0,0 +1,119 @@ +import { type RefObject, useCallback, useEffect, useMemo } from 'react' + +import { caretOffsetInEditor, composerPlainText, placeCaretAtOffset, renderComposerContents } from '../rich-editor' +import { type ComposerSnapshot, createComposerUndoHistory } from '../undo-history' + +interface UseComposerUndoArgs { + editorRef: RefObject + /** Push a restored snapshot back into draftRef + composer state. */ + syncDraftFromEditor: () => string +} + +/** + * Undo/redo for the rich composer. + * + * The editor mutates its DOM through `Range` to dodge Chromium's O(n²) editing + * pipeline (#45812), which also dodges Chromium's undo stack — so a paste was + * invisible to ⌘Z and the keystroke undid whatever edit came before it instead. + * We own the stack outright rather than half of it: every edit path records the + * pre-edit state here, and the editor claims ⌘Z / ⌘⇧Z itself. + */ +export function useComposerUndo({ editorRef, syncDraftFromEditor }: UseComposerUndoArgs) { + const history = useMemo(() => createComposerUndoHistory(), []) + + const snapshot = useCallback((): ComposerSnapshot => { + const editor = editorRef.current + + if (!editor) { + return { caret: 0, text: '' } + } + + return { caret: caretOffsetInEditor(editor), text: composerPlainText(editor) } + }, [editorRef]) + + /** Bank the current state before mutating the editor. `coalesce` marks a + * keystroke, so a run of typing collapses into one undo step. */ + const recordUndoPoint = useCallback( + (options?: { coalesce?: boolean }) => { + if (editorRef.current) { + history.record(snapshot(), options) + } + }, + [editorRef, history, snapshot] + ) + + const applySnapshot = useCallback( + (next: ComposerSnapshot | null) => { + const editor = editorRef.current + + if (!next || !editor) { + return false + } + + renderComposerContents(editor, next.text) + placeCaretAtOffset(editor, next.caret) + syncDraftFromEditor() + + return true + }, + [editorRef, syncDraftFromEditor] + ) + + /** Run a conditional edit, banking its pre-edit state only if it actually + * ran. The snapshot has to be taken first (the edit destroys the state we'd + * be saving), but recording unconditionally would clear the redo stack on + * every Backspace that falls through to the native path. */ + const withUndoPoint = useCallback( + (edit: () => boolean) => { + const before = snapshot() + const ran = edit() + + if (ran) { + history.record(before) + } + + return ran + }, + [history, snapshot] + ) + + const undo = useCallback(() => applySnapshot(history.undo(snapshot())), [applySnapshot, history, snapshot]) + const redo = useCallback(() => applySnapshot(history.redo(snapshot())), [applySnapshot, history, snapshot]) + + // A session/draft swap makes prior history meaningless — undoing into another + // conversation's text is worse than having no history at all. + const resetUndoHistory = useCallback(() => history.reset(), [history]) + + // Electron's Edit menu ships `{ role: 'undo' }`, whose accelerator the macOS + // menu bar consumes before the web contents sees the keystroke (the same + // hazard main.ts documents for ⌘W). It fires the native editing command, + // which knows nothing about our stack. Claim it at the document level while + // the composer holds focus, so the menu item and the keystroke agree. + useEffect(() => { + const onBeforeInput = (event: Event) => { + const inputType = (event as InputEvent).inputType + + if (inputType !== 'historyUndo' && inputType !== 'historyRedo') { + return + } + + if (document.activeElement !== editorRef.current) { + return + } + + event.preventDefault() + + if (inputType === 'historyUndo') { + undo() + } else { + redo() + } + } + + document.addEventListener('beforeinput', onBeforeInput, true) + + return () => document.removeEventListener('beforeinput', onBeforeInput, true) + }, [editorRef, redo, undo]) + + return { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index f8b75183f30..8e53096f322 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 @@ -5,11 +5,11 @@ import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' import { resetBrowseState } from '@/store/composer-input-history' import { notifyError } from '@/store/notifications' -import { $messages } from '@/store/session' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import type { ComposerTarget } from '../focus' import { onComposerVoiceToggleRequest } from '../focus' +import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' import { useAutoSpeakReplies } from './use-auto-speak-replies' @@ -50,6 +50,8 @@ export function useComposerVoice({ target }: UseComposerVoiceArgs) { const { t } = useI18n() + // A tile's composer speaks ITS transcript, not the primary chat's. + const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts index 32301cc8294..d35c78ea7b3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -25,9 +25,18 @@ export function useLiveCompletionAdapter(options: { enabled: boolean debounceMs?: number fetcher: (query: string) => Promise + /** True when `fetcher` will answer this query from cache. Such a query skips + * both the debounce and the loading state — the debounce exists to avoid a + * request per keystroke, and a spinner over an answer we already hold reads + * as latency the user isn't actually paying. */ + isCached?: (query: string) => boolean + /** Bump to declare the held answer stale. Without it a popover left open on + * an unchanged query would keep serving what it fetched before the source + * changed, because the adapter de-dupes on the query alone. */ + epoch?: number toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem }): { adapter: Unstable_TriggerAdapter; loading: boolean } { - const { enabled, debounceMs = 60, fetcher, toItem } = options + const { enabled, debounceMs = 60, epoch = 0, fetcher, isCached, toItem } = options const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({ query: EMPTY_QUERY, @@ -62,6 +71,16 @@ export function useLiveCompletionAdapter(options: { setState({ query: EMPTY_QUERY, items: [] }) }, [cancelTimer, enabled]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) + useEffect(() => { + // Invalidate by forgetting which query the held items answer, so the next + // search() re-fetches. The items themselves stay until the new answer + // lands — an open popover must not blink empty on a background refresh. + // On mount this is already the state, so the first run is a no-op. + pendingQueryRef.current = null + setState(current => (current.query === EMPTY_QUERY ? current : { ...current, query: EMPTY_QUERY })) + }, [epoch]) + const scheduleFetch = useCallback( (query: string) => { if (!enabled) { @@ -75,9 +94,13 @@ export function useLiveCompletionAdapter(options: { pendingQueryRef.current = query cancelTimer() const token = ++tokenRef.current - setLoading(true) + const cached = isCached?.(query) ?? false - timerRef.current = window.setTimeout(() => { + if (!cached) { + setLoading(true) + } + + const run = () => { timerRef.current = null fetcher(query) @@ -103,9 +126,13 @@ export function useLiveCompletionAdapter(options: { setLoading(false) } }) - }, debounceMs) + } + + // A cached answer resolves in a microtask, so debouncing it would only + // add a frame of empty popover on every keystroke. + cached ? run() : (timerRef.current = window.setTimeout(run, debounceMs)) }, - [cancelTimer, debounceMs, enabled, fetcher, toItem] + [cancelTimer, debounceMs, enabled, fetcher, isCached, toItem] ) const adapter = useMemo( diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 79e1cc067da..e4a53889e5b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, us import { POPOUT_ESTIMATED_HEIGHT, POPOUT_WIDTH_REM, + type PopoutBounds, type PopoutPosition, type PopoutSize, readPopoutBounds, @@ -35,6 +36,8 @@ interface PressState { interface ComposerPopoutGesturesOptions { composerRef: RefObject + /** Layout zone this composer belongs to — the scope its float is stored under. */ + groupId: string onDock: () => void onPopOut: () => void poppedOut: boolean @@ -67,10 +70,17 @@ function isFloatDragPlatform(target: EventTarget | null) { } /** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the - * release-to-dock test (which fires at proximity 1). */ -function dockProximityOf(rect: DOMRect) { - const horizontalDist = Math.abs(rect.left + rect.width / 2 - window.innerWidth / 2) - const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom + * release-to-dock test (which fires at proximity 1). + * + * Measured against THIS surface's area, not the window: the dock target is the + * docked composer, which sits at the bottom-center of its own chat surface. In + * a split (or any layout where the chat isn't the full window) the viewport's + * bottom-center is somewhere else entirely, so dragging onto the real dock + * never registered. */ +function dockProximityOf(rect: DOMRect, area?: PopoutBounds) { + const a = area ?? { bottom: window.innerHeight, left: 0, right: window.innerWidth, top: 0 } + const horizontalDist = Math.abs(rect.left + rect.width / 2 - (a.left + a.right) / 2) + const verticalGap = a.bottom - DOCK_ZONE_BOTTOM_PX - rect.bottom const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX) @@ -109,6 +119,7 @@ function popoutPositionUnderPointer( */ export function useComposerPopoutGestures({ composerRef, + groupId, onDock, onPopOut, poppedOut, @@ -142,7 +153,12 @@ export function useComposerPopoutGestures({ const beginFloatDrag = useCallback( (state: PressState, clientX: number, clientY: number, next: PopoutPosition, size?: PopoutSize) => { clearTimer() - const clamped = setComposerPopoutPosition(next, { area: readPopoutBounds(composerRef.current), size }) + + const clamped = setComposerPopoutPosition(groupId, next, { + area: readPopoutBounds(composerRef.current), + size + }) + liveRef.current = clamped state.mode = 'float' @@ -154,7 +170,7 @@ export function useComposerPopoutGestures({ setDragging(true) }, - [clearTimer, composerRef] + [clearTimer, composerRef, groupId] ) const peelOffFromDock = useCallback( @@ -255,17 +271,19 @@ export function useComposerPopoutGestures({ const composer = composerRef.current const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined + const area = readPopoutBounds(composer) liveRef.current = setComposerPopoutPosition( + groupId, { bottom: state.startBottom - (pending.y - state.startY), right: state.startRight - (pending.x - state.startX) }, - { area: readPopoutBounds(composer), size } + { area, size } ) if (composer) { - setDockProximity(dockProximityOf(composer.getBoundingClientRect())) + setDockProximity(dockProximityOf(composer.getBoundingClientRect(), area)) } } @@ -317,13 +335,14 @@ export function useComposerPopoutGestures({ if (state.armed && state.mode === 'float') { const composer = composerRef.current const rect = composer?.getBoundingClientRect() + const area = readPopoutBounds(composer) - if (rect && dockProximityOf(rect) >= 1) { + if (rect && dockProximityOf(rect, area) >= 1) { onDock() } else { // Persist the resting position once, on release — never per move. const size = composer ? { height: composer.offsetHeight, width: composer.offsetWidth } : undefined - setComposerPopoutPosition(liveRef.current, { area: readPopoutBounds(composer), persist: true, size }) + setComposerPopoutPosition(groupId, liveRef.current, { area, persist: true, size }) } } @@ -340,7 +359,7 @@ export function useComposerPopoutGestures({ window.removeEventListener('pointerup', handleUp) window.removeEventListener('pointercancel', handleUp) } - }, [composerRef, onDock, peelOffFromDock, resetGesture]) + }, [composerRef, groupId, onDock, peelOffFromDock, resetGesture]) useEffect(() => clearTimer, [clearTimer]) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx new file mode 100644 index 00000000000..eebed60ce4d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -0,0 +1,98 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { HermesGateway } from '@/hermes' +import { queryClient } from '@/lib/query-client' +import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' + +import { isSkillItem } from '../composer-utils' + +import { useSlashCompletions } from './use-slash-completions' + +const CATALOG = { + categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }], + pairs: [ + ['/new', 'Start a new session'], + ['/work', 'Kick off a task in a fresh worktree'] + ] +} + +function harness(gateway: HermesGateway) { + const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {} + + function Probe() { + const { adapter } = useSlashCompletions({ gateway }) + api.search = adapter.search + + return null + } + + render() + + return api as { search: (query: string) => readonly Unstable_TriggerItem[] } +} + +/** Drive the adapter until its async fetch has settled into `search`'s result. */ +async function completions(api: { search: (query: string) => readonly Unstable_TriggerItem[] }, query: string) { + await act(async () => { + api.search(query) + await Promise.resolve() + }) + + // The debounce is skipped only for cached queries; give the timer a beat. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 120)) + }) + + return api.search(query) +} + +afterEach(() => { + cleanup() + queryClient.clear() +}) + +describe('useSlashCompletions', () => { + it('serves the bare-slash catalog from cache instead of re-requesting it', async () => { + const request = vi.fn().mockResolvedValue(CATALOG) + const api = harness({ request } as unknown as HermesGateway) + + await completions(api, '') + expect(request).toHaveBeenCalledTimes(1) + + // Reopening `/` must not hit the gateway again. + queryClient.setQueryData(['unrelated'], 1) + await completions(api, '') + expect(request).toHaveBeenCalledTimes(1) + + // …until something that changes the command set invalidates it. + await act(async () => invalidateSlashCompletions()) + await completions(api, '') + expect(request).toHaveBeenCalledTimes(2) + }) + + it('offers skill commands on a bare slash, not just built-ins', async () => { + const request = vi.fn().mockResolvedValue(CATALOG) + const api = harness({ request } as unknown as HermesGateway) + + const items = await completions(api, '') + const work = items.find(item => (item.metadata as { command?: string })?.command === '/work') + + expect((work?.metadata as { group?: string })?.group).toBe('Skills') + }) + + // A `/` typed mid-message is a reference dropped into prose, so the trigger + // filters the list to skills (use-composer-trigger). A bare mid-message `/` + // resolves to the same empty query as an opening `/`, so that filter runs + // over the catalog — which listed no skill-group rows at all, leaving the + // inline popover empty. Asserted through isSkillItem, the real predicate. + it('leaves only skills for a mid-message slash', async () => { + const request = vi.fn().mockResolvedValue(CATALOG) + const api = harness({ request } as unknown as HermesGateway) + + const inline = (await completions(api, '')).filter(isSkillItem) + + expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work']) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index bf6e5006bea..0f023a1d3f1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -1,4 +1,5 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' +import { useStore } from '@nanostores/react' import { useCallback } from 'react' import type { HermesGateway } from '@/hermes' @@ -12,6 +13,7 @@ import { isDesktopSlashExtensionCommand, isDesktopSlashSuggestion } from '@/lib/desktop-slash-commands' +import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache' import { normalize } from '@/lib/text' import { $sessions } from '@/store/session' @@ -63,6 +65,7 @@ export function useSlashCompletions(options: { } { const { gateway, skinThemes, activeSkin } = options const enabled = Boolean(gateway) + const epoch = useStore($slashCompletionsEpoch) const fetcher = useCallback( async (query: string): Promise => { @@ -133,7 +136,9 @@ export function useSlashCompletions(options: { try { if (!query) { - const catalog = filterDesktopCommandsCatalog(await gateway.request('commands.catalog')) + const catalog = filterDesktopCommandsCatalog( + await cachedSlashCompletion('catalog', () => gateway.request('commands.catalog')) + ) // Prefer the categorized layout so the popover renders section headers // (Session, Tools & Skills, ...). Fall back to the flat list when the @@ -149,12 +154,26 @@ export function useSlashCompletions(options: { })) ) + // Skill commands reach us only through the flat `pairs` list — the + // backend categorizes registry commands but appends skills + // uncategorized, so the categorized layout alone drops every skill + // from the bare `/` list even though typing `/wo` offers them. + // Re-add the leftovers under one Skills header (which also gives them + // the skill pill accent and makes them offerable mid-message). + const categorized = new Set(items.map(item => item.text.toLowerCase())) + + for (const [command, meta] of catalog.pairs ?? []) { + if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) { + items.push({ text: command, display: command, group: 'Skills', meta }) + } + } + return { items, query } } - const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { - text - }) + const result = await cachedSlashCompletion(`slash:${text.toLowerCase()}`, () => + gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', { text }) + ) // Arg-completion items (replace_from > 1) carry just the arg stub — // e.g. complete.slash returns `{text: "alice"}` for `/personality alic` @@ -231,5 +250,21 @@ export function useSlashCompletions(options: { } }, []) - return useLiveCompletionAdapter({ enabled, fetcher, toItem }) + // Mirrors the fetcher's branching: the `/skin` and `/resume` arg stages are + // answered from client-side state, so they never wait on the network; every + // other query is served from the completion cache when it's still warm. + const isCached = useCallback( + (query: string) => { + const text = `/${query}` + + if ((skinThemes && /^\/skin\s+/is.test(text)) || /^\/(?:resume|sessions|switch)\s+/is.test(text)) { + return true + } + + return hasCachedSlashCompletion(query ? `slash:${text.toLowerCase()}` : 'catalog') + }, + [skinThemes] + ) + + return useLiveCompletionAdapter({ enabled, epoch, fetcher, isCached, toItem }) } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1ce110f4e09..74f9a7459f6 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,6 +1,6 @@ import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react' +import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' @@ -11,7 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { $compactionActive } from '@/store/compaction' +import { sessionCompacting } from '@/store/compaction' import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history' import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue' @@ -22,7 +22,12 @@ import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' import { AttachmentList } from './attachments' -import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils' +import { + acceptsTriggerCompletion, + COMPOSER_FADE_BACKGROUND, + type QueueEditState, + slashArgStage +} from './composer-utils' import { ContextMenu } from './context-menu' import { COMPOSER_AREAS, runComposerMiddleware } from './contrib' import { ComposerControls } from './controls' @@ -40,16 +45,18 @@ import { useComposerPopout } from './hooks/use-composer-popout' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' import { useComposerTrigger } from './hooks/use-composer-trigger' +import { useComposerUndo } from './hooks/use-composer-undo' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' import { useSlashCompletions } from './hooks/use-slash-completions' import { useSessionStatusPresence } from './hooks/use-status-presence' +import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' import { QueuePanel } from './queue-panel' import { composerPlainText, deleteChipBeforeCaret, deleteSelectionInEditor, - insertPlainTextAtCaret, + insertComposerContentsAtCaret, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor' @@ -59,7 +66,9 @@ import { CodingStatusRow } from './status-stack/coding-row' import { extractClipboardImageBlobs } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' +import { isRedoShortcut, isUndoShortcut } from './undo-history' import { UrlDialog } from './url-dialog' +import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' export function ChatBar({ @@ -105,7 +114,7 @@ export function ChatBar({ // focus-bus key, and awaiting-input edge. Main scope = the legacy globals. const scope = useComposerScope() const attachments = useStore(scope.attachments.$attachments) - const compacting = useStore($compactionActive) + const compacting = useStore(useMemo(() => sessionCompacting(sessionId ?? null), [sessionId])) const scrolledUp = useStore($threadScrolledUp) const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must @@ -172,9 +181,24 @@ export function ChatBar({ requestMainFocus, sessionIdRef, setComposerText, - stashAt + stashAt, + syncDraftFromEditor } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + // Undo/redo. The rich editor bypasses Chromium's editing pipeline for speed, + // which also bypasses its undo stack — so we own the stack and every edit + // path below banks its pre-edit state through `recordUndoPoint`. + const { recordUndoPoint, redo, resetUndoHistory, undo, withUndoPoint } = useComposerUndo({ + editorRef, + syncDraftFromEditor + }) + + // Prior history belongs to the draft that just left — undoing into another + // conversation's text is worse than having none. + useEffect(() => { + resetUndoHistory() + }, [activeQueueSessionKey, resetUndoHistory]) + // "Add URL" dialog — open/value state, autofocus, and submit (host onAddUrl or // an @url: directive into the draft). const { openUrlDialog, setUrlOpen, setUrlValue, submitUrl, urlInputRef, urlOpen, urlValue } = useComposerUrlDialog({ @@ -281,13 +305,17 @@ export function ChatBar({ // this API; keyup uses triggerKeyConsumedRef to skip its refresh. const { argStageEmpty, + ascendTriggerPath, closeTrigger, commitTypedSlashDirective, + moveTriggerActive, refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, + triggerActiveExplicit, triggerItems, triggerKeyConsumedRef, triggerLoading @@ -356,6 +384,23 @@ export function ChatBar({ scheduleFlushEditorToDraft(event.currentTarget) } + // Native typing/deleting mutates the DOM through Chromium's editing pipeline, + // whose undo stack we've taken over — so bank the pre-edit state here, before + // the change lands. `beforeinput` is the only hook that still sees the old + // text. Consecutive keystrokes coalesce into one entry, so ⌘Z steps back by a + // burst rather than a character. + const handleEditorBeforeInput = (event: FormEvent) => { + const inputType = (event.nativeEvent as InputEvent).inputType + + // Undo/redo are ours (handled in useComposerUndo + keydown), and IME preedit + // is not a committed edit — compositionend is where that text becomes real. + if (inputType === 'historyUndo' || inputType === 'historyRedo' || composingRef.current) { + return + } + + recordUndoPoint({ coalesce: inputType === 'insertText' || inputType === 'deleteContentBackward' }) + } + const handlePaste = (event: ClipboardEvent) => { const imageBlobs = extractClipboardImageBlobs(event.clipboardData) @@ -402,7 +447,12 @@ export function ChatBar({ } event.preventDefault() - insertPlainTextAtCaret(event.currentTarget, pastedText) + + // Links in the paste land as `@url:` chips rather than a wall of URL text — + // the same reference the "Add URL" dialog inserts, parsed in place so a link + // mid-sentence keeps its position. Bare `@path` tokens promote the same way. + recordUndoPoint() + insertComposerContentsAtCaret(event.currentTarget, pathifyRefs(linkifyUrls(pastedText))) scheduleFlushEditorToDraft(event.currentTarget) } @@ -416,6 +466,23 @@ export function ChatBar({ return } + // Undo/redo before anything else — we own the stack (see useComposerUndo), + // so these never reach Chromium's native history, which has no record of + // the Range-based edits the rich editor makes. + if (isUndoShortcut(event.nativeEvent)) { + event.preventDefault() + undo() + + return + } + + if (isRedoShortcut(event.nativeEvent)) { + event.preventDefault() + redo() + + return + } + // Plain Backspace right after a directive chip: remove the chip + its // auto-inserted trailing space as one unit, so deleting a directive never // leaves an orphaned space. (Modified backspaces stay native.) @@ -424,7 +491,7 @@ export function ChatBar({ !event.metaKey && !event.ctrlKey && !event.altKey && - deleteChipBeforeCaret(event.currentTarget) + withUndoPoint(() => deleteChipBeforeCaret(event.currentTarget)) ) { event.preventDefault() flushEditorToDraft(event.currentTarget) @@ -434,7 +501,29 @@ export function ChatBar({ // Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large // drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through. - if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) { + if ( + (event.key === 'Backspace' || event.key === 'Delete') && + withUndoPoint(() => deleteSelectionInEditor(event.currentTarget)) + ) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + + // A typed link finished with a space chips like a pasted one — the space + // itself rides along inside the insert. + if (withUndoPoint(() => chipTypedUrlOnSpace(event))) { + event.preventDefault() + flushEditorToDraft(event.currentTarget) + + return + } + + // Same for a bare `@path` — a hand-typed or Tab-descended path chips into + // the `@file:`/`@folder:` ref it means, instead of submitting as plain text + // the backend never resolves. + if (withUndoPoint(() => chipTypedPathOnSpace(event))) { event.preventDefault() flushEditorToDraft(event.currentTarget) @@ -457,7 +546,7 @@ export function ChatBar({ if (event.key === 'ArrowDown') { event.preventDefault() triggerKeyConsumedRef.current = true - setTriggerActive(idx => (idx + 1) % triggerItems.length) + moveTriggerActive(1) return } @@ -465,18 +554,21 @@ export function ChatBar({ if (event.key === 'ArrowUp') { event.preventDefault() triggerKeyConsumedRef.current = true - setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) + moveTriggerActive(-1) return } - // Enter / Tab / Space all accept the highlighted item: a no-arg command - // commits its directive chip, an arg-taking command expands to its - // options step, and an arg option commits the full `/cmd arg` chip. Space - // is slash-only (an `@` mention takes a literal space) and gated to a - // non-empty query so a bare `/ ` still types a space. - const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) - const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace + // Accepting the highlighted item: a no-arg command commits its directive + // chip, an arg-taking command expands to its options step, and an arg + // option commits the full `/cmd arg` chip. + const accept = acceptsTriggerCompletion({ + activeExplicit: triggerActiveExplicit, + freeTextArgStage: slashFreeTextArgStage, + key: event.key, + kind: trigger.kind, + query: trigger.query + }) if (accept) { event.preventDefault() @@ -484,12 +576,24 @@ export function ChatBar({ const item = triggerItems[triggerActive] if (item) { - replaceTriggerWithChip(item) + // Tab means "go deeper" on a folder; Enter means "I want this one". + // Everything else treats them alike. + replaceTriggerWithChip(item, { descend: event.key === 'Tab' }) } return } + // Backspace climbs out of an `@` path one segment at a time, mirroring + // Tab's one-key descent. Only when the caret sits at the end of the + // token — mid-token editing keeps normal character deletion. + if (event.key === 'Backspace' && !event.metaKey && !event.altKey && ascendTriggerPath()) { + event.preventDefault() + triggerKeyConsumedRef.current = true + + return + } + if (event.key === 'Escape') { event.preventDefault() triggerKeyConsumedRef.current = true @@ -510,11 +614,12 @@ export function ChatBar({ slashArgStage(trigger.query) && trigger.query.trim() ) { - event.preventDefault() - triggerKeyConsumedRef.current = true - commitTypedSlashDirective() + if (commitTypedSlashDirective()) { + event.preventDefault() + triggerKeyConsumedRef.current = true - return + return + } } // ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in @@ -551,7 +656,7 @@ export function ChatBar({ // $messages is read imperatively (not subscribed) so the composer // doesn't re-render on every streaming delta flush. - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const entry = browseBackward(sessionId, currentDraft, history) if (entry !== null) { @@ -576,7 +681,7 @@ export function ChatBar({ event.preventDefault() triggerKeyConsumedRef.current = true - const history = deriveUserHistory(scope.readMessages(), chatMessageText) + const history = deriveUserHistory(scope.$messages.get(), chatMessageText) const result = browseForward(sessionId, history) if (result !== null) { @@ -630,12 +735,21 @@ export function ChatBar({ return } - // Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc), - // never a stray Enter after sending. With a payload, submitDraft queues it. - // Gate on the live DOM payload (not the render-lagged composer state) so a - // message typed fast / via IME while busy still reaches submitDraft() and - // gets queued instead of being mistaken for an empty Enter. + // Empty Enter while busy. With prompts queued this is the double-send: + // the first Enter put the words in the queue, a second sends them now + // (promote + interrupt + drain on settle), mirroring the idle empty-Enter + // drain above. With nothing queued it stays a no-op — interrupting is + // explicit (Stop/Esc), never a stray Enter after sending. Gate on the live + // DOM payload (not the render-lagged composer state) so a message typed + // fast / via IME while busy still reaches submitDraft() and gets queued + // instead of being mistaken for an empty Enter. if (busy && !hasLivePayload) { + const head = queuedPrompts.find(entry => entry.id !== queueEdit?.entryId) + + if (head) { + sendQueuedNow(head.id) + } + return } @@ -777,6 +891,7 @@ export function ChatBar({ contentEditable={!inputDisabled} data-placeholder={placeholder} data-slot={RICH_INPUT_SLOT} + onBeforeInput={handleEditorBeforeInput} onBlur={() => window.setTimeout(closeTrigger, 80)} onCompositionEnd={event => { composingRef.current = false diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index 5fd62f4cc94..5e282f6a040 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -4,7 +4,13 @@ import { contextPath } from '@/lib/chat-runtime' import type { DroppedFile } from '../hooks/use-composer-actions' -import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor' +import { + composerPlainText, + normalizeComposerEditorDom, + placeCaretEnd, + refChipElement, + RICH_INPUT_SLOT +} from './rich-editor' /** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ export type InlineRefInput = string | { kind: string; label?: string; value: string } @@ -92,7 +98,12 @@ function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | slice.setStart(range.endContainer, range.endOffset) } + // Carry the editor's slot marker: composerPlainText appends a trailing "\n" + // to any other block element, so a bare
made `beforeText` always look + // like it ended in whitespace and the separating space was never inserted — + // a chip dropped after a word came out glued to it (`review@file:...`). const container = document.createElement('div') + container.dataset.slot = RICH_INPUT_SLOT container.appendChild(slice.cloneContents()) return composerPlainText(container) diff --git a/apps/desktop/src/app/chat/composer/path-refs.test.ts b/apps/desktop/src/app/chat/composer/path-refs.test.ts new file mode 100644 index 00000000000..da7cf1dc6f1 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/path-refs.test.ts @@ -0,0 +1,103 @@ +import type { KeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' + +import { chipTypedPathOnSpace, pathifyRefs } from './path-refs' +import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor' + +/** + * Tab-descending into a folder from the `@` popover leaves a bare + * `@apps/desktop/` in the editor. That is not a reference — the backend's + * REFERENCE_PATTERN only matches `@kind:value` — so it used to submit as plain + * text and silently attach nothing. + */ + +/** An editor holding `text` with a collapsed caret at its end, plus the space + * keydown the composer would hand `chipTypedPathOnSpace`. */ +const spaceOn = (text: string) => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = text + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, text.length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent } +} + +describe('pathifyRefs', () => { + it('promotes a Tab-descended folder token', () => { + expect(pathifyRefs('look at @apps/desktop/')).toBe('look at @folder:`apps/desktop`') + }) + + it('promotes a typed file path', () => { + expect(pathifyRefs('read @src/main.ts please')).toBe('read @file:`src/main.ts` please') + }) + + it('promotes every bare path in one message', () => { + expect(pathifyRefs('@apps/desktop/ and @src/main.ts')).toBe('@folder:`apps/desktop` and @file:`src/main.ts`') + }) + + it('leaves an already-typed directive alone', () => { + const text = 'look at @folder:`apps/desktop` ok' + + expect(pathifyRefs(text)).toBe(text) + }) + + it('leaves a handle alone', () => { + expect(pathifyRefs('cc @teknium1 on this')).toBe('cc @teknium1 on this') + }) + + it('leaves simple refs alone', () => { + expect(pathifyRefs('check @diff and @staged')).toBe('check @diff and @staged') + }) + + it('leaves an email alone', () => { + expect(pathifyRefs('mail me at brooklyn@nous.dev/x')).toBe('mail me at brooklyn@nous.dev/x') + }) + + it('is a no-op without an @', () => { + expect(pathifyRefs('nothing here')).toBe('nothing here') + }) +}) + +describe('chipTypedPathOnSpace', () => { + it('chips a Tab-descended folder token into a real @folder: chip', () => { + const { editor, event } = spaceOn('look at @apps/desktop/') + + expect(chipTypedPathOnSpace(event)).toBe(true) + + const chip = editor.querySelector('[data-ref-text]') + + expect(chip?.getAttribute('data-ref-kind')).toBe('folder') + expect(chip?.getAttribute('data-ref-id')).toBe('apps/desktop') + expect(chip?.textContent).toContain('desktop') + expect(composerPlainText(editor)).toBe('look at @folder:`apps/desktop` ') + }) + + it('chips a typed file path', () => { + const { editor, event } = spaceOn('read @src/main.ts') + + expect(chipTypedPathOnSpace(event)).toBe(true) + expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull() + expect(composerPlainText(editor)).toBe('read @file:`src/main.ts` ') + }) + + it('leaves a handle alone', () => { + const { editor, event } = spaceOn('cc @teknium1') + + expect(chipTypedPathOnSpace(event)).toBe(false) + expect(editor.querySelector('[data-ref-text]')).toBeNull() + }) + + it('leaves an already-typed ref alone', () => { + const { event } = spaceOn('look at @folder:`apps/desktop`') + + expect(chipTypedPathOnSpace(event)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/path-refs.ts b/apps/desktop/src/app/chat/composer/path-refs.ts new file mode 100644 index 00000000000..c326727afa2 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/path-refs.ts @@ -0,0 +1,103 @@ +/** + * Bare-path recognition for the composer. Walking into a folder from the `@` + * popover (Tab) re-types the token as a bare `@apps/desktop/` so the next + * `complete.path` lists that folder's children. That's right while typing, but + * a bare token is not a reference: `REFERENCE_PATTERN` only matches + * `@kind:value`, so a draft sent with one attaches nothing and renders as + * plain text instead of a chip. + * + * So the bare token is promoted to its typed form on the way out — the same + * shape `url-refs.ts` uses for bare links. + */ +import type { KeyboardEvent } from 'react' + +import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor' +import { textBeforeCaret } from './text-utils' + +// A `/` is required, exactly like URL_RE requires an explicit scheme: `@teknium1` +// and `@diff` are a handle and a simple ref, not paths, and guessing wrong turns +// someone's name into a file reference. A separator is the cheap signal that the +// user meant a path. The token also can't start with a `:` kind prefix — that is +// already a typed ref and REF_RE owns it. +const BARE_PATH_RE = /(? { + const start = match.index ?? 0 + + return { end: start + match[0].length, start } + }) + + let out = '' + let cursor = 0 + + for (const match of text.matchAll(BARE_PATH_RE)) { + const start = match.index ?? 0 + const ref = barePathRef(match[1] || '') + + if (!ref || fenced.some(span => start >= span.start && start < span.end)) { + continue + } + + out += `${text.slice(cursor, start)}${ref}` + cursor = start + match[0].length + } + + return out + text.slice(cursor) +} + +/** A plain space finishing a typed `@path` commits it as a chip, so a hand-typed + * path chips the same way a picked one does. Returns whether it ran, so a + * keydown handler can fall through on anything else. */ +export function chipTypedPathOnSpace(event: KeyboardEvent) { + if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + const editor = event.currentTarget + + // Runs on every space, so bail on the cheap native read before paying for the + // caret range walk (same guard shape as the trigger detector). + if (!editor.textContent?.includes('@')) { + return false + } + + const before = textBeforeCaret(editor) + const match = before ? TYPED_BARE_PATH_RE.exec(before) : null + const token = match?.[0] + const ref = match?.[1] ? barePathRef(match[1]) : null + + if (!token || !ref) { + return false + } + + const directive = ref.match(/^@([^:]+):(.+)$/) + + if (!directive) { + return false + } + + const fragment = document.createDocumentFragment() + + fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' ')) + + return replaceBeforeCaret(editor, token.length, fragment) +} diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 6bb04ec23c4..2f5612efd13 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' -import { ArrowUp, iconSize, Pencil, Trash2 } from '@/lib/icons' +import { CornerDownLeft, iconSize, Pencil, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import type { QueuedPromptEntry } from '@/store/composer-queue' @@ -22,7 +22,7 @@ interface QueuePanelProps { } const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) => - entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) + (entry.displayText ?? entry.text).trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn) export function QueuePanel({ busy, @@ -103,7 +103,7 @@ export function QueuePanel({ type="button" variant="ghost" > - + diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index 12e3e9613ef..842765388ff 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -4,10 +4,11 @@ import { insertInlineRefsIntoEditor } from './inline-refs' import { composerPlainText, deleteSelectionInEditor, - insertPlainTextAtCaret, + insertComposerContentsAtCaret, normalizeComposerEditorDom, refChipElement, renderComposerContents, + replaceBeforeCaret, RICH_INPUT_SLOT } from './rich-editor' @@ -70,16 +71,40 @@ describe('insertInlineRefsIntoEditor', () => { expect(editor.querySelector(':scope > div')).toBeNull() expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ') }) + + it('separates a chip from the word the caret sits after', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(document.createTextNode('review')) + document.body.append(editor) + caretIn(editor) + + expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') + + editor.remove() + }) + + it('does not double the space when one is already there', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.append(document.createTextNode('review ')) + document.body.append(editor) + caretIn(editor) + + expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') + + editor.remove() + }) }) -describe('insertPlainTextAtCaret', () => { +describe('insertComposerContentsAtCaret', () => { it('inserts multiline text as text nodes + br', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) caretIn(editor) - insertPlainTextAtCaret(editor, 'one\ntwo\nthree') + insertComposerContentsAtCaret(editor, 'one\ntwo\nthree') expect(editor.querySelectorAll('br').length).toBe(2) expect(composerPlainText(editor)).toBe('one\ntwo\nthree') @@ -102,12 +127,76 @@ describe('insertPlainTextAtCaret', () => { selection.removeAllRanges() selection.addRange(range) - insertPlainTextAtCaret(editor, 'cd') + insertComposerContentsAtCaret(editor, 'cd') expect(composerPlainText(editor)).toBe('abcdef') editor.remove() }) + + it('lands directives in the text as chips', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + caretIn(editor) + + insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now') + + expect(editor.querySelectorAll('[data-ref-kind="url"]').length).toBe(1) + expect(composerPlainText(editor)).toBe('read @url:`https://example.dev/a` now') + + editor.remove() + }) +}) + +describe('replaceBeforeCaret', () => { + it('swaps the token before the caret and leaves the caret after the insert', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'see foo' + document.body.append(editor) + + const text = editor.firstChild! + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(text, 7) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + const fragment = document.createDocumentFragment() + fragment.append(refChipElement('file', '`src/foo.ts`'), document.createTextNode(' ')) + + expect(replaceBeforeCaret(editor, 3, fragment)).toBe(true) + expect(composerPlainText(editor)).toBe('see @file:`src/foo.ts` ') + expect(selection.getRangeAt(0).collapsed).toBe(true) + + editor.remove() + }) + + it('leaves the editor alone when the caret has no room for the token', () => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = 'hi' + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, 2) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + const fragment = document.createDocumentFragment() + fragment.append(document.createTextNode('x')) + + expect(replaceBeforeCaret(editor, 20, fragment)).toBe(false) + expect(composerPlainText(editor)).toBe('hi') + + editor.remove() + }) }) describe('deleteSelectionInEditor', () => { diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 21f0286c9f8..7bf770eda17 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -11,11 +11,11 @@ import { directiveIconElement, directiveIconSvg, formatRefValue, + refChipLabel, slashChipClass, type SlashChipKind, slashIconElement } from '@/components/assistant-ui/directive-text' -import { sessionRefFallbackLabel } from '@/lib/session-refs' export const RICH_INPUT_SLOT = 'composer-rich-input' @@ -35,10 +35,6 @@ export function unquoteRef(raw: string) { return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '') } -export function refLabel(id: string) { - return id.split(/[\\/]/).filter(Boolean).pop() || id -} - /** Always-quote variant of formatRefValue — chips need a fence even for safe values. */ export function quoteRefValue(value: string) { if (!value.includes('`')) { @@ -60,9 +56,9 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` - const label = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id)) + const label = displayLabel || refChipLabel(kind, id) - return `${directiveIconSvg(kind)}${escapeHtml(label)}` + return `${directiveIconSvg(kind)}${escapeHtml(label)}` } export function refChipElement(kind: string, rawValue: string, displayLabel?: string) { @@ -72,12 +68,13 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st const label = document.createElement('span') chip.contentEditable = 'false' + chip.title = id chip.dataset.refText = text chip.dataset.refId = id chip.dataset.refKind = kind chip.className = DIRECTIVE_CHIP_CLASS label.className = 'truncate' - label.textContent = displayLabel || (kind === 'session' ? sessionRefFallbackLabel(id) : refLabel(id)) + label.textContent = displayLabel || refChipLabel(kind, id) chip.append(directiveIconElement(kind), label) return chip @@ -147,14 +144,15 @@ function composerSelectionRange(editor: HTMLElement) { return { range, selection } } -/** Insert plain text at the caret (replacing any selection). Pastes use this - * instead of `execCommand('insertText')` — Chromium's editing pipeline is - * ~O(n²) on large multiline blobs. */ -export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { +/** Insert text at the caret (replacing any selection), with any `@kind:value` + * directives in it landing as chips. Pastes use this instead of + * `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large + * multiline blobs. */ +export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) { const hit = composerSelectionRange(editor) const fragment = document.createDocumentFragment() - appendTextWithBreaks(fragment, text) + appendComposerContents(fragment, text) const tail = fragment.lastChild @@ -175,6 +173,41 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) { } } +/** Swap the `length` characters immediately before a collapsed caret for + * `fragment`, leaving the caret after it. Returns whether it ran — a caret that + * isn't inside a text node holding the whole token is left alone. */ +export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) { + const hit = composerSelectionRange(editor) + + if (!hit?.range.collapsed) { + return false + } + + const { startContainer, startOffset } = hit.range + + if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) { + return false + } + + const range = document.createRange() + const tail = fragment.lastChild + + range.setStart(startContainer, startOffset - length) + range.setEnd(startContainer, startOffset) + range.deleteContents() + range.insertNode(fragment) + + if (tail) { + range.setStartAfter(tail) + } + + range.collapse(true) + hit.selection.removeAllRanges() + hit.selection.addRange(range) + + return true +} + /** Backspace at a collapsed caret immediately after a chip: delete the chip AND * the single trailing space we auto-insert after it, atomically — so removing a * directive never strands an orphaned space (the contenteditable-driven cleanup @@ -299,6 +332,106 @@ export function placeCaretEnd(element: HTMLElement) { selection?.addRange(range) } +/** The caret's offset in `composerPlainText` coordinates, so it can be restored + * after the editor is re-rendered from text (undo/redo). A chip counts as its + * whole `@kind:value` text — the same units the snapshot measures. */ +export function caretOffsetInEditor(editor: HTMLElement): number { + const selection = window.getSelection() + const range = selection?.rangeCount ? selection.getRangeAt(0) : null + + if (!range || !editor.contains(range.commonAncestorContainer)) { + return composerPlainText(editor).length + } + + const before = range.cloneRange() + before.selectNodeContents(editor) + before.setEnd(range.startContainer, range.startOffset) + + // The scratch container must carry the editor's slot marker: composerPlainText + // appends a trailing "\n" to any other block element, which would inflate + // every offset by one and land the restored caret a character late. + const container = document.createElement('div') + container.dataset.slot = RICH_INPUT_SLOT + container.append(before.cloneContents()) + + return composerPlainText(container).length +} + +/** Place the caret `offset` characters into the editor, in the same + * `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a + * chip it would otherwise split, since a chip is a single atomic unit. */ +export function placeCaretAtOffset(editor: HTMLElement, offset: number) { + const selection = window.getSelection() + + if (!selection) { + return + } + + let remaining = offset + + const walk = (node: Node): Range | null => { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const length = (child.textContent || '').length + + if (remaining <= length) { + const range = document.createRange() + range.setStart(child, remaining) + range.collapse(true) + + return range + } + + remaining -= length + + continue + } + + if (child.nodeType !== Node.ELEMENT_NODE) { + continue + } + + const el = child as HTMLElement + + // Chips and
are atomic: consume their serialized length whole. + if (el.dataset.refText || el.tagName === 'BR') { + const length = el.dataset.refText ? el.dataset.refText.length : 1 + + if (remaining < length) { + const range = document.createRange() + range.setStartBefore(el) + range.collapse(true) + + return range + } + + remaining -= length + + continue + } + + const hit = walk(el) + + if (hit) { + return hit + } + } + + return null + } + + const range = walk(editor) + + if (range) { + selection.removeAllRanges() + selection.addRange(range) + + return + } + + placeCaretEnd(editor) +} + /** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */ function isBlankNode(node: ChildNode | null): boolean { if (!node) { diff --git a/apps/desktop/src/app/chat/composer/scope.tsx b/apps/desktop/src/app/chat/composer/scope.tsx index 67581a39e30..e7e7aff880e 100644 --- a/apps/desktop/src/app/chat/composer/scope.tsx +++ b/apps/desktop/src/app/chat/composer/scope.tsx @@ -23,20 +23,18 @@ export interface ComposerScope { /** This scope's "turn parked on user input" edge — gates Esc-to-stop. */ $awaitingInput: ReadableAtom attachments: ComposerAttachmentScope - /** Only the main scope may pop out (the floating composer is a singleton). */ - popoutAllowed: boolean - /** Imperative read of this scope's transcript (input-history browse) — - * never subscribed, so streaming stays out of the composer's renders. */ - readMessages: () => ChatMessage[] + /** This scope's transcript. Read it imperatively (input-history browse) to + * keep streaming out of the composer's renders; subscribe only off-render + * (auto-speak) where the reply edge is the whole point. */ + $messages: ReadableAtom /** Focus-bus routing key (`'main'` | `'tile:'`). */ target: ComposerTarget } export const MAIN_COMPOSER_SCOPE: ComposerScope = { $awaitingInput: $activeSessionAwaitingInput, + $messages, attachments: mainComposerScope, - popoutAllowed: true, - readMessages: () => $messages.get(), target: 'main' } 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 5093658af88..14cb89d3ddc 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 @@ -3,17 +3,16 @@ import { memo, useEffect, useRef, useState } from 'react' import { WorktreeDialog } from '@/app/chat/sidebar/projects/worktree-dialog' import { StatusRow } from '@/components/chat/status-row' +import { + type ActionItemSpec, + ActionsContextMenu, + ActionsMenu, + type MenuKit, + renderActionItem +} from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DiffCount } from '@/components/ui/diff-count' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' import { $repoStatus, $repoWorktrees } from '@/store/coding-status' @@ -153,34 +152,87 @@ export const CodingStatusRow = memo(function CodingStatusRow({ // they're the only change (otherwise +/- tells the story). const untrackedOnly = !hasLineDelta && status.untracked > 0 + // The branch actions, rendered identically by the kebab dropdown and the + // row's right-click menu so the two never drift. `onBranchOff` gates the + // whole menu (omitted = remote backend), matching the kebab. + const renderBranchItems = (kit: MenuKit) => { + const branchItems: ActionItemSpec[] = branchTargets.map(target => ({ + key: target.base ?? '__head__', + label: {target.label}, + onSelect: () => startBranch(target.base) + })) + + const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({ + key: worktree.path, + label: {worktree.branch}, + onSelect: () => onOpenWorktree?.(worktree.path) + })) + + return ( + <> + {s.newBranch} + {branchItems.map(item => renderActionItem(kit, item))} + {switchTarget && + renderActionItem(kit, { + key: '__switch__', + label: {s.switchTo(switchTarget)}, + onSelect: () => void switchToBranch(switchTarget) + })} + + {s.worktrees} + {worktreeItems.map(item => renderActionItem(kit, item))} + {/* Create a fresh worktree off the current HEAD (the generic "spin up a + worktree here", mirroring the sidebar's + button). */} + {renderActionItem(kit, { + key: '__start__', + label: {p.startWork}, + onSelect: () => startBranch(undefined) + })} + {onConvertBranch && + renderActionItem(kit, { + key: '__convert__', + label: {p.convertBranch}, + onSelect: () => startBranch(undefined) + })} + + ) + } + return ( <> - } - onActivate={onOpen} - > -
- - {branchLabel} - + + } + onActivate={onOpen} + > +
+ + {branchLabel} + - {/* Branch actions kebab — same pattern as the session/worktree rows. - ALWAYS laid out; only its opacity flips on hover/focus/open, so - revealing it never reflows the row (no layout shift). pointer-events - follow opacity so the invisible trigger isn't clickable at rest. */} - {onBranchOff && ( - - + {/* Branch actions kebab — same pattern as the session/worktree rows. + ALWAYS laid out; only its opacity flips on hover/focus/open, so + revealing it never reflows the row (no layout shift). pointer-events + follow opacity so the invisible trigger isn't clickable at rest. */} + {onBranchOff && ( + - - {/* The row sits at the bottom of the screen (above the composer), - so the menu opens upward. */} - - {s.newBranch} - {branchTargets.map(target => ( - startBranch(target.base)}> - {target.label} - - ))} + + )} +
- {switchTarget && ( - void switchToBranch(switchTarget)}> - {s.switchTo(switchTarget)} - - )} - - - {s.worktrees} - {otherWorktrees.map(worktree => ( - onOpenWorktree?.(worktree.path)}> - {worktree.branch} - - ))} - {/* Create a fresh worktree off the current HEAD (the generic - "spin up a worktree here", mirroring the sidebar's + button). */} - startBranch(undefined)}> - {p.startWork} - - {/* Create a fresh worktree off the current HEAD (the generic - "spin up a worktree here", mirroring the sidebar's + button). */} - {onConvertBranch && ( - startBranch(undefined)}> - {p.convertBranch} - - )} - - + {(status.ahead > 0 || status.behind > 0) && ( + + {status.ahead > 0 && ( + + + {status.ahead} + + )} + {status.behind > 0 && ( + + + {status.behind} + + )} + )} -
- {(status.ahead > 0 || status.behind > 0) && ( - - {status.ahead > 0 && ( - - - {status.ahead} - - )} - {status.behind > 0 && ( - - - {status.behind} - - )} - - )} - - {hasLineDelta ? ( - - ) : untrackedOnly ? ( - - {s.changed(status.untracked)} - - ) : null} -
+ {hasLineDelta ? ( + + ) : untrackedOnly ? ( + + {s.changed(status.untracked)} + + ) : null} + + {resolvedRepoPath && onOpenWorktree && ( ({ + detail, + status, + title, + updatedAt: Date.now() +}) + +function renderStack(sessionId: null | string = SID) { + return render( + + + + + + ) +} + +describe('ComposerStatusStack goal indicator', () => { + beforeEach(() => { + $goalsBySession.set({}) + }) + + afterEach(() => { + cleanup() + $goalsBySession.set({}) + }) + + it('renders nothing when the session has no goal', () => { + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) + + it('shows an active goal with its title', () => { + $goalsBySession.set({ [SID]: goal('active') }) + + renderStack() + + expect(screen.getByText('Goal active')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('labels a paused goal as paused', () => { + $goalsBySession.set({ [SID]: goal('paused') }) + + renderStack() + + expect(screen.getByText('Goal paused')).toBeTruthy() + expect(screen.getByText('ship the feature')).toBeTruthy() + }) + + it('shows the continuation detail line for an active goal', () => { + $goalsBySession.set({ [SID]: goal('active', 'ship it', 'Continuing toward goal (3/20)') }) + + renderStack() + + expect(screen.getByText('Continuing toward goal (3/20)')).toBeTruthy() + }) + + it('scopes the indicator to the goal-owning session', () => { + $goalsBySession.set({ 'other-session': goal('active') }) + + const view = renderStack() + + expect(view.container.firstChild).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 10b7a0776bf..fbbd2b94d5a 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { $billingBlock } from '@/store/billing-block' import { @@ -23,6 +24,7 @@ import { type StatusGroup, stopBackgroundProcess } from '@/store/composer-status' +import { refreshSessionGoal } from '@/store/goals' import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status' import { $threadScrolledUp } from '@/store/thread-scroll' import { openSessionInNewWindow } from '@/store/windows' @@ -42,12 +44,25 @@ const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\. // Real codicons per group (no sparkles): a checklist for todos, the agent glyph // for subagents, a background process glyph for background tasks. const GROUP_ICON: Record = { + goal: 'target', todo: 'checklist', subagent: 'agent', background: 'server-process' } const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => { + if (group.type === 'goal') { + const status = group.items[0]?.goalStatus + + return status === 'paused' + ? s.goalPaused + : status === 'waiting' + ? s.goalWaiting + : status === 'done' + ? s.goalDone + : s.goalActive + } + if (group.type === 'todo') { return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length) } @@ -70,23 +85,25 @@ interface ComposerStatusStackProps { export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) { const { t } = useI18n() const navigate = useNavigate() - const itemsBySession = useStore($statusItemsBySession) - const previewsBySession = useStore($previewStatusBySession) + // Subscribe to THIS session's slice only. Both maps churn on other + // sessions' activity (subagent ticks, background polls, preview updates in + // any tile); a whole-map `useStore` re-rendered every mounted stack — one + // per open tile — on all of it. The per-key arrays are referentially stable + // across unrelated writes, so the slice hook bails out unless OUR session's + // items actually changed. + const items = useSessionSlice($statusItemsBySession, sessionId) + const previews = useSessionSlice($previewStatusBySession, sessionId) const scrolledUp = useStore($threadScrolledUp) const billing = useStore($billingBlock) - const groups = useMemo( - () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), - [itemsBySession, sessionId] - ) - - const previews = sessionId ? (previewsBySession[sessionId] ?? []) : [] + const groups = useMemo(() => groupStatusItems(items), [items]) // Seed from the registry on session open; event-driven refreshes (terminal / // process tool completions) live in use-message-stream. useEffect(() => { if (sessionId) { void refreshBackgroundProcesses(sessionId) + void refreshSessionGoal(sessionId) } }, [sessionId]) @@ -154,7 +171,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
) : undefined } - defaultCollapsed={group.type !== 'todo'} + defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'} icon={} label={groupLabel(group, t.statusStack)} > diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index cf721d2ae93..dc40c31de2e 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils' import { PREVIEW_PANE_ID } from '@/store/layout' import { notifyError } from '@/store/notifications' import { $paneOpen } from '@/store/panes' -import { $previewTarget, dismissPreviewTarget, setCurrentSessionPreviewTarget } from '@/store/preview' +import { $previewTabSources, closePreviewForSource, openPreview } from '@/store/preview' import { type PreviewArtifact } from '@/store/preview-status' interface PreviewStatusRowProps { @@ -22,10 +22,10 @@ interface PreviewStatusRowProps { /** One detected artifact, single line, always visible: filename + open + close. */ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) { const { t } = useI18n() - const activePreview = useStore($previewTarget) + const openSources = useStore($previewTabSources) const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) const [opening, setOpening] = useState(false) - const isOpen = activePreview?.source === item.target && previewPaneOpen + const isOpen = openSources.includes(item.target) && previewPaneOpen const resolveTarget = async () => { const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined) @@ -43,7 +43,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss } if (isOpen) { - dismissPreviewTarget() + closePreviewForSource(item.target) return } @@ -51,7 +51,7 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss setOpening(true) try { - setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target) + openPreview(await resolveTarget(), 'tool-result') } catch (error) { notifyError(error, t.preview.unavailable) } finally { diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index 6857be46ccf..2c43743c40c 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -25,6 +25,24 @@ const TODO_GLYPHS: Record, { icon // Left slot: braille spinner while running, otherwise a small status dot // (green = done, red = failed) so the slot is always filled and rows align. function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode { + if (item.type === 'goal') { + if (item.goalStatus === 'paused') { + return + } + + if (item.goalStatus === 'done') { + return + } + + return ( + + ) + } + if (item.todoStatus === 'pending') { return ( )} + {item.type === 'goal' && item.currentTool && ( + + {item.currentTool} + + )} {failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && ( {s.exit(item.exitCode)} diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index ca2ac803576..2a350593f45 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -51,6 +51,39 @@ describe('detectTrigger', () => { expect(detectTrigger('and/or')).toBeNull() }) + it('keeps the at-mention live while walking into subfolders', () => { + // A `/` inside the query is path navigation, not the end of the token — + // the popover has to stay open so the next directory level can load. + expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 }) + expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 }) + expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 }) + expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 }) + expect(detectTrigger('@apps/desktop/src')).toEqual({ + kind: '@', + query: 'apps/desktop/src', + tokenLength: 17 + }) + }) + + it('keeps the at-mention live for a typed ref kind with a path', () => { + expect(detectTrigger('@file:src/main.tsx')).toEqual({ + kind: '@', + query: 'file:src/main.tsx', + tokenLength: 18 + }) + expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 }) + }) + + it('still ends the at-mention token at whitespace', () => { + // The token is whitespace-delimited; a path doesn't change that. + expect(detectTrigger('@./src and more')).toBeNull() + expect(detectTrigger('look at @apps/desktop')).toEqual({ + kind: '@', + query: 'apps/desktop', + tokenLength: 13 + }) + }) + it('treats a mid-message slash as an inline reference', () => { // Skills have to be reachable anywhere in a prompt, not just at position 0. expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 }) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 8bc6663210e..bf91f1db6a4 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -10,7 +10,11 @@ export interface TriggerState { } // `@` triggers stop at the first whitespace — `@file:path` and `@diff` are -// single tokens. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids +// single tokens, and a path is part of that token: `@./src/`, `@~/Desktop/`, +// and `@file:src/foo` all have to keep the popover live while the user walks +// into subdirectories. Excluding `/` from the query class would end the token +// at the first separator, which is exactly the "can't browse into a folder" +// bug. Restricting the slash command name to `[a-zA-Z][\w-]*` avoids // matching file paths like `src/foo/bar`. // // `/` triggers fire in two shapes, because a slash means two different things @@ -24,10 +28,15 @@ export interface TriggerState { // there are no args to complete — the trigger is a single token that ends at // the next space, exactly like `@`. // +// Only the FIRST slash can be an invocation, so the inline shape is tested +// first: the command regex's argument tail (`(?:\s+\S*)*`) happily swallows a +// later `/skill` as if it were an argument, which killed completion for every +// slash after a leading command (`/work /cle` → nothing). +// // The inline shape is what makes skills reachable anywhere in a prompt. Both // shapes need the trailing `$`: detection runs against the text BEFORE the // caret, so the match must end where the user is typing. -const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/ +const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/ const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/ const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/ @@ -120,14 +129,10 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { } export function detectTrigger(textBefore: string): TriggerState | null { - const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore) - - if (command) { - return { kind: '/', query: command[2], tokenLength: 1 + command[2].length } - } - // An inline `/skill` is a reference dropped into prose, so it carries no args - // and the whole match is the token the chip replaces. + // and the whole match is the token the chip replaces. Checked before the + // anchored command shape so a second slash isn't mistaken for the first + // command's argument. const inline = SLASH_INLINE_TRIGGER_RE.exec(textBefore) if (inline) { @@ -136,6 +141,12 @@ export function detectTrigger(textBefore: string): TriggerState | null { return { inline: true, kind: '/', query, tokenLength: 1 + query.length } } + const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore) + + if (command) { + return { kind: '/', query: command[2], tokenLength: 1 + command[2].length } + } + const at = AT_TRIGGER_RE.exec(textBefore) if (at) { diff --git a/apps/desktop/src/app/chat/composer/undo-history.test.ts b/apps/desktop/src/app/chat/composer/undo-history.test.ts new file mode 100644 index 00000000000..5ce477a8020 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + caretOffsetInEditor, + composerPlainText, + placeCaretAtOffset, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' +import { createComposerUndoHistory, isRedoShortcut, isUndoShortcut } from './undo-history' + +const key = (over: Partial = {}) => + ({ altKey: false, ctrlKey: false, key: 'z', metaKey: false, shiftKey: false, ...over }) as KeyboardEvent + +describe('undo/redo shortcut recognition', () => { + it('claims Cmd+Z and Ctrl+Z as undo, but not with Shift or Alt', () => { + expect(isUndoShortcut(key({ metaKey: true }))).toBe(true) + expect(isUndoShortcut(key({ ctrlKey: true }))).toBe(true) + expect(isUndoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(false) + expect(isUndoShortcut(key({ altKey: true, metaKey: true }))).toBe(false) + expect(isUndoShortcut(key({ key: 'a', metaKey: true }))).toBe(false) + expect(isUndoShortcut(key())).toBe(false) + }) + + it('claims Cmd+Shift+Z everywhere and Ctrl+Y as redo', () => { + expect(isRedoShortcut(key({ metaKey: true, shiftKey: true }))).toBe(true) + expect(isRedoShortcut(key({ ctrlKey: true, shiftKey: true }))).toBe(true) + expect(isRedoShortcut(key({ ctrlKey: true, key: 'y' }))).toBe(true) + expect(isRedoShortcut(key({ metaKey: true }))).toBe(false) + expect(isRedoShortcut(key({ altKey: true, ctrlKey: true, key: 'y' }))).toBe(false) + }) + + it('never treats one keystroke as both undo and redo', () => { + const chords = [ + key({ metaKey: true }), + key({ ctrlKey: true }), + key({ metaKey: true, shiftKey: true }), + key({ ctrlKey: true, shiftKey: true }), + key({ ctrlKey: true, key: 'y' }) + ] + + for (const chord of chords) { + expect(isUndoShortcut(chord) && isRedoShortcut(chord)).toBe(false) + } + }) +}) + +describe('composer undo history', () => { + const snap = (text: string, caret = text.length) => ({ caret, text }) + + it('steps back through discrete edits, newest first', () => { + const history = createComposerUndoHistory() + + history.record(snap('')) + history.record(snap('a')) + history.record(snap('ab')) + + expect(history.undo(snap('abc'))?.text).toBe('ab') + expect(history.undo(snap('ab'))?.text).toBe('a') + expect(history.undo(snap('a'))?.text).toBe('') + expect(history.undo(snap(''))).toBeNull() + }) + + it('redoes back to the state undo left, and stops at the newest', () => { + const history = createComposerUndoHistory() + + history.record(snap('before')) + + const undone = history.undo(snap('before + pasted')) + + expect(undone?.text).toBe('before') + expect(history.redo(snap('before'))?.text).toBe('before + pasted') + expect(history.redo(snap('before + pasted'))).toBeNull() + }) + + it('restores the caret along with the text', () => { + const history = createComposerUndoHistory() + + history.record({ caret: 3, text: 'hello world' }) + + expect(history.undo({ caret: 0, text: 'changed' })).toEqual({ caret: 3, text: 'hello world' }) + }) + + it('collapses a run of typing into one entry, so undo steps back by a burst', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 50 + history.record(snap('h'), { coalesce: true }) + now += 50 + history.record(snap('he'), { coalesce: true }) + + // One burst → one entry, back to the state before the burst started. + expect(history.undo(snap('hel'))?.text).toBe('') + expect(history.undo(snap(''))).toBeNull() + }) + + it('starts a new entry once the typing pause exceeds the coalesce window', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 5_000 + history.record(snap('word one'), { coalesce: true }) + + expect(history.undo(snap('word one two'))?.text).toBe('word one') + expect(history.undo(snap('word one'))?.text).toBe('') + }) + + it('does not coalesce a paste into the typing burst that preceded it', () => { + let now = 1_000 + const history = createComposerUndoHistory(200, () => now) + + history.record(snap(''), { coalesce: true }) + now += 20 + // A paste is a discrete edit — no coalesce flag. + history.record(snap('typed ')) + + expect(history.undo(snap('typed PASTED'))?.text).toBe('typed ') + expect(history.undo(snap('typed '))?.text).toBe('') + }) + + it('drops the redo stack once a new edit lands', () => { + const history = createComposerUndoHistory() + + history.record(snap('one')) + history.undo(snap('one two')) + history.record(snap('one')) + + expect(history.redo(snap('one three'))).toBeNull() + }) + + it('ignores a no-op edit so undo never looks stuck for a press', () => { + const history = createComposerUndoHistory() + + history.record(snap('same')) + history.record(snap('same')) + + expect(history.undo(snap('same'))?.text).toBe('same') + expect(history.undo(snap('same'))).toBeNull() + }) + + it('bounds the stack, discarding the oldest entries', () => { + const history = createComposerUndoHistory(3) + + for (const text of ['a', 'b', 'c', 'd', 'e']) { + history.record(snap(text)) + } + + expect(history.undo(snap('f'))?.text).toBe('e') + expect(history.undo(snap('e'))?.text).toBe('d') + expect(history.undo(snap('d'))?.text).toBe('c') + expect(history.undo(snap('c'))).toBeNull() + }) + + it('reset clears both directions', () => { + const history = createComposerUndoHistory() + + history.record(snap('a')) + history.undo(snap('ab')) + history.reset() + + expect(history.undo(snap('ab'))).toBeNull() + expect(history.redo(snap('ab'))).toBeNull() + }) +}) + +describe('caret offsets in composerPlainText coordinates', () => { + let editor: HTMLDivElement + + beforeEach(() => { + editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + }) + + const caretAfter = (node: Node, offset: number) => { + const range = document.createRange() + const selection = window.getSelection()! + range.setStart(node, offset) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + it('round-trips a caret in plain text', () => { + renderComposerContents(editor, 'hello world') + placeCaretAtOffset(editor, 5) + + expect(caretOffsetInEditor(editor)).toBe(5) + }) + + it('counts a chip as its whole @kind:value text', () => { + editor.append( + document.createTextNode('see '), + refChipElement('file', '`src/a.ts`'), + document.createTextNode(' now') + ) + + const chipText = '@file:`src/a.ts`' + // Caret at the very end = everything before it. + placeCaretAtOffset(editor, composerPlainText(editor).length) + + expect(caretOffsetInEditor(editor)).toBe(4 + chipText.length + 4) + }) + + it('lands the caret before a chip rather than splitting it', () => { + editor.append(document.createTextNode('a '), refChipElement('file', '`x.ts`')) + + // An offset that falls midway through the chip's serialized text. + placeCaretAtOffset(editor, 2 + 3) + + expect(caretOffsetInEditor(editor)).toBe(2) + }) + + it('counts a line break as one character', () => { + renderComposerContents(editor, 'one\ntwo') + placeCaretAtOffset(editor, 5) + + expect(caretOffsetInEditor(editor)).toBe(5) + expect(composerPlainText(editor)).toBe('one\ntwo') + }) + + it('clamps past-the-end offsets to the end instead of throwing', () => { + renderComposerContents(editor, 'short') + placeCaretAtOffset(editor, 999) + + expect(caretOffsetInEditor(editor)).toBe(5) + }) + + it('reports the end when the selection is outside the editor', () => { + renderComposerContents(editor, 'hello') + + const outside = document.createElement('div') + outside.textContent = 'elsewhere' + document.body.append(outside) + caretAfter(outside.firstChild!, 3) + + expect(caretOffsetInEditor(editor)).toBe(5) + + outside.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/undo-history.ts b/apps/desktop/src/app/chat/composer/undo-history.ts new file mode 100644 index 00000000000..62bed71be83 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/undo-history.ts @@ -0,0 +1,126 @@ +/** + * The composer's own undo stack. + * + * The rich editor mutates its DOM through `Range` rather than the browser's + * editing commands — `execCommand('insertText')` is ~O(n²) on large multiline + * blobs and froze the composer for seconds on a big paste (#45812). The cost of + * that bypass is that those mutations never reach Chromium's undo stack, so + * ⌘Z skipped straight past a paste and undid whatever came *before* it, leaving + * the pasted text stranded. + * + * Owning the whole stack is the only coherent fix: a half-owned one interleaves + * our snapshots with Chromium's own typing entries and undoes them out of order. + * So every composer edit — typed or programmatic — records here, and the editor + * intercepts ⌘Z / ⌘⇧Z instead of letting the native command run. + * + * Snapshots are plain text + a caret offset, not DOM: the editor already + * round-trips losslessly through `composerPlainText`/`renderComposerContents`, + * so text is the smallest thing that fully restores a state. + */ + +export interface ComposerSnapshot { + caret: number + text: string +} + +/** Consecutive typing inside this window collapses into one undo entry, so ⌘Z + * steps back by a burst the way a native editor does — not one character. */ +const COALESCE_WINDOW_MS = 600 + +const DEFAULT_LIMIT = 200 + +export interface ComposerUndoHistory { + /** Drop all history and start over from `snapshot`'s state (session swap). */ + reset: () => void + /** Redo one step. `current` is the live state, banked for a subsequent undo. */ + redo: (current: ComposerSnapshot) => ComposerSnapshot | null + /** Bank the state that existed *before* an edit. `coalesce` merges this into + * the previous entry when it lands inside the typing window. */ + record: (previous: ComposerSnapshot, options?: { coalesce?: boolean }) => void + /** Undo one step. `current` is the live state, banked for a subsequent redo. */ + undo: (current: ComposerSnapshot) => ComposerSnapshot | null +} + +export function createComposerUndoHistory( + limit = DEFAULT_LIMIT, + now: () => number = () => Date.now() +): ComposerUndoHistory { + let past: ComposerSnapshot[] = [] + let future: ComposerSnapshot[] = [] + let lastRecordedAt = 0 + let lastWasCoalescable = false + + const record: ComposerUndoHistory['record'] = (previous, options) => { + const coalesce = options?.coalesce ?? false + const at = now() + const merges = coalesce && lastWasCoalescable && past.length > 0 && at - lastRecordedAt < COALESCE_WINDOW_MS + + lastRecordedAt = at + lastWasCoalescable = coalesce + // A fresh edit invalidates anything the user had redone past. + future = [] + + // Merging keeps the OLDER snapshot — the entry already holds the state from + // the start of the burst, which is what ⌘Z should step back to. + if (merges) { + return + } + + // A no-op edit (same text) would make ⌘Z look broken for one press. + if (past[past.length - 1]?.text === previous.text) { + return + } + + past.push(previous) + + if (past.length > limit) { + past = past.slice(past.length - limit) + } + } + + const step = (from: ComposerSnapshot[], to: ComposerSnapshot[], current: ComposerSnapshot) => { + const next = from.pop() + + if (!next) { + return null + } + + to.push(current) + // Any traversal ends the typing burst, so the next keystroke opens a new entry. + lastWasCoalescable = false + + return next + } + + return { + record, + redo: current => step(future, past, current), + reset: () => { + past = [] + future = [] + lastRecordedAt = 0 + lastWasCoalescable = false + }, + undo: current => step(past, future, current) + } +} + +/** True for the keystroke that means "undo" (⌘Z / Ctrl+Z, without Shift). */ +export function isUndoShortcut(event: Pick) { + return (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'z' +} + +/** True for "redo" — ⌘⇧Z everywhere, plus Ctrl+Y on Windows/Linux. */ +export function isRedoShortcut(event: Pick) { + if (event.altKey) { + return false + } + + const key = event.key.toLowerCase() + + if ((event.metaKey || event.ctrlKey) && event.shiftKey && key === 'z') { + return true + } + + return event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'y' +} diff --git a/apps/desktop/src/app/chat/composer/url-refs.test.ts b/apps/desktop/src/app/chat/composer/url-refs.test.ts new file mode 100644 index 00000000000..febbd533ce4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/url-refs.test.ts @@ -0,0 +1,98 @@ +import type { KeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' + +import { composerPlainText, RICH_INPUT_SLOT } from './rich-editor' +import { chipTypedUrlOnSpace, linkifyUrls } from './url-refs' + +/** An editor holding `text` with a collapsed caret at `caret`, plus the space + * keydown the composer would hand `chipTypedUrlOnSpace`. */ +const spaceOn = (text: string, caret: number) => { + const editor = document.createElement('div') + editor.dataset.slot = RICH_INPUT_SLOT + editor.textContent = text + document.body.append(editor) + + const selection = window.getSelection()! + const range = document.createRange() + + range.setStart(editor.firstChild!, caret) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + + return { editor, event: { currentTarget: editor, key: ' ' } as KeyboardEvent } +} + +describe('linkifyUrls', () => { + it('rewrites a bare link as a url directive', () => { + expect(linkifyUrls('https://example.dev/a/b')).toBe('@url:`https://example.dev/a/b`') + }) + + it('keeps the link in place mid-sentence and leaves its punctuation behind', () => { + expect(linkifyUrls('read https://example.dev/a. then stop')).toBe('read @url:`https://example.dev/a`. then stop') + }) + + it('keeps balanced parens but drops the one that closed the sentence', () => { + expect(linkifyUrls('(see https://en.wikipedia.org/wiki/A_(b))')).toBe( + '(see @url:`https://en.wikipedia.org/wiki/A_(b)`)' + ) + }) + + it('rewrites every link in a multi-link paste', () => { + expect(linkifyUrls('http://a.dev and https://b.dev')).toBe('@url:`http://a.dev` and @url:`https://b.dev`') + }) + + it('leaves a link that is already a directive alone', () => { + expect(linkifyUrls('@url:`https://example.dev`')).toBe('@url:`https://example.dev`') + }) + + it('leaves text without a scheme alone', () => { + expect(linkifyUrls('example.dev/a and src/foo.ts')).toBe('example.dev/a and src/foo.ts') + }) +}) + +describe('chipTypedUrlOnSpace', () => { + it('chips a link typed right before the caret and adds the space', () => { + const { editor, event } = spaceOn('see https://example.dev/a', 25) + + expect(chipTypedUrlOnSpace(event)).toBe(true) + expect(composerPlainText(editor)).toBe('see @url:`https://example.dev/a` ') + + editor.remove() + }) + + it('keeps sentence punctuation outside the chip', () => { + const { editor, event } = spaceOn('https://example.dev.', 20) + + expect(chipTypedUrlOnSpace(event)).toBe(true) + expect(composerPlainText(editor)).toBe('@url:`https://example.dev`. ') + + editor.remove() + }) + + it('ignores a caret that is not sitting on a link', () => { + const { editor, event } = spaceOn('https://example.dev is nice', 27) + + expect(chipTypedUrlOnSpace(event)).toBe(false) + expect(composerPlainText(editor)).toBe('https://example.dev is nice') + + editor.remove() + }) + + it('ignores a scheme with no host yet', () => { + const { editor, event } = spaceOn('https://', 8) + + expect(chipTypedUrlOnSpace(event)).toBe(false) + + editor.remove() + }) + + it('leaves a modified space alone', () => { + const { editor, event } = spaceOn('https://example.dev', 19) + + expect(chipTypedUrlOnSpace({ ...event, altKey: true })).toBe(false) + expect(composerPlainText(editor)).toBe('https://example.dev') + + editor.remove() + }) +}) diff --git a/apps/desktop/src/app/chat/composer/url-refs.ts b/apps/desktop/src/app/chat/composer/url-refs.ts new file mode 100644 index 00000000000..580abdc1c6f --- /dev/null +++ b/apps/desktop/src/app/chat/composer/url-refs.ts @@ -0,0 +1,103 @@ +/** + * Bare-link recognition for the composer. A link the user pastes or types is the + * same thing the "+ → Add URL" dialog inserts, so it becomes an `@url:` + * directive: a chip that truncates instead of a wall of URL text, and a + * reference the gateway resolves. + */ +import type { KeyboardEvent } from 'react' + +import { quoteRefValue, REF_RE, refChipElement, replaceBeforeCaret } from './rich-editor' +import { textBeforeCaret } from './text-utils' + +// An explicit scheme only — `example.com` bare is too easy to hit by accident +// (a filename, a version, a sentence). Brackets and quotes fence a URL in prose; +// parens don't, so they stay in and an unbalanced tail is trimmed below. +const URL_RE = /https?:\/\/[^\s<>[\]{}"'`]+/gi +const TYPED_URL_RE = /(?:^|\s)(https?:\/\/[^\s<>[\]{}"'`]+)$/i + +/** A URL at the end of a sentence carries the punctuation that ended it. */ +function splitUrlTail(raw: string) { + let url = raw.replace(/[,.;:!?]+$/, '') + + while (url.endsWith(')') && url.split(')').length > url.split('(').length) { + url = url.slice(0, -1) + } + + return { trailing: raw.slice(url.length), url } +} + +/** A URL needs a host past the scheme to be worth chipping. */ +const hasHost = (url: string) => /^https?:\/\/[^/\s]/i.test(url) + +/** Rewrite bare links in `text` as `@url:` directives, leaving links that are + * already part of a directive alone. Returns `text` unchanged when there are + * none. */ +export function linkifyUrls(text: string) { + REF_RE.lastIndex = 0 + + const fenced = Array.from(text.matchAll(REF_RE)).map(match => { + const start = match.index ?? 0 + + return { end: start + match[0].length, start } + }) + + let out = '' + let cursor = 0 + + for (const match of text.matchAll(URL_RE)) { + const start = match.index ?? 0 + const { url } = splitUrlTail(match[0]) + + if (!hasHost(url) || fenced.some(span => start >= span.start && start < span.end)) { + continue + } + + out += `${text.slice(cursor, start)}@url:${quoteRefValue(url)}` + cursor = start + url.length + } + + return out + text.slice(cursor) +} + +/** A plain space finishing a typed link commits it as a chip (followed by + * whatever punctuation ended it, then the space). Returns whether it ran, so a + * keydown handler can fall through on anything else. */ +export function chipTypedUrlOnSpace(event: KeyboardEvent) { + if (event.key !== ' ' || event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + const editor = event.currentTarget + + // Runs on every space, so bail on the cheap native read before paying for the + // caret range walk (same guard shape as the trigger detector). + if (!editor.textContent?.includes('://')) { + return false + } + + const before = textBeforeCaret(editor) + const match = before ? TYPED_URL_RE.exec(before) : null + const token = match?.[1] + + if (!token) { + return false + } + + const { trailing, url } = splitUrlTail(token) + + if (!hasHost(url)) { + return false + } + + const fragment = document.createDocumentFragment() + + fragment.append(refChipElement('url', quoteRefValue(url))) + + if (trailing) { + fragment.append(document.createTextNode(trailing)) + } + + fragment.append(document.createTextNode(' ')) + + return replaceBeforeCaret(editor, token.length, fragment) +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 5fdf5a0487b..40a8922d453 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -71,7 +71,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onCancel: () => Promise | void onAddContextRef: (refText: string, label?: string, detail?: string) => void onAddUrl: (url: string) => void - onBranchInNewChat: (messageId: string) => void + onBranchInNewChat?: (messageId: string) => void maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void diff --git a/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx new file mode 100644 index 00000000000..5b1a84e2098 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-artifact.test.tsx @@ -0,0 +1,112 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { $artifactRegistry, $artifactVersionSelection, artifactPreviewTarget, upsertArtifact } from '@/store/artifacts' + +import { ArtifactPreview } from './preview-artifact' + +function register(title: string, kind: 'code' | 'html' | 'svg', content: string) { + const result = upsertArtifact('session-1', { kind, language: kind === 'code' ? 'python' : kind, title }, content) + + if (!result) { + throw new Error('artifact did not register') + } + + return result +} + +async function renderArtifact(artifactId: string) { + const record = $artifactRegistry.get()['session-1']!.find(item => item.id === artifactId)! + + await act(async () => { + render() + }) +} + +describe('ArtifactPreview', () => { + afterEach(() => { + cleanup() + $artifactRegistry.set({}) + $artifactVersionSelection.set({}) + }) + + it('renders html in a scripts-only sandboxed frame the parent app is unreachable from', async () => { + const { artifactId } = register('Dashboard', 'html', '

Hi

') + await renderArtifact(artifactId) + + const frame = screen.getByTitle('Dashboard') as HTMLIFrameElement + + expect(frame.getAttribute('sandbox')).toBe('allow-scripts') + expect(frame.srcdoc).toContain('

Hi

') + // No allow-same-origin: scripts inside cannot reach the renderer's origin. + expect(frame.getAttribute('sandbox')).not.toContain('same-origin') + }) + + it('strips scripts out of svg before it renders inline', async () => { + const { artifactId } = register( + 'Logo', + 'svg', + '' + ) + + await renderArtifact(artifactId) + + expect(document.querySelector('svg')).not.toBeNull() + expect(document.querySelector('svg script')).toBeNull() + }) + + it('offers only the source view for code, which has nothing to render', async () => { + const { artifactId } = register('Solver', 'code', 'print("hi")') + await renderArtifact(artifactId) + + expect(screen.queryByRole('button', { name: /rendered/i })).toBeNull() + }) + + it('shows the version stepper once an artifact has history, and follows the selection', async () => { + register('Dashboard', 'html', '

v1

') + const { artifactId } = register('Dashboard', 'html', '

v2

') + await renderArtifact(artifactId) + + expect(screen.getByText('v2 of 2')).toBeTruthy() + expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v2') + + await act(async () => { + $artifactVersionSelection.set({ [artifactId]: 0 }) + }) + + expect(screen.getByText('v1 of 2')).toBeTruthy() + expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v1') + }) + + it('hides the stepper for a single-version artifact', async () => { + const { artifactId } = register('Dashboard', 'html', '

only

') + await renderArtifact(artifactId) + + expect(screen.queryByText('v1 of 1')).toBeNull() + }) + + it('picks up a new version in an already-open tab', async () => { + const { artifactId } = register('Dashboard', 'html', '

v1

') + await renderArtifact(artifactId) + + await act(async () => { + register('Dashboard', 'html', '

v2

') + }) + + expect((screen.getByTitle('Dashboard') as HTMLIFrameElement).srcdoc).toContain('v2') + }) + + it('falls back to an empty state when the registry no longer has the artifact', async () => { + const { artifactId } = register('Dashboard', 'html', '

gone

') + const record = $artifactRegistry.get()['session-1']!.find(item => item.id === artifactId)! + const target = artifactPreviewTarget(record) + + $artifactRegistry.set({}) + + await act(async () => { + render() + }) + + expect(screen.queryByTitle('Dashboard')).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx b/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx new file mode 100644 index 00000000000..07d60393a8e --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-artifact.tsx @@ -0,0 +1,263 @@ +import { useStore } from '@nanostores/react' +import DOMPurify from 'dompurify' +import { useEffect, useMemo, useState } from 'react' + +import { CopyButton } from '@/components/ui/copy-button' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { artifactDownloadName, type ArtifactKind } from '@/lib/artifact-detect' +import { downloadTextFile } from '@/lib/download-text' +import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons' +import { $artifactRegistry, $artifactVersionSelection, findArtifact, selectArtifactVersion } from '@/store/artifacts' +import { notifyError } from '@/store/notifications' +import type { PreviewTarget } from '@/store/preview' + +import { PreviewEmptyState, PreviewModeSwitcher, type PreviewViewMode, SourceView } from './preview-file' + +const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const + +// Shiki has no `svg` grammar; code artifacts keep their detected fence language. +const SOURCE_LANGUAGE_BY_KIND: Record = { + code: undefined, + html: 'html', + svg: 'xml' +} + +const HEADER_BUTTON_CLASS = + 'flex items-center gap-1 rounded-md px-1.5 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40' + +/** Wrap an HTML fragment in a minimal document shell; full documents pass + * through untouched. Keeps generated fragments (no /) rendering + * with sane defaults instead of quirks-mode soup. */ +function composeArtifactHtml(content: string): string { + if (/]|', + '', + '', + content, + '' + ].join('\n') +} + +/** Write the composed document to a real temp file through the existing + * buffer-save IPC, then hand it to the OS browser. A blob/data URL can't + * cross into the OS default browser, so a file on disk is the honest path. */ +async function openHtmlInBrowser(content: string): Promise { + const bridge = window.hermesDesktop + + if (!bridge?.saveImageBuffer || !bridge.openExternal) { + throw new Error('Desktop bridge unavailable') + } + + const bytes = new TextEncoder().encode(composeArtifactHtml(content)) + const path = await bridge.saveImageBuffer(bytes, '.html') + + if (!path) { + throw new Error('Could not write artifact file') + } + + const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}` + + if (bridge.openPreviewInBrowser) { + await bridge.openPreviewInBrowser(fileUrl) + + return + } + + await bridge.openExternal(fileUrl) +} + +/** + * Live view for renderable artifact content. + * + * HTML runs in an `