Compare commits

..
Author SHA1 Message Date
Ben Barclay 73dccbe9c1 Merge remote-tracking branch 'origin/main' into fix/discord-interaction-command-normalization 2026-07-25 07:58:00 +10:00
Ben Barclay ceed22d2af fix(relay): normalize forwarded Discord interactions to leading-slash commands
A real APPLICATION_COMMAND interaction forwarded over the relay arrived
slash-less: _discord_interaction_to_event set text = data['name'] ("new",
not "/new"), MessageType.TEXT, and dropped options entirely — so a
registered /new dispatched as plain chat instead of a command
(MessageEvent.is_command() is text.startswith("/")).

Port the connector's Slack slash-command precedent (normalizeSlackCommand
builds `${command} ${args}`.trim() with a leading slash and explicit
command type): for type-2 interactions build "/" + name, append rendered
options space-separated (scalar options contribute their value, matching
the native adapter's f"/model {name}" shape; SUB_COMMAND/
SUB_COMMAND_GROUP contribute their name then recurse into nested
options), and set MessageType.COMMAND. Type-3 (custom_id) and other
interaction types are unchanged. This implements the interaction->command
sub-design previously flagged as deferred in the _on_passthrough
docstring.

Companion connector fix in gateway-gateway: fix(relay): strip own-mention
prefix so addressed slash commands dispatch.
2026-07-24 11:22:19 +10:00
541 changed files with 3813 additions and 40798 deletions
-2
View File
@@ -1,8 +1,6 @@
.DS_Store
/venv/
/venv.old/
/venv.stale.runtime-*/
/.hermes-runtime/
/_pycache/
*.pyc*
__pycache__/
+5 -8
View File
@@ -32,7 +32,6 @@ else:
import argparse
import asyncio
import logging
import os
import sys
from pathlib import Path
from hermes_constants import get_hermes_home
@@ -252,13 +251,11 @@ def main(argv: list[str] | None = None) -> None:
# MCP servers dynamically via asyncio.to_thread inside the event
# loop; that path is unaffected.) Moved from model_tools.py module
# scope to avoid freezing the gateway's loop on lazy import (#16856).
# Metadata-only hosts can opt out of unrelated global MCP startup.
if os.environ.get("HERMES_ACP_SKIP_CONFIGURED_MCP", "").strip() != "1":
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug("MCP tool discovery failed at ACP startup", exc_info=True)
try:
from tools.mcp_tool import discover_mcp_tools
discover_mcp_tools()
except Exception:
logger.debug("MCP tool discovery failed at ACP startup", exc_info=True)
agent = HermesACPAgent()
try:
+23 -196
View File
@@ -85,110 +85,6 @@ from tools.approval import (
logger = logging.getLogger(__name__)
def _named_custom_provider_catalogs() -> list[tuple[str, str, list[tuple[str, str]]]]:
"""Return ``(slug, label, [(model_id, description), ...])`` for named endpoints.
Covers both the v12 ``providers:`` mapping and the legacy
``custom_providers:`` list. These endpoints never appear in canonical
provider enumeration, so without this the ACP model selector hides every
named endpoint that the TUI ``/model`` picker already renders (#47039
implemented named-endpoint rows for the TUI surface only).
Model lists come from the entry's declared models (``default_model`` +
``models``), refreshed from the endpoint's live ``/models`` listing when a
credential is available and ``discover_models`` is not disabled. Declared
models are kept even when live discovery fails — some OpenAI-compatible
endpoints (e.g. Bedrock Mantle Responses) expose no ``/models`` route at
all yet serve the declared models fine.
Slugs use the ``custom:<name>`` shape that ``parse_model_input`` and
``resolve_runtime_provider`` already resolve, so encoded choice ids
(``custom:<name>:<model>``) round-trip through ``set_session_model``
unchanged.
"""
try:
from hermes_cli.config import (
get_compatible_custom_providers,
is_provider_enabled,
load_config,
)
from hermes_cli.models import fetch_api_models
except ImportError:
return []
try:
cfg = load_config()
entries = get_compatible_custom_providers(cfg)
except Exception:
logger.debug("Could not load named custom providers", exc_info=True)
return []
# ``get_compatible_custom_providers`` drops the ``enabled`` flag during
# normalization, so collect explicitly disabled provider keys from the
# raw config and skip their entries below.
disabled_keys: set[str] = set()
raw_providers = cfg.get("providers") if isinstance(cfg, dict) else None
if isinstance(raw_providers, dict):
for raw_key, raw_entry in raw_providers.items():
if isinstance(raw_entry, dict) and not is_provider_enabled(raw_entry):
disabled_keys.add(str(raw_key).strip().lower())
catalogs: list[tuple[str, str, list[tuple[str, str]]]] = []
for entry in entries:
if not isinstance(entry, dict):
continue
provider_key = str(entry.get("provider_key", "") or "").strip()
if provider_key.lower() in disabled_keys:
continue
name = str(entry.get("name", "") or "").strip()
base_url = str(entry.get("base_url", "") or "").strip()
if not name or not base_url:
continue
slug_source = provider_key or name
slug = "custom:" + slug_source.strip().lower().replace(" ", "-")
api_key = str(entry.get("api_key", "") or "").strip()
if not api_key:
key_env = str(entry.get("key_env", "") or "").strip()
api_key = os.environ.get(key_env, "").strip() if key_env else ""
declared: list[str] = []
default_model = str(entry.get("model", "") or "").strip()
if default_model:
declared.append(default_model)
models_cfg = entry.get("models")
if isinstance(models_cfg, dict):
for mid in models_cfg:
mid = str(mid or "").strip()
if mid and mid not in declared:
declared.append(mid)
if not api_key and not declared:
# No credential to discover with and nothing declared:
# not addressable from the selector.
continue
model_ids = list(declared)
discover = entry.get("discover_models", True)
if isinstance(discover, str):
discover = discover.lower() not in {"false", "no", "0"}
if discover and api_key:
try:
live = fetch_api_models(
api_key, base_url, api_mode=entry.get("api_mode")
)
except Exception:
live = None
if live:
model_ids = declared + [m for m in live if m not in declared]
if not model_ids:
continue
catalogs.append((slug, name, [(mid, "") for mid in model_ids]))
return catalogs
try:
from hermes_cli import __version__ as HERMES_VERSION
except Exception:
@@ -201,13 +97,6 @@ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent")
# does not expose a client-side limit, so this is a fixed cap that clients
# paginate against using `cursor` / `next_cursor`.
_LIST_SESSIONS_PAGE_SIZE = 50
# Per-provider cap for the ACP model selector. ACP clients (Zed, Buzz) render
# the whole `availableModels` array in one dropdown, so an unbounded
# cross-provider catalog degrades the picker. Mirrors the cap the MoA picker
# already uses (`hermes_cli/moa_cmd.py`). This bounds each provider's row, not
# the total; aggregator providers stay intentionally uncapped inside the shared
# inventory, and the current model is always kept via the fallback insert below.
ACP_MAX_MODELS_PER_PROVIDER = 200
_MAX_ACP_RESOURCE_BYTES = 512 * 1024
_TEXT_RESOURCE_MIME_PREFIXES = ("text/",)
_TEXT_RESOURCE_MIME_TYPES = {
@@ -696,108 +585,46 @@ class HermesACPAgent(acp.Agent):
return f"{raw_provider}:{raw_model}"
def _build_model_state(self, state: SessionState) -> SessionModelState | None:
"""Return authenticated providers and their models for ACP clients.
The shared Hermes inventory is also used by ``hermes model``, the TUI,
and the dashboard. Keeping ACP on that substrate prevents its selector
from silently collapsing to the current provider's curated list.
"""
"""Return the ACP model selector payload for editors like Zed."""
model = str(state.model or getattr(state.agent, "model", "") or "").strip()
provider = getattr(state.agent, "provider", None) or detect_provider() or "openrouter"
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
from hermes_cli.models import normalize_provider, provider_label
from hermes_cli.models import curated_models_for_provider, normalize_provider, provider_label
normalized_provider = normalize_provider(provider)
context = load_picker_context().with_overrides(
current_provider=normalized_provider,
current_model=model,
current_base_url=str(getattr(state.agent, "base_url", "") or ""),
)
payload = build_models_payload(
context,
explicit_only=True,
include_unconfigured=False,
picker_hints=False,
canonical_order=True,
pricing=False,
capabilities=False,
refresh=False,
probe_custom_providers=False,
probe_current_custom_provider=False,
max_models=ACP_MAX_MODELS_PER_PROVIDER,
)
provider_name = provider_label(normalized_provider)
available_models: list[ModelInfo] = []
seen_ids: set[str] = set()
for row in payload.get("providers") or []:
row_provider = normalize_provider(str(row.get("slug") or "").strip())
if not row_provider:
continue
provider_name = str(row.get("name") or "").strip() or provider_label(
row_provider
)
for model_entry in row.get("models") or []:
if isinstance(model_entry, dict):
rendered_model = str(
model_entry.get("id")
or model_entry.get("model")
or model_entry.get("name")
or ""
).strip()
else:
rendered_model = str(model_entry or "").strip()
if not rendered_model:
continue
choice_id = self._encode_model_choice(row_provider, rendered_model)
if choice_id in seen_ids:
continue
is_current = (
row_provider == normalized_provider and rendered_model == model
)
description = f"Provider: {provider_name}"
if is_current:
description += " • current"
available_models.append(
ModelInfo(
model_id=choice_id,
name=f"{provider_name} · {rendered_model}",
description=description,
)
)
seen_ids.add(choice_id)
# Named user-defined endpoints (providers: / custom_providers:)
# are invisible to canonical provider enumeration — append them
# so editor clients can select them like the TUI /model picker.
for named_slug, named_label, named_catalog in _named_custom_provider_catalogs():
for named_model, named_desc in named_catalog:
named_choice = self._encode_model_choice(named_slug, named_model)
if not named_choice or named_choice in seen_ids:
continue
named_parts = [f"Provider: {named_label}"]
if named_desc:
named_parts.append(str(named_desc).strip())
if named_slug == normalized_provider and named_model == model:
named_parts.append("current")
available_models.append(
ModelInfo(
model_id=named_choice,
name=named_model,
description=" • ".join(part for part in named_parts if part),
)
for model_id, description in curated_models_for_provider(normalized_provider):
rendered_model = str(model_id or "").strip()
if not rendered_model:
continue
choice_id = self._encode_model_choice(normalized_provider, rendered_model)
if choice_id in seen_ids:
continue
desc_parts = [f"Provider: {provider_name}"]
if description:
desc_parts.append(str(description).strip())
if rendered_model == model:
desc_parts.append("current")
available_models.append(
ModelInfo(
model_id=choice_id,
name=rendered_model,
description=" • ".join(part for part in desc_parts if part),
)
seen_ids.add(named_choice)
)
seen_ids.add(choice_id)
current_model_id = self._encode_model_choice(normalized_provider, model)
if current_model_id and current_model_id not in seen_ids:
provider_name = provider_label(normalized_provider)
available_models.insert(
0,
ModelInfo(
model_id=current_model_id,
name=f"{provider_name} · {model}",
name=model,
description=f"Provider: {provider_name} • current",
),
)
+3 -14
View File
@@ -823,10 +823,9 @@ def init_agent(
# Anthropic prompt caching: auto-enabled for Claude models on native
# Anthropic, OpenRouter, and third-party gateways that speak the
# Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces
# input costs by ~75% on multi-turn conversations. Uses four breakpoints:
# the static system prefix, full system prompt, and last two messages
# (falling back to system-and-3 when no static prefix is available). See
# ``_anthropic_prompt_cache_policy`` for the layout-vs-transport decision.
# input costs by ~75% on multi-turn conversations. Uses system_and_3
# strategy (4 breakpoints). See ``_anthropic_prompt_cache_policy``
# for the layout-vs-transport decision.
agent._use_prompt_caching, agent._use_native_cache_layout = (
agent._anthropic_prompt_cache_policy()
)
@@ -1342,13 +1341,6 @@ def init_agent(
print("⚠️ Warning: API key appears invalid or missing")
except Exception as e:
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
# Keep a stable identity for the pool entry that supplied this runtime.
# OAuth refreshes can replace the runtime token before a failed request is
# recovered, so the mutable API-key value alone cannot reliably attribute
# the failure to its source entry.
from agent.agent_runtime_helpers import sync_credential_pool_entry_id
sync_credential_pool_entry_id(agent)
# Provider fallback chain — ordered list of backup providers tried
# when the primary is exhausted (rate-limit, overload, connection
@@ -1495,9 +1487,6 @@ def init_agent(
# Cached system prompt -- built once per session, only rebuilt on compression
agent._cached_system_prompt: Optional[str] = None
# Cross-session-stable prefix of the cached prompt. It remains separate
# from the persisted string and is used only to place an early cache marker.
agent._cached_system_prompt_static: Optional[str] = None
# Filesystem checkpoint manager (transparent — not a tool)
from tools.checkpoint_manager import CheckpointManager
+26 -70
View File
@@ -850,25 +850,6 @@ def strip_think_blocks(agent, content: str) -> str:
def sync_credential_pool_entry_id(agent) -> None:
"""Rebind ``agent._credential_pool_entry_id`` from the current pool + key.
OAuth refreshes can replace the runtime token before a failed request is
recovered, so the mutable API-key value alone cannot reliably attribute
the failure to its source entry. This resolves the stable pool-entry ID
for the agent's current ``api_key`` and clears it when no pool is bound.
"""
pool = getattr(agent, "_credential_pool", None)
try:
agent._credential_pool_entry_id = (
pool.entry_id_for_api_key(getattr(agent, "api_key", None))
if pool is not None
else None
)
except Exception:
agent._credential_pool_entry_id = None
def recover_with_credential_pool(
agent,
*,
@@ -953,30 +934,10 @@ def recover_with_credential_pool(
# failing entry exactly; fall back to current()'s key only when the agent
# carries no key at all.
_api_key_hint = getattr(agent, "api_key", None) or None
_raw_credential_id = getattr(agent, "_credential_pool_entry_id", None)
_credential_id = (
_raw_credential_id
if isinstance(_raw_credential_id, str) and _raw_credential_id
else None
)
if not _api_key_hint:
_cur = pool.current()
if _cur:
_api_key_hint = getattr(_cur, "runtime_api_key", None)
if not _credential_id:
_current_id = getattr(_cur, "id", None)
if isinstance(_current_id, str) and _current_id:
_credential_id = _current_id
def _rotate_failed_credential(rotate_status: int):
kwargs = {
"status_code": rotate_status,
"error_context": error_context,
"api_key_hint": _api_key_hint,
}
if _credential_id:
kwargs["credential_id"] = _credential_id
return pool.mark_exhausted_and_rotate(**kwargs)
effective_reason = classified_reason
if effective_reason is None:
@@ -1011,7 +972,11 @@ def recover_with_credential_pool(
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (billing) — rotated to pool entry %s",
@@ -1030,13 +995,8 @@ def recover_with_credential_pool(
# Prefer the entry matching the failing key over the shared current()
# pointer, for the same attribution reason as above.
current_entry = None
if _credential_id:
current_entry = next(
(e for e in pool.entries() if e.id == _credential_id),
None,
)
if _api_key_hint:
current_entry = current_entry or next(
current_entry = next(
(e for e in pool.entries() if e.runtime_api_key == _api_key_hint),
None,
)
@@ -1049,7 +1009,11 @@ def recover_with_credential_pool(
current_last_status,
)
rotate_status = status_code if status_code is not None else 429
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
@@ -1073,7 +1037,11 @@ def recover_with_credential_pool(
if not has_retried_429 and not usage_limit_reached:
return False, True
rotate_status = status_code if status_code is not None else 429
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit) — rotated to pool entry %s",
@@ -1145,10 +1113,7 @@ def recover_with_credential_pool(
# the shared pointer can reference a different, healthy entry, and
# refreshing it would consume that entry's single-use refresh token
# (or mark it exhausted on failure) for a failure it never had.
refresh_kwargs = {"api_key_hint": _api_key_hint}
if _credential_id:
refresh_kwargs["credential_id"] = _credential_id
refreshed = pool.try_refresh_matching(**refresh_kwargs)
refreshed = pool.try_refresh_matching(api_key_hint=_api_key_hint)
if refreshed is not None:
# ``try_refresh_matching()`` re-mints a fresh OAuth token and reports
# success even when the upstream keeps rejecting it — a single-entry
@@ -1180,7 +1145,11 @@ def recover_with_credential_pool(
# Refresh failed — rotate to next credential instead of giving up.
# The failed entry is already marked exhausted by the refresh attempt.
rotate_status = status_code if status_code is not None else 401
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (auth refresh failed) — rotated to pool entry %s",
@@ -1225,17 +1194,11 @@ def try_recover_primary_transport(
return False
try:
# Retire the existing client to release stale connections. #70773:
# never hard-close the shared client here — this runs on the
# conversation-loop thread while workers from stale-killed streaming
# attempts may still be unwinding their SSL BIOs on the old pool.
# ``_retire_shared_openai_client`` shuts the sockets down (FD-safe
# from any thread) and defers the FD release to GC, which cannot
# complete until every borrowing thread has unwound.
# Close existing client to release stale connections
if getattr(agent, "client", None) is not None:
try:
agent._retire_shared_openai_client(
agent.client, reason="primary_recovery",
agent._close_openai_client(
agent.client, reason="primary_recovery", shared=True,
)
except Exception:
pass
@@ -1497,7 +1460,6 @@ def restore_primary_runtime(agent) -> bool:
pool_matches_primary = False
if pool is not None and pool_provider and not pool_matches_primary:
agent._credential_pool = None
agent._credential_pool_entry_id = None
try:
from agent.credential_pool import load_pool
@@ -1517,7 +1479,6 @@ def restore_primary_runtime(agent) -> bool:
# the pool for its current best entry and swap the live credential in.
# When the pool is absent, empty, or the entry has no usable key, we
# keep the snapshot key (the existing behavior). Fixes #25205.
agent._credential_pool_entry_id = None
pool = getattr(agent, "_credential_pool", None)
if pool is not None and pool.has_available():
entry = pool.select()
@@ -2114,9 +2075,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# restore the original pool (issue #52727: pool reload is part of this
# switch and must be reversible on rollback).
_snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING)
_snapshot["_credential_pool_entry_id"] = getattr(
agent, "_credential_pool_entry_id", _MISSING
)
try:
# Clear the per-config context_length override so the new model's
@@ -2173,7 +2131,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# A pool bound to the old provider is worse than no pool: the
# recovery guard rejects it and every later 401/429 skips rotation.
agent._credential_pool = None
agent._credential_pool_entry_id = None
try:
from agent.credential_pool import load_pool
agent._credential_pool = load_pool(new_provider)
@@ -2183,6 +2140,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"continuing without pool rotation this turn",
new_provider, _pool_exc,
)
# ── Build new client ──
if (new_provider or "").strip().lower() == "moa":
from agent.moa_loop import build_moa_facade
@@ -2278,8 +2236,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
reason="switch_model",
shared=True,
)
sync_credential_pool_entry_id(agent)
except Exception:
# Rollback every mutated field to the pre-swap snapshot so the agent
# is left consistent (old model + old provider + old client) and the
+24 -92
View File
@@ -368,7 +368,7 @@ def _detect_claude_code_version() -> str:
try:
result = _sp.run(
[cmd, "--version"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
# Output is like "2.1.74 (Claude Code)" or just "2.1.74"
@@ -914,7 +914,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
"-s", "Claude Code-credentials",
"-w"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
@@ -1920,18 +1920,10 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return None
btype = b.get("type")
if btype == "text":
text_val = b.get("text", "")
# Bedrock and strict Anthropic-compatible endpoints reject text
# blocks where "text" is empty or whitespace-only (#69512). Drop the
# blank block (the caller relocates any cache_control it carried and
# falls back to a non-whitespace placeholder when nothing survives)
# rather than coercing in place — a coerced "(empty)" block would be
# model-visible noise next to surviving thinking/tool_use blocks.
# Type-safe: captured blocks can carry text=None from an invalid
# upstream payload, which a bare .strip() would crash on.
if not isinstance(text_val, str) or not text_val.strip():
return None
out: Dict[str, Any] = {"type": "text", "text": text_val}
# Coerce empty/whitespace-only text to a non-whitespace placeholder;
# the Messages input schema rejects blank text blocks (#69512), and a
# blank block stored in history replays on every turn → permanent 400.
out: Dict[str, Any] = {"type": "text", "text": _safe_text(b.get("text", ""))}
# citations is input-valid ONLY when it's a non-empty list; the SDK
# emits citations=None on responses, which the input schema rejects.
cits = b.get("citations")
@@ -2019,17 +2011,9 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
parsed_args = {}
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
replayed: List[Dict[str, Any]] = []
_relocated_replay_cache_control = None
_dropped_blank_text = False
for b in ordered_blocks:
clean = _sanitize_replay_block(b)
if clean is None:
if isinstance(b, dict) and b.get("type") == "text":
_dropped_blank_text = True
if isinstance(b, dict) and isinstance(b.get("cache_control"), dict):
# A dropped blank text block can still carry the cache
# breakpoint marker -- relocate it rather than losing it.
_relocated_replay_cache_control = b["cache_control"]
continue
if clean.get("type") == "tool_use":
# Override raw (un-redacted) input with the redacted copy when
@@ -2039,68 +2023,20 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
if redacted is not None:
clean["input"] = redacted
replayed.append(clean)
# When every text block was blank and nothing cacheable survived
# (e.g. signed thinking + a blank text block, or a SOLE blank
# cache-marked block), emit the non-whitespace placeholder so the
# replayed message stays schema-valid (#69512) and a relocated cache
# marker still has a carrier instead of being silently lost.
_has_cacheable_replay = any(
isinstance(b, dict) and b.get("type") in {"text", "tool_use"}
for b in replayed
)
if not _has_cacheable_replay and (
_dropped_blank_text or _relocated_replay_cache_control is not None
):
replayed.append({"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER})
if replayed:
if _relocated_replay_cache_control is not None:
_apply_assistant_cache_control_to_last_cacheable_block(
replayed, _relocated_replay_cache_control
)
_apply_assistant_cache_control_to_last_cacheable_block(
replayed, m.get("cache_control")
)
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
# Cache markers dropped along with a blank block are relocated onto the
# last surviving cacheable block below (via
# _apply_assistant_cache_control_to_last_cacheable_block), rather than
# lost -- prompt_caching.py's _apply_cache_marker() sets cache_control
# directly on content[-1] for list content, so if that last part happens
# to be blank text, dropping it silently would lose the breakpoint.
_relocated_cache_control = None
if content:
if isinstance(content, list):
converted_content = _convert_content_to_anthropic(content)
if isinstance(converted_content, list):
# Bedrock and strict Anthropic-compatible endpoints reject
# text blocks where "text" is empty or whitespace-only. The
# ordered-replay path enforces the same invariant via
# _sanitize_replay_block(). Type-safe against ANY invalid
# "text" value from an upstream payload -- None, or a
# truthy non-string like an int -- not just None: checking
# isinstance() first (rather than `blk.get("text") or ""`)
# means a non-string value is treated as blank/invalid
# instead of reaching .strip() and raising AttributeError.
for blk in converted_content:
_blk_text = blk.get("text") if isinstance(blk, dict) else None
if (
isinstance(blk, dict)
and blk.get("type") == "text"
and (not isinstance(_blk_text, str) or not _blk_text.strip())
):
if isinstance(blk.get("cache_control"), dict):
_relocated_cache_control = blk["cache_control"]
continue
blocks.append(blk)
blocks.extend(converted_content)
else:
# Scalar (non-list) content: a whitespace-only string is the
# same invalid-payload case as an empty list block -- drop it
# rather than emitting a blank text block.
text_str = str(content)
if text_str.strip():
blocks.append({"type": "text", "text": text_str})
blocks.append({"type": "text", "text": str(content)})
for tc in m.get("tool_calls", []):
if not tc or not isinstance(tc, dict):
continue
@@ -2116,6 +2052,9 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"name": fn.get("name", ""),
"input": parsed_args,
})
_apply_assistant_cache_control_to_last_cacheable_block(
blocks, m.get("cache_control")
)
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
# tool-call messages to carry reasoning_content when thinking is
# enabled server-side. Preserve it as a thinking block so Kimi
@@ -2141,26 +2080,19 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
)
if isinstance(reasoning_content, str) and not _already_has_thinking:
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
# Anthropic rejects empty assistant content. IMPORTANT: fall back only
# to the placeholder, never to the raw `content` variable -- `content`
# is the UNFILTERED original message content, and can itself be exactly
# the blank/whitespace-only payload the filtering above just removed
# (a sole blank text block, or scalar whitespace with no tool_calls).
# `blocks or content` there would silently restore the invalid provider
# payload this function exists to prevent (#69512).
effective = blocks if blocks else [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
# Applied here (after the empty-fallback resolution) rather than
# earlier against `blocks` directly, so a cache_control relocated from
# a dropped blank block that was the ONLY block still lands on the
# (empty) placeholder instead of being silently lost when blocks was
# empty at the point the marker would otherwise have been applied.
if _relocated_cache_control is not None:
_apply_assistant_cache_control_to_last_cacheable_block(
effective, _relocated_cache_control
)
_apply_assistant_cache_control_to_last_cacheable_block(
effective, m.get("cache_control")
)
# Anthropic rejects empty assistant content
effective = blocks or content
if not effective or effective == "":
effective = [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
elif isinstance(effective, list):
# The all-empty guard above misses a list that still contains a
# whitespace-only text block (e.g. from a content array of blank parts,
# or compression). Those also trip "text content blocks must contain
# non-whitespace text" (#69512). Coerce text blocks in place; other
# block types (thinking/tool_use/image) are left untouched.
for blk in effective:
if isinstance(blk, dict) and blk.get("type") == "text":
blk["text"] = _safe_text(blk.get("text", ""))
return {"role": "assistant", "content": effective}
+3 -16
View File
@@ -1058,29 +1058,16 @@ class _CodexCompletionsAdapter:
# key in extra_body (not top-level) and GitHub/Copilot Responses opts
# out of cache-key routing entirely — for those hosts, skip it here.
try:
from agent.transports.codex import (
_content_cache_key,
_default_prompt_cache_retention_for_request,
)
from agent.transports.codex import _content_cache_key
from utils import base_url_host_matches
_host_src = str(getattr(self._client, "base_url", "") or "")
_is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai")
_is_github = (
base_url_host_matches(_host_src, "githubcopilot.com")
or base_url_host_matches(_host_src, "models.github.ai")
)
_is_github = base_url_host_matches(_host_src, "githubcopilot.com")
if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs:
_cache_key = _content_cache_key(instructions, resp_kwargs.get("tools"))
if _cache_key:
resp_kwargs["prompt_cache_key"] = _cache_key
if "prompt_cache_retention" not in resp_kwargs:
_cache_retention = _default_prompt_cache_retention_for_request(
model,
_host_src,
)
if _cache_retention:
resp_kwargs["prompt_cache_retention"] = _cache_retention
except Exception:
logger.debug(
"Codex auxiliary: prompt_cache_key derivation skipped", exc_info=True
@@ -1716,7 +1703,7 @@ def _read_nous_auth() -> Optional[dict]:
try:
if not _AUTH_JSON_PATH.is_file():
return None
data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8"))
data = json.loads(_AUTH_JSON_PATH.read_text())
if data.get("active_provider") != "nous":
return None
provider = data.get("providers", {}).get("nous", {})
+24 -69
View File
@@ -1076,7 +1076,6 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
tools=tools_for_api,
reasoning_config=agent.reasoning_config,
session_id=getattr(agent, "session_id", None),
base_url=agent.base_url,
max_tokens=agent.max_tokens,
timeout=agent._resolved_api_call_timeout(),
request_overrides=agent.request_overrides,
@@ -1514,45 +1513,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()
@@ -1641,9 +1601,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# 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.
# same shim/proxy URL also dedup. See issue #22548.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
current_model = (getattr(agent, "model", "") or "").strip()
current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower()
@@ -1654,13 +1612,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
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,
if (
fb_base_url_for_dedup
and current_base_url
and fb_base_url_for_dedup == current_base_url
and fb_model == current_model
):
logger.warning(
"Fallback skip: chain entry base_url %s matches current backend",
@@ -1783,7 +1739,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model, _pool_provider,
)
agent._credential_pool = None
agent._credential_pool_entry_id = None
if getattr(agent, "_credential_pool", None) is None:
try:
from agent.credential_pool import load_pool
@@ -1846,9 +1801,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# not only after a later credential-rotation rebuild.
agent._replace_primary_openai_client(reason="fallback_timeout_apply")
from agent.agent_runtime_helpers import sync_credential_pool_entry_id
sync_credential_pool_entry_id(agent)
# Re-evaluate prompt caching for the new provider/model
agent._use_prompt_caching, agent._use_native_cache_layout = (
agent._anthropic_prompt_cache_policy(
@@ -3492,9 +3444,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# already worker-owned-closed by _close_request_client_once
# above; the next attempt builds a fresh one. The shared
# _anthropic_client is never closed from inside a request.
# #70773: same FD-recycle corruption vector for OpenAI.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next attempt.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_mid_tool_retry_pool_cleanup"
)
except Exception:
pass
continue
# SSE error events from proxies (e.g. OpenRouter sends
@@ -3553,9 +3509,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# above; next attempt builds fresh), so the shared
# _anthropic_client is never closed from inside a
# request — only the OpenAI-wire primary is refreshed.
# #70773: same FD-recycle corruption vector for OpenAI.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next attempt.
if agent.api_mode != "anthropic_messages":
try:
agent._replace_primary_openai_client(
reason="stream_retry_pool_cleanup"
)
except Exception:
pass
continue
# Retries exhausted. Log the final failure with
# full diagnostic detail (chain, headers,
@@ -3798,15 +3758,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# FD-recycle corruption vector. Nothing further is needed.
pass
else:
# #70773: same FD-recycle corruption vector as #67142.
# The shared OpenAI client's connection pool must NOT be
# closed from this watchdog/poll thread — worker threads
# from previous stale-killed attempts may still be
# unwinding their SSL BIOs. The request-local client is
# already closed above via _close_request_client_once.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next request.
pass
try:
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
except Exception:
pass
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
+3 -9
View File
@@ -912,8 +912,7 @@ def _preflight_codex_api_kwargs(
allowed_keys = {
"model", "instructions", "input", "tools", "store",
"reasoning", "include", "max_output_tokens", "temperature",
"tool_choice", "parallel_tool_calls", "prompt_cache_key",
"prompt_cache_retention", "service_tier",
"tool_choice", "parallel_tool_calls", "prompt_cache_key", "service_tier",
"extra_headers", "extra_body", "timeout",
}
normalized: Dict[str, Any] = {
@@ -951,13 +950,8 @@ def _preflight_codex_api_kwargs(
if isinstance(temperature, (int, float)):
normalized["temperature"] = float(temperature)
# Pass through cache routing/retention and tool-dispatch hints.
for passthrough_key in (
"tool_choice",
"parallel_tool_calls",
"prompt_cache_key",
"prompt_cache_retention",
):
# Pass through tool_choice, parallel_tool_calls, prompt_cache_key
for passthrough_key in ("tool_choice", "parallel_tool_calls", "prompt_cache_key"):
val = api_kwargs.get(passthrough_key)
if val is not None:
normalized[passthrough_key] = val
+8 -37
View File
@@ -520,46 +520,30 @@ class RuntimeMode:
return None
return [self.profile.toolset, *_enabled_mcp_servers(config)]
def system_prompt_parts(self) -> tuple[list[str], list[str], list[str]]:
"""Return prefix, workspace, and trailing posture blocks separately.
def system_blocks(self) -> list[str]:
"""Stable system-prompt blocks for this posture (brief + workspace).
The operating brief carries a model-family edit-format nudge appended
to it (one cached string, not a separate block) so the model is steered
toward the `patch` mode it handles best — see ``_edit_format_line``.
The three lists preserve the historical flat prompt order: the brief,
the live workspace snapshot, then configured operator instructions.
Prompt assembly can therefore put a cache boundary before the snapshot
without changing the persisted system-prompt bytes.
"""
if not self.is_coding:
return [], [], []
prefix: list[str] = []
workspace_parts: list[str] = []
trailing: list[str] = []
return []
blocks: list[str] = []
if self.profile.guidance:
brief = self.profile.guidance
edit_line = _edit_format_line(self.model)
if edit_line:
brief = f"{brief}\n{edit_line}"
prefix.append(brief)
blocks.append(brief)
workspace = build_coding_workspace_block(self.cwd)
if workspace:
workspace_parts.append(workspace)
blocks.append(workspace)
# Operator instructions ride their own block so the brief (block 0) stays
# byte-stable and cache-keyed independently of user config.
if self.instructions:
trailing.append(f"Operator instructions (from config):\n{self.instructions}")
return prefix, workspace_parts, trailing
def system_blocks(self) -> list[str]:
"""Return posture blocks in their historical display order.
``system_prompt_parts`` is the cache-aware API. This compatibility
helper retains the public flat list for callers outside prompt assembly.
"""
prefix, workspace, trailing = self.system_prompt_parts()
return [*prefix, *workspace, *trailing]
blocks.append(f"Operator instructions (from config):\n{self.instructions}")
return blocks
def compact_skill_categories(self) -> frozenset[str]:
"""Skill categories to demote to names-only in the prompt's skill index.
@@ -660,19 +644,6 @@ def coding_system_blocks(
).system_blocks()
def coding_system_prompt_parts(
*,
platform: Optional[str] = None,
cwd: Optional[str | Path] = None,
config: Optional[dict[str, Any]] = None,
model: Optional[str] = None,
) -> tuple[list[str], list[str], list[str]]:
"""Return coding prefix, workspace snapshot, and trailing guidance."""
return resolve_runtime_mode(
platform=platform, cwd=cwd, config=config, model=model
).system_prompt_parts()
def coding_compact_skill_categories(
*,
platform: Optional[str] = None,
+36 -108
View File
@@ -90,6 +90,9 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool:
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
SUMMARY_PREFIX = (
@@ -104,7 +107,9 @@ SUMMARY_PREFIX = (
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
f"'{HISTORICAL_TASK_HEADING}' entirely — do not 'wrap up' or "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
@@ -214,45 +219,8 @@ _MERGED_SUMMARY_DELIMITER = "[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]
# stale directive it carried (e.g. "resume exactly from Active Task") survives
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
# NEVER mutate or reorder an existing entry — each one is the exact wire text a
# shipped build persisted, so editing it silently un-normalizes every summary
# written by that build generation; prepend only. tests/agent/
# test_summary_prefix_semantics.py byte-pins every entry to enforce this.
_HISTORICAL_SUMMARY_PREFIXES = (
# Pre-#69619: identical to the current prefix except the stale-item
# discard clause named all four historical headings (the three
# section headers removed by #69619 were still in the template).
# Summaries persisted by builds immediately before #69619 carry this
# exact text and must remain detectable/strippable on resume.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
"'## Historical Task Snapshot' / '## Historical In-Progress State' / "
"'## Historical Pending User Asks' / "
"'## Historical Remaining Work' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"None of the above restricts HOW you work: your tools remain fully "
"active — keep calling them normally for the active task (edit files, "
"run commands, search) instead of merely narrating what you would do. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:",
# Jul 2026 (#65848 class): identical to the pre-#69619 prefix except it
# Jul 2026 (#65848 class): identical to the current prefix except it
# lacked the explicit "tools remain fully active" clause — the strong
# REFERENCE ONLY framing bled into general tool-use suppression
# (observed: 7 consecutive narration-only turns immediately after a
@@ -268,9 +236,9 @@ _HISTORICAL_SUMMARY_PREFIXES = (
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
"'## Historical Task Snapshot' / '## Historical In-Progress State' / "
"'## Historical Pending User Asks' / "
"'## Historical Remaining Work' entirely — do not 'wrap up' or "
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
@@ -1245,7 +1213,6 @@ class ContextCompressor(ContextEngine):
self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._anti_thrash_recovery_deadline = 0.0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
@@ -1383,7 +1350,6 @@ class ContextCompressor(ContextEngine):
self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._anti_thrash_recovery_deadline = 0.0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
@@ -1410,7 +1376,6 @@ class ContextCompressor(ContextEngine):
self._consecutive_timeout_failures = 0
self._fallback_compression_streak = 0
self._ineffective_compression_count = 0
self._anti_thrash_recovery_deadline = 0.0
self.get_active_compression_failure_cooldown()
self._load_fallback_compression_streak()
self._load_ineffective_compression_count()
@@ -1782,15 +1747,6 @@ class ContextCompressor(ContextEngine):
# rationale as the gpt-5.5/Codex 85% autoraise.
_MIN_CTX_TRIGGER_RATIO = 0.85
# Anti-thrash recovery window (#14694): once the ineffective/fallback
# breaker trips, automatic compaction stays blocked for this long, then
# ONE probe attempt is allowed (counters drop to 1 strike, so another
# ineffective pass re-trips immediately). Long enough that a genuinely
# incompressible session isn't compacting in a loop; short enough that a
# session which has since grown real compressible material recovers well
# before it rides into the provider's hard context limit.
_ANTI_THRASH_RECOVERY_SECONDS = 300.0
@staticmethod
def _coerce_max_tokens(value: Any) -> int | None:
"""Normalize a max_tokens value to a positive int or None.
@@ -2060,12 +2016,6 @@ class ContextCompressor(ContextEngine):
# Anti-thrashing: track whether last compression was effective
self._last_compression_savings_pct: float = 100.0
self._ineffective_compression_count: int = 0
# Monotonic deadline after which a tripped anti-thrash guard grants
# one probation probe (#14694). 0.0 = clock not armed. Armed lazily on
# the first blocked evaluation; deliberately NOT durable, so a process
# restart with a persisted tripped counter (#69872) waits a full fresh
# window before probing (#54923: restart must never disarm a guard).
self._anti_thrash_recovery_deadline: float = 0.0
# Consecutive completed deterministic-fallback boundaries. Unlike the
# real-usage effectiveness counter, ordinary fitting responses must not
# reset this breaker; only a healthy completed summary does.
@@ -2356,66 +2306,21 @@ class ContextCompressor(ContextEngine):
_cooldown_remaining,
)
return True
# Anti-thrashing: back off if recent compressions were ineffective.
# The back-off must not be permanent (#14694): the tripped state was
# judged against the transcript as it existed THEN (e.g. a middle
# region too small to matter), but the conversation keeps growing and
# can accumulate plenty of compressible material later. Without a
# recovery path the session never auto-compacts again and rides into
# the provider's hard context limit. Recovery is a probation probe:
# after _ANTI_THRASH_RECOVERY_SECONDS of continuous block, allow ONE
# attempt by dropping the tripped counter(s) to 1 strike (persisted,
# so sibling agents on the same session row unblock too). If the probe
# is ineffective again the very next verdict re-trips the guard, so
# the worst case in the truly-incompressible state is one compaction
# attempt per recovery window — bounded, not thrash.
#
# The clock is armed lazily on the first BLOCKED evaluation rather
# than persisted at trip time: a fresh process that loads a durable
# tripped counter (#69872) therefore starts a full window blocked,
# preserving the restart-must-not-disarm contract (#54923).
# Anti-thrashing: back off if recent compressions were ineffective
if (
self._ineffective_compression_count >= 2
or self._fallback_compression_streak >= 2
):
_now = time.monotonic()
if self._anti_thrash_recovery_deadline <= 0.0:
self._anti_thrash_recovery_deadline = (
_now + self._ANTI_THRASH_RECOVERY_SECONDS
)
elif _now >= self._anti_thrash_recovery_deadline:
self._anti_thrash_recovery_deadline = 0.0
if self._ineffective_compression_count >= 2:
self._record_ineffective_compression_verdict(1)
if self._fallback_compression_streak >= 2:
self._fallback_compression_streak = 1
self._persist_fallback_compression_streak()
if not self.quiet_mode:
logger.info(
"Anti-thrashing recovery: %.0fs elapsed since the "
"guard tripped — allowing one compaction probe "
"(ineffective=%d fallback=%d).",
self._ANTI_THRASH_RECOVERY_SECONDS,
self._ineffective_compression_count,
self._fallback_compression_streak,
)
return False
if not self.quiet_mode:
logger.warning(
"Compression skipped — repeated compaction attempts did not "
"restore healthy context. ineffective=%d fallback=%d. "
"Auto-compaction will retry once in %.0fs. Consider /new "
"to start fresh, or /compress <topic> for focused "
"compression.",
"Consider /new to start fresh, or /compress <topic> for "
"focused compression.",
self._ineffective_compression_count,
self._fallback_compression_streak,
max(0.0, self._anti_thrash_recovery_deadline - _now),
)
return True
# Guard not tripped (counters were cleared by an effective compaction
# or a fitting real-usage reading) — disarm any pending recovery clock
# so a LATER trip starts its own full window.
self._anti_thrash_recovery_deadline = 0.0
return False
# ------------------------------------------------------------------
@@ -3063,6 +2968,12 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
## Active State
Unknown from deterministic fallback. Inspect current repository/session state if needed.
{HISTORICAL_IN_PROGRESS_HEADING}
Unknown from deterministic fallback — the latest user ask is recorded once under
"{HISTORICAL_TASK_HEADING}" above as historical context only. Do NOT treat it as an
unfulfilled instruction to re-answer; verify current state and continue from the
protected recent messages after this summary.
## Blocked
{_bullets(blockers, limit=5)}
@@ -3072,9 +2983,17 @@ None recoverable from deterministic fallback.
## Resolved Questions
None recoverable from deterministic fallback.
{HISTORICAL_PENDING_ASKS_HEADING}
None recoverable from deterministic fallback. (The latest user ask is preserved once
under "{HISTORICAL_TASK_HEADING}" as historical context — it is NOT necessarily
outstanding.)
## Relevant Files
{_bullets(relevant_files, limit=12)}
{HISTORICAL_REMAINING_WORK_HEADING}
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
## Last Dropped Turns
{_bullets(last_dropped_turns, limit=8)}
@@ -3385,6 +3304,9 @@ Be specific with file paths, commands, line numbers, and results.]
- Any running processes or servers
- Environment details that matter]
{HISTORICAL_IN_PROGRESS_HEADING}
[Work currently underway — what was being done when compaction fired]
## Blocked
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
@@ -3394,9 +3316,15 @@ Be specific with file paths, commands, line numbers, and results.]
## Resolved Questions
{_resolved_questions_instructions}
{HISTORICAL_PENDING_ASKS_HEADING}
{_pending_asks_instructions}
## Relevant Files
[Files read, modified, or created — with brief note on each]
{HISTORICAL_REMAINING_WORK_HEADING}
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
+2 -18
View File
@@ -19,7 +19,6 @@ REFERENCE_PATTERN = re.compile(
rf"(?<![\w/])@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|git|url):(?P<value>{_QUOTED_REFERENCE_VALUE}(?::\d+(?:-\d+)?)?|\S+))"
)
TRAILING_PUNCTUATION = ",.;!?"
_NEEDS_QUOTING = re.compile(r"""[\s()\[\]{}<>"'`]""")
_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh")
_SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",)
_SENSITIVE_HOME_FILES = (
@@ -61,21 +60,6 @@ class ContextReferenceResult:
blocked: bool = False
def format_reference_value(value: str) -> str:
"""Quote a reference value so ``REFERENCE_PATTERN`` reads it back whole.
The unquoted alternative in the pattern is ``\\S+``, so a path containing a
space parses as a truncated ref with the tail left behind as loose text.
Mirrors ``formatRefValue`` in the desktop's directive-text.tsx.
"""
if not _NEEDS_QUOTING.search(value):
return value
for quote in ("`", '"', "'"):
if quote not in value:
return f"{quote}{value}{quote}"
return value
def parse_context_references(message: str) -> list[ContextReference]:
refs: list[ContextReference] = []
if not message:
@@ -324,7 +308,7 @@ def _expand_git_reference(
["git", *args],
cwd=cwd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
@@ -550,7 +534,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
["rg", "--files", str(path.relative_to(cwd))],
cwd=cwd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
+76 -241
View File
@@ -40,7 +40,7 @@ import uuid
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Optional, Tuple
from agent.context_engine import (
automatic_compaction_status_message,
@@ -373,125 +373,6 @@ def compression_skipped_due_to_lock(agent: Any) -> bool:
return _sig is True or isinstance(_sig, str)
def _adopt_live_compression_child(
agent: Any,
session_db: Any,
parent_session_id: str,
) -> Optional[List[Dict[str, Any]]]:
"""Move a stale compression contender onto the unique durable child.
Resolve and load first, then mutate the live agent. This ordering keeps the
stale contender fail-closed when lineage is ambiguous or the compacted
handoff cannot be read.
"""
finder = getattr(type(session_db), "find_live_compression_child", None)
loader = getattr(type(session_db), "get_messages_as_conversation", None)
if not callable(finder) or not callable(loader):
return None
child = finder(session_db, parent_session_id)
if not child or not child.get("id"):
return None
child_session_id = str(child["id"])
recovered = loader(session_db, child_session_id)
if not isinstance(recovered, list) or not recovered:
return None
# Revalidate after loading: the child may have rotated or a competing
# continuation may have appeared between the two DB reads.
confirmed = finder(session_db, parent_session_id)
if not confirmed or str(confirmed.get("id") or "") != child_session_id:
return None
agent.session_id = child_session_id
try:
from gateway.session_context import set_current_session_id
set_current_session_id(child_session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = child_session_id
try:
from hermes_logging import set_session_context
set_session_context(child_session_id)
except Exception:
pass
agent._session_db_created = True
if child.get("system_prompt"):
agent._cached_system_prompt = child["system_prompt"]
agent._last_flushed_db_idx = len(recovered)
agent._flushed_db_message_session_id = child_session_id
agent._flushed_db_message_ids = {
id(message) for message in recovered if isinstance(message, dict)
}
on_session_start = getattr(agent.context_compressor, "on_session_start", None)
if callable(on_session_start):
try:
on_session_start(
child_session_id,
boundary_reason="compression",
old_session_id=parent_session_id,
session_db=session_db,
platform=getattr(agent, "platform", None) or "cli",
conversation_id=getattr(agent, "_gateway_session_key", None),
)
except Exception as exc:
logger.debug("context engine compression-child adoption failed: %s", exc)
else:
bind_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(bind_state):
try:
bind_state(session_db=session_db, session_id=child_session_id)
except Exception:
pass
try:
if agent._memory_manager:
agent._memory_manager.on_session_switch(
child_session_id,
parent_session_id=parent_session_id,
reset=False,
reason="compression",
)
except Exception as exc:
logger.debug("memory manager compression-child adoption failed: %s", exc)
return recovered
def recover_rotated_compression_session(
agent: Any,
) -> Optional[List[Dict[str, Any]]]:
"""Recover a stale live agent before a new turn writes to its old parent."""
session_db = getattr(agent, "_session_db", None)
session_id = getattr(agent, "session_id", None) or ""
if session_db is None or not session_id:
return None
try:
if not _session_was_rotated_by_compression(session_db, session_id):
return None
# Rotation publication holds the parent compression lease until the
# child handoff is durable. A concurrent turn waits briefly rather than
# observing the intentional parent-ended/child-empty intermediate state.
holder_getter = getattr(session_db, "get_compression_lock_holder", None)
for attempt in range(21):
recovered = _adopt_live_compression_child(agent, session_db, session_id)
if recovered is not None:
return recovered
holder = holder_getter(session_id) if callable(holder_getter) else None
if not holder or attempt == 20:
return None
time.sleep(0.05)
return None
except Exception as exc:
logger.warning(
"compression session recovery failed for session=%s (%s: %s)",
session_id,
type(exc).__name__,
exc,
)
return None
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -1011,7 +892,6 @@ _SYNTHETIC_USER_FLAGS = (
"_empty_recovery_synthetic",
"_verification_stop_synthetic",
"_pre_verify_synthetic",
"_dropped_toolcall_nudge",
)
@@ -1580,8 +1460,6 @@ def compress_context(
if _lock_released:
return
_lock_released = True
if getattr(agent, "_active_compression_lock_holder", None) == _lock_holder:
agent._active_compression_lock_holder = None
if _lock_refresher is not None:
try:
_lock_refresher.stop()
@@ -1593,9 +1471,6 @@ def compress_context(
except Exception as _rel_err:
logger.debug("compression lock release failed: %s", _rel_err)
if _lock_holder is not None:
agent._active_compression_lock_holder = _lock_holder
# A delayed contender can acquire the parent lock after the winning path
# has released it and completed rotation. The lock serializes work but does
# not by itself prove that this stale agent still owns a live parent.
@@ -1618,25 +1493,15 @@ def compress_context(
_existing_sp = agent._build_system_prompt(system_message)
return messages, _existing_sp
if _parent_already_rotated:
recovered_messages = _adopt_live_compression_child(
agent, _lock_db, _lock_sid
logger.info(
"compression skipped: session=%s was already rotated by "
"another compression path",
_lock_sid,
)
_release_lock()
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
if recovered_messages is not None:
logger.warning(
"compression recovery: stale session=%s adopted live child=%s",
_lock_sid,
agent.session_id,
)
return recovered_messages, _existing_sp
logger.warning(
"compression skipped: session=%s was already rotated by "
"another compression path, but no unique live child could be adopted",
_lock_sid,
)
return messages, _existing_sp
# The agent may have been constructed before another path completed an
@@ -1670,37 +1535,6 @@ def compress_context(
)
_lock_refresher.start()
# The caller's history snapshot predates lease acquisition. Reload the
# durable parent after the lease is live; MORE durable rows than the
# snapshot carries means a frontend/background writer committed a turn
# in that window, so publishing from this snapshot would omit it.
# Deliberately a LENGTH check, not content equality: in-memory
# mutation of past turns is legal (multimodal compression, retry
# history replacement, think-tag stripping), and a content-equality
# abort would permanently wedge compression on such sessions — the
# #14694 failure shape.
# Rotation-only: in-place compaction (archive_and_compact) is
# non-destructive — pre-compaction rows are soft-archived (active=0,
# compacted=1), stay searchable and recoverable, so snapshot/durable
# drift cannot lose data there and must not abort compaction.
if not in_place and _lock_db is not None and _lock_sid:
durable_loader = getattr(
type(_lock_db), "get_messages_as_conversation", None
)
if callable(durable_loader):
durable_parent = durable_loader(_lock_db, _lock_sid)
if isinstance(durable_parent, list) and len(durable_parent) > len(messages):
logger.warning(
"compression aborted: session=%s changed before lease "
"acquisition; preserving newer durable messages",
_lock_sid,
)
_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
# Notify external memory provider before compression discards context.
# The provider's on_pre_compress() may return a string of insights it
# wants surfaced inside the compression summary; capture and forward it
@@ -1969,21 +1803,6 @@ def compress_context(
):
new_system_prompt = cached_system_prompt
agent._cached_system_prompt = cached_system_prompt
# _invalidate_system_prompt() above also cleared the
# cross-session-stable prefix marker boundary. The kept prompt
# is byte-identical, so reconstruct the stable tier and reuse
# it ONLY when the kept prompt still literally starts with it
# (same startswith gate as the restore path); otherwise the
# request layer falls back to the legacy single-breakpoint
# layout with the prompt bytes untouched.
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
_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
else:
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt
@@ -2060,54 +1879,81 @@ def compress_context(
)
except Exception:
pass # best-effort — don't block compression on a flush error
# Publish parent closure + child row + compacted handoff in
# one transaction. No reader can observe a missing/empty child.
# The rotation child must stay on the parent's profile —
# mirror _ensure_db_session's stamp ("default" persists as
# NULL). publish_compression_child additionally COALESCEs
# from the parent row, covering app-global remote sessions
# whose thread lacks the HERMES_HOME context.
try:
from hermes_cli.profiles import get_active_profile_name
_profile_for_child = get_active_profile_name()
if _profile_for_child == "default":
_profile_for_child = None
except Exception:
_profile_for_child = None
# Propagate title to the new session with auto-numbering
old_title = agent._session_db.get_session_title(agent.session_id)
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
new_session_id = (
f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_"
f"{uuid.uuid4().hex[:6]}"
)
agent._session_db.publish_compression_child(
parent_session_id=old_session_id,
child_session_id=new_session_id,
source=agent.platform
or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=agent.model,
model_config=agent._session_init_model_config,
system_prompt=new_system_prompt,
messages=compressed,
cwd=getattr(agent, "working_directory", None),
profile_name=_profile_for_child,
compression_lock_holder=_lock_holder,
require_compression_lease=_lock_holder is not None,
)
agent.session_id = new_session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
# Ordering contract: the agent thread updates the contextvar here;
# the gateway propagates to SessionEntry after run_in_executor returns.
try:
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id
# The gateway/tools session context (ContextVar + env) and the
# logging session context are SEPARATE mechanisms. The call above
# moves the former; the ``[session_id]`` tag on log lines comes
# from ``hermes_logging._session_context`` (set once per turn in
# conversation_loop.py). Without this, post-rotation log lines in
# the same turn keep the STALE old id while the message/DB/gateway
# state carry the new one — breaking log correlation exactly at the
# compaction boundary (see #34089). Guarded separately so a logging
# failure can never regress the routing update above.
try:
from hermes_logging import set_session_context
set_session_context(agent.session_id)
except Exception:
pass
agent._session_db_created = False
try:
agent._session_db.create_session(
session_id=agent.session_id,
source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=agent.model,
model_config=agent._session_init_model_config,
parent_session_id=old_session_id,
)
except Exception as _cs_err:
# The child row could not be created (e.g. FK constraint,
# contended write). Previously the outer handler simply
# warned and let the agent continue on the NEW id — which
# has no row in state.db, producing an orphan: the parent
# is ended, the child is never indexed, and every
# subsequent message is attributed to a session that
# doesn't exist (#33906/#33907). Roll the live id back to
# the parent so the conversation stays attached to a real,
# indexed session instead of a phantom.
logger.warning(
"Compression child session create failed (%s) — "
"rolling back to parent session %s to avoid an orphan.",
_cs_err, old_session_id,
)
agent.session_id = old_session_id
try:
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id
try:
from hermes_logging import set_session_context
set_session_context(agent.session_id)
except Exception:
pass
# Re-open the parent: it was ended above, but we're
# continuing on it, so it must not stay closed.
try:
agent._session_db.reopen_session(old_session_id)
except Exception:
pass
old_session_id = None # no rotation happened
# The parent row already exists in state.db, so mark the
# session as created — _ensure_db_session would otherwise
# retry a (harmless INSERT OR IGNORE) create next turn.
agent._session_db_created = True
raise
agent._session_db_created = True
split_status = "rotated_committed"
# Carry a persistent /goal onto the continuation session.
@@ -2127,14 +1973,18 @@ def compress_context(
except (ValueError, Exception) as e:
logger.debug("Could not propagate title on compression: %s", e)
# In-place mode still updates/replaces the current row here.
# Rotation already published prompt + compacted handoff atomically.
# Shared post-write steps (both modes target agent.session_id, which
# in-place keeps and rotation has already reassigned to the new id):
# refresh the stored system prompt and reset the flush cursor so the
# next turn re-bases its append diff.
agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
if in_place:
agent._session_db.update_system_prompt(
agent.session_id, new_system_prompt
)
agent._last_flushed_db_idx = 0
else:
# A headless turn can be killed before its finalizer. Persist
# the rotated child's compacted handoff at the boundary so
# the new session is immediately resumable.
agent._session_db.replace_messages(agent.session_id, compressed)
agent._last_flushed_db_idx = len(compressed)
agent._flushed_db_message_session_id = agent.session_id
agent._flushed_db_message_ids = {
@@ -2144,22 +1994,7 @@ def compress_context(
}
_session_commit_succeeded = True
except Exception as e:
if (
not in_place
and locals().get("old_session_id")
and agent.session_id == old_session_id
):
# Atomic publication failed (including lease loss): keep the
# parent live and discard the stale compacted snapshot.
old_session_id = None
messages[:] = copy.deepcopy(messages_before_compression)
compressed = messages
_compression_made_progress = False
split_status = (
"aborted"
if locals().get("old_session_id") is None and not in_place
else "failed_not_indexed"
)
split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed"
# If the rotation rolled back to the parent (orphan-avoidance
# above), agent.session_id is the still-indexed parent and
# old_session_id was cleared — so this is recovery, not an
+17 -146
View File
@@ -436,37 +436,6 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
# Reconstruct the cross-session-stable prefix for the early cache
# breakpoint. The static prefix is not persisted (only the full
# prompt is), so gateway surfaces that build a fresh AIAgent per
# turn would otherwise lose the two-block system layout after the
# first turn — flip-flopping the wire shape mid-conversation and
# silently degrading to the legacy single-breakpoint layout.
#
# 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
_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,
)
return
if stored_prompt:
stored_state = "stale_runtime"
@@ -1309,9 +1278,9 @@ def run_conversation(
#
# Hermes invariant: the system prompt is built ONCE per session
# (cached on ``_cached_system_prompt``) and replayed verbatim on
# every turn. ``apply_anthropic_cache_control`` may split its stable
# prefix into content blocks on the wire, but the stored string and
# its byte-stability remain unchanged.
# every turn. We send it as a single content string so the
# bytes are byte-stable across turns and upstream prompt caches
# stay warm.
effective_system = active_system_prompt or ""
if agent.ephemeral_system_prompt:
effective_system = (effective_system + "\n\n" + agent.ephemeral_system_prompt).strip()
@@ -1396,6 +1365,19 @@ def run_conversation(
logger=request_logger,
)
# Apply Anthropic prompt caching for Claude models on native
# Anthropic, OpenRouter, and third-party Anthropic-compatible
# gateways. Auto-detected: if ``_use_prompt_caching`` is set,
# inject cache_control breakpoints (system + last 3 messages)
# to reduce input token costs by ~75% on multi-turn
# conversations.
if agent._use_prompt_caching:
api_messages = apply_anthropic_cache_control(
api_messages,
cache_ttl=agent._cache_ttl,
native_anthropic=agent._use_native_cache_layout,
)
# Safety net: strip orphaned tool results / add stubs for missing
# results before sending to the API. Runs unconditionally — not
# gated on context_compressor — so orphans from session loading or
@@ -1454,39 +1436,6 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
# Apply Anthropic prompt caching for Claude models on native
# Anthropic, OpenRouter, and third-party Anthropic-compatible
# gateways. Auto-detected: if ``_use_prompt_caching`` is set, inject
# cache_control breakpoints for the static system prefix, full system
# prompt, and last two messages (or the legacy system-and-3 layout
# when no static prefix is available).
#
# Runs LAST, after every message mutation above. Marking earlier
# defeats the prefix stability the mutations exist to create:
# ``_apply_cache_marker`` rewrites ``content`` from a plain string
# into a ``[{"type": "text", ...}]`` block, so the marked messages
# no longer match the ``isinstance(content, str)`` test in the
# whitespace-normalization pass and silently keep their raw
# leading/trailing whitespace. A tool result ending in "\n" is
# therefore sent unstripped while it sits in the last-3 window and
# stripped once it rolls out of it — the same message, different
# bytes on consecutive turns, which breaks the prefix match at
# exactly the point the breakpoints were meant to protect. Marking
# last also keeps breakpoints off messages that the orphan sweep or
# the thinking-only drop is about to remove or merge away.
if agent._use_prompt_caching:
_static_system_prefix = getattr(agent, "_cached_system_prompt_static", None)
api_messages = apply_anthropic_cache_control(
api_messages,
cache_ttl=agent._cache_ttl,
native_anthropic=agent._use_native_cache_layout,
static_system_prefix=(
_static_system_prefix
if isinstance(_static_system_prefix, str)
else None
),
)
# Build a persistent-MoA request before measuring compression pressure.
# MoA reference output is injected into the aggregator prompt, but it
# is deliberately ephemeral and therefore absent from ``messages``.
@@ -5668,10 +5617,6 @@ def run_conversation(
# flag so it can fire again if the model goes empty on
# a LATER tool round.
agent._post_tool_empty_retried = False
# A landed tool call means any earlier dropped-tool-call stall
# was recovered — refresh that budget too so it guards each
# stall independently rather than capping the whole run.
agent._dropped_toolcall_retries = 0
previous_msg = messages[-1] if messages else None
current_interim_visible = agent._interim_assistant_visible_text(assistant_msg)
@@ -5920,26 +5865,11 @@ def run_conversation(
# Save session log incrementally (so progress is visible even if interrupted)
agent._session_messages = messages
# Touch activity before continuing so the gateway's
# inactivity monitor never sees a stale timestamp
# between tool completion and the start of the next
# API call. Without this, a tool-call result (which
# takes ~0s to process) followed by slow post-tool
# processing (compression, persist) and a slow
# follow-up API call can exceed the gateway inactivity
# timeout (HERMES_AGENT_TIMEOUT, default 1800s) and the
# gateway kills the session before the next activity
# touch fires (#69559, #69131).
agent._touch_activity(f"tool results posted, continuing iteration #{api_call_count}")
# Continue loop for next response
continue
else:
# No tool calls - this is the final response.
# (Dropped tool-call recovery — finish_reason=="tool_calls" with
# an empty tool_calls array — is handled at the finalization
# chokepoint below, after final_msg is built, so it catches
# every path that reaches turn finalization, not just this one.)
# No tool calls - this is the final response
final_response = assistant_message.content or ""
# Fix: unmute output when entering the no-tool-call branch
@@ -6271,64 +6201,6 @@ def run_conversation(
final_msg = agent._build_assistant_message(assistant_message, finish_reason)
# ── Dropped tool-call recovery (copilot/Claude) ────────
# Some providers (observed: claude-opus-4.8 / claude-sonnet-4.5
# on GitHub Copilot, ~2026-07) return finish_reason="tool_calls"
# while the parsed tool_calls array is empty — the model
# signalled it wanted to act but the payload shipped no call.
# Reaching finalization with that mismatch means the turn is
# about to end with the task unstarted (the narration, which may
# be in content or only in the reasoning field, gets treated as
# the final answer). Re-prompt (bounded to 3 CONSECUTIVE stalls;
# the budget resets after any successful tool round) to make the
# model emit the call instead of exiting. finish_reason="stop"
# text finishes never enter this guard.
if (
finish_reason == "tool_calls"
and not assistant_message.tool_calls
and getattr(agent, "_dropped_toolcall_retries", 0) < 3
):
agent._dropped_toolcall_retries = getattr(agent, "_dropped_toolcall_retries", 0) + 1
logger.warning(
"finish_reason=tool_calls with empty tool_calls array "
"(narration only) — re-prompting to emit the call "
"(retry %d/3, model=%s provider=%s)",
agent._dropped_toolcall_retries, agent.model, agent.provider,
)
agent._emit_status(
"↻ Model signaled a tool call but sent none — "
f"re-prompting ({agent._dropped_toolcall_retries}/3)"
)
# Both halves of the re-prompt pair are ephemeral recovery
# scaffolding (mirrors the empty-response nudge pattern):
# the interim narration-only assistant turn exists solely to
# keep role alternation valid for the nudge, and the nudge
# exists solely to drive the retry. Flag both so the
# persistence layer never writes them to the durable
# transcript and the finalization pop below can strip an
# unanswered tail pair. A recovered (answered) pair stays
# buried mid-list in live memory but is skipped by the
# flush regardless of position.
final_msg["_dropped_toolcall_nudge"] = True
messages.append(final_msg)
messages.append({
"role": "user",
"content": (
"Your previous turn indicated a tool call but none was "
"included. Do not narrate a plan or restate intent — issue "
"the actual tool call now to continue the task."
),
"_dropped_toolcall_nudge": True,
})
agent._session_messages = messages
final_response = None
continue
# Reached finalization without the dropped-tool-call mismatch —
# a genuine turn end. Clear the consecutive-stall budget so the
# next turn starts fresh.
agent._dropped_toolcall_retries = 0
# Pop thinking-only prefill and empty-response retry
# scaffolding before appending either a final response or a
# verification-stop follow-up. These internal turns are only
@@ -6341,7 +6213,6 @@ def run_conversation(
messages[-1].get("_thinking_prefill")
or messages[-1].get("_empty_recovery_synthetic")
or messages[-1].get("_empty_terminal_sentinel")
or messages[-1].get("_dropped_toolcall_nudge")
)
):
messages.pop()
+3 -3
View File
@@ -512,7 +512,7 @@ class CopilotACPClient:
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, encoding='utf-8', errors='replace',
text=True,
bufsize=1,
cwd=self._acp_cwd,
env=_build_subprocess_env(),
@@ -708,7 +708,7 @@ class CopilotACPClient:
if block_error:
raise PermissionError(block_error)
try:
content = path.read_text(encoding="utf-8")
content = path.read_text()
except FileNotFoundError:
content = ""
line = params.get("line")
@@ -736,7 +736,7 @@ class CopilotACPClient:
if denied:
raise PermissionError(denied)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(params.get("content") or ""), encoding="utf-8")
path.write_text(str(params.get("content") or ""))
response = {
"jsonrpc": "2.0",
"id": message_id,
+21 -118
View File
@@ -594,14 +594,6 @@ class CredentialPool:
# Re-armed to None on every successful selection so a recover→re-exhaust
# transition logs promptly instead of being swallowed by a stale window.
self._last_no_entries_log_at: Optional[float] = None
# #70401: consecutive mark_exhausted_and_rotate() calls whose supplied
# credential identity matched no pool entry (OAuth wrappers whose
# runtime key rotates, entries pruned by another process, ...). These
# rotations mark nothing exhausted, so without a cap the pool can
# never converge to "no available entries" and the caller's 401 retry
# loop runs unbounded and non-interruptible. Reset whenever a real
# entry is identified or an escape path returns None.
self._unmatched_rotation_streak: int = 0
def has_credentials(self) -> bool:
with self._lock:
@@ -630,28 +622,6 @@ class CredentialPool:
with self._lock:
return self._current_unlocked()
def entry_id_for_api_key(self, api_key_hint: Any = None) -> Optional[str]:
"""Return the stable id for the runtime credential in use.
Prefer the current selection when it still supplies ``api_key_hint``.
If the cursor was cleared, fall back to an unambiguous key match.
"""
with self._lock:
current = self._current_unlocked()
if current is not None and (
api_key_hint is None
or current.runtime_api_key == api_key_hint
):
return current.id
if api_key_hint is None:
return None
matches = [
entry
for entry in self._entries
if entry.runtime_api_key == api_key_hint
]
return matches[0].id if len(matches) == 1 else None
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
"""Swap an entry in-place by id, preserving sort order."""
for idx, entry in enumerate(self._entries):
@@ -1592,13 +1562,7 @@ class CredentialPool:
def select(self) -> Optional[PooledCredential]:
with self._lock:
entry = self._select_unlocked()
if entry is not None:
# A normal (non-recovery) selection starts a fresh episode —
# don't let a leftover unmatched-rotation streak from an old
# failure trip the #70401 bound early next time.
self._unmatched_rotation_streak = 0
return entry
return self._select_unlocked()
def _available_entries(self, *, clear_expired: bool = False, refresh: bool = False) -> List[PooledCredential]:
"""Return entries not currently in exhaustion cooldown.
@@ -1799,17 +1763,10 @@ class CredentialPool:
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
) -> Optional[PooledCredential]:
with self._lock:
entry = None
identity_supplied = bool(credential_id or api_key_hint)
if credential_id:
entry = next(
(e for e in self._entries if e.id == credential_id),
None,
)
if entry is None and api_key_hint:
if api_key_hint:
# Prefer the specific entry whose API key matches the one that
# actually failed. When this pool was freshly loaded from disk
# (another process already rotated), current() is None and
@@ -1818,59 +1775,20 @@ class CredentialPool:
(e for e in self._entries if e.runtime_api_key == api_key_hint),
None,
)
if entry is None and identity_supplied:
# The failed credential is identifiable but matches no entry
# (rotated away, or a wrapper whose runtime key differs).
# Falling through to current()/_select_unlocked() would mark an
# innocent healthy key exhausted for the full cooldown TTL.
#
# #70401: this branch must still be BOUNDED. With OAuth-token
# auth the upstream 401's key hint never matches any entry's
# ``runtime_api_key``, so every retry lands here, nothing is
# ever marked exhausted, and the pool can never reach the
# "no available entries" state — the caller retries the same
# dead token forever (~6/sec, starving the event loop so chat
# interrupts are never processed). The single-entry case
# below already escapes; multi-entry pools could still
# ping-pong A→B→A indefinitely without marking anything.
# Cap consecutive no-mark rotations at one full lap of the
# available entries: past that, every candidate has been
# handed back at least once without recovery, so stop
# guessing and surface the error (no cooldown is written for
# anybody — healthy keys stay available for the next turn).
self._unmatched_rotation_streak += 1
available_count = len(self._available_entries())
if self._unmatched_rotation_streak > max(available_count, 1):
logger.warning(
"credential pool: failed credential identity matched no "
"%s entry for %d consecutive rotations (pool size %d) — "
"surfacing the error instead of rotating again",
if entry is None:
# The failed key is identifiable but matches no entry
# (rotated away, or a wrapper whose runtime key differs).
# Falling through to current()/_select_unlocked() would
# mark an INNOCENT healthy key exhausted for the full
# cooldown TTL. Don't guess — just hand back a fresh
# selection so the caller can retry.
logger.info(
"credential pool: failed key hint matched no %s entry; "
"rotating without marking any credential exhausted",
self.provider,
self._unmatched_rotation_streak,
available_count,
)
self._unmatched_rotation_streak = 0
self._current_id = None
return None
logger.info(
"credential pool: failed credential identity matched no %s "
"entry; rotating without marking any credential exhausted",
self.provider,
)
self._current_id = None
next_entry = self._select_unlocked()
if next_entry is not None and len(self._available_entries()) == 1:
# A single-entry pool cannot rotate. Returning its only
# entry reports a successful recovery without changing
# the credential, so the caller retries the same 401
# indefinitely. Let fallback/error propagation proceed.
self._unmatched_rotation_streak = 0
self._current_id = None
return None
return next_entry
# A real entry was identified — any prior unmatched-rotation
# streak is stale (this mark WILL advance pool state).
self._unmatched_rotation_streak = 0
return self._select_unlocked()
if entry is None:
entry = self._current_unlocked() or self._select_unlocked()
if entry is None:
@@ -1888,13 +1806,12 @@ class CredentialPool:
# disconnects (a ~2.5min hang with no error surfaced to the user).
# Mark every entry sharing the failed key so the pool can reach the
# "no available entries" state and let the error propagate.
failed_runtime_key = getattr(entry, "runtime_api_key", None)
if identity_supplied and failed_runtime_key:
if api_key_hint:
siblings_marked = False
for sibling in self._entries:
if sibling.id == entry.id:
continue
if sibling.runtime_api_key == failed_runtime_key:
if sibling.runtime_api_key == api_key_hint:
self._mark_exhausted(
sibling, status_code, error_context, persist=False
)
@@ -1968,11 +1885,9 @@ class CredentialPool:
return self._try_refresh_current_unlocked()
def try_refresh_matching(
self,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
self, api_key_hint: Optional[str] = None
) -> Optional[PooledCredential]:
"""Force-refresh the entry that supplied the failed request.
"""Force-refresh the entry that supplied ``api_key_hint``.
Direct provider integrations may reload the pool after a request has
already failed, so they cannot rely on ``current_id`` identifying the
@@ -1982,29 +1897,17 @@ class CredentialPool:
"""
with self._lock:
entry = None
if credential_id:
if api_key_hint:
entry = next(
(
candidate
for candidate in self._entries
if candidate.id == credential_id
if candidate.runtime_api_key == api_key_hint
),
None,
)
if entry is None:
if api_key_hint:
entry = next(
(
candidate
for candidate in self._entries
if candidate.runtime_api_key == api_key_hint
),
None,
)
else:
entry = self._current_unlocked() or self._select_unlocked(
refresh=False
)
else:
entry = self._current_unlocked() or self._select_unlocked(refresh=False)
if entry is None:
return None
self._current_id = entry.id
+1 -1
View File
@@ -164,7 +164,7 @@ def _remove_env_source(provider: str, removed) -> RemovalResult:
if env_path.exists():
env_in_dotenv = any(
line.strip().startswith(f"{env_var}=")
for line in env_path.read_text(errors="replace", encoding="utf-8").splitlines()
for line in env_path.read_text(errors="replace").splitlines()
)
except OSError:
pass
+2 -6
View File
@@ -25,8 +25,7 @@ Language resolution order:
3. ``display.language`` from config.yaml
4. ``"en"`` (baseline)
Supported languages: en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga,
pt, ru, hu, ar. Unknown values fall back to en.
Supported languages: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
"""
from __future__ import annotations
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
SUPPORTED_LANGUAGES: tuple[str, ...] = (
"en", "zh", "zh-hant", "ja", "de", "es", "fr", "tr", "uk",
"af", "ko", "it", "ga", "pt", "ru", "hu", "ar",
"af", "ko", "it", "ga", "pt", "ru", "hu",
)
DEFAULT_LANGUAGE = "en"
@@ -79,9 +78,6 @@ _LANGUAGE_ALIASES: dict[str, str] = {
"russian": "ru", "русский": "ru", "ru-ru": "ru",
# Hungarian
"hungarian": "hu", "magyar": "hu", "hu-hu": "hu",
# Arabic — bare "arabic"/endonym plus the common regional BCP-47 tags.
"arabic": "ar", "العربية": "ar",
"ar-sa": "ar", "ar-eg": "ar", "ar-ae": "ar", "ar-ma": "ar", "ar-dz": "ar",
}
_catalog_cache: dict[str, dict[str, str]] = {}
+2 -2
View File
@@ -267,7 +267,7 @@ def _install_npm(
[npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets],
check=False,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
@@ -316,7 +316,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
[go, "install", pkg],
check=False,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
+8 -9
View File
@@ -388,10 +388,10 @@ def _maybe_apply_moa_cache_control(
Reuses the SAME policy function as the main agent loop
(``anthropic_prompt_cache_policy``) resolved against the slot's own
provider/base_url/api_mode/model and shared marker helper
(``apply_anthropic_cache_control``). MoA has no per-session static prefix,
so it uses the helper's legacy system-and-3 fallback without carrying a
separate caching strategy.
provider/base_url/api_mode/model, and the SAME breakpoint layout
(``apply_anthropic_cache_control``, system_and_3). This keeps advisor and
aggregator calls decorated exactly like an acting agent on that provider
would be — no MoA-specific caching logic to drift.
Returns the messages unchanged on any resolution error or when the
policy says the route doesn't honor markers.
@@ -480,11 +480,10 @@ def _run_reference(
reserve_output_tokens=max_tokens,
context_length_cache=context_length_cache,
)
# Apply the Anthropic-style prompt-caching decoration used by the main
# agent loop. This fixed reference prompt has no session-specific
# prefix split, so the helper uses its legacy system-and-3 fallback.
# The advisory view is append-only across iterations (new turns append
# before the trailing synthetic marker), so on cache-honoring routes (Claude via
# Apply the same Anthropic-style prompt-caching decoration the main
# agent loop applies (system_and_3 breakpoints). The advisory view is
# append-only across iterations (new turns append before the trailing
# synthetic marker), so on cache-honoring routes (Claude via
# OpenRouter/native, MiniMax, Qwen/DashScope) iteration N+1's prefix
# replays iteration N's cached prefix. Without this, Claude advisors
# served ZERO cache reads across an entire benchmark run (measured:
-1
View File
@@ -215,7 +215,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-5": 1000000,
"claude-sonnet-5": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
+1 -1
View File
@@ -117,7 +117,7 @@ def record_nous_rate_limit(
# Atomic write: write to temp file + rename
fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
with os.fdopen(fd, "w") as f:
json.dump(state, f)
atomic_replace(tmp_path, path)
except Exception:
+9 -55
View File
@@ -1,11 +1,9 @@
"""Anthropic prompt caching strategy.
The default layout uses 4 cache_control breakpoints: the static system
prefix, the end of the system prompt, and the last 2 non-system messages.
When a static system prefix is unavailable, it falls back to one system
breakpoint plus the last 3 messages. All markers use the same TTL (5m or 1h).
This preserves intra-session caching while allowing new sessions to reuse the
stable system-prompt prefix.
Single layout: ``system_and_3``. 4 cache_control breakpoints — system
prompt + last 3 non-system messages, all at the same TTL (5m or 1h).
Reduces input token costs by ~75% on multi-turn conversations within a
single session.
Pure functions -- no class state, no AIAgent dependency.
"""
@@ -83,55 +81,15 @@ def _build_marker(ttl: str) -> Dict[str, str]:
return marker
def _apply_system_cache_markers(
message: dict,
cache_marker: dict,
static_system_prefix: str | None,
*,
native_anthropic: bool,
) -> int:
"""Mark the static system prefix and full prompt when they can be split.
The system prompt remains one stored string. Splitting it only in the
outgoing request keeps session persistence and non-Anthropic transports
unchanged while making the stable prefix independently cacheable.
"""
content = message.get("content")
if (
isinstance(static_system_prefix, str)
and static_system_prefix
and isinstance(content, str)
and content.startswith(static_system_prefix)
):
suffix = content[len(static_system_prefix):]
if suffix:
message["content"] = [
{
"type": "text",
"text": static_system_prefix,
"cache_control": cache_marker,
},
{"type": "text", "text": suffix, "cache_control": cache_marker},
]
return 2
_apply_cache_marker(message, cache_marker, native_anthropic=native_anthropic)
return 1
def apply_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
cache_ttl: str = "5m",
native_anthropic: bool = False,
static_system_prefix: str | None = None,
) -> List[Dict[str, Any]]:
"""Apply Anthropic cache-control markers to API messages.
"""Apply system_and_3 caching strategy to messages for Anthropic models.
When ``static_system_prefix`` exactly matches the beginning of a string
system prompt, it receives an early marker and the full system prompt gets
a trailing marker. The remaining two markers target the latest cacheable
non-system messages. Without that prefix, the legacy system-and-3 layout
is retained.
Places up to 4 cache_control breakpoints: system prompt + last 3 non-system
messages, all at the same TTL.
Returns:
Deep copy of messages with cache_control breakpoints injected.
@@ -145,12 +103,8 @@ def apply_anthropic_cache_control(
breakpoints_used = 0
if messages[0].get("role") == "system":
breakpoints_used = _apply_system_cache_markers(
messages[0],
marker,
static_system_prefix,
native_anthropic=native_anthropic,
)
_apply_cache_marker(messages[0], marker, native_anthropic=native_anthropic)
breakpoints_used += 1
remaining = 4 - breakpoints_used
non_sys = [
-8
View File
@@ -1,8 +0,0 @@
"""Egress proxy integrations.
Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts
outbound traffic from remote terminal sandboxes and swaps proxy tokens
for real upstream credentials at the network edge.
Design notes live in :mod:`agent.proxy_sources.iron_proxy`.
"""
File diff suppressed because it is too large Load Diff
-1
View File
@@ -102,7 +102,6 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# ``claude-opus-4`` so non-thinking Claude 3.x or future
# non-reasoning Claude variants don't match.
("claude-opus-4", 240),
("claude-opus-5", 240),
("claude-sonnet-5", 180),
("claude-sonnet-4.5", 180),
("claude-sonnet-4.6", 180),
+1 -1
View File
@@ -295,7 +295,7 @@ def run_secret_cli(
list(argv),
env=env,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
+2 -2
View File
@@ -200,7 +200,7 @@ def _platform_asset_name() -> str:
res = subprocess.run(
["ldd", "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
@@ -684,7 +684,7 @@ def _run_bws_list(
cmd,
env=env,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
+2 -2
View File
@@ -464,7 +464,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
input=stdin_json,
capture_output=True,
timeout=spec.timeout,
text=True, encoding='utf-8', errors='replace',
text=True,
shell=False,
**_popen_kwargs,
)
@@ -632,7 +632,7 @@ def allowlist_path() -> Path:
def load_allowlist() -> Dict[str, Any]:
"""Return the parsed allowlist, or an empty skeleton if absent."""
try:
raw = json.loads(allowlist_path().read_text(encoding="utf-8"))
raw = json.loads(allowlist_path().read_text())
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {"approvals": []}
if not isinstance(raw, dict):
+1 -1
View File
@@ -74,7 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
["bash", "-c", command],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
+1 -2
View File
@@ -31,8 +31,7 @@ def _skip_ssl_guard_enabled() -> bool:
def _repair_hint() -> str:
return (
"Repair: run `hermes doctor --fix` (auto-reinstalls certifi), or "
"manually: python -m pip install --force-reinstall certifi openai httpx\n"
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
"If you configured a custom corporate CA bundle, fix or unset the "
"broken CA bundle environment variable."
)
+23 -42
View File
@@ -12,11 +12,9 @@ Three tiers are joined with ``\\n\\n``:
* ``stable`` — identity (SOUL.md or DEFAULT_AGENT_IDENTITY), tool
guidance, computer-use guidance, nous subscription block, tool-use
enforcement guidance + per-model operational guidance, skills prompt,
alibaba model-name workaround, environment hints, coding guidance,
platform hints.
alibaba model-name workaround, environment hints, platform hints.
* ``context`` — caller-supplied ``system_message`` plus context files
(AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``,
plus the session's coding-workspace snapshot.
(AGENTS.md / .cursorrules / etc.) discovered under ``TERMINAL_CWD``.
* ``volatile`` — memory snapshot, USER.md profile, external memory
provider block, timestamp/session/model/provider line.
@@ -147,14 +145,14 @@ def _tui_embedded_pane_clarifier(hint: str) -> str:
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered cache tiers.
"""Assemble the system prompt as three ordered parts.
Returns a dict with three keys:
* ``stable`` — the cross-session-stable prefix, through the coding
operating brief when a workspace snapshot follows.
* ``context`` — the workspace snapshot followed by the remaining
session-stable guidance, context files, and caller-supplied
system_message.
* ``stable`` — identity, tool guidance, skills prompt,
environment hints, platform hints, model-family operational
guidance.
* ``context`` — context files (AGENTS.md, .cursorrules, etc.)
and caller-supplied system_message.
* ``volatile`` — memory snapshot, user profile, external
memory provider block, timestamp line.
@@ -347,35 +345,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
stable_parts.append(_env_hints)
# Coding posture (base Hermes, any interactive coding surface in a code
# workspace — see agent/coding_context.py). Keep the operating brief in
# the cross-session-stable prefix, while placing the live git/workspace
# snapshot behind its own cache boundary. The post-snapshot blocks must
# stay in their historical position after the workspace snapshot.
coding_workspace_parts: List[str] = []
coding_trailing_parts: List[str] = []
# workspace — see agent/coding_context.py). The operating brief + the live
# git/workspace snapshot are built once here and cached for the session;
# the snapshot is never re-probed per turn (that would break the prompt
# cache), so the brief tells the model to re-check git before relying on it.
if agent.valid_tool_names:
try:
from agent.coding_context import coding_system_prompt_parts
from agent.coding_context import coding_system_blocks
coding_prefix_parts, coding_workspace_parts, coding_trailing_parts = coding_system_prompt_parts(
platform=agent.platform,
cwd=resolve_context_cwd(),
model=agent.model,
stable_parts.extend(
coding_system_blocks(
platform=agent.platform,
cwd=resolve_context_cwd(),
model=agent.model,
)
)
stable_parts.extend(coding_prefix_parts)
except Exception:
# Coding-context probing must never block prompt build.
pass
# Guidance assembled after the coding posture historically followed the
# workspace snapshot. With no snapshot, the coding tail instead remains
# directly after the coding prefix in the cacheable prefix.
if coding_workspace_parts:
post_workspace_parts: List[str] = []
else:
stable_parts.extend(coding_trailing_parts)
post_workspace_parts = stable_parts
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
# something is non-default so the model can pick the right install
# strategy without discovering by failure. Emits a single line; emits
@@ -388,7 +376,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
from tools.env_probe import get_environment_probe_line
_probe_line = get_environment_probe_line()
if _probe_line:
post_workspace_parts.append(_probe_line)
stable_parts.append(_probe_line)
except Exception:
# Probe failure must never block prompt build.
pass
@@ -406,7 +394,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
except Exception:
active_profile = "default"
if active_profile == "default":
post_workspace_parts.append(
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
@@ -415,7 +403,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
"you to."
)
else:
post_workspace_parts.append(
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default "
f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, "
@@ -461,16 +449,11 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)
if _effective_hint:
post_workspace_parts.append(_effective_hint)
stable_parts.append(_effective_hint)
# ── Context tier (cwd-dependent, may change between sessions) ─
context_parts: List[str] = []
if coding_workspace_parts:
context_parts.extend(coding_workspace_parts)
context_parts.extend(coding_trailing_parts)
context_parts.extend(post_workspace_parts)
# Note: ephemeral_system_prompt is NOT included here. It's injected at
# API-call time only so it stays out of the cached/stored system prompt.
if system_message is not None:
@@ -558,7 +541,6 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
"""
parts = build_system_prompt_parts(agent, system_message=system_message)
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
agent._cached_system_prompt_static = parts["stable"]
# Surface context-file truncation warnings through the normal agent status
# channel so gateway/CLI users see them in chat instead of only in logs.
@@ -575,7 +557,6 @@ def invalidate_system_prompt(agent: Any) -> None:
so the rebuilt prompt captures any writes from this session.
"""
agent._cached_system_prompt = None
agent._cached_system_prompt_static = None
if agent._memory_store:
agent._memory_store.load_from_disk()
+2 -4
View File
@@ -964,8 +964,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
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}")
agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s)")
if not blocked and agent.tool_complete_callback:
try:
@@ -1656,8 +1655,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
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}")
agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s)")
if agent.verbose_logging:
logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s")
+1 -1
View File
@@ -162,7 +162,7 @@ def build_trace_jsonl(
if cwd:
r = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=cwd,
capture_output=True, text=True, timeout=3, cwd=cwd,
)
if r.returncode == 0:
git_branch = r.stdout.strip()
-51
View File
@@ -7,7 +7,6 @@ streaming, or the _run_codex_stream() call path.
import hashlib
import json
import re
from typing import Any, Dict, List, Optional
from agent.transports.base import ProviderTransport
@@ -28,49 +27,6 @@ def _bounded_prompt_cache_key(value: Any) -> Optional[str]:
return f"pck_{digest}"
_EXTENDED_PROMPT_CACHE_MODELS = (
"gpt-5.5-pro",
"gpt-5.5",
"gpt-5.4",
"gpt-5.2",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.1-chat-latest",
"gpt-5.1-codex",
"gpt-5.1",
"gpt-5-codex",
"gpt-5",
"gpt-4.1",
)
_EXTENDED_PROMPT_CACHE_MODEL_RE = re.compile(
rf"(?:^|[./:])(?:{'|'.join(re.escape(name) for name in _EXTENDED_PROMPT_CACHE_MODELS)})"
r"(?:-\d{4}-\d{2}-\d{2})?$"
)
def _default_prompt_cache_retention_for_request(
model: str,
base_url: Any,
) -> Optional[str]:
"""Return ``24h`` for supported models on Amazon Bedrock Mantle."""
from utils import base_url_hostname
hostname_parts = base_url_hostname(str(base_url or "")).split(".")
is_bedrock_mantle = (
len(hostname_parts) == 4
and hostname_parts[0] == "bedrock-mantle"
and bool(hostname_parts[1])
and hostname_parts[2:] == ["api", "aws"]
)
if not is_bedrock_mantle:
return None
normalized = str(model or "").strip().lower().replace("_", "-")
if _EXTENDED_PROMPT_CACHE_MODEL_RE.search(normalized):
return "24h"
return None
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
"""Content-address the prompt cache key from the static request prefix.
@@ -328,13 +284,6 @@ class ResponsesApiTransport(ProviderTransport):
if not is_github_responses and not is_xai_responses and cache_key:
kwargs["prompt_cache_key"] = cache_key
cache_retention = _default_prompt_cache_retention_for_request(
model,
params.get("base_url"),
)
if cache_retention:
kwargs.setdefault("prompt_cache_retention", cache_retention)
if reasoning_enabled and is_xai_responses:
from agent.model_metadata import grok_supports_reasoning_effort
+1 -1
View File
@@ -394,7 +394,7 @@ def check_codex_binary(
proc = subprocess.run(
[codex_bin, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
-8
View File
@@ -36,7 +36,6 @@ from agent.conversation_compression import (
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
compression_skipped_due_to_lock,
conversation_history_after_compression,
recover_rotated_compression_session,
)
from agent.context_engine import automatic_compaction_status_message
from agent.iteration_budget import IterationBudget
@@ -354,13 +353,6 @@ def build_turn_context(
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
install_safe_stdio()
# Recover a session rotated by another path before binding log/turn ids or
# copying client-supplied history. Everything in this turn must consistently
# belong to the canonical child, including observability metadata.
recovered_history = recover_rotated_compression_session(agent)
if recovered_history is not None:
conversation_history = recovered_history
# NOTE: the DB session row is created later, AFTER the system prompt is
# restored/built (see _ensure_db_session() below the system-prompt block).
# Creating it here — before _cached_system_prompt is populated — inserts a
+7 -33
View File
@@ -13,11 +13,10 @@ import shlex
import sqlite3
import tempfile
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator, Optional
from typing import Any, Optional
from hermes_constants import get_hermes_home
@@ -66,38 +65,13 @@ def _connect() -> sqlite3.Connection:
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
apply_wal_with_fallback(conn, db_label="verification_evidence.db")
conn.execute("PRAGMA busy_timeout=5000")
conn.row_factory = sqlite3.Row
try:
apply_wal_with_fallback(conn, db_label="verification_evidence.db")
conn.execute("PRAGMA busy_timeout=5000")
_ensure_schema(conn)
except Exception:
# A PRAGMA/DDL failure after a successful connect() must not leak the
# just-opened connection back to the caller.
conn.close()
raise
_ensure_schema(conn)
return conn
@contextmanager
def _transaction() -> Iterator[sqlite3.Connection]:
"""Open a connection, commit/rollback on exit, and ALWAYS close it.
``sqlite3.Connection.__enter__``/``__exit__`` only commit or roll back the
transaction; they do not close the connection. Using ``with _connect()``
alone therefore leaks a connection — and its WAL/SHM file descriptors — on
every call, deferring the close to the garbage collector, which over a
long-running process can exhaust ``RLIMIT_NOFILE`` (the cron-ledger sibling
of this bug was #69567 / PR #69594).
"""
conn = _connect()
try:
with conn:
yield conn
finally:
conn.close()
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
@@ -480,7 +454,7 @@ def record_terminal_result(
created_at = _utc_now()
with _DB_LOCK:
with _transaction() as conn:
with _connect() as conn:
cur = conn.execute(
"""
INSERT INTO verification_events(
@@ -546,7 +520,7 @@ def mark_workspace_edited(
edited_at = _utc_now()
with _DB_LOCK:
with _transaction() as conn:
with _connect() as conn:
row = conn.execute(
"""
SELECT changed_paths_json FROM verification_state
@@ -596,7 +570,7 @@ def verification_status(
sid = str(session_id or "default")
root = str(facts.get("root") or Path(cwd or ".").resolve())
with _DB_LOCK:
with _transaction() as conn:
with _connect() as conn:
state = conn.execute(
"""
SELECT last_event_id, last_edit_at, changed_paths_json
+1 -14
View File
@@ -30,7 +30,7 @@ Already have the Hermes CLI? Just run:
hermes desktop
```
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model.
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.
### Prebuilt installers
@@ -134,19 +134,6 @@ Desktop supports a managed local backend, explicit remote gateways, and Hermes
Cloud connections. Remote and cloud modes use the same remote-capability path;
authentication and discovery differ, not the renderer feature model.
When no usable local runtime or saved remote connection exists, the first-run
screen offers **Connect to existing Hermes** before starting the local installer.
Desktop probes the gateway to discover token or OAuth authentication, requires a
successful HTTP and WebSocket connection test, and saves the connection using
the same encrypted Desktop configuration used by Settings. A saved remote
connection bypasses this choice on later launches. The regular Desktop build
still includes the local-install option; this is a remote operating mode, not a
separate client-only application.
In remote mode the gateway host is the execution boundary: agent tools,
terminal commands, and file operations run against the remote Hermes host, not
the computer displaying the Desktop UI.
Projects are the workspace abstraction. A project may own multiple folders,
repositories, worktrees, and sessions; a bare new chat remains detached unless
the user enters a project or configures a default project directory. Use the
-20
View File
@@ -68,26 +68,6 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () =
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly', () => {
const defaulted = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
currentEnv: { PATH: '/usr/bin' },
platform: 'darwin',
pathModule: path.posix
})
assert.equal(defaulted.PYTHONUTF8, '1')
const optedOut = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
currentEnv: { PATH: '/usr/bin', PYTHONUTF8: '0' },
platform: 'darwin',
pathModule: path.posix
})
assert.equal(optedOut.PYTHONUTF8, '0')
})
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
-7
View File
@@ -104,13 +104,6 @@ function buildDesktopBackendEnv({
return {
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
// Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and
// subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK,
// cp1252, ...). hermes_bootstrap sets this inside the child too, but only
// after import — anything emitted earlier (interpreter startup errors,
// pre-bootstrap tracebacks) still decodes with the locale default without
// this. User's explicit setting wins. Re-port of PR #56499 (echoriver89).
PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1',
[key]: buildDesktopBackendPath({
hermesHome,
venvRoot,
@@ -1,136 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { DEFAULT_HEALTH_PROBE_TIMEOUT_MS, isMissingHealthEndpointError, waitForHermesReady } from './backend-health'
test('uses lightweight /api/health for current backends', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000/', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
return { ok: true }
},
fetchJson: async url => {
calls.push(['token', url])
throw new Error('status should not be called')
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
})
test('falls back to /api/status only for old backends without /api/health', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (url, token) => {
calls.push(['token', url, token ?? ''])
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['public', 'http://127.0.0.1:9000/api/health'],
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
])
})
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
const calls: string[][] = []
let currentTime = 0
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('Timed out connecting to Hermes backend after 15000ms')
},
fetchJson: async url => {
calls.push(['token', url])
},
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 50,
pollMs: 1
}),
/Timed out connecting/
)
assert.ok(calls.length > 0)
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
})
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
const timeouts: (number | undefined)[] = []
await waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async (_url, options) => {
timeouts.push(options?.timeoutMs)
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (_url, _token, options) => {
timeouts.push(options?.timeoutMs)
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
})
test('aborts as superseded when the bootstrap signal fires', async () => {
const controller = new AbortController()
controller.abort()
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
signal: controller.signal,
fetchPublicJson: async () => {
throw new Error('should not probe after abort')
},
fetchJson: async () => {
throw new Error('should not probe after abort')
},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => error.kind === 'superseded'
)
})
test('recognizes missing-route shapes only', () => {
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
assert.equal(
isMissingHealthEndpointError(
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
),
true
)
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
})
-95
View File
@@ -1,95 +0,0 @@
export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000
export const DEFAULT_BACKEND_READY_POLL_MS = 500
// A cold backend can stall its event loop for tens of seconds while Windows
// scans and byte-compiles the gateway import tree. At the default 15s socket
// timeout only three probes fit in the budget; a short one keeps retrying
// across the stall. Health only — the legacy /api/status fallback is genuinely
// slow to answer and keeps the caller's default timeout.
export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000
type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise<unknown>
export interface HermesReadyOptions {
fetchPublicJson: FetchPublicJson
fetchJson: FetchJson
token?: string | null
signal?: AbortSignal
timeoutMs?: number
pollMs?: number
healthProbeTimeoutMs?: number
sleep?: (ms: number) => Promise<void>
now?: () => number
}
export function isMissingHealthEndpointError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '')
return /^404:/.test(message) || message.includes('endpoint is likely missing')
}
function supersededError() {
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
error.kind = 'superseded'
return error
}
export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise<void> {
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS
const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS
const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS
const now = options.now ?? Date.now
const signal = options.signal
const sleep =
options.sleep ??
(ms =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms)
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
reject(supersededError())
},
{ once: true }
)
}))
const base = baseUrl.replace(/\/+$/, '')
const deadline = now() + timeoutMs
let lastError: unknown = null
let useStatusFallback = false
while (now() < deadline) {
if (signal?.aborted) {
throw supersededError()
}
try {
if (useStatusFallback) {
await options.fetchJson(`${base}/api/status`, options.token)
} else {
await options.fetchPublicJson(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs })
}
return
} catch (error) {
lastError = error
// Only an explicitly missing route means the backend predates
// /api/health; timeouts and server errors keep polling health.
if (!useStatusFallback && isMissingHealthEndpointError(error)) {
useStatusFallback = true
continue
}
await sleep(pollMs)
}
}
const detail = lastError instanceof Error ? lastError.message : 'timeout'
throw new Error(`Hermes backend did not become ready: ${detail}`)
}
@@ -1,7 +1,6 @@
async function applyConnectionChange({
cancelAndWait,
isPrimary,
rehomePrimary = null,
scope,
sendApplied,
stopPool,
@@ -17,12 +16,6 @@ async function applyConnectionChange({
return
}
if (rehomePrimary) {
await rehomePrimary()
return
}
await teardownPrimary()
sendApplied()
}
@@ -1,133 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function settledState(promise: Promise<unknown>) {
return Promise.race([promise.then(() => 'resolved'), delay(10).then(() => 'pending')])
}
test('first-run setup gate skips non-bootstrap backends', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
await gate.wait({ kind: 'remote' })
await gate.wait(null)
assert.deepEqual(prompts, [])
assert.equal(gate.hasWaiter(), false)
})
test('first-run setup gate prompts once for concurrent waits', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const first = gate.wait(bootstrapBackend)
const second = gate.wait(bootstrapBackend)
assert.equal(gate.hasWaiter(), true)
assert.equal(prompts.length, 1)
assert.equal(await settledState(first), 'pending')
gate.continueLocal()
assert.deepEqual(await Promise.all([first, second]), ['continue-local', 'continue-local'])
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('continueLocal keeps the setup choice visible until bootstrap owns the overlay', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
assert.equal(await pending, 'continue-local')
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('retry reset preserves the local install confirmation', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
gate.resetForRetry()
await gate.wait(bootstrapBackend)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
assert.equal(prompts.length, 1)
assert.equal(gate.hasWaiter(), false)
})
test('retry reset explicitly settles an active waiter without allowing local bootstrap', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.resetForRetry()
assert.equal(await pending, 'reset')
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
})
test('repair reset clears the local install confirmation and shows the gate again', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
gate.resetForRepair()
const next = gate.wait(bootstrapBackend)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(prompts.length, 2)
assert.equal(gate.hasWaiter(), true)
gate.continueLocal()
await next
})
test('remote apply settles the gated boot for remote re-resolution and hides the choice', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
const resumedWaiter = gate.abandonForRemoteApply()
assert.equal(resumedWaiter, true)
assert.equal(hidden, 1)
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(await pending, 'remote-applied')
})
test('remote apply without a waiter has no first-run side effects', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
assert.equal(gate.abandonForRemoteApply(), false)
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
@@ -1,146 +0,0 @@
interface FirstRunSetupBackend {
activeRoot?: string
kind?: string
platform?: string
}
interface FirstRunSetupGateOptions {
hideChoice?: () => void
log?: (message: string) => void
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
promptChoice?: (backend: FirstRunSetupBackend) => void
stuckAfterMs?: number
}
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
export function createFirstRunSetupGate({
hideChoice,
log,
onStuck,
promptChoice,
stuckAfterMs = 120000
}: FirstRunSetupGateOptions = {}) {
let localBootstrapConfirmed = false
let waiter: {
promise: Promise<FirstRunSetupDecision>
resolve: (decision: FirstRunSetupDecision) => void
} | null = null
let stuckTimer: ReturnType<typeof setTimeout> | null = null
const clearStuckTimer = () => {
if (stuckTimer) {
clearTimeout(stuckTimer)
stuckTimer = null
}
}
const armStuckTimer = (backend: FirstRunSetupBackend) => {
clearStuckTimer()
if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') {
return
}
stuckTimer = setTimeout(() => {
onStuck?.(backend, stuckAfterMs)
log(
`[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` +
`(platform=${backend?.platform || 'unknown'})`
)
}, stuckAfterMs)
if (typeof stuckTimer.unref === 'function') {
stuckTimer.unref()
}
}
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
const wait = async (backend?: FirstRunSetupBackend | null) => {
if (!shouldGate(backend)) {
return 'continue-local' as const
}
if (waiter) {
return waiter.promise
}
promptChoice?.(backend)
armStuckTimer(backend)
let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {}
const promise = new Promise<FirstRunSetupDecision>(resolve => {
resolveWaiter = resolve
})
waiter = { promise, resolve: resolveWaiter }
return promise
}
const settleWaiter = (decision: FirstRunSetupDecision) => {
clearStuckTimer()
if (!waiter) {
return false
}
const activeWaiter = waiter
waiter = null
activeWaiter.resolve(decision)
return true
}
const continueLocal = () => {
localBootstrapConfirmed = true
settleWaiter('continue-local')
}
const resetForRetry = () => {
// Reset paths are followed by a renderer reload / fresh startHermes() call.
// Settle the old boot explicitly so it cannot fall through into local
// bootstrap and cannot leak a forever-pending connection promise.
settleWaiter('reset')
}
const resetForRepair = () => {
resetForRetry()
localBootstrapConfirmed = false
}
const abandonForRemoteApply = () => {
// Resume the gated startHermes() with an explicit remote decision. The
// caller re-resolves the newly-persisted remote config instead of falling
// through into local bootstrap or leaking the original connection promise.
const resumedWaiter = settleWaiter('remote-applied')
if (!resumedWaiter) {
return false
}
localBootstrapConfirmed = false
hideChoice?.()
return true
}
const isLocalBootstrapConfirmed = () => localBootstrapConfirmed
const hasWaiter = () => Boolean(waiter)
return {
abandonForRemoteApply,
continueLocal,
hasWaiter,
isLocalBootstrapConfirmed,
resetForRepair,
resetForRetry,
shouldGate,
wait
}
}
@@ -1,119 +0,0 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { applyConnectionChange } from './connection-apply'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
test('a first-run bootstrap-needed remote apply connects without ensuring or bootstrapping locally', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
const candidateRemote = {
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
source: 'settings',
token: 'secret',
wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret'
}
let savedRemote: typeof candidateRemote | null = null
const resolveRemote = vi.fn(async () => savedRemote)
const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const }))
const runBootstrap = vi.fn()
const ensureLocalRuntime = vi.fn(async backend => {
await runBootstrap()
return { ...backend, command: 'hermes' }
})
const teardownPrimaryBackend = vi.fn(async () => {})
const cancelSshBootstrap = vi.fn(async () => {})
const teardownSsh = vi.fn(async () => {})
const clearLocalBootstrapFailure = vi.fn()
const notifyConnectionApplied = vi.fn()
const waitForLocalStart = vi.fn(async () => {})
const prepareLocalBackend = vi.fn(async () => bootstrapBackend)
const pendingConnection = runPrimaryBackendStartup({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
resolveRemote,
waitForDecision: gate.wait,
waitForLocalStart
})
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
// Mirrors the IPC handler's production ordering: persist the tested config,
// then re-home. The pending start must re-resolve this saved value.
savedRemote = candidateRemote
await applyConnectionChange({
cancelAndWait: cancelSshBootstrap,
isPrimary: true,
rehomePrimary: () =>
rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode: 'remote',
notifyConnectionApplied,
resumeFirstRunRemote: gate.abandonForRemoteApply,
teardownPrimaryBackend
}),
scope: '',
sendApplied: notifyConnectionApplied,
stopPool: vi.fn(),
teardownPrimary: teardownPrimaryBackend,
teardownSsh
})
assert.deepEqual(await pendingConnection, {
kind: 'remote',
connection: { ...candidateRemote, mode: 'remote' }
})
assert.deepEqual(resolveRemote.mock.calls, [[], []])
assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]])
assert.deepEqual(waitForLocalStart.mock.calls, [[]])
assert.deepEqual(prepareLocalBackend.mock.calls, [[]])
assert.equal(ensureLocalRuntime.mock.calls.length, 0)
assert.equal(runBootstrap.mock.calls.length, 0)
assert.deepEqual(cancelSshBootstrap.mock.calls, [['']])
assert.deepEqual(teardownSsh.mock.calls, [['']])
assert.equal(teardownPrimaryBackend.mock.calls.length, 0)
assert.equal(clearLocalBootstrapFailure.mock.calls.length, 1)
assert.equal(notifyConnectionApplied.mock.calls.length, 0)
})
test('a primary apply without an active first-run gate tears down before reconnect notification', async () => {
const order: string[] = []
const clearLocalBootstrapFailure = vi.fn(() => order.push('clear-failure'))
const teardownPrimaryBackend = vi.fn(async () => {
order.push('teardown')
})
const notifyConnectionApplied = vi.fn(() => order.push('notify'))
assert.deepEqual(
await rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode: 'remote',
notifyConnectionApplied,
resumeFirstRunRemote: () => false,
teardownPrimaryBackend
}),
{ resumedFirstRunRemote: false }
)
assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]])
assert.deepEqual(order, ['clear-failure', 'teardown', 'notify'])
})
+62 -274
View File
@@ -34,7 +34,6 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
import { createBackendConnectionState } from './backend-connection-state'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
import { waitForHermesReady } from './backend-health'
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
import { waitForDashboardPortAnnouncement } from './backend-ready'
import { shouldLatchBackendStartFailure } from './backend-start-failure'
@@ -80,7 +79,6 @@ import {
import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { findGitBash as _findGitBash } from './find-git-bash'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { scanGitRepos } from './git-repo-scan'
@@ -130,8 +128,6 @@ import {
import { runNativeLogin } from './native-oauth-login'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { createKeepAwake } from './power-save'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import * as remoteLifecycle from './remote-lifecycle'
import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'
@@ -1403,17 +1399,13 @@ let bootstrapState = {
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
let firstRunSetupGate = null
function broadcastBootstrapEvent(ev) {
if (ev.type === 'manifest') {
bootstrapState.manifest = ev
bootstrapState.active = true
bootstrapState.setupChoice = null
bootstrapState.startedAt = bootstrapState.startedAt || Date.now()
bootstrapState.stages = {}
@@ -1441,30 +1433,14 @@ function broadcastBootstrapEvent(ev) {
} else if (ev.type === 'failed') {
bootstrapState.active = false
bootstrapState.error = ev.error || 'unknown error'
bootstrapState.setupChoice = null
} else if (ev.type === 'unsupported-platform') {
bootstrapState.active = false
bootstrapState.setupChoice = null
bootstrapState.unsupportedPlatform = {
platform: ev.platform,
activeRoot: ev.activeRoot,
installCommand: ev.installCommand,
docsUrl: ev.docsUrl
}
} else if (ev.type === 'setup-choice') {
bootstrapState.active = false
bootstrapState.error = null
bootstrapState.manifest = null
bootstrapState.stages = {}
bootstrapState.setupChoice = ev.active
? {
platform: ev.platform,
activeRoot: ev.activeRoot
}
: null
bootstrapState.unsupportedPlatform = null
} else if (ev.type === 'dismissed') {
resetBootstrapSnapshot()
}
if (!mainWindow || mainWindow.isDestroyed()) {
@@ -1484,100 +1460,6 @@ function getBootstrapState() {
return bootstrapState
}
function resetBootstrapSnapshot() {
bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
}
function promptFirstRunSetupChoice(backend) {
broadcastBootstrapEvent({
type: 'setup-choice',
active: true,
platform: backend.platform || process.platform,
activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT
})
}
function hideFirstRunSetupChoice() {
if (bootstrapState.setupChoice) {
broadcastBootstrapEvent({ type: 'setup-choice', active: false })
}
}
function getFirstRunSetupGate() {
if (!firstRunSetupGate) {
firstRunSetupGate = createFirstRunSetupGate({
hideChoice: hideFirstRunSetupChoice,
log: rememberLog,
onStuck: (_backend, stuckAfterMs) => {
updateBootProgress(
{
error: null,
message: `Still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)} seconds`,
phase: 'bootstrap.choice',
progress: 12,
running: true
},
{ allowDecrease: true }
)
},
promptChoice: promptFirstRunSetupChoice
})
}
return firstRunSetupGate
}
async function waitForFirstRunSetupChoice(backend) {
const gate = getFirstRunSetupGate()
if (!gate.shouldGate(backend)) {
return 'continue-local'
}
updateBootProgress(
{
error: null,
message: 'Waiting for first-run setup choice',
phase: 'bootstrap.choice',
progress: 12,
running: true
},
{ allowDecrease: true }
)
return gate.wait(backend)
}
function continueFirstRunLocalBootstrap() {
getFirstRunSetupGate().continueLocal()
}
function abandonFirstRunSetupChoiceForRemoteApply() {
const gate = getFirstRunSetupGate()
if (!gate.hasWaiter()) {
return false
}
const resumedGatedConnection = gate.abandonForRemoteApply()
if (resumedGatedConnection) {
broadcastBootstrapEvent({ type: 'dismissed' })
}
return resumedGatedConnection
}
function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) {
const nextProgressRaw =
typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress
@@ -2773,11 +2655,6 @@ async function applyUpdates(opts = {}) {
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
// ── Pre-flight state.db integrity guard (#68474) ─────────────────
// Emergency backup and header verification before the update touches
// anything. Runs while the backend is still alive.
preflightStateDb(HERMES_HOME, rememberLog)
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
// spawn the updater. Without this the updater races a still-locked
// hermes.exe (held by the backend child / its grandchildren) and the update
@@ -2975,92 +2852,6 @@ function runningAppBundle() {
return dir.endsWith('.app') ? dir : null
}
// ── Pre-flight state.db integrity guard (#68474) ─────────────────────
// Take an emergency snapshot of state.db and verify the live copy is
// intact before any update process mutates the install. Runs in the
// desktop Electron process itself, before the backend is killed and
// before the updater is spawned — a separate safety net from the
// Python-level pre-update snapshot inside `hermes update`.
function preflightStateDb(hermesHome, rememberLog) {
const stateDbPath = path.join(hermesHome, 'state.db')
if (!fileExists(stateDbPath)) {
rememberLog('[updates] state.db pre-flight: not found (fresh install?)')
return
}
try {
const stat = fs.statSync(stateDbPath)
if (stat.size > 100) {
const fd = fs.openSync(stateDbPath, 'r')
const header = Buffer.alloc(16)
fs.readSync(fd, header, 0, 16, 0)
fs.closeSync(fd)
const expectedHeader = Buffer.from('SQLite format 3\0')
const headerOk = header.equals(expectedHeader)
rememberLog(
`[updates] state.db pre-flight: size=${stat.size}, ` +
`headerOk=${headerOk}, headerHex=${header.toString('hex')}`
)
if (!headerOk) {
rememberLog(
'[updates] state.db header is INVALID before update — ' +
'this indicates pre-existing corruption or a concurrent write issue'
)
}
// Emergency timestamped backup, separate from the Python-level snapshot.
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const emergencyPath = path.join(hermesHome, `state.db.pre-update-emergency-${ts}.bak`)
try {
fs.copyFileSync(stateDbPath, emergencyPath)
const emergStat = fs.statSync(emergencyPath)
rememberLog(`[updates] emergency state.db backup: ${emergencyPath} ` + `(${emergStat.size} bytes)`)
// Prune to the 2 most recent emergency backups.
try {
const homeDir = fs.readdirSync(hermesHome)
const backups = homeDir
.filter(
f =>
f.startsWith('state.db.pre-update-emergency-') &&
f.endsWith('.bak') &&
f !== path.basename(emergencyPath)
)
.sort()
.reverse()
for (const old of backups.slice(2)) {
try {
fs.unlinkSync(path.join(hermesHome, old))
} catch {
void 0
}
}
} catch {
void 0
}
} catch (copyErr) {
rememberLog(`[updates] emergency state.db backup failed: ${copyErr.message}`)
}
} else {
rememberLog(`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`)
}
} catch (statErr) {
rememberLog(`[updates] could not stat state.db before update: ${statErr.message}`)
}
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`
}
@@ -3079,12 +2870,9 @@ async function applyUpdatesPosixInApp(opts: any) {
return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot }
}
// ── Pre-flight state.db integrity guard (#68474) ──
preflightStateDb(HERMES_HOME, rememberLog)
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
// npm build can find them on a machine with no system Node. Windows portable
// Node lives directly under %LOCALAPPDATA%\\hermes\\node, not node\\bin.
// Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin.
// PYTHONUNBUFFERED: `hermes update` writes to a pipe here, so CPython
// block-buffers stdout and long quiet steps (the pre-update backup can zip
// multi-GB archives for minutes) stream nothing to the progress UI — users
@@ -4816,12 +4604,39 @@ function closePreviewWatchers() {
}
async function waitForHermes(baseUrl, token, signal?) {
return waitForHermesReady(baseUrl, {
token,
signal,
fetchPublicJson,
fetchJson
})
const deadline = Date.now() + 45_000
let lastError = null
while (Date.now() < deadline) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
error.kind = 'superseded'
throw error
}
try {
await fetchJson(`${baseUrl}/api/status`, token)
return
} catch (error) {
lastError = error
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, 500)
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
const aborted: any = new Error('SSH bootstrap was superseded by newer connection settings.')
aborted.kind = 'superseded'
reject(aborted)
},
{ once: true }
)
})
}
}
throw new Error(`Hermes backend did not become ready: ${lastError?.message || 'timeout'}`)
}
function getWindowButtonPosition() {
@@ -7952,7 +7767,14 @@ async function startHermes() {
let attemptedRemote = primaryBackendIsRemote()
const connectionPromise = (async () => {
const connectRemote = async remote => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
// Re-read once resolved so the classification tracks the value actually used.
attemptedRemote = primaryBackendIsRemote()
const remote = await resolveRemoteBackend(primaryProfileKey())
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
updateBootProgress({
@@ -7978,9 +7800,14 @@ async function startHermes() {
}
}
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
// Mutual exclusion with an in-app update (#50238). If this instance was
// relaunched while the Tauri updater is still applying an update, spawning
// a local backend now re-locks the venv shim and gets killed by the
// updater's straggler cleanup — looping. Park until the update finishes (or
// is detected stale), THEN start the backend. Local backends only; remote
// connections returned above and never touch the install tree.
await waitForUpdateToFinish()
const token = crypto.randomBytes(32).toString('base64url')
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0']
@@ -7995,32 +7822,8 @@ async function startHermes() {
backendArgs.unshift('--profile', activeProfile)
}
const setup = await runPrimaryBackendStartup({
connectRemote,
ensureLocalRuntime: ensureRuntime,
prepareLocalBackend: async () => {
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
return resolveHermesBackend(backendArgs)
},
resolveRemote: () => {
// Classify immediately before each throwing resolve. This callback runs
// both for an already-saved remote and after first-run remote Apply.
attemptedRemote = primaryBackendIsRemote()
return resolveRemoteBackend(primaryProfileKey())
},
waitForDecision: waitForFirstRunSetupChoice,
// Mutual exclusion with an in-app update (#50238). Remote connections
// return before this waiter; local starts park until the updater exits.
waitForLocalStart: waitForUpdateToFinish
})
if (setup.kind === 'remote') {
return setup.connection
}
const backend = setup.backend
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
backend.args = getBackendArgsForRuntime(backend)
const hermesCwd = resolveHermesCwd()
@@ -8176,10 +7979,6 @@ async function startHermes() {
throw error
}
if (error instanceof FirstRunSetupResetError) {
throw error
}
const message = error instanceof Error ? error.message : String(error)
// Only latch LOCAL boot failures. A remote failure (lapsed session / mint
@@ -8994,8 +8793,16 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
backendStartFailure = null
getFirstRunSetupGate().resetForRetry()
resetBootstrapSnapshot()
bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
unsupportedPlatform: null
}
return { ok: true }
})
@@ -9016,17 +8823,10 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
bootstrapFailure = null
backendStartFailure = null
getFirstRunSetupGate().resetForRepair()
resetHermesConnection()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:continue-local', async () => {
rememberLog('[bootstrap] local install selected by renderer; continuing first-launch bootstrap')
continueFirstRunLocalBootstrap()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:cancel', async () => {
// Renderer's Cancel button during first-launch install. Abort the running
// install script (SIGTERM via the runner's abortSignal). runBootstrap
@@ -9205,18 +9005,6 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
await applyConnectionChange({
cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value),
isPrimary: !key || key === primaryProfileKey(),
rehomePrimary: () =>
rehomePrimaryConnection({
clearLocalBootstrapFailure: () => {
// A remote connection bypasses local runtime/bootstrap failures. Clear
// the local-install latch so unsupported/failure escape paths can re-home.
bootstrapFailure = null
},
mode: config.mode,
notifyConnectionApplied: sendConnectionApplied,
resumeFirstRunRemote: abandonFirstRunSetupChoiceForRemoteApply,
teardownPrimaryBackend: teardownPrimaryBackendAndWait
}),
scope,
sendApplied: sendConnectionApplied,
stopPool: stopPoolBackend,
-1
View File
@@ -237,7 +237,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap.
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
@@ -1,110 +0,0 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
function startupOptions(overrides: Record<string, unknown> = {}) {
return {
connectRemote: vi.fn(async remote => ({ baseUrl: remote.baseUrl, mode: 'remote' as const })),
ensureLocalRuntime: vi.fn(async backend => ({ ...backend, command: 'hermes' })),
prepareLocalBackend: vi.fn(async () => bootstrapBackend),
resolveRemote: vi.fn(async () => null),
waitForDecision: vi.fn(async () => 'continue-local' as const),
waitForLocalStart: vi.fn(async () => {}),
...overrides
}
}
test('remote apply re-resolves the saved connection without ensuring a local runtime', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' }
let configuredRemote: typeof savedRemote | null = null
const options = startupOptions({
resolveRemote: vi.fn(async () => configuredRemote),
waitForDecision: gate.wait
})
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
configuredRemote = savedRemote
assert.equal(gate.abandonForRemoteApply(), true)
assert.deepEqual(await pending, {
kind: 'remote',
connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' }
})
assert.deepEqual(options.resolveRemote.mock.calls, [[], []])
assert.deepEqual(options.connectRemote.mock.calls, [[savedRemote]])
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('an already-saved remote bypasses every local startup step', async () => {
const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' }
const options = startupOptions({ resolveRemote: vi.fn(async () => savedRemote) })
assert.deepEqual(await runPrimaryBackendStartup(options), {
kind: 'remote',
connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' }
})
assert.equal(options.waitForLocalStart.mock.calls.length, 0)
assert.equal(options.prepareLocalBackend.mock.calls.length, 0)
assert.equal(options.waitForDecision.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('remote apply fails clearly when no saved remote can be resolved', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const options = startupOptions({ waitForDecision: gate.wait })
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.abandonForRemoteApply()
await assert.rejects(pending, /without a saved remote backend/)
assert.equal(options.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('continue local waits for update exclusion and ensures the prepared runtime exactly once', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const runtimeBackend = { ...bootstrapBackend, command: 'hermes' }
const options = startupOptions({
ensureLocalRuntime: vi.fn(async () => runtimeBackend),
waitForDecision: gate.wait
})
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.continueLocal()
assert.deepEqual(await pending, { kind: 'local', backend: runtimeBackend })
assert.deepEqual(options.waitForLocalStart.mock.calls, [[]])
assert.deepEqual(options.prepareLocalBackend.mock.calls, [[]])
assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[bootstrapBackend]])
assert.deepEqual(options.resolveRemote.mock.calls, [[]])
})
test('reset rejects with a typed error and never enters either backend', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const options = startupOptions({ waitForDecision: gate.wait })
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.resetForRetry()
await assert.rejects(pending, error => error instanceof FirstRunSetupResetError && error.firstRunSetupReset)
assert.equal(options.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
@@ -1,66 +0,0 @@
import type { FirstRunSetupDecision } from './first-run-setup-gate'
export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection> {
connectRemote: (remote: Remote) => Promise<Connection>
ensureLocalRuntime: (backend: Backend) => Promise<RuntimeBackend>
prepareLocalBackend: () => Backend | Promise<Backend>
resolveRemote: () => Promise<Remote | null>
waitForDecision: (backend: Backend) => Promise<FirstRunSetupDecision>
waitForLocalStart: () => Promise<unknown>
}
export type PrimaryBackendStartupResult<RuntimeBackend, Connection> =
| { kind: 'local'; backend: RuntimeBackend }
| { kind: 'remote'; connection: Connection }
export class FirstRunSetupResetError extends Error {
readonly firstRunSetupReset = true
constructor() {
super('First-run setup was reset before a choice completed.')
this.name = 'FirstRunSetupResetError'
}
}
// Owns the production startHermes path up to the local process spawn. Keeping
// the full ordering here makes the first-run remote boundary executable in a
// test: an already-saved remote wins immediately; otherwise update exclusion
// and local backend resolution happen before the setup gate, and a remote Apply
// re-resolves persisted config without ever entering ensureRuntime/bootstrap.
export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote, Connection>({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
resolveRemote,
waitForDecision,
waitForLocalStart
}: PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection>): Promise<
PrimaryBackendStartupResult<RuntimeBackend, Connection>
> {
const savedRemote = await resolveRemote()
if (savedRemote) {
return { kind: 'remote', connection: await connectRemote(savedRemote) }
}
await waitForLocalStart()
const backend = await prepareLocalBackend()
const decision = await waitForDecision(backend)
if (decision === 'remote-applied') {
const appliedRemote = await resolveRemote()
if (!appliedRemote) {
throw new Error('First-run remote setup completed without a saved remote backend.')
}
return { kind: 'remote', connection: await connectRemote(appliedRemote) }
}
if (decision === 'reset') {
throw new FirstRunSetupResetError()
}
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
}
@@ -1,35 +0,0 @@
export interface PrimaryConnectionRehomeOptions {
clearLocalBootstrapFailure: () => void
mode: string
notifyConnectionApplied: () => void
resumeFirstRunRemote: () => boolean
teardownPrimaryBackend: (options: { soft: boolean }) => Promise<void>
}
// Production seam shared by the connection-config IPC handler and the
// first-run integration test. A remote apply that resumes the active setup
// gate must keep that connection attempt alive; ordinary mode changes tear the
// current backend down before the renderer is told to reconnect.
export async function rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode,
notifyConnectionApplied,
resumeFirstRunRemote,
teardownPrimaryBackend
}: PrimaryConnectionRehomeOptions): Promise<{ resumedFirstRunRemote: boolean }> {
let resumedFirstRunRemote = false
if (mode === 'remote') {
resumedFirstRunRemote = resumeFirstRunRemote()
clearLocalBootstrapFailure()
}
if (resumedFirstRunRemote) {
return { resumedFirstRunRemote: true }
}
await teardownPrimaryBackend({ soft: true })
notifyConnectionApplied()
return { resumedFirstRunRemote: false }
}
+1 -1
View File
@@ -111,7 +111,7 @@
"motion": "^12.38.0",
"nanostores": "^1.3.0",
"node-pty": "1.1.0",
"radix-ui": "^1.6.5",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dnd-html5-backend": "^14.0.3",
@@ -1,130 +0,0 @@
import { act, cleanup, render } from '@testing-library/react'
import { useLayoutEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type ComposerAttachment, mainComposerScope, stashSessionDraft } from '@/store/composer'
import type { QueueEditState } from '../composer-utils'
import { useComposerDraft } from './use-composer-draft'
const mockComposerApi = { setText: vi.fn() }
vi.mock('@assistant-ui/react', () => ({
useAui: () => ({ composer: () => mockComposerApi }),
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({ composer: { text: '' } }),
useComposerRuntime: () => ({
getState: () => ({ text: '' }),
subscribe: () => () => undefined
})
}))
interface ProbeHarnessProps {
activeQueueSessionKey: string | null
onLayoutSnapshot: (attachments: ComposerAttachment[]) => void
sessionId: string
}
function ProbeHarness({ activeQueueSessionKey, onLayoutSnapshot, sessionId }: ProbeHarnessProps) {
useComposerDraft({
activeQueueSessionKey,
focusKey: null,
inputDisabled: false,
queueEditRef: { current: null as QueueEditState | null },
sessionId
})
// useLayoutEffect fires synchronously right after the DOM commit, BEFORE
// the hook's per-thread scope-swap useEffect (a passive effect) has a
// chance to swap attachmentScope.$attachments over to the new session. A
// synchronous read here — the same read ChatBar's `attachments` prop
// performs at render time — observes the OUTGOING session's attachments.
useLayoutEffect(() => {
onLayoutSnapshot(mainComposerScope.$attachments.get())
})
return null
}
describe('useComposerDraft — attachment scope stays coherent with the committed session on switch (#59305)', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
})
it('clears the outgoing session attachments by the layout phase right after switching sessions', () => {
const attachmentA: ComposerAttachment = { id: 'url-A', kind: 'url', label: 'A' }
stashSessionDraft('session-A', 'hi from A', [attachmentA])
const snapshots: ComposerAttachment[][] = []
const { rerender } = render(
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={s => snapshots.push(s)} sessionId="session-A" />
)
// Mount loads session A's stashed attachment into the (module-level) main
// scope — confirms the fixture actually seeded the leak precondition.
expect(mainComposerScope.$attachments.get()).toEqual([attachmentA])
snapshots.length = 0 // drop the initial-mount snapshot; only the switch matters
act(() => {
rerender(
<ProbeHarness
activeQueueSessionKey="session-B"
onLayoutSnapshot={s => snapshots.push(s)}
sessionId="session-B"
/>
)
})
// By the layout phase the scope must already be B's (empty) — a submit
// fired the instant B renders must never ship session A's attachment.
expect(snapshots[0]).toEqual([])
})
})
describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
vi.restoreAllMocks()
})
it('logs counts/kinds/scope on restore but never the raw url, refText, or label', () => {
const secretUrl = 'https://secret.example.com/private-workspace-path'
const attachment: ComposerAttachment = {
id: 'url-secret',
kind: 'url',
label: 'do-not-leak-label',
refText: `@url:${secretUrl}`
}
stashSessionDraft('session-secret', '', [attachment])
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined)
render(
<ProbeHarness
activeQueueSessionKey="session-secret"
onLayoutSnapshot={() => undefined}
sessionId="session-secret"
/>
)
const rehydrateCalls = debugSpy.mock.calls.filter(call => call[0] === '[composer-rehydrate]')
expect(rehydrateCalls.length).toBeGreaterThan(0)
const serialized = JSON.stringify(rehydrateCalls)
expect(serialized).not.toContain(secretUrl)
expect(serialized).not.toContain(attachment.label)
expect(serialized).not.toContain(attachment.refText)
expect(rehydrateCalls[0]?.[1]).toMatchObject({
attachmentCount: 1,
attachmentKinds: ['url'],
scope: 'session-secret'
})
})
})
@@ -1,5 +1,5 @@
import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
@@ -20,7 +20,7 @@ import {
onComposerInsertRequest
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import { composerPlainText, placeCaretEnd, REF_RE, renderComposerContents } from '../rich-editor'
import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
@@ -189,21 +189,6 @@ export function useComposerDraft({
stashSessionDraft(scope, text, attachments)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
// Diagnostic breadcrumb for #59305-class reports: identifies WHAT kind of
// state got restored into the composer (session switch, queue-edit
// restore, history browse) without logging any raw content. REF_RE has the
// global flag — testing against a throwaway clone avoids mutating the
// shared instance's lastIndex, which would otherwise corrupt this check on
// the next call.
if (attachments.length > 0 || new RegExp(REF_RE.source, REF_RE.flags).test(text)) {
console.debug('[composer-rehydrate]', {
attachmentCount: attachments.length,
attachmentKinds: attachments.map(a => a.kind),
hasTextRefs: new RegExp(REF_RE.source, REF_RE.flags).test(text),
scope: activeQueueSessionKeyRef.current
})
}
attachmentScope.$attachments.set(cloneAttachments(attachments))
paintDraft(text, false)
}
@@ -333,16 +318,7 @@ export function useComposerDraft({
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
// on enter. Keyed writes are idempotent, so no skip-sentinel.
//
// MUST be a layout effect, not a passive one: it swaps attachmentScope's
// module-level $attachments atom, and a passive effect fires only after the
// browser paints the new session's view — leaving a window where the DOM
// already shows session B while $attachments (and therefore ChatBar's
// `attachments` prop) still holds session A's chips. A submit fired in that
// window (e.g. a fast session switch immediately followed by Enter) would
// ship A's attachments into B's turn (#59305). useLayoutEffect closes the
// window by running before paint.
useLayoutEffect(() => {
useEffect(() => {
// A pending debounce timer from the outgoing session is now stale — its
// scope was correct when scheduled, but the authoritative stash below
// (and the cleanup on the way out) already covers that text. Letting it
@@ -113,9 +113,7 @@ describe('useComposerSubmit busy-turn routing', () => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('/compress preserve context', { composerScope: 'stored-session' })
)
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/compress preserve context'))
expect(clearDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
@@ -161,26 +159,9 @@ describe('useComposerSubmit busy-turn routing', () => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('ordinary question', {
attachments: [],
composerScope: 'stored-session'
})
)
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ordinary question', { attachments: [] }))
expect(onSteer).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('threads the loaded composer scope through onSubmit for the #59305 submit-time guard', async () => {
const { hook, onSubmit } = renderSubmitHook({ text: 'hello' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('hello', expect.objectContaining({ composerScope: 'stored-session' }))
)
})
})
@@ -89,11 +89,7 @@ export function useComposerSubmit({
stashAt(submittedScope, text, submittedAttachments)
}
void Promise.resolve(
attachments
? onSubmit(text, { attachments, composerScope: submittedScope })
: onSubmit(text, { composerScope: submittedScope })
)
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
.catch(restore)
}
+6 -13
View File
@@ -42,7 +42,7 @@ import {
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { primaryRouteSelectedSessionId, routeSessionId } from '../routes'
import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
import { ChatDropOverlay } from './chat-drop-overlay'
@@ -285,18 +285,11 @@ export function ChatView({
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
// Durable composer/queue scope (lineage root) so auto-compression tip rotation
// does not wipe an in-progress draft or orphan /queue entries. For the
// primary view, the route is authoritative over the store selection — the
// latter can be momentarily null/stale mid-switch, which used to leak into
// the composer's scope key (#59305). A tile has no route, so it always uses
// its own selection directly.
const queueSessionKey = useMemo(() => {
const effectiveSelectedSessionId = isPrimary
? primaryRouteSelectedSessionId(location.pathname, selectedSessionId)
: selectedSessionId
return resolveComposerSessionKey(effectiveSelectedSessionId, sessions)
}, [isPrimary, location.pathname, selectedSessionId, sessions])
// does not wipe an in-progress draft or orphan /queue entries.
const queueSessionKey = useMemo(
() => resolveComposerSessionKey(selectedSessionId, sessions),
[selectedSessionId, sessions]
)
// When the tip row arrives after compression, migrate any tip-keyed stash onto
// the durable lineage key before the composer remounts onto that key.
@@ -38,8 +38,7 @@ import {
planEdit,
planReload,
planRestore,
runRewindSubmit,
truncateSubmitParams
runRewindSubmit
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
@@ -275,11 +274,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
try {
await requestGateway(
'prompt.submit',
{
session_id: runtimeIdRef.current,
text: plan.text,
...truncateSubmitParams(plan.truncateOrdinal)
},
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
@@ -43,22 +43,6 @@ export function SidebarRowNest({ className, ...props }: React.ComponentProps<'di
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
}
/**
* Chronological date-bucket separator ("Yesterday" / "Last week" / "June") for
* the session list. One flat row — a small caption plus a hairline rule — so it
* groups sessions by recency without adding a level of indentation.
*/
export function SidebarDateDivider({ className, label, ...props }: React.ComponentProps<'div'> & { label: string }) {
return (
<div className={cn('flex select-none items-center gap-2 px-2 pb-0.5 pt-2', className)} {...props}>
<span className="shrink-0 text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-(--ui-text-quaternary)">
{label}
</span>
<span aria-hidden="true" className="h-px flex-1 bg-(--ui-stroke-tertiary)" />
</div>
)
}
/** Outer grid — sole owner of row height. */
export function SidebarRowShell({
actions,
+1 -2
View File
@@ -1239,7 +1239,7 @@ export function ChatSidebar({
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-[50vh] flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
@@ -1275,7 +1275,6 @@ export function ChatSidebar({
// virtualized long list, which must keep its own scroller.
!recentsVirtualizes && COMPACT_FLAT
)}
dateGrouped={inProject || !agentOrderManual}
dndSensors={dndSensors}
emptyState={
showSessionSkeletons ? (
@@ -7,14 +7,11 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { flattenSessionsWithBranches } from '@/lib/session-branch-tree'
import { groupEntriesByRecency, type SidebarListRow, toSessionRows } from '@/lib/session-date-groups'
import { sessionBucketLabel } from '@/lib/time'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarCount, SidebarDateDivider } from './chrome'
import { SidebarCount } from './chrome'
import {
EnteredProjectContent,
ProjectOverviewRow,
@@ -142,11 +139,6 @@ interface SidebarSessionsSectionProps {
// lists (Pinned / search results) in the All-profiles view, where no group
// header communicates ownership (#66003).
showProfileTags?: boolean
// Insert "Yesterday" / "Last week" date dividers into the chronological
// session list (flat recents + entered-project lanes). Off for hand-ordered
// lists, pinned, messaging groups, and the project overview, where the order
// isn't strictly by recency so a date bucket would be misleading.
dateGrouped?: boolean
}
export function SidebarSessionsSection({
@@ -187,11 +179,8 @@ export function SidebarSessionsSection({
onReorderProjects,
projectBackRow,
dndSensors,
showProfileTags = false,
dateGrouped = false
showProfileTags = false
}: SidebarSessionsSectionProps) {
const { t } = useI18n()
const dividerLabels = t.sidebar.dateDivider
const sectionOpen = collapsible ? open : true
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
// A defined project list is itself content (even an empty project should
@@ -230,30 +219,10 @@ export function SidebarSessionsSection({
)
}
// A single flat/virtual/lane list row — either a date divider or a session.
const renderListRow = (row: SidebarListRow, draggable: boolean) =>
row.kind === 'divider' ? (
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
) : (
renderRow(row.entry.session, draggable, row.entry.branchStem)
)
// Sessions inside repos/worktrees are date-ordered and static.
const renderRows = (items: SessionInfo[]) =>
flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem))
// Same as `renderRows`, but with date dividers folded in — used for
// entered-project lanes so a lane spanning multiple days reads
// chronologically, matching the flat recents list.
const renderRowsDated = (items: SessionInfo[]) => {
const entries = flattenSessionsWithBranches(items)
return (dateGrouped ? groupEntriesByRecency(entries) : toSessionRows(entries)).map(row => renderListRow(row, false))
}
// Flat recents as list rows: grouped by recency when enabled, plain otherwise.
const flatRows: SidebarListRow[] = dateGrouped ? groupEntriesByRecency(displayEntries) : toSessionRows(displayEntries)
const flatVirtualized =
!showEmptyState &&
!groups?.length &&
@@ -285,7 +254,7 @@ export function SidebarSessionsSection({
onNewSession={onNewSessionInWorkspace}
project={projectContent}
removedSessionIds={removedSessionIds}
renderRows={renderRowsDated}
renderRows={renderRows}
repoWorktrees={projectRepoWorktrees}
/>
) : (
@@ -341,13 +310,13 @@ export function SidebarSessionsSection({
<VirtualSessionList
activeSessionId={activeSessionId}
className={contentClassName}
entries={displayEntries}
onArchiveSession={onArchiveSession}
onBranchSession={onBranchSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
pinned={pinned}
rows={flatRows}
showProfileTags={showProfileTags}
sortable={sessionsDraggable}
workingSessionIdSet={workingSessionIdSet}
@@ -365,11 +334,11 @@ export function SidebarSessionsSection({
} else if (sessionsDraggable && onReorderSessions) {
inner = (
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
{flatRows.map(row => renderListRow(row, true))}
{displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))}
</ReorderableList>
)
} else {
inner = flatRows.map(row => renderListRow(row, false))
inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem))
}
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
@@ -4,13 +4,10 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { type SidebarListRow } from '@/lib/session-date-groups'
import { sessionBucketLabel } from '@/lib/time'
import { type SidebarSessionEntry } from '@/lib/session-branch-tree'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarDateDivider } from './chrome'
import { SidebarSessionRow } from './session-row'
interface SessionRowCommonProps {
@@ -30,7 +27,7 @@ interface SessionRowCommonProps {
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
rows: SidebarListRow[]
entries: SidebarSessionEntry[]
onArchiveSession: (sessionId: string) => void
onBranchSession?: (sessionId: string, profile?: string) => void
onDeleteSession: (sessionId: string) => void
@@ -48,7 +45,7 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
rows: listRows,
entries,
onArchiveSession,
onBranchSession,
onDeleteSession,
@@ -59,18 +56,12 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
sortable,
workingSessionIdSet
}) => {
const { t } = useI18n()
const dividerLabels = t.sidebar.dateDivider
const scrollerRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: listRows.length,
count: entries.length,
estimateSize: () => ROW_ESTIMATE_PX,
getItemKey: index => {
const row = listRows[index]
return row ? (row.kind === 'divider' ? row.key : row.entry.session.id) : index
},
getItemKey: index => entries[index]?.session.id ?? index,
getScrollElement: () => scrollerRef.current,
// jsdom-friendly default; the real rect takes over on first observe.
initialRect: { height: 600, width: 240 },
@@ -83,25 +74,13 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
const rows = virtualItems.map(virtualItem => {
const row = listRows[virtualItem.index]
const entry = entries[virtualItem.index]
if (!row) {
if (!entry) {
return null
}
// Dividers are non-sortable, self-measured rows interleaved with sessions.
if (row.kind === 'divider') {
return (
<SidebarDateDivider
data-index={virtualItem.index}
key={row.key}
label={sessionBucketLabel(row.bucket, dividerLabels)}
ref={virtualizer.measureElement}
/>
)
}
const { branchStem, session } = row.entry
const { branchStem, session } = entry
const reorderable = sortable && !branchStem
const commonProps: SessionRowCommonProps = {
@@ -245,7 +245,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'],
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
labelKey: 'providerApiKeys',
tab: 'providers&pview=keys'
},
@@ -258,7 +258,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'],
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
labelKey: 'keysSettings',
tab: 'keys&kview=settings'
},
@@ -54,7 +54,6 @@ import {
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@@ -398,10 +397,6 @@ watchContributedPanes()
watchSessionTiles()
watchRouteTiles()
// Mirror sidebar pins into the backend keep-flag so the auto-archive sweep
// never hides a pinned chat (and pre-existing pins migrate transparently).
watchSessionPins()
// The main tab reads as its SESSION (the loaded title, "New session" on a
// fresh draft) — a stack of main + tiles is then just a row of session names.
// register() replaces same-id in place; the render fn is the shared constant
@@ -189,7 +189,6 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="starmap" />
<Route element={null} path="webhooks" />
{/* Registry-contributed pages (core features + plugins) render in the
workspace pane like any built-in view — behind the same blast wall
as every other contribution mount. */}
+1 -9
View File
@@ -111,7 +111,6 @@ import type { WiringActions, WiringApi } from './types'
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
const WebhooksView = lazy(async () => ({ default: (await import('../webhooks')).WebhooksView }))
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))
@@ -198,8 +197,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
resetOverlayReturnRoute,
settingsOpen,
starmapOpen,
toggleCommandCenter,
webhooksOpen
toggleCommandCenter
} = useOverlayRouting()
const {
@@ -1008,12 +1006,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
</Suspense>
)}
{webhooksOpen && (
<Suspense fallback={null}>
<WebhooksView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
@@ -1,105 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AutomationBlueprint } from '@/hermes'
import { initialBlueprintValues } from './blueprints'
function blueprint(fields: AutomationBlueprint['fields']): AutomationBlueprint {
return {
key: 'test',
title: 'Test',
description: '',
category: 'general',
tags: [],
command: '',
appUrl: '',
fields
}
}
describe('initialBlueprintValues', () => {
it('seeds each field from its default', () => {
const values = initialBlueprintValues(
blueprint([
{ name: 'time', type: 'time', label: 'Time', default: '08:00', options: [], optional: false, help: '' },
{
name: 'topic',
type: 'enum',
label: 'Topic',
default: 'news',
options: ['news', 'sports'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ time: '08:00', topic: 'news' })
})
it('falls back to an empty string when a field has no default', () => {
const values = initialBlueprintValues(
blueprint([{ name: 'topic', type: 'text', label: 'Topic', default: null, options: [], optional: true, help: '' }])
)
expect(values).toEqual({ topic: '' })
})
it('returns an empty object for a blueprint with no fields', () => {
expect(initialBlueprintValues(blueprint([]))).toEqual({})
})
it("seeds the deliver slot to 'local' when its default is the dashboard-only 'origin'", () => {
const values = initialBlueprintValues(
blueprint([
{
name: 'deliver',
type: 'enum',
label: 'Deliver',
default: 'origin',
options: ['origin', 'local', 'telegram'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ deliver: 'local' })
})
it("seeds the deliver slot to 'local' when it has no default", () => {
const values = initialBlueprintValues(
blueprint([
{
name: 'deliver',
type: 'enum',
label: 'Deliver',
default: null,
options: ['origin', 'local'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ deliver: 'local' })
})
it('leaves a non-origin deliver default untouched', () => {
const values = initialBlueprintValues(
blueprint([
{
name: 'deliver',
type: 'enum',
label: 'Deliver',
default: 'telegram',
options: ['origin', 'local', 'telegram'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ deliver: 'telegram' })
})
})
-91
View File
@@ -1,91 +0,0 @@
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { AutomationBlueprint, AutomationBlueprintField } from '@/hermes'
// The blueprint catalog is shared with the dashboard, so its deliver slot
// defaults to "origin" (the chat/home-channel a dashboard or gateway job was
// created from). Desktop has no origin chat, so seed the deliver slot to the
// desktop's native target ("local" = This desktop) instead. The dialog then
// renders that slot with the shared DeliverSelect (backend-sourced targets), so
// the raw "origin" option never reaches the desktop UI.
const DELIVER_FIELD = 'deliver'
const DESKTOP_DELIVER_DEFAULT = 'local'
function isDeliverField(field: AutomationBlueprintField): boolean {
return field.name === DELIVER_FIELD
}
// Initial form state for a blueprint = each field's default (or ''). Pure so the
// suite can assert the form seeds correctly without mounting React. The deliver
// slot is special-cased: an "origin" default (or empty) becomes "local" so a
// desktop-created job delivers to This desktop instead of nowhere.
export function initialBlueprintValues(blueprint: AutomationBlueprint): Record<string, string> {
const out: Record<string, string> = {}
for (const field of blueprint.fields) {
const seeded = field.default ?? ''
out[field.name] = isDeliverField(field) && (seeded === '' || seeded === 'origin') ? DESKTOP_DELIVER_DEFAULT : seeded
}
return out
}
// A slot-level validation error from the backend arrives as "422: <message>"
// (or "<code>: <message>"); strip the leading numeric code for inline display.
export function cleanBlueprintFieldError(message: string): string {
return message.replace(/^\d+:\s*/, '')
}
// Help text to show under a slot control. The backend deliver help is
// origin/dashboard-centric and even contradicts desktop semantics ("local =
// save only" vs. This desktop), and the DeliverSelect is self-explanatory —
// skip it for the deliver slot.
export function blueprintSlotHelp(field: AutomationBlueprintField): string | undefined {
return field.help && field.type !== 'text' && !isDeliverField(field) ? field.help : undefined
}
// Renders one blueprint slot's control (enum/weekdays → Select, time → time
// input, else text). The deliver slot is handled separately by the dialog's
// shared DeliverSelect, so it's not rendered here.
export function BlueprintSlotControl({
field,
id,
onChange,
value
}: {
field: AutomationBlueprintField
id: string
onChange: (next: string) => void
value: string
}) {
if (field.type === 'enum' || field.type === 'weekdays') {
return (
<Select onValueChange={onChange} value={value}>
<SelectTrigger className="h-9 rounded-md" id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (field.type === 'time') {
return <Input id={id} onChange={event => onChange(event.target.value)} type="time" value={value} />
}
return (
<Input
id={id}
onChange={event => onChange(event.target.value)}
placeholder={field.help || field.label}
type="text"
value={value}
/>
)
}
+200 -372
View File
@@ -14,7 +14,6 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
@@ -27,16 +26,11 @@ import {
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
type AutomationBlueprint,
createCronJob,
type CronDeliveryTarget,
type CronJob,
deleteCronJob,
getAutomationBlueprints,
getCronDeliveryTargets,
getCronJobRuns,
getCronJobs,
instantiateAutomationBlueprint,
pauseCronJob,
resumeCronJob,
type SessionInfo,
@@ -71,7 +65,6 @@ import {
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
@@ -81,9 +74,7 @@ const DEFAULT_DELIVER = 'local'
// the model picker carries this sentinel and is mapped back to '' on save.
const MODEL_DEFAULT_VALUE = '__default__'
// "Start from" default: the manual editor (blank cron). Any other value is a
// blueprint key. Blueprint keys never collide with this sentinel.
const CUSTOM_TEMPLATE = 'custom'
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ expr: '0 9 * * *', value: 'daily' },
@@ -446,28 +437,8 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
setEditor({ mode: 'closed' })
}
// Blueprint instantiation is a distinct backend path (fills typed slots, then
// creates the job) so it can't share the raw-cron onSave contract. Merge the
// created job into $cronJobs like every other create path. A blueprint writes a
// real per-profile job, and "all" is not a writable target — collapse it to
// 'default', matching the manual create path in handleEditorSave.
async function handleBlueprintCreate(blueprint: AutomationBlueprint, values: Record<string, string>) {
const profile = profileScope === ALL_PROFILES ? 'default' : profileScope
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
updateCronJobs(rows => {
const rest = rows.filter(row => row.id !== job.id)
return [...rest, job]
})
notify({ kind: 'success', title: c.blueprints.scheduled, message: asText(job.schedule_display) || blueprint.title })
setEditor({ mode: 'closed' })
}
return (
<Panel closeLabel={c.close} onClose={onClose}>
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : totalCount === 0 ? (
@@ -482,61 +453,59 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
title={c.emptyTitleNew}
/>
) : (
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchHints={jobs
.map(jobTitle)
.filter(Boolean)
.slice(0, 5)
.map(title => t.common.tryHint(title))}
searchLabel={c.search}
searchPlaceholder={c.search}
searchValue={query}
>
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
job={job}
key={job.id}
menu={
<PanelRowMenu
items={[
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
]}
/>
}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
)}
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
</PanelList>
<>
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchHints={jobs
.map(jobTitle)
.filter(Boolean)
.slice(0, 5)
.map(title => t.common.tryHint(title))}
searchLabel={c.search}
searchPlaceholder={c.search}
searchValue={query}
>
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
job={job}
key={job.id}
menu={
<PanelRowMenu
items={[
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
]}
/>
}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
)}
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
</PanelList>
{selectedJob ? (
<CronJobDetail
busy={busyJobId === selectedJob.id}
c={c}
job={selectedJob}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
/>
) : (
<PanelEmpty description={c.emptyDescSearch} icon="search" />
)}
</PanelBody>
{selectedJob ? (
<CronJobDetail
busy={busyJobId === selectedJob.id}
c={c}
job={selectedJob}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
/>
) : (
<PanelEmpty description={c.emptyDescSearch} icon="search" />
)}
</PanelBody>
</>
)}
<CronEditorDialog
editor={editor}
onBlueprintCreate={handleBlueprintCreate}
onClose={() => setEditor({ mode: 'closed' })}
onSave={handleEditorSave}
/>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
@@ -624,7 +593,7 @@ function CronJobDetail({
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
{isPaused ? c.resumeTitle : c.pauseTitle}
</PanelAction>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger} primary>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
{c.triggerNow}
</PanelAction>
</div>
@@ -758,57 +727,12 @@ function CronJobRuns({
)
}
// Label a cron delivery target: 'local' → localized "This desktop", known
// platforms → their delivery label, anything else → the backend name. Configured
// platforms without a cron home channel get a "set a home channel first" hint.
function deliverTargetLabel(target: CronDeliveryTarget, c: Translations['cron']): string {
const base = target.id === 'local' ? c.deliveryLabels.local : (c.deliveryLabels[target.id] ?? target.name)
return target.id !== 'local' && !target.home_target_set ? `${base} — ${c.deliverNeedsHomeChannel}` : base
}
// The delivery-target dropdown, shared by the manual cron editor and the
// blueprint form so both offer exactly the connected platforms (never a
// hardcoded list). While the targets load, keep the current value selectable.
function DeliverSelect({
c,
id,
onChange,
targets,
value
}: {
c: Translations['cron']
id: string
onChange: (next: string) => void
targets: CronDeliveryTarget[]
value: string
}) {
const options = targets.length > 0 ? targets : [{ home_env_var: null, home_target_set: true, id: value, name: value }]
return (
<Select onValueChange={onChange} value={value}>
<SelectTrigger className="h-9 rounded-md" id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map(target => (
<SelectItem key={target.id} value={target.id}>
{deliverTargetLabel(target, c)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function CronEditorDialog({
editor,
onBlueprintCreate,
onClose,
onSave
}: {
editor: EditorState
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>) => Promise<void>
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
@@ -827,47 +751,16 @@ function CronEditorDialog({
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
// Blueprint fills typed slots (time/enum/weekdays/text) instead of the raw
// cron fields; the backend renders the prompt + schedule from them.
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
// Create mode can start from a ready-made blueprint instead of a blank cron.
// CUSTOM_TEMPLATE (default) = the manual editor; any other value is a
// blueprint key that swaps the form for that blueprint's typed slots.
const [templateChoice, setTemplateChoice] = useState(CUSTOM_TEMPLATE)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// The blueprint catalog powers the create dialog's "Start from" dropdown; it's
// meaningless when editing an existing job, so skip the fetch there.
const blueprintsQuery = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints,
enabled: open && !isEdit
})
const blueprintList = blueprintsQuery.data ?? []
const blueprint =
templateChoice === CUSTOM_TEMPLATE ? null : (blueprintList.find(item => item.key === templateChoice) ?? null)
const isBlueprint = blueprint !== null
// Same catalog the chat model picker uses: configured providers and their
// actually-available models only. Script-only + blueprint forms never pick a
// model here, so skip the fetch entirely for them.
// actually-available models only. Script-only jobs never run an agent, so
// skip the fetch entirely for them.
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
queryFn: () => requestModelOptions({}),
enabled: open && !scriptOnlyJob && !isBlueprint
})
// Single source of truth for where a cron can deliver (local + configured
// gateways) — same endpoint the dashboard uses, so no dialog offers a platform
// that isn't connected. Shared by the manual editor and the blueprint form.
const deliveryTargets = useQuery({
queryKey: ['cron-delivery-targets'],
queryFn: getCronDeliveryTargets,
enabled: open
enabled: open && !scriptOnlyJob
})
useEffect(() => {
@@ -881,19 +774,10 @@ function CronEditorDialog({
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
setSlotValues({})
setTemplateChoice(CUSTOM_TEMPLATE)
setError(null)
setSaving(false)
}, [initial, open])
// Seed the typed slots with the blueprint's defaults whenever a blueprint is
// picked from "Start from" (and reset them when switching back to Custom).
useEffect(() => {
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setError(null)
}, [blueprint])
const selectedScheduleOption =
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
@@ -971,26 +855,6 @@ function CronEditorDialog({
}
}
async function handleBlueprintSubmit(event: React.FormEvent) {
event.preventDefault()
if (!blueprint) {
return
}
setSaving(true)
setError(null)
try {
await onBlueprintCreate(blueprint, slotValues)
} catch (err) {
// 422 carries the slot-level validation message; surface it inline.
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
@@ -999,203 +863,167 @@ function CronEditorDialog({
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
</DialogHeader>
{!isEdit && blueprintList.length > 0 && (
<Field htmlFor="cron-template" label={c.blueprints.startFrom}>
<Select onValueChange={setTemplateChoice} value={templateChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CUSTOM_TEMPLATE}>{c.blueprints.custom}</SelectItem>
{blueprintList.map(item => (
<SelectItem key={item.key} value={item.key}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
{blueprint?.description && <FieldHint>{blueprint.description}</FieldHint>}
<form className="grid gap-4" onSubmit={handleSubmit}>
{scriptOnlyJob && initial && (
<FieldHint>
{c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span>
</FieldHint>
)}
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder={c.namePlaceholder}
value={name}
/>
</Field>
)}
{isBlueprint && blueprint ? (
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
{blueprint.fields.map(field => {
const fieldId = `blueprint-${blueprint.key}-${field.name}`
const help = blueprintSlotHelp(field)
<Field htmlFor="cron-prompt" label={c.promptLabel} optional={scriptOnlyJob} optionalLabel={c.optional}>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder={c.promptPlaceholder}
value={prompt}
/>
</Field>
return (
<Field htmlFor={fieldId} key={field.name} label={field.label}>
{field.name === 'deliver' ? (
// Use the shared, backend-sourced delivery targets (same as the
// manual editor) rather than the blueprint's static field.options,
// so both dialogs offer exactly the connected platforms.
<DeliverSelect
c={c}
id={fieldId}
onChange={next => setSlotValues(prev => ({ ...prev, [field.name]: next }))}
targets={deliveryTargets.data ?? []}
value={slotValues[field.name] ?? DEFAULT_DELIVER}
/>
) : (
<BlueprintSlotControl
field={field}
id={fieldId}
onChange={next => setSlotValues(prev => ({ ...prev, [field.name]: next }))}
value={slotValues[field.name] ?? ''}
/>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{c.scheduleLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_VALUES.map(value => (
<SelectItem key={value} value={value}>
{c.deliveryLabels[value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
{!scriptOnlyJob && (
<Field htmlFor="cron-model" label={c.modelLabel} optional optionalLabel={c.optional}>
<Select onValueChange={setModelChoice} value={modelChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MODEL_DEFAULT_VALUE}>{c.modelDefault}</SelectItem>
{!modelChoiceKnown && (
<SelectItem className="font-mono" value={modelChoice}>
{modelChoice.slice(modelChoice.indexOf(':') + 1)}
</SelectItem>
)}
{help && <FieldHint>{help}</FieldHint>}
</Field>
)
})}
{modelProviders.map(provider => (
<SelectGroup key={provider.slug}>
<SelectLabel>{provider.name}</SelectLabel>
{(provider.models ?? []).map(model => (
<SelectItem
className="font-mono"
key={`${provider.slug}:${model}`}
value={`${provider.slug}:${model}`}
>
{model}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</Field>
)}
{error && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? c.blueprints.scheduling : c.blueprints.scheduleIt}
</Button>
</DialogFooter>
</form>
) : (
<form className="grid gap-4" onSubmit={handleSubmit}>
{scriptOnlyJob && initial && (
<FieldHint>
{c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span>
</FieldHint>
)}
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder={c.namePlaceholder}
value={name}
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder={c.customPlaceholder}
value={schedule}
/>
<FieldHint>{c.customHint}</FieldHint>
</Field>
<Field htmlFor="cron-prompt" label={c.promptLabel} optional={scriptOnlyJob} optionalLabel={c.optional}>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder={c.promptPlaceholder}
value={prompt}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{c.scheduleLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<DeliverSelect
c={c}
id="cron-deliver"
onChange={setDeliver}
targets={deliveryTargets.data ?? []}
value={deliver}
/>
</Field>
) : (
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="font-medium text-foreground">{scheduleHint}</span>
<span className="font-mono text-muted-foreground">{schedule}</span>
</div>
</div>
)}
{!scriptOnlyJob && (
<Field htmlFor="cron-model" label={c.modelLabel} optional optionalLabel={c.optional}>
<Select onValueChange={setModelChoice} value={modelChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MODEL_DEFAULT_VALUE}>{c.modelDefault}</SelectItem>
{!modelChoiceKnown && (
<SelectItem className="font-mono" value={modelChoice}>
{modelChoice.slice(modelChoice.indexOf(':') + 1)}
</SelectItem>
)}
{modelProviders.map(provider => (
<SelectGroup key={provider.slug}>
<SelectLabel>{provider.name}</SelectLabel>
{(provider.models ?? []).map(model => (
<SelectItem
className="font-mono"
key={`${provider.slug}:${model}`}
value={`${provider.slug}:${model}`}
>
{model}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</Field>
)}
{error && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder={c.customPlaceholder}
value={schedule}
/>
<FieldHint>{c.customHint}</FieldHint>
</Field>
) : (
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="font-medium text-foreground">{scheduleHint}</span>
<span className="font-mono text-muted-foreground">{schedule}</span>
</div>
</div>
)}
{error && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
</Button>
</DialogFooter>
</form>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
type EditorState = { job: CronJob; mode: 'edit' } | { mode: 'closed' } | { mode: 'create' }
function Field({
children,
htmlFor,
label,
optional,
optionalLabel
}: {
children: React.ReactNode
htmlFor: string
label: string
optional?: boolean
optionalLabel?: string
}) {
return (
<div className="grid gap-1.5">
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
{label}
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
</label>
{children}
</div>
)
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
}
type EditorState = { mode: 'closed' } | { mode: 'create' } | { job: CronJob; mode: 'edit' }
interface EditorValues {
deliver: string
@@ -4,9 +4,7 @@ import { TabDropdown } from '@/components/ui/tab-dropdown'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_MAX_W } from '../layout-constants'
import { OVERLAY_TOP_CLEARANCE } from './overlay-view'
import { PAGE_INSET_X, PAGE_MAX_W } from '../layout-constants'
// The wide rail and the narrow dropdown swap at exactly the width where
// OverlaySplitLayout drops to a single column, so the rail never stacks.
@@ -59,12 +57,10 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
// The left links sit beside (not under) the floating close button, so
// they ride up via the shorter shared OVERLAY_TOP_CLEARANCE (same line
// as a Panel header) instead of main's taller X-clearance. The bg still
// fills from the card's top edge, so there's no gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3',
OVERLAY_TOP_CLEARANCE,
// pt clears the in-card close button (the OverlayView now insets the
// whole card below the OS titlebar); the bg fills from the card's top
// edge so there's no surface-colored gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
className
)}
>
@@ -77,15 +73,11 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main
className={cn(
// Main sits UNDER the floating close button (top-right), so it keeps the
// taller top pad to clear the X — unlike the sidebar / Panel header,
// which sit to its left and ride up via OVERLAY_TOP_CLEARANCE. All four
// paddings are 1/3 tighter than the raw values (×2/3): the wide/narrow
// top clearance, the bottom gutter, and the horizontal clamp gutter
// (inlined from PAGE_INSET_X so only overlay panes tighten, not the
// shared page gutter). Narrow top drops toward the OverlayNav bar.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-2 pt-[calc((var(--titlebar-height)/2+1rem)*2/3)] max-[47.5rem]:pt-[calc(0.5rem*2/3)] px-[clamp(0.8333rem,2.6667vw,2.6667rem)]',
// Narrow: the OverlayNav dropdown bar already clears the titlebar, so
// drop the tall top pad to a normal gap below it.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)] max-[47.5rem]:pt-2',
PAGE_MAX_W,
PAGE_INSET_X,
className
)}
>
@@ -9,14 +9,6 @@ import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
// Shared top clearance for overlay content that sits *beside* the floating
// close button (which is absolute at `0.1875rem + titlebar/2`, -translate-y-1/2,
// so it costs no layout space): a Panel's header and the split layout's left
// sidebar links. They ride up next to the X on the same line across every
// overlay (settings, system, agents, cron, …) — change it here, not per-surface.
// Main content sits *under* the X (top-right) and keeps its own taller pad.
export const OVERLAY_TOP_CLEARANCE = 'pt-[calc(var(--titlebar-height)/2-0.4375rem)]'
interface OverlayViewProps {
children: ReactNode
onClose: () => void
+13 -19
View File
@@ -9,7 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
import { translateNow } from '@/i18n'
import { cn } from '@/lib/utils'
import { OVERLAY_TOP_CLEARANCE, OverlayView } from './overlay-view'
import { OverlayView } from './overlay-view'
// Overlay "panel" primitive — the centered, capped card + framed chrome lifted
// straight from the trace / agents overlay so every non-settings overlay (cron,
@@ -47,9 +47,13 @@ export function Panel({
return (
<OverlayView
closeLabel={closeLabel}
// Header title rides up next to the floating close button — see
// OVERLAY_TOP_CLEARANCE, the shared clearance every overlay column uses.
contentClassName={cn('flex h-full min-h-0 flex-col px-4 pb-4 sm:px-5', OVERLAY_TOP_CLEARANCE, contentClassName)}
// Top pad aligns the header title's center with the floating close button
// (which sits at 0.1875rem + titlebar/2, -translate-y-1/2). The X is
// absolute so it costs no layout space — the header rides up next to it.
contentClassName={cn(
'flex h-full min-h-0 flex-col px-4 pb-4 pt-[calc(var(--titlebar-height)/2-0.4375rem)] sm:px-5',
contentClassName
)}
onClose={onClose}
rootClassName={cn('flex h-full w-full flex-col', className)}
>
@@ -67,11 +71,7 @@ interface PanelHeaderProps {
export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
return (
// The overlay's close (X) is absolutely positioned at right-3 and costs no
// layout space, so header actions would otherwise slide right up against it.
// Reserve clearance (button footprint from the card edge + a small gap) on
// the right whenever actions are present.
<header className={cn('mb-3 flex shrink-0 items-start justify-between gap-3', actions ? 'pr-8' : undefined)}>
<header className="mb-3 flex shrink-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{subtitle ? <p className="truncate text-xs text-muted-foreground/80">{subtitle}</p> : null}
@@ -369,31 +369,25 @@ export function PanelAddButton({
)
}
// Visible action for a detail header (cron pause/resume/trigger, …). Ghost by
// default; `primary` promotes the header's main action to a filled button.
// Visible ghost action for a detail header (cron pause/resume/trigger, …).
export function PanelAction({
children,
disabled,
icon,
onClick,
primary
onClick
}: {
children: ReactNode
disabled?: boolean
icon: string
onClick: () => void
primary?: boolean
}) {
return (
<Button
className={cn(
'gap-1.5',
!primary && 'text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground'
)}
className="gap-1.5 text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground"
disabled={disabled}
onClick={onClick}
size="sm"
variant={primary ? 'default' : 'ghost'}
variant="ghost"
>
<Codicon name={icon} size="0.875rem" />
{children}
@@ -10,13 +10,13 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ProfileInfo } from '@/types/hermes'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -100,7 +100,10 @@ export function CreateProfileDialog({
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="new-profile-name" label={p.nameLabel}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
{p.nameLabel}
</label>
<Input
aria-invalid={invalid}
autoFocus
@@ -109,10 +112,15 @@ export function CreateProfileDialog({
placeholder="my-profile"
value={name}
/>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
<Field htmlFor="new-profile-clone-from" label={p.cloneFrom}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
{p.cloneFrom}
</label>
<Select
onValueChange={value => setCloneFrom(value === '__none__' ? null : value)}
value={cloneFrom ?? '__none__'}
@@ -129,10 +137,13 @@ export function CreateProfileDialog({
))}
</SelectContent>
</Select>
<FieldHint>{p.cloneFromDesc}</FieldHint>
</Field>
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
</div>
<Field htmlFor="new-profile-soul" label="SOUL.md" optional optionalLabel={p.soulOptional}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
SOUL.md <span className="font-normal text-muted-foreground">- {p.soulOptional}</span>
</label>
<Textarea
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
@@ -140,7 +151,7 @@ export function CreateProfileDialog({
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
value={soul}
/>
</Field>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -10,11 +10,11 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldHint } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName } from './create-profile-dialog'
@@ -93,8 +93,11 @@ export function RenameProfileDialog({
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<Field htmlFor="rename-profile-name" label={p.newNameLabel}>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
{p.newNameLabel}
</label>
<Input
aria-invalid={invalid}
autoFocus
@@ -102,8 +105,10 @@ export function RenameProfileDialog({
onChange={event => setName(event.target.value)}
value={name}
/>
<FieldHint error={invalid}>{p.nameHint}</FieldHint>
</Field>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{p.nameHint}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
-30
View File
@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import { NEW_CHAT_ROUTE, primaryRouteSelectedSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
const SESS_A = 'sess-a'
const SESS_B = 'sess-b'
describe('primaryRouteSelectedSessionId', () => {
it('prefers the routed session id over a stale/different store selection (#59305)', () => {
// The route already committed to B while the store selection hasn't
// caught up yet (still reads A) — the route wins.
expect(primaryRouteSelectedSessionId(sessionRoute(SESS_B), SESS_A)).toBe(SESS_B)
})
it('returns null on the new-chat route even with a leftover selection from the previous chat', () => {
expect(primaryRouteSelectedSessionId(NEW_CHAT_ROUTE, SESS_A)).toBeNull()
})
it('falls back to the store selection on a non-chat route (settings, overlays)', () => {
expect(primaryRouteSelectedSessionId(SETTINGS_ROUTE, SESS_A)).toBe(SESS_A)
})
it('falls back to the store selection when the route matches the same session', () => {
expect(primaryRouteSelectedSessionId(sessionRoute(SESS_A), SESS_A)).toBe(SESS_A)
})
it('returns null on a non-chat route with no store selection', () => {
expect(primaryRouteSelectedSessionId(SETTINGS_ROUTE, null)).toBeNull()
})
})
+1 -23
View File
@@ -9,7 +9,6 @@ export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SKILLS_ROUTE = '/skills'
export const MESSAGING_ROUTE = '/messaging'
export const WEBHOOKS_ROUTE = '/webhooks'
export const ARTIFACTS_ROUTE = '/artifacts'
export const CRON_ROUTE = '/cron'
export const PROFILES_ROUTE = '/profiles'
@@ -32,7 +31,6 @@ export type AppView =
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
export type AppRouteId =
| 'agents'
@@ -45,7 +43,6 @@ export type AppRouteId =
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
export interface AppRoute {
id: AppRouteId
@@ -59,7 +56,6 @@ export const APP_ROUTES = [
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' },
{ id: 'webhooks', path: WEBHOOKS_ROUTE, view: 'webhooks' },
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
{ id: 'cron', path: CRON_ROUTE, view: 'cron' },
{ id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' },
@@ -125,8 +121,7 @@ export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'cron',
'profiles',
'settings',
'starmap',
'webhooks'
'starmap'
])
export function isOverlayView(view: AppView): boolean {
@@ -147,23 +142,6 @@ export function routeSessionId(pathname: string): string | null {
return id && !id.includes('/') ? decodeURIComponent(id) : null
}
/**
* The primary composer's durable scope key candidate: the route is the source
* of truth for which chat is on screen, so prefer its (stable) stored session
* id over a store selection that can be momentarily null/stale mid-switch
* (#59305). A genuine new-chat route always wins with `null`, never falling
* back to a leftover selection from the chat just left. A non-chat route
* (settings, an overlay) has no session opinion, so the store selection passes
* through unchanged.
*/
export function primaryRouteSelectedSessionId(pathname: string, storeSelectedSessionId: string | null): string | null {
if (isNewChatRoute(pathname)) {
return null
}
return routeSessionId(pathname) ?? storeSelectedSessionId
}
export function sessionRoute(sessionId: string): string {
return `${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}`
}
@@ -122,77 +122,4 @@ describe('sessionContextDrift', () => {
expect(reason).toBe('route:__new__->sess-b')
})
it('does not drift when composerScope is omitted (non-composer submits: queue drain, steer)', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_A)),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A
})
expect(reason).toBeNull()
})
it('does not drift when composerScope matches the resolved (lineage) submit target', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_A)),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A,
composerScope: SESS_A,
submitTargetComposerScope: SESS_A
})
expect(reason).toBeNull()
})
it('drifts (composer prong) when the loaded composer scope disagrees with the resolved submit target (#59305)', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_A)),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A,
composerScope: SESS_B,
submitTargetComposerScope: SESS_A
})
expect(reason).toBe('composer:sess-b->sess-a')
})
it('checks the composer prong before the route/selection prongs', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_B)),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_B,
submitTargetStoredId: SESS_A,
composerScope: SESS_B,
submitTargetComposerScope: SESS_A
})
expect(reason).toBe('composer:sess-b->sess-a')
})
it('does not drift when the session has rotated via compression (composerScope is the lineage root, submitTargetStoredId is the live tip)', () => {
const ROOT_ID = 'stored-root'
const TIP_ID = 'stored-tip-after-compression'
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(TIP_ID)),
nowRouteToken: routeToken(sessionRoute(TIP_ID)),
startSelectedStoredId: TIP_ID,
nowSelectedStoredId: TIP_ID,
submitTargetStoredId: TIP_ID,
composerScope: ROOT_ID,
// What submit.ts actually passes: resolveComposerSessionKey(TIP_ID, sessions),
// which resolves to the lineage root for a session that has compressed.
submitTargetComposerScope: ROOT_ID
})
expect(reason).toBeNull()
})
})
@@ -37,25 +37,6 @@ interface SessionContextDriftArgs {
* a real chat as drift.
*/
submitTargetStoredId?: string | null
/**
* The composer scope that was actually loaded when the text was submitted
* (SubmitTextOptions.composerScope). The composer and the session-side refs
* live in separate React subtrees and can each be internally consistent yet
* still disagree with each other at the instant of send — this prong catches
* that cross-component drift (#59305). Omit for non-composer submits.
*/
composerScope?: string | null
/**
* resolveComposerSessionKey(submitTargetStoredId, sessions) — the durable
* lineage-root form of the submit target, in the SAME domain as
* composerScope. Compared against composerScope instead of the raw
* submitTargetStoredId: the composer keys drafts/attachments on the lineage
* root (stable across auto-compression tip rotation) while
* submitTargetStoredId tracks the live tip — comparing composerScope
* directly against the tip would false-positive-abort every submit into any
* session that has ever compressed.
*/
submitTargetComposerScope?: string | null
}
/**
@@ -75,21 +56,8 @@ export function sessionContextDrift({
nowRouteToken,
startSelectedStoredId,
nowSelectedStoredId,
submitTargetStoredId,
composerScope,
submitTargetComposerScope
submitTargetStoredId
}: SessionContextDriftArgs): string | null {
// Composer prong: the composer's loaded scope disagrees with the resolved
// submit target. Not a start/now comparison like the two prongs below — the
// composer only hands us one snapshot per submit — but it belongs in the
// same fail-closed gate since it's exactly the same "wrong session" failure
// mode. Compared against submitTargetComposerScope (lineage-pinned), NOT
// submitTargetStoredId (live tip) — see the field doc on
// SessionContextDriftArgs for why those two must not be conflated.
if (composerScope !== undefined && composerScope !== null && composerScope !== submitTargetComposerScope) {
return `composer:${composerScope}->${submitTargetComposerScope}`
}
const targetStart = routeTargetFromToken(startRouteToken)
const targetNow = routeTargetFromToken(nowRouteToken)
@@ -1450,8 +1450,7 @@ describe('usePromptActions restoreToMessage', () => {
{
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
truncate_before_user_ordinal: 0
},
1_800_000
)
@@ -1518,8 +1517,7 @@ describe('usePromptActions restoreToMessage', () => {
{
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
truncate_before_user_ordinal: 0
},
1_800_000
)
@@ -1564,8 +1562,7 @@ describe('usePromptActions restoreToMessage', () => {
{
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
truncate_before_user_ordinal: 0
},
1_800_000
)
@@ -2458,7 +2455,6 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setSessions(() => [])
})
it('aborts submit when the user switches sessions during session.resume (no misroute)', async () => {
@@ -2516,100 +2512,6 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
})
})
it('does not false-positive-abort when the session has rotated via compression (lineage root vs tip)', async () => {
// The composer keys drafts/attachments on the DURABLE lineage root
// (resolveComposerSessionKey / sessionPinId — survives auto-compression
// tip rotation), but selectedStoredSessionIdRef tracks the CURRENT TIP.
// For any session that has compressed at least once, root !== tip — if
// composerScope is compared against the raw tip, every legitimate submit
// into that session would look like drift.
const ROOT_ID = 'stored-root-original'
const TIP_ID = 'stored-tip-after-compression'
setSessions(() => [sessionInfo({ id: TIP_ID, _lineage_root_id: ROOT_ID })])
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={TIP_ID}
/>
)
// The composer's scope is the lineage root (what resolveComposerSessionKey
// actually returns for this session) — a legitimate, non-drifted submit.
const ok = await handle!.submitText('message into the rotated session', { composerScope: ROOT_ID })
expect(ok).toBe(true)
expect(calls.some(c => c.method === 'prompt.submit')).toBe(true)
})
it('aborts submit when the composer scope disagrees with the resolved target (#59305)', async () => {
// The composer (ChatBar) and the session-side refs live in separate React
// subtrees; each can be internally consistent yet still disagree with each
// other at the instant of send if the two updated on different commits.
// composerScope carries the composer's own snapshot of "what session was
// loaded" into submit.ts, which must refuse to send when it disagrees with
// the session the submit is actually about to target.
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_A}
/>
)
const ok = await handle!.submitText('typed while B was on screen', { composerScope: STORED_SESSION_B })
expect(ok).toBe(false)
expect(calls.some(c => c.method === 'prompt.submit')).toBe(false)
})
it('submits normally when the composer scope agrees with the resolved target', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_A}
/>
)
const ok = await handle!.submitText('typed while A was on screen', { composerScope: STORED_SESSION_A })
expect(ok).toBe(true)
expect(calls.some(c => c.method === 'prompt.submit')).toBe(true)
})
it('aborts recovery submit when the user switches sessions during timeout resume', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
@@ -53,8 +53,7 @@ import {
planEdit,
planReload,
planRestore,
runRewindSubmit,
truncateSubmitParams
runRewindSubmit
} from './rewind'
import { useSlashCommand } from './slash'
import { useSubmitPrompt } from './submit'
@@ -757,11 +756,7 @@ export function usePromptActions({
try {
await requestGateway(
'prompt.submit',
{
session_id: activeSessionId,
text: plan.text,
...truncateSubmitParams(plan.truncateOrdinal)
},
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import { truncateSubmitParams } from './rewind'
describe('truncateSubmitParams', () => {
it('omits truncation fields when no ordinal is set', () => {
expect(truncateSubmitParams(undefined)).toEqual({})
})
it('requires confirm_empty_truncate only for ordinal 0', () => {
expect(truncateSubmitParams(0)).toEqual({
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
})
expect(truncateSubmitParams(1)).toEqual({
truncate_before_user_ordinal: 1
})
})
})
@@ -25,23 +25,6 @@ import {
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
/**
* Build `prompt.submit` truncation params. Ordinal 0 truncates to an empty
* transcript (restore/regenerate the first user turn) — the gateway refuses
* that edge unless `confirm_empty_truncate` is set, so stale clients cannot
* silently wipe a session via a leftover ordinal.
*/
export function truncateSubmitParams(truncateOrdinal: number | undefined): Record<string, unknown> {
if (truncateOrdinal === undefined) {
return {}
}
return {
truncate_before_user_ordinal: truncateOrdinal,
...(truncateOrdinal === 0 ? { confirm_empty_truncate: true } : {})
}
}
/**
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
* (drops that user turn + everything after). Idle rewinds submit directly
@@ -70,7 +53,7 @@ export async function runRewindSubmit(
{
session_id: sessionId,
text,
...truncateSubmitParams(truncateOrdinal)
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
@@ -20,7 +20,7 @@ import {
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $sessions, resolveComposerSessionKey, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState } from '../../../types'
import { sessionContextDrift } from '../session-context-drift'
@@ -210,15 +210,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
nowRouteToken: getRouteToken(),
startSelectedStoredId: startingStoredSessionId,
nowSelectedStoredId: selectedStoredSessionIdRef.current,
submitTargetStoredId: startingStoredSessionId,
composerScope: options?.composerScope,
// The composer keys drafts/attachments on the durable lineage
// root (survives auto-compression tip rotation), while
// startingStoredSessionId is the live tip — resolve the target
// into the same lineage-root domain before comparing, or every
// submit into a session that has ever compressed would
// false-positive-abort.
submitTargetComposerScope: resolveComposerSessionKey(startingStoredSessionId, $sessions.get())
submitTargetStoredId: startingStoredSessionId
})
: null
@@ -359,14 +359,6 @@ export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targ
export interface SubmitTextOptions {
attachments?: ComposerAttachment[]
/** The composer scope key that was actually loaded when this text was
* submitted (see use-composer-draft's activeQueueSessionKeyRef). Compared
* against the resolved submit target in sessionContextDrift — a mismatch
* means the composer and the session-side refs disagreed about which
* session this send belongs to (#59305). Omit for non-composer submits
* (queue drain, steer, external submit requests): the check is a no-op
* without it. */
composerScope?: string | null
fromQueue?: boolean
/** Runtime session id to submit into. Queue drains pass this so a
* backgrounded/source session cannot be replaced by the current foreground
@@ -1102,12 +1102,8 @@ describe('branchStoredSession desktop source tagging', () => {
session_id: 'stored-parent'
} as never)
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.create') {
createParams = params
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
@@ -1124,71 +1120,9 @@ describe('branchStoredSession desktop source tagging', () => {
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
// The create itself must carry the owning profile: in app-global remote
// mode the soft gateway swap alone is not enough — an omitted profile
// lands the branch on the launch (default) profile's state.db.
expect(createParams).toMatchObject({ parent_session_id: 'stored-parent', profile: 'work' })
vi.mocked(getSession).mockReset()
})
it('creates the branch on the cached parent session profile', async () => {
setSessions([storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })])
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
expect(createParams).toMatchObject({ profile: 'work' })
})
it('omits profile for a profile-less parent so single-profile users are unchanged', async () => {
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(createParams).toBeDefined()
expect(createParams).not.toHaveProperty('profile')
})
})
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
@@ -1307,76 +1241,6 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})
it('preserves cached image attachments through an idle persisted transcript refresh', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const state = clientState('stored-A')
state.messages = [
{
id: 'cached-user',
role: 'user',
parts: [{ type: 'text', text: 'describe this image' }],
attachmentRefs: ['@image:/tmp/photo.png']
},
{
id: 'cached-assistant',
role: 'assistant',
parts: [{ type: 'text', text: 'It is a photo.' }]
}
]
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', state]])
}
const persistedMessages = [
{ content: 'describe this image', role: 'user', timestamp: 1 },
{ content: 'It is a photo.', role: 'assistant', timestamp: 2 }
]
vi.mocked(getSessionMessages).mockResolvedValue({
messages: persistedMessages,
session_id: 'stored-A'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
return {
session_id: 'rt-A',
session_key: 'stored-A',
resumed: 'stored-A',
message_count: persistedMessages.length,
messages: persistedMessages,
running: false,
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, next) => (resumedState = next)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
expect(requestGateway.mock.calls.map(([method]) => method)).toContain('session.activate')
expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined)
expect(resumedState?.messages[0]?.attachmentRefs).toEqual(['@image:/tmp/photo.png'])
})
it('repairs an idle warm cache from a divergent equal-length persisted transcript', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
@@ -82,7 +82,6 @@ import {
patchSessionWorkspace,
preserveLocalPendingTurnMessages,
reconcileResumeMessages,
resolveSessionProfile,
resolveStoredSession,
sessionMatchesStoredId,
sessionShouldHaveTranscript,
@@ -1057,30 +1056,15 @@ export function useSessionActions({
// `parentStoredId` so it nests under its parent, then open it as its own tab
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
const forkBranch = useCallback(
async (
branchMessages: BranchMessage[],
parentStoredId: null | string,
cwd?: string,
profile?: null | string
): Promise<boolean> => {
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
creatingSessionRef.current = true
try {
// A branch belongs to its parent's OWNING profile. Swapping the live
// gateway first AND passing `profile` on the create mirrors
// desktopSessionCreateParams/resumeSession: in app-global remote mode
// one backend serves every profile, so an omitted profile silently
// lands the branch on the launch (default) profile — the "session
// jumps between profiles after branching" bug. The swap also makes
// upsertOptimisticSession's $activeGatewayProfile stamp correct.
await ensureGatewayProfile(profile)
// No title: the backend auto-names the branch from its parent's lineage.
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
...(parentStoredId && { parent_session_id: parentStoredId })
})
@@ -1181,12 +1165,7 @@ export function useSessionActions({
clearNotifications()
// The open chat's owning profile, NOT the picker's / launch profile —
// /profile only retargets new chats, so a branch of an existing thread
// must stay on that thread's backend (cache hit for an open session).
const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile)
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim())
},
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
)
@@ -1218,7 +1197,7 @@ export function useSessionActions({
return false
}
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim())
} catch (err) {
notifyError(err, copy.branchFailed)
@@ -317,52 +317,6 @@ describe('reconcileResumeMessages', () => {
const [out] = reconcileResumeMessages(next, previous)
expect(out.parts.some(p => p.type === 'reasoning')).toBe(true)
})
it('preserves attachment refs for a matching user turn', () => {
const next = [msg('stored-user', 'user', 'describe this image')]
const previous = [
msg('live-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/photo.png']
})
]
const [out] = reconcileResumeMessages(next, previous)
expect(out.attachmentRefs).toEqual(['@image:/tmp/photo.png'])
})
it('does not overwrite attachment refs already present on the resumed message', () => {
const next = [
msg('stored-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/authoritative.png']
})
]
const previous = [
msg('live-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/cached.png']
})
]
const [out] = reconcileResumeMessages(next, previous)
expect(out.attachmentRefs).toEqual(['@image:/tmp/authoritative.png'])
})
it('does not preserve attachment refs when the user text differs', () => {
const next = [msg('stored-user', 'user', 'a different prompt')]
const previous = [
msg('live-user', 'user', 'describe this image', {
attachmentRefs: ['@image:/tmp/photo.png']
})
]
const [out] = reconcileResumeMessages(next, previous)
expect(out.attachmentRefs).toBeUndefined()
})
})
describe('preserveLocalPendingTurnMessages', () => {
@@ -486,91 +440,9 @@ describe('preserveLocalPendingTurnMessages', () => {
'user-optimistic'
])
})
// #70720: the gateway persists an attached image as a leading `@image:<path>`
// directive line, while the local optimistic composer keeps it as separate
// `attachmentRefs`. A naive text compare (chatMessageText a === b) therefore
// always mismatched whenever an image was attached and re-appended the
// optimistic row as a distinct, duplicate user bubble. Both sides must now
// reduce to the same visible text via textWithoutImageRefs.
it('does not duplicate the optimistic image turn when the persisted turn carries @image refs', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'what is in this photo?', {
attachmentRefs: ['@image:/tmp/cat.png']
})
]
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', '@image:/tmp/cat.png\nwhat is in this photo?')
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
it('still keeps a genuinely uncommitted optimistic image turn when the persisted text differs', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'a different caption', {
attachmentRefs: ['@image:/tmp/cat.png']
})
]
// Persisted turn has a different caption — the optimistic row is still
// uncommitted and must survive (image-aware compare must not over-correct).
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', '@image:/tmp/cat.png\nwhat is in this photo?')
]
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user-stored',
'2-assistant-stored',
'3-user-stored',
'user-optimistic'
])
})
})
describe('appendLiveSessionProjection', () => {
it('does not duplicate the inflight user when the persisted turn carries @image refs', () => {
// By the time a stored transcript reaches appendLiveSessionProjection it
// has already been run through toChatMessages, so the @image directive has
// been lifted into attachmentRefs and the visible text is the bare caption.
const stored = [
msg('stored-user', 'user', 'current running prompt', {
attachmentRefs: ['@image:/tmp/cat.png']
}),
msg('stored-assistant', 'assistant', 'earlier answer')
]
const restored = appendLiveSessionProjection(stored, {
session_id: 'runtime-1',
inflight: {
user: 'current running prompt',
assistant: 'partial answer',
streaming: true
}
})
// The persisted user already carries the same visible text (the attachment
// lives in attachmentRefs on both sides), so the inflight *user* projection
// must be suppressed — exactly one user row, no duplicated bubble stacked
// on top of the persisted one. The live assistant tail is still projected.
const userRows = restored.filter(message => message.role === 'user')
expect(userRows).toHaveLength(1)
expect(userRows[0].id).toBe('stored-user')
const userText = userRows[0].parts
.map(part => ('text' in part ? part.text : ''))
.join('')
expect(userText).toBe('current running prompt')
})
it('restores the running turn and accepted queued prompt after a renderer restart', () => {
const stored = [msg('stored-user', 'user', 'earlier'), msg('stored-assistant', 'assistant', 'earlier answer')]
@@ -1,7 +1,7 @@
import { getSession } from '@/hermes'
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages, textWithoutImageRefs } from '@/lib/embedded-images'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
@@ -211,10 +211,6 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
if (nextText === previousVisibleText || nextText === previousText.trim()) {
preserved = preserveReasoningParts(preserved, previous)
if (message.role === 'user' && preserved.attachmentRefs === undefined && previous.attachmentRefs?.length) {
preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] }
}
}
const previousImages = embeddedImageUrls(previousText)
@@ -310,7 +306,7 @@ export function preserveLocalPendingTurnMessages(
if (
isOptimisticUser &&
latestAuthoritativeUser &&
textWithoutImageRefs(chatMessageText(latestAuthoritativeUser)) === textWithoutImageRefs(chatMessageText(message))
chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()
) {
continue
}
@@ -322,7 +318,7 @@ export function preserveLocalPendingTurnMessages(
continue
}
if (textWithoutImageRefs(chatMessageText(authoritative)) === textWithoutImageRefs(chatMessageText(message))) {
if (chatMessageText(authoritative).trim() === chatMessageText(message).trim()) {
continue
}
}
@@ -363,8 +359,7 @@ export function appendLiveSessionProjection(
// Only suppress the projection when the latest authoritative user row is the
// same turn — older identical prompts must not hide a newly accepted repeat.
const latestUser = [...messages].reverse().find(message => message.role === 'user')
const inflightUserAlreadyPersisted =
latestUser && textWithoutImageRefs(chatMessageText(latestUser)) === textWithoutImageRefs(inflightUser)
const inflightUserAlreadyPersisted = latestUser && chatMessageText(latestUser).trim() === inflightUser
if (inflightUser && !inflightUserAlreadyPersisted) {
projected.push({
@@ -1,5 +1,5 @@
import { act, cleanup, render } from '@testing-library/react'
import { type MutableRefObject, useLayoutEffect } from 'react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
@@ -260,111 +260,6 @@ describe('useSessionStateCache — per-session turn timer', () => {
})
})
interface LayoutProbeHarnessProps {
activeSessionId: string | null
onLayoutSnapshot: (snapshot: { active: string | null; selected: string | null }) => void
onReady: (cache: Cache) => void
selectedStoredSessionId: string | null
}
function LayoutProbeHarness({
activeSessionId,
onLayoutSnapshot,
onReady,
selectedStoredSessionId
}: LayoutProbeHarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
setMessages: () => undefined
})
onReady(cache)
// useLayoutEffect fires synchronously right after the DOM commit, BEFORE
// the hook's own useEffect (a passive effect) has a chance to mirror the
// new props into activeSessionIdRef/selectedStoredSessionIdRef. Anything
// that reads the refs in this window — including a synchronous DOM event
// handler firing against the just-committed view — observes the outgoing
// session's ids.
useLayoutEffect(() => {
onLayoutSnapshot({
active: cache.activeSessionIdRef.current,
selected: cache.selectedStoredSessionIdRef.current
})
})
return null
}
describe('useSessionStateCache — refs stay coherent with the committed session on switch (#59305)', () => {
afterEach(() => cleanup())
it('reflects the new session ids from the layout phase right after switching to a new session', () => {
let cache!: Cache
const snapshots: Array<{ active: string | null; selected: string | null }> = []
const { rerender } = render(
<LayoutProbeHarness
activeSessionId="runtime-A"
onLayoutSnapshot={s => snapshots.push(s)}
onReady={c => (cache = c)}
selectedStoredSessionId="stored-A"
/>
)
void cache
snapshots.length = 0 // drop the initial-mount snapshot; only the switch matters
rerender(
<LayoutProbeHarness
activeSessionId="runtime-B"
onLayoutSnapshot={s => snapshots.push(s)}
onReady={c => (cache = c)}
selectedStoredSessionId="stored-B"
/>
)
// The refs must already reflect B by the layout phase — a callback firing
// in this window must never observe the outgoing session's ids.
expect(snapshots[0]).toEqual({ active: 'runtime-B', selected: 'stored-B' })
})
it('does not clobber an imperative ref pin on a re-render that leaves the props unchanged (#54527-class)', () => {
// submit.ts pins activeSessionIdRef.current to a freshly resumed runtime id
// WITHOUT updating the source atom that feeds the activeSessionId prop (by
// design — see submit.ts's "pin the foreground session context" comment).
// The prop-mirroring here must only fire when the prop itself changes; an
// unconditional resync would silently undo that pin on the next incidental
// render (wiring.tsx re-renders constantly during an active turn).
let cache!: Cache
const { rerender } = render(
<Harness activeSessionId="runtime-A" onReady={c => (cache = c)} selectedStoredSessionId="stored-A" />
)
// Simulate submit.ts's imperative pin: a resume swapped in a new runtime
// id without touching the prop.
cache.activeSessionIdRef.current = 'runtime-resumed'
// A re-render with the SAME props (e.g. an unrelated $busy/$messages
// change elsewhere in the tree) must not touch the pinned ref.
rerender(<Harness activeSessionId="runtime-A" onReady={c => (cache = c)} selectedStoredSessionId="stored-A" />)
expect(cache.activeSessionIdRef.current).toBe('runtime-resumed')
// A genuine prop change (a real navigation/selection move) still wins.
rerender(<Harness activeSessionId="runtime-B" onReady={c => (cache = c)} selectedStoredSessionId="stored-B" />)
expect(cache.activeSessionIdRef.current).toBe('runtime-B')
})
})
function userMessage(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
}
@@ -51,33 +51,8 @@ export function useSessionStateCache({
setMessages
}: SessionStateCacheOptions) {
const busy = useStore($busy)
const activeSessionIdRef = useRef<string | null>(activeSessionId)
const selectedStoredSessionIdRef = useRef<string | null>(selectedStoredSessionId)
// Mirror the latest prop into its ref synchronously during render — not via
// a passive useEffect, which only fires a frame after paint and left the
// ref pointing at the outgoing session for one commit (#59305). Guarded to
// fire only when the PROP itself changed since the last render (the same
// condition a `useEffect(..., [activeSessionId])` dependency array already
// enforced) rather than unconditionally: submit.ts and use-session-actions
// pin these refs imperatively mid-flight (e.g. to a just-resumed runtime id)
// without updating the source atom in lockstep, and wiring.tsx re-renders
// constantly during an active turn — an unconditional resync would silently
// clobber that pin on the next incidental render (#54527-class regression).
const activeSessionIdPropRef = useRef(activeSessionId)
if (activeSessionIdPropRef.current !== activeSessionId) {
activeSessionIdPropRef.current = activeSessionId
activeSessionIdRef.current = activeSessionId
}
const selectedStoredSessionIdPropRef = useRef(selectedStoredSessionId)
if (selectedStoredSessionIdPropRef.current !== selectedStoredSessionId) {
selectedStoredSessionIdPropRef.current = selectedStoredSessionId
selectedStoredSessionIdRef.current = selectedStoredSessionId
}
const activeSessionIdRef = useRef<string | null>(null)
const selectedStoredSessionIdRef = useRef<string | null>(null)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
@@ -86,10 +61,18 @@ export function useSessionStateCache({
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
useEffect(() => {
activeSessionIdRef.current = activeSessionId
}, [activeSessionId])
useEffect(() => {
setMutableRef(busyRef, busy)
}, [busy, busyRef])
useEffect(() => {
selectedStoredSessionIdRef.current = selectedStoredSessionId
}, [selectedStoredSessionId])
const ensureSessionState = useCallback((sessionId: string, storedSessionId?: string | null) => {
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
+1 -1
View File
@@ -288,7 +288,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />
<OverlayMain className="px-0 pb-0">
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
@@ -1,15 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import {
deleteSession,
getHermesConfigRecord,
listAllProfileSessions,
saveHermesConfig,
setSessionArchived
} from '@/hermes'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -17,13 +10,11 @@ import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { untombstoneSessions } from '@/store/projects'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { HermesConfigRecord, SessionInfo } from '@/types/hermes'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const DEFAULT_AUTO_ARCHIVE_DAYS = 3
const ARCHIVED_FETCH_LIMIT = 200
function workspaceLabel(cwd: null | string | undefined): string {
@@ -123,8 +114,6 @@ export function SessionsSettings() {
<SettingsContent>
<DefaultProjectDirSetting />
<AutoArchiveSetting />
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
@@ -185,112 +174,6 @@ export function SessionsSettings() {
)
}
// Opt-in retention: soft-hide chats untouched for N days. The policy itself
// (last-activity sweep, pin exemption) lives in the backend
// (sessions.auto_archive in config.yaml + SessionDB.maybe_auto_archive); this
// just toggles the config keys, so CLI / gateway / Desktop all honour one
// setting. Pins are exempt on the backend, so pinned chats survive regardless.
function AutoArchiveSetting() {
const { t } = useI18n()
const s = t.settings.sessions
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [enabled, setEnabled] = useState(false)
const [days, setDays] = useState(DEFAULT_AUTO_ARCHIVE_DAYS)
useEffect(() => {
// Config REST is only reachable through the Electron bridge; skip in
// non-Electron contexts (tests/storybook) rather than throwing.
if (!window.hermesDesktop) {
return
}
let alive = true
void getHermesConfigRecord()
.then(record => {
if (!alive) {
return
}
const sessions = (record.sessions ?? {}) as Record<string, unknown>
const parsedDays = Number(sessions.auto_archive_days)
setConfig(record)
setEnabled(Boolean(sessions.auto_archive))
setDays(Number.isFinite(parsedDays) && parsedDays > 0 ? Math.round(parsedDays) : DEFAULT_AUTO_ARCHIVE_DAYS)
})
.catch(() => {
// Leave the control unmounted if config can't be read.
})
return () => {
alive = false
}
}, [])
const persist = useCallback(
async (autoArchive: boolean, archiveDays: number) => {
if (!config) {
return
}
const sessions = {
...((config.sessions ?? {}) as Record<string, unknown>),
auto_archive: autoArchive,
auto_archive_days: archiveDays
}
const updated = { ...config, sessions }
setConfig(updated)
try {
await saveHermesConfig(updated)
} catch (err) {
notifyError(err, s.autoArchiveFailed)
}
},
[config, s.autoArchiveFailed]
)
if (!config) {
return null
}
return (
<div className="mb-6">
<ToggleRow
checked={enabled}
description={s.autoArchiveDesc}
label={s.autoArchiveTitle}
onChange={on => {
setEnabled(on)
void persist(on, days)
}}
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={s.autoArchiveDaysLabel}
className="w-20"
min={1}
onBlur={() => void persist(true, days)}
onChange={e => setDays(Math.max(1, Math.round(Number(e.target.value) || 1)))}
type="number"
value={days}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{s.autoArchiveDaysUnit}
</span>
</div>
}
title={s.autoArchiveDaysLabel}
/>
)}
</div>
)
}
// Lets the user pin the default cwd for new sessions. Without this, packaged
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
// / Program Files), which buried any files Hermes wrote there.
@@ -24,7 +24,6 @@ export function useOverlayRouting() {
const starmapOpen = currentView === 'starmap'
const cronOpen = currentView === 'cron'
const profilesOpen = currentView === 'profiles'
const webhooksOpen = currentView === 'webhooks'
const chatOpen = currentView === 'chat'
const overlayOpen = isOverlayView(currentView)
@@ -83,7 +82,6 @@ export function useOverlayRouting() {
resetOverlayReturnRoute,
settingsOpen,
starmapOpen,
toggleCommandCenter,
webhooksOpen
toggleCommandCenter
}
}
@@ -9,7 +9,7 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@@ -43,7 +43,7 @@ import {
} from '@/store/updates'
import type { StatusResponse, UsageStats } from '@/types/hermes'
import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes'
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
@@ -420,14 +420,6 @@ export function useStatusbarItems({
title: copy.openCron,
to: CRON_ROUTE,
variant: 'action'
},
{
icon: <Globe className="size-3" />,
id: 'webhooks',
label: copy.webhooks,
title: copy.openWebhooks,
to: WEBHOOKS_ROUTE,
variant: 'action'
}
],
[
-614
View File
@@ -1,614 +0,0 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
createWebhook,
deleteWebhook,
enableWebhooks,
getWebhooks,
setWebhookEnabled,
type WebhookRoute,
type WebhooksResponse
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Globe, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profileScope } from '@/store/profile'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import {
Panel,
PanelAddButton,
PanelBlock,
PanelBody,
PanelDetail,
PanelEmpty,
PanelHeader,
PanelList,
PanelListRow,
PanelMeta,
PanelPill,
PanelRowMenu,
PanelSectionLabel
} from '../overlays/panel'
import { ListRow } from '../settings/primitives'
const DELIVER_OPTIONS: readonly string[] = ['log', 'telegram', 'discord', 'slack', 'email', 'github_comment']
interface CreatedWebhook {
secret: string
url: string
}
// One affordance for "value + CopyButton": flat, token-backed (no border, no
// raw literals). DESIGN.md Principle 1 (flat, not boxed) + 4 (tokens, not
// literals). Reused by the detail URL row and the create-result URL/secret so
// there is a single copyable-value chrome in this file.
function CopyValueRow({ copyLabel, mono = true, value }: { copyLabel: string; mono?: boolean; value: string }) {
return (
<div className="flex items-center gap-1 rounded bg-foreground/5 px-2.5 py-1.5 text-[0.7rem]">
<span className={cn('min-w-0 flex-1 truncate text-foreground/80', mono && 'font-mono')}>{value}</span>
<CopyButton appearance="icon" buttonSize="icon-sm" label={copyLabel} text={value} />
</div>
)
}
interface WebhooksViewProps {
onClose: () => void
}
export function WebhooksView({ onClose }: WebhooksViewProps) {
const { t } = useI18n()
const w = t.webhooks
// Re-load when the active profile changes so REST routes to the right backend.
const profileScope = useStore($profileScope)
const queryClient = useQueryClient()
const queryKey = useMemo(() => ['webhooks', profileScope] as const, [profileScope])
const [query, setQuery] = useState('')
const [enabling, setEnabling] = useState(false)
const [restartNeeded, setRestartNeeded] = useState(false)
const [restartError, setRestartError] = useState<null | string>(null)
const [restarting, setRestarting] = useState(false)
// Master/detail: the subscription whose config fills the right pane.
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [events, setEvents] = useState('')
const [deliver, setDeliver] = useState('log')
const [deliverOnly, setDeliverOnly] = useState(false)
const [prompt, setPrompt] = useState('')
const [skills, setSkills] = useState('')
const [creating, setCreating] = useState(false)
const [created, setCreated] = useState<CreatedWebhook | null>(null)
const [pendingDelete, setPendingDelete] = useState<null | string>(null)
const {
data,
error,
isPending: loading,
refetch
} = useQuery({
queryKey,
queryFn: getWebhooks
})
// React Query v5 dropped useQuery onError; surface a load failure toast once
// per error object instead.
useEffect(() => {
if (error) {
notifyError(error, w.loadFailed)
}
}, [error, w.loadFailed])
const enabled = data?.enabled ?? false
const subscriptions = useMemo(() => data?.subscriptions ?? [], [data])
// Pull fresh backend truth into the cache. `silent` swallows the error toast
// for post-mutation reconciles (the mutation already reported success/failure).
const reload = useCallback(
async (silent = false) => {
try {
await queryClient.invalidateQueries({ queryKey })
} catch (err) {
if (!silent) {
notifyError(err, w.loadFailed)
}
}
},
[queryClient, queryKey, w.loadFailed]
)
useRefreshHotkey(() => void refetch())
const restartGatewayNow = useCallback(async () => {
setRestarting(true)
try {
await runGatewayRestart()
setRestartNeeded(false)
setRestartError(null)
// Give the receiver a moment to bind before re-reading state.
window.setTimeout(() => void reload(true), 4000)
} catch (err) {
setRestartNeeded(true)
setRestartError(String(err))
notifyError(err, w.restartFailed(''))
} finally {
setRestarting(false)
}
}, [reload, w])
const handleEnable = useCallback(async () => {
setEnabling(true)
setRestartNeeded(false)
setRestartError(null)
try {
const result = await enableWebhooks()
await reload(true)
if (result.restart_started) {
notify({ kind: 'success', message: w.enabledRestarting })
window.setTimeout(() => void reload(true), 4000)
} else {
const detail = result.restart_error ? `: ${result.restart_error}` : '.'
setRestartNeeded(true)
setRestartError(w.restartFailed(detail))
notify({ kind: 'error', message: w.restartFailed(detail) })
}
} catch (err) {
notifyError(err, w.restartFailed(''))
} finally {
setEnabling(false)
}
}, [reload, w])
const resetForm = useCallback(() => {
setName('')
setDescription('')
setEvents('')
setDeliver('log')
setDeliverOnly(false)
setPrompt('')
setSkills('')
}, [])
const closeCreate = useCallback(() => {
if (creating) {
return
}
setCreateOpen(false)
setCreated(null)
}, [creating])
const handleCreate = useCallback(async () => {
if (!name.trim()) {
notify({ kind: 'error', message: w.nameRequired })
return
}
setCreating(true)
try {
const eventsList = events
.split(',')
.map(e => e.trim())
.filter(Boolean)
const skillsList = skills
.split(',')
.map(s => s.trim())
.filter(Boolean)
const res = await createWebhook({
deliver,
deliver_only: deliverOnly,
description: description.trim() || undefined,
events: eventsList.length ? eventsList : undefined,
name: name.trim(),
prompt: prompt.trim() || undefined,
skills: skillsList.length ? skillsList : undefined
})
notify({ kind: 'success', message: w.created })
setCreated({ secret: res.secret, url: res.url })
resetForm()
void reload(true)
} catch (err) {
notifyError(err, w.createFailed(''))
} finally {
setCreating(false)
}
}, [deliver, deliverOnly, description, events, name, prompt, reload, resetForm, skills, w])
const handleToggle = useCallback(
async (subName: string, nextEnabled: boolean) => {
// Optimistic cache paint; the invalidate below lets backend truth win.
queryClient.setQueryData<WebhooksResponse>(queryKey, current =>
current
? {
...current,
subscriptions: current.subscriptions.map(s => (s.name === subName ? { ...s, enabled: nextEnabled } : s))
}
: current
)
try {
await setWebhookEnabled(subName, nextEnabled)
notify({ kind: 'success', message: nextEnabled ? w.enabled(subName) : w.disabled(subName) })
void reload(true)
} catch (err) {
await reload(true)
notifyError(err, w.toggleFailed(subName))
}
},
[queryClient, queryKey, reload, w]
)
// ConfirmDialog owns the pending→done→close beat; throw to surface its inline
// error and keep the dialog open. Success toast matches the cron delete idiom
// (title + name).
const handleDelete = useCallback(async () => {
if (!pendingDelete) {
return
}
try {
await deleteWebhook(pendingDelete)
notify({ kind: 'success', title: w.deleted, message: pendingDelete })
void reload(true)
} catch (err) {
notifyError(err, w.deleteFailed(pendingDelete))
throw err
}
}, [pendingDelete, reload, w])
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) {
return subscriptions
}
return subscriptions.filter(s =>
[s.name, s.description, s.deliver, ...s.events].filter(Boolean).some(v => v.toLowerCase().includes(q))
)
}, [query, subscriptions])
// Detail always reflects a concrete sub: the explicitly selected one, else the
// first visible row, so the right pane is never empty while subs exist.
const selectedSub = useMemo(
() => visible.find(s => s.name === selectedName) ?? visible[0] ?? null,
[visible, selectedName]
)
const banners = (
<>
{!enabled && (
<Alert className="mb-4" variant="warning">
<Globe />
<AlertTitle>{w.disabledTitle}</AlertTitle>
<AlertDescription>
<p>{w.disabledBody}</p>
<Button className="mt-1" disabled={enabling} onClick={() => void handleEnable()} size="sm">
<Globe />
{enabling ? w.enabling : w.enable}
</Button>
</AlertDescription>
</Alert>
)}
{restartNeeded && (
<Alert className="mb-4" variant="warning">
<AlertTriangle />
<AlertDescription>
<p>{restartError ?? w.restartNeeded}</p>
<Button
className="mt-1"
disabled={restarting}
onClick={() => void restartGatewayNow()}
size="sm"
variant="secondary"
>
<RefreshCw />
{restarting ? w.restartingGateway : w.restartGateway}
</Button>
</AlertDescription>
</Alert>
)}
</>
)
return (
<Panel onClose={onClose}>
{loading ? (
<PageLoader label={w.loading} />
) : subscriptions.length === 0 ? (
<>
{banners}
<PanelEmpty
action={
<Button
disabled={!enabled || enabling}
onClick={() => {
setCreated(null)
setCreateOpen(true)
}}
size="sm"
>
<Plus />
{w.newSubscription}
</Button>
}
description={w.empty}
icon="globe"
/>
</>
) : (
<>
<PanelHeader subtitle={w.hint} title={w.subscriptions(subscriptions.length)} />
{banners}
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchLabel={w.search}
searchPlaceholder={w.search}
searchValue={query}
>
{visible.map(sub => (
<PanelListRow
active={selectedSub?.name === sub.name}
dotClassName={sub.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/50'}
key={sub.name}
menu={
<PanelRowMenu
items={[
{
icon: sub.enabled ? 'circle-slash' : 'check',
label: sub.enabled ? w.disableRow : w.enableRow,
onSelect: () => void handleToggle(sub.name, !sub.enabled)
},
{ icon: 'trash', label: w.delete, onSelect: () => setPendingDelete(sub.name), tone: 'danger' }
]}
/>
}
onSelect={() => setSelectedName(sub.name)}
title={sub.name}
/>
))}
{visible.length === 0 && <p className="px-2 py-4 text-center text-xs text-muted-foreground">{w.empty}</p>}
<PanelAddButton
label={w.newSubscription}
onClick={() => {
setCreated(null)
setCreateOpen(true)
}}
/>
</PanelList>
{selectedSub ? <WebhookDetail sub={selectedSub} /> : <PanelEmpty description={w.empty} icon="search" />}
</PanelBody>
</>
)}
{/* Create subscription dialog */}
<Dialog onOpenChange={open => !open && closeCreate()} open={createOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{created ? w.createdTitle : w.newSubscription}</DialogTitle>
{created && <DialogDescription>{w.createdSecretHint}</DialogDescription>}
</DialogHeader>
{created ? (
<div className="grid gap-2">
<ListRow action={<CopyValueRow copyLabel={w.copy} value={created.url} />} title={w.webhookUrl} wide />
<ListRow action={<CopyValueRow copyLabel={w.copy} value={created.secret} />} title={w.secretOnce} wide />
<DialogFooter>
<Button onClick={closeCreate} size="sm">
{w.done}
</Button>
</DialogFooter>
</div>
) : (
<form
className="grid gap-4"
onSubmit={e => {
e.preventDefault()
void handleCreate()
}}
>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-name" label={w.fieldName}>
<Input
autoFocus
id="webhook-name"
onChange={e => setName(e.target.value)}
placeholder={w.fieldNamePlaceholder}
value={name}
/>
</Field>
<Field htmlFor="webhook-description" label={w.fieldDescription}>
<Input
id="webhook-description"
onChange={e => setDescription(e.target.value)}
placeholder={w.fieldDescriptionPlaceholder}
value={description}
/>
</Field>
</div>
<Field htmlFor="webhook-prompt" label={w.fieldPrompt}>
<Textarea
className="min-h-24"
id="webhook-prompt"
onChange={e => setPrompt(e.target.value)}
placeholder={w.fieldPromptPlaceholder}
value={prompt}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-events" label={w.fieldEvents}>
<Input
id="webhook-events"
onChange={e => setEvents(e.target.value)}
placeholder={w.fieldEventsPlaceholder}
value={events}
/>
</Field>
<Field htmlFor="webhook-skills" label={w.fieldSkills}>
<Input
id="webhook-skills"
onChange={e => setSkills(e.target.value)}
placeholder={w.fieldSkillsPlaceholder}
value={skills}
/>
</Field>
</div>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="webhook-deliver" label={w.fieldDeliver}>
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVER_OPTIONS.map(opt => (
<SelectItem key={opt} value={opt}>
{w.deliverOptions[opt] ?? opt}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="webhook-deliver-only" label={w.fieldDeliverOnly}>
<div className="flex h-9 items-center">
<Switch checked={deliverOnly} id="webhook-deliver-only" onCheckedChange={setDeliverOnly} />
</div>
</Field>
</div>
<DialogFooter>
<Button disabled={creating} size="sm" type="submit">
{creating ? w.creating : w.create}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
busyLabel={w.deleting}
cancelLabel={t.common.cancel}
confirmLabel={w.delete}
description={
pendingDelete ? (
<>
{w.deleteDescPrefix}
<span className="font-medium text-foreground">{pendingDelete}</span>
{w.deleteDescSuffix}
</>
) : null
}
destructive
onClose={() => setPendingDelete(null)}
onConfirm={handleDelete}
open={pendingDelete !== null}
title={w.deleteTitle}
/>
</Panel>
)
}
function WebhookDetail({ sub }: { sub: WebhookRoute }) {
const { t } = useI18n()
const w = t.webhooks
return (
<PanelDetail>
<header className="space-y-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{sub.name}</h3>
{sub.deliver_only && <PanelPill tone="warn">{w.deliverOnly}</PanelPill>}
</div>
<PanelMeta
rows={[
{ label: w.fieldDeliver, value: w.deliverOptions[sub.deliver] ?? sub.deliver },
{
label: w.fieldEvents,
value:
sub.events.length === 0 ? (
w.all
) : (
<span className="flex flex-wrap gap-1">
{sub.events.map(evt => (
<PanelPill key={evt}>{evt}</PanelPill>
))}
</span>
)
},
...(sub.skills.length > 0
? [
{
label: w.fieldSkills,
value: (
<span className="flex flex-wrap gap-1">
{sub.skills.map(skill => (
<PanelPill key={skill}>{skill}</PanelPill>
))}
</span>
)
}
]
: [])
]}
/>
<CopyValueRow copyLabel={w.copy} value={sub.url} />
</header>
{sub.description ? (
<div className="space-y-1.5">
<PanelSectionLabel>{w.fieldDescription}</PanelSectionLabel>
<p className="text-xs leading-relaxed text-foreground/80">{sub.description}</p>
</div>
) : null}
{sub.prompt ? (
<div className="space-y-1.5">
<PanelSectionLabel>{w.fieldPrompt}</PanelSectionLabel>
<PanelBlock>{sub.prompt}</PanelBlock>
</div>
) : null}
</PanelDetail>
)
}
@@ -351,32 +351,19 @@ export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
// `@image:<path>` directives render as a block-level thumbnail row (like
// embedded base64 images below), not inline mid-text — otherwise a large
// thumbnail gets wedged between words and breaks the text's line flow.
const imageSegments = useMemo(
() =>
segments.filter(
(segment): segment is Extract<Unstable_DirectiveSegment, { kind: 'mention' }> =>
segment.kind === 'mention' && segment.type === 'image'
),
[segments]
)
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">
{segments.map((segment, index) =>
segment.kind === 'text' ? (
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
) : segment.type === 'image' ? null : (
) : segment.type === 'image' ? (
<DirectiveImage id={segment.id} key={`img-${index}-${segment.id}`} label={segment.label} />
) : (
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
)
)}
{(imageSegments.length > 0 || images.length > 0) && (
{images.length > 0 && (
<span className="mt-2 flex flex-wrap gap-2" data-slot="aui_embedded-images">
{imageSegments.map((segment, index) => (
<DirectiveImage id={segment.id} key={`img-ref-${index}-${segment.id}`} label={segment.label} />
))}
{images.map((src, index) => (
<ZoomableImage
alt=""
@@ -443,7 +430,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
return (
<ZoomableImage
alt={label}
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
className="max-h-32 max-w-48 rounded-md border border-border/40 object-contain"
draggable={false}
slot="aui_directive-image"
src={src}
@@ -1,5 +1,4 @@
import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out
// of the .tsx so they can be unit-tested without a React/jsdom render (the
@@ -137,7 +136,18 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
isPassword: boolean
providerLabel: string
} {
return deriveRemoteAuthProviderShape(providers)
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: 'your identity provider' }
}
const isPassword = list.every(p => Boolean(p.supportsPassword))
const providerLabel =
list.length === 1 ? list[0].displayName || list[0].name : list.map(p => p.displayName || p.name).join(' / ')
return { isPassword, providerLabel }
}
// Button copy for the remote sign-in action.
@@ -1,499 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopBootstrapEvent, DesktopBootstrapState, DesktopConnectionProbeResult } from '@/global'
import { DesktopInstallOverlay } from './desktop-install-overlay'
function bootstrapState(overrides: Partial<DesktopBootstrapState> = {}): DesktopBootstrapState {
return {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null,
...overrides
}
}
function installDesktopMock(state: DesktopBootstrapState) {
const bootstrapListeners = new Set<(event: DesktopBootstrapEvent) => void>()
const desktop = {
getBootstrapState: vi.fn().mockResolvedValue(state),
onBootstrapEvent: vi.fn((listener: (event: DesktopBootstrapEvent) => void) => {
bootstrapListeners.add(listener)
return () => bootstrapListeners.delete(listener)
}),
continueBootstrapLocal: vi.fn().mockResolvedValue({ ok: true }),
probeConnectionConfig: vi.fn(),
testConnectionConfig: vi.fn(),
applyConnectionConfig: vi.fn(),
oauthLoginConnectionConfig: vi.fn(),
openExternal: vi.fn(),
emitBootstrapEvent: (event: DesktopBootstrapEvent) => {
for (const listener of bootstrapListeners) {
listener(event)
}
}
}
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: desktop
})
return desktop
}
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
Reflect.deleteProperty(window, 'hermesDesktop')
})
describe('DesktopInstallOverlay first-run setup', () => {
it('shows the remote/local choice without installer progress', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
expect(screen.getByText('Connect to existing Hermes')).toBeTruthy()
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
expect(screen.queryByText(/steps complete/i)).toBeNull()
expect(screen.queryByText(/Fetching installer manifest/i)).toBeNull()
})
it('continues local bootstrap only when Install Hermes locally is selected', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Install Hermes locally'))
expect(desktop.continueBootstrapLocal).toHaveBeenCalledTimes(1)
expect(screen.getByText('Set up Hermes Desktop')).toBeTruthy()
act(() => {
desktop.emitBootstrapEvent({ type: 'manifest', protocolVersion: 1, stages: [] })
})
await waitFor(() => expect(screen.queryByText('Set up Hermes Desktop')).toBeNull())
expect(screen.getByText(/Fetching installer manifest/i)).toBeTruthy()
})
it('surfaces a recoverable error when the local-bootstrap bridge is unavailable', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
desktop.continueBootstrapLocal = undefined as never
render(<DesktopInstallOverlay />)
const install = (await screen.findByText('Install Hermes locally')).closest('button') as HTMLButtonElement
fireEvent.click(install)
expect(
await screen.findByText('Local installation could not start. Restart Hermes Desktop and try again.')
).toBeTruthy()
expect(install.disabled).toBe(false)
})
it('opens the remote connection form from the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
expect(screen.getByText('Test connection')).toBeTruthy()
expect(screen.getByText('Apply and reconnect')).toBeTruthy()
})
it('returns from the remote connection form to the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
fireEvent.click(screen.getByText('Back'))
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
})
it('requires a successful token connection test before applying remote config', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockImplementation(async () => {
desktop.emitBootstrapEvent({ type: 'dismissed' })
return { mode: 'remote' }
})
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
expect(apply.disabled).toBe(true)
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => {
expect(desktop.testConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'token',
remoteToken: 'session-secret',
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
expect(apply.disabled).toBe(false)
fireEvent.click(screen.getByText('Apply and reconnect'))
await waitFor(() => {
expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'token',
remoteToken: 'session-secret',
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull())
})
it('ignores a completed probe after the gateway URL becomes invalid', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
let resolveProbe: ((result: DesktopConnectionProbeResult) => void) | undefined
const pendingProbe = new Promise<DesktopConnectionProbeResult>(resolve => {
resolveProbe = resolve
})
desktop.probeConnectionConfig.mockReturnValue(pendingProbe)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
const urlInput = await screen.findByPlaceholderText('https://gateway.example.com/hermes')
fireEvent.change(urlInput, { target: { value: 'https://gateway.example.com/hermes' } })
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
expect(desktop.probeConnectionConfig).toHaveBeenCalledTimes(1)
fireEvent.change(urlInput, { target: { value: 'not-a-url' } })
await act(async () => {
resolveProbe?.({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
await pendingProbe
})
expect(screen.queryByPlaceholderText('Paste session token')).toBeNull()
expect((screen.getByText('Test connection').closest('button') as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement).disabled).toBe(true)
})
it('does not enable Apply when credentials change during a connection test', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
let resolveTest: ((result: { baseUrl: string; ok: boolean; version: string }) => void) | undefined
const pendingTest = new Promise<{ baseUrl: string; ok: boolean; version: string }>(resolve => {
resolveTest = resolve
})
desktop.testConnectionConfig.mockReturnValue(pendingTest)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
const tokenInput = await screen.findByPlaceholderText('Paste session token')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
fireEvent.change(tokenInput, { target: { value: 'token-a' } })
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => expect(desktop.testConnectionConfig).toHaveBeenCalledTimes(1))
fireEvent.change(tokenInput, { target: { value: 'token-b' } })
await act(async () => {
resolveTest?.({ baseUrl: 'https://gateway.example.com/hermes', ok: true, version: '0.17.0' })
await pendingTest
})
expect(screen.queryByText('Connected to https://gateway.example.com/hermes (0.17.0).')).toBeNull()
expect(apply.disabled).toBe(true)
})
it('restores remote apply controls when applying the tested connection fails', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockRejectedValue(new Error('remote apply failed'))
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
fireEvent.click(apply)
expect(await screen.findByText('remote apply failed')).toBeTruthy()
expect(apply.disabled).toBe(false)
expect(screen.getByText('Gateway URL')).toBeTruthy()
})
it('signs in, tests, and applies a password-style remote gateway', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'oauth',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [{ displayName: 'Username & Password', name: 'password', supportsPassword: true }],
reachable: true,
version: '0.17.0'
})
desktop.oauthLoginConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
connected: true,
ok: true
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: null
})
desktop.applyConnectionConfig.mockResolvedValue({ mode: 'remote' })
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
expect(screen.queryByText('Sign in with Username & Password')).toBeNull()
fireEvent.click(await screen.findByText('Sign in'))
await waitFor(() => {
expect(desktop.oauthLoginConnectionConfig).toHaveBeenCalledWith('https://gateway.example.com/hermes')
})
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => {
expect(desktop.testConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteToken: undefined,
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await screen.findByText('Connected to https://gateway.example.com/hermes.')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
expect(apply.disabled).toBe(false)
fireEvent.click(apply)
await waitFor(() => {
expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteToken: undefined,
remoteUrl: 'https://gateway.example.com/hermes'
})
})
})
it('offers remote connection from the unsupported packaged install screen', async () => {
const desktop = installDesktopMock(
bootstrapState({
unsupportedPlatform: {
platform: 'darwin',
activeRoot: '/Users/me/.hermes/hermes-agent',
installCommand: 'curl -fsSL https://example.invalid/install.sh | sh',
docsUrl: 'https://example.invalid/docs'
}
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Hermes needs a one-time install')).toBeTruthy()
fireEvent.click(screen.getByText('Connect existing'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockImplementation(async () => {
desktop.emitBootstrapEvent({ type: 'dismissed' })
return { mode: 'remote' }
})
fireEvent.change(screen.getByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
fireEvent.click(screen.getByText('Apply and reconnect'))
await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull())
expect(screen.queryByText('Hermes needs a one-time install')).toBeNull()
})
})
@@ -15,12 +15,10 @@ import type {
DesktopBootstrapState
} from '@/global'
import { useI18n } from '@/i18n'
import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons'
import { ChevronDown, ChevronRight, iconSize } from '@/lib/icons'
import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { FirstRunRemoteForm } from './first-run-remote-form'
/**
* DesktopInstallOverlay
*
@@ -157,10 +155,6 @@ function StageRow({ descriptor, result, now }: StageRowProps) {
)
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err || 'Unknown error')
}
const EMPTY_STATE: DesktopBootstrapState = {
active: false,
manifest: null,
@@ -169,32 +163,10 @@ const EMPTY_STATE: DesktopBootstrapState = {
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
if (ev.type === 'dismissed') {
return { ...EMPTY_STATE }
}
if (ev.type === 'setup-choice') {
return {
...state,
active: false,
manifest: null,
stages: {},
error: null,
setupChoice: ev.active
? {
platform: ev.platform || state.setupChoice?.platform || 'unknown',
activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || ''
}
: null,
unsupportedPlatform: null
}
}
if (ev.type === 'manifest') {
const stages: Record<string, DesktopBootstrapStageResult> = {}
@@ -208,7 +180,6 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion },
stages,
error: null,
setupChoice: null,
startedAt: state.startedAt || Date.now()
}
}
@@ -248,14 +219,13 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
if (ev.type === 'failed') {
return { ...state, active: false, error: ev.error || 'unknown error', setupChoice: null }
return { ...state, active: false, error: ev.error || 'unknown error' }
}
if (ev.type === 'unsupported-platform') {
return {
...state,
active: false,
setupChoice: null,
unsupportedPlatform: {
platform: ev.platform,
activeRoot: ev.activeRoot,
@@ -276,9 +246,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [remoteOpen, setRemoteOpen] = useState(false)
const [localStarting, setLocalStarting] = useState(false)
const [localStartError, setLocalStartError] = useState<string | null>(null)
const [now, setNow] = useState(() => Date.now())
const logEndRef = useRef<HTMLDivElement | null>(null)
@@ -345,14 +312,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
}
}, [state.error])
// The choice remains mounted while main hands off to local bootstrap. Once
// a manifest/failure takes ownership (or a later repair presents a fresh
// choice), clear the transient button state so it cannot leak across phases.
useEffect(() => {
setLocalStarting(false)
setLocalStartError(null)
}, [state.setupChoice?.activeRoot])
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
// or actively running with a manifest. Hide entirely after a successful
// completion so the rest of the UI can take over.
@@ -373,96 +332,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return true
}
if (state.setupChoice) {
return true
}
return false
}, [enabled, state.active, state.error, state.setupChoice, state.unsupportedPlatform])
}, [enabled, state.active, state.error, state.unsupportedPlatform])
if (!shouldShow) {
return null
}
if (remoteOpen) {
return <FirstRunRemoteForm onBack={() => setRemoteOpen(false)} />
}
if (state.setupChoice) {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.setupChoiceTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.setupChoiceDesc}</p>
</div>
</div>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover)"
onClick={() => setRemoteOpen(true)}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<Globe className="size-4 text-muted-foreground" />
<span>{copy.connectExistingTitle}</span>
</div>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.connectExistingDesc}</p>
</button>
<button
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-wait disabled:opacity-60"
disabled={localStarting}
onClick={async () => {
setLocalStarting(true)
setLocalStartError(null)
try {
const desktop = window.hermesDesktop
if (!desktop || typeof desktop.continueBootstrapLocal !== 'function') {
throw new Error(copy.localStartUnavailable)
}
await desktop.continueBootstrapLocal()
} catch (err) {
setLocalStartError(errorMessage(err))
setLocalStarting(false)
}
}}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
{localStarting ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<Monitor className="size-4 text-muted-foreground" />
)}
<span>{copy.installLocalTitle}</span>
</div>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.installLocalDesc}</p>
</button>
</div>
{localStartError ? (
<div className="mt-4 flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{localStartError}</span>
</div>
) : null}
<div className="mt-6 text-xs text-muted-foreground">
{copy.installTo}{' '}
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
</div>
</div>
</div>
)
}
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
// there's no Hermes Agent installed yet and we can't drive install.sh
// (no stage protocol equivalent yet). Show a copy-paste install command
@@ -508,15 +384,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
{copy.installTo} <code className="font-mono text-(--ui-text-secondary)">{ups.activeRoot}</code>
</span>
<div className="flex items-center gap-2">
<Button onClick={() => setRemoteOpen(true)} size="sm" variant="secondary">
<Globe className="size-4" />
{copy.connectExistingShort}
</Button>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
{copy.retryAfterRun}
</Button>
</div>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
{copy.retryAfterRun}
</Button>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More