Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73dccbe9c1 | ||
|
|
ceed22d2af |
@@ -1,8 +1,6 @@
|
||||
.DS_Store
|
||||
/venv/
|
||||
/venv.old/
|
||||
/venv.stale.runtime-*/
|
||||
/.hermes-runtime/
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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.]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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]] = {}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user