mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge remote-tracking branch 'origin/main' into feat/gateway-health-diagnostics-monitoring
# Conflicts: # uv.lock
This commit is contained in:
commit
1773752c8c
689 changed files with 71676 additions and 5912 deletions
5
.github/workflows/docker.yml
vendored
5
.github/workflows/docker.yml
vendored
|
|
@ -88,6 +88,11 @@ jobs:
|
|||
# ---------------------------------------------------------------------
|
||||
- name: Install uv (for docker tests)
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
|
||||
# raw.githubusercontent.com every job; transient fetch failures
|
||||
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
|
||||
version: "0.9.28"
|
||||
|
||||
- name: Set up Python 3.11 (for docker tests)
|
||||
run: uv python install 3.11
|
||||
|
|
|
|||
5
.github/workflows/e2e-desktop.yml
vendored
5
.github/workflows/e2e-desktop.yml
vendored
|
|
@ -52,6 +52,11 @@ jobs:
|
|||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Pin the uv version: unpinned, setup-uv resolves "latest" by
|
||||
# fetching a manifest from raw.githubusercontent.com on EVERY job —
|
||||
# a transient fetch failure fails the whole job (2026-07-28 slice-5
|
||||
# incident). Pinned, the binary downloads directly; no manifest hop.
|
||||
version: "0.9.28"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
|
|
|
|||
10
.github/workflows/lint.yml
vendored
10
.github/workflows/lint.yml
vendored
|
|
@ -40,6 +40,11 @@ jobs:
|
|||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
|
||||
# raw.githubusercontent.com every job; transient fetch failures
|
||||
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
|
||||
version: "0.9.28"
|
||||
|
||||
- name: Install ruff + ty
|
||||
uses: ./.github/actions/retry
|
||||
|
|
@ -129,6 +134,11 @@ jobs:
|
|||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
|
||||
# raw.githubusercontent.com every job; transient fetch failures
|
||||
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
|
||||
version: "0.9.28"
|
||||
|
||||
- name: Install ruff
|
||||
uses: ./.github/actions/retry
|
||||
|
|
|
|||
10
.github/workflows/tests.yml
vendored
10
.github/workflows/tests.yml
vendored
|
|
@ -74,6 +74,11 @@ jobs:
|
|||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Pin the uv version: unpinned, setup-uv resolves "latest" by
|
||||
# fetching a manifest from raw.githubusercontent.com on EVERY job —
|
||||
# a transient fetch failure fails the whole job (2026-07-28 slice-5
|
||||
# incident). Pinned, the binary downloads directly; no manifest hop.
|
||||
version: "0.9.28"
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
# pyproject.toml or uv.lock changes. `uv sync` still runs every
|
||||
|
|
@ -188,6 +193,11 @@ jobs:
|
|||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Pin the uv version: unpinned, setup-uv resolves "latest" by
|
||||
# fetching a manifest from raw.githubusercontent.com on EVERY job —
|
||||
# a transient fetch failure fails the whole job (2026-07-28 slice-5
|
||||
# incident). Pinned, the binary downloads directly; no manifest hop.
|
||||
version: "0.9.28"
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
# pyproject.toml or uv.lock changes. `uv sync` still runs every
|
||||
|
|
|
|||
5
.github/workflows/uv-lockfile-check.yml
vendored
5
.github/workflows/uv-lockfile-check.yml
vendored
|
|
@ -70,6 +70,11 @@ jobs:
|
|||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
|
||||
# raw.githubusercontent.com every job; transient fetch failures
|
||||
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
|
||||
version: "0.9.28"
|
||||
|
||||
# `uv lock --check` re-resolves the project from pyproject.toml and
|
||||
# compares the result to uv.lock, exiting non-zero if they disagree.
|
||||
|
|
|
|||
16
Dockerfile
16
Dockerfile
|
|
@ -200,6 +200,22 @@ RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
|
|||
done && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
|
||||
# The photon plugin's Node sidecar needs its own node_modules
|
||||
# (spectrum-ts). The install tree is immutable at runtime, so a lazy
|
||||
# `npm ci` on first connect would hit EROFS — bake the deps here instead
|
||||
# (deterministic installs, NS-559). The patch script is copied alongside
|
||||
# the manifests because package.json's postinstall runs it, which also
|
||||
# means the spectrum-ts patch is applied at build time. Layer-cached:
|
||||
# only re-runs when the sidecar manifests/patch change.
|
||||
COPY plugins/platforms/photon/sidecar/package.json \
|
||||
plugins/platforms/photon/sidecar/package-lock.json \
|
||||
plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \
|
||||
plugins/platforms/photon/sidecar/
|
||||
RUN cd plugins/platforms/photon/sidecar && \
|
||||
npm ci --no-audit --fetch-retries=5 && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Layer-cached Python dependency install ----------
|
||||
# Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel
|
||||
# download + native-extension compile layer is cached unless those inputs
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ def _provider_default_routes(provider: str) -> set[str]:
|
|||
from hermes_cli.providers import HERMES_OVERLAYS, get_provider
|
||||
|
||||
overlay = HERMES_OVERLAYS.get(provider)
|
||||
provider_def = get_provider(provider)
|
||||
provider_def = get_provider(provider, allow_network=False)
|
||||
for value in (
|
||||
getattr(overlay, "base_url_override", ""),
|
||||
getattr(provider_def, "base_url", ""),
|
||||
|
|
@ -887,8 +887,10 @@ def init_agent(
|
|||
# report cumulative micros spent. Surfaced behind HERMES_DEV_CREDITS.
|
||||
agent._credits_state = None
|
||||
agent._credits_session_start_micros = None
|
||||
# Threshold-notice latch (L4): active sticky-notice keys + the warn90 crossing gate.
|
||||
agent._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None}
|
||||
# Threshold-notice latch (L4): active sticky-notice keys + the crossing gates.
|
||||
from agent.credits_tracker import new_credits_latch
|
||||
|
||||
agent._credits_latch = new_credits_latch()
|
||||
|
||||
# OpenRouter response cache hit counter — incremented when
|
||||
# X-OpenRouter-Cache-Status: HIT is seen in streaming response headers.
|
||||
|
|
@ -2276,7 +2278,18 @@ def init_agent(
|
|||
# AFTER the custom_providers branch so per-model overrides aren't lost.
|
||||
agent._config_context_length = _config_context_length
|
||||
|
||||
agent._ensure_lmstudio_runtime_loaded(_config_context_length)
|
||||
_lmstudio_runtime_context_length = agent._ensure_lmstudio_runtime_loaded(
|
||||
_config_context_length
|
||||
)
|
||||
if agent._lmstudio_load_was_unverified(_lmstudio_runtime_context_length):
|
||||
_ra().logger.warning(
|
||||
"LM Studio model activation was rejected or completed without a "
|
||||
"verifiable active context length; falling back to configured context"
|
||||
)
|
||||
_effective_context_length = agent._effective_lmstudio_context_length(
|
||||
_config_context_length,
|
||||
_lmstudio_runtime_context_length,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
|
@ -2353,7 +2366,7 @@ def init_agent(
|
|||
agent.model,
|
||||
base_url=agent.base_url,
|
||||
api_key=getattr(agent, "api_key", ""),
|
||||
config_context_length=_config_context_length,
|
||||
config_context_length=_effective_context_length,
|
||||
provider=agent.provider,
|
||||
custom_providers=_custom_providers,
|
||||
)
|
||||
|
|
@ -2388,7 +2401,7 @@ def init_agent(
|
|||
quiet_mode=agent.quiet_mode,
|
||||
base_url=agent.base_url,
|
||||
api_key=getattr(agent, "api_key", ""),
|
||||
config_context_length=_config_context_length,
|
||||
config_context_length=_effective_context_length,
|
||||
provider=agent.provider,
|
||||
api_mode=agent.api_mode,
|
||||
abort_on_summary_failure=compression_abort_on_summary_failure,
|
||||
|
|
@ -2417,7 +2430,13 @@ def init_agent(
|
|||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
_ctx = getattr(agent.context_compressor, "context_length", 0)
|
||||
if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH:
|
||||
_allow_lmstudio_explicit_below_floor = (
|
||||
str(getattr(agent, "provider", "") or "").strip().lower() == "lmstudio"
|
||||
and isinstance(agent._config_context_length, int)
|
||||
and not isinstance(agent._config_context_length, bool)
|
||||
and agent._config_context_length > 0
|
||||
)
|
||||
if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH and not _allow_lmstudio_explicit_below_floor:
|
||||
raise ValueError(
|
||||
f"Model {agent.model} has a context window of {_ctx:,} tokens, "
|
||||
f"which is below the minimum {MINIMUM_CONTEXT_LENGTH:,} required "
|
||||
|
|
|
|||
|
|
@ -2137,6 +2137,16 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
agent, "_credential_pool_entry_id", _MISSING
|
||||
)
|
||||
|
||||
def _restore_snapshot() -> None:
|
||||
for _name, _value in _snapshot.items():
|
||||
if _value is _MISSING:
|
||||
# Attribute did not exist before the swap — don't fabricate it.
|
||||
continue
|
||||
try:
|
||||
setattr(agent, _name, _value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
try:
|
||||
# Clear the per-config context_length override so the new model's
|
||||
# actual context window is resolved via get_model_context_length()
|
||||
|
|
@ -2305,16 +2315,42 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
# caller's exception handler can surface a meaningful warning. The
|
||||
# exception is re-raised; cli.py / gateway/run.py / tui_gateway catch
|
||||
# it and print "Agent swap failed; change applied to next session".
|
||||
for _name, _value in _snapshot.items():
|
||||
if _value is _MISSING:
|
||||
# Attribute did not exist before the swap — don't fabricate it.
|
||||
continue
|
||||
try:
|
||||
setattr(agent, _name, _value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_restore_snapshot()
|
||||
raise
|
||||
|
||||
# ── LM Studio: preload before probing context length ──
|
||||
_sm_custom_providers = None
|
||||
try:
|
||||
from hermes_cli.config import (
|
||||
get_compatible_custom_providers,
|
||||
get_custom_provider_context_length,
|
||||
load_config,
|
||||
)
|
||||
|
||||
_sm_cfg = load_config()
|
||||
_sm_custom_providers = get_compatible_custom_providers(_sm_cfg)
|
||||
_destination_context_intent = get_custom_provider_context_length(
|
||||
model=agent.model,
|
||||
base_url=agent.base_url,
|
||||
custom_providers=_sm_custom_providers,
|
||||
)
|
||||
except Exception:
|
||||
_destination_context_intent = None
|
||||
agent._config_context_length = _destination_context_intent
|
||||
_runtime_context_length = agent._ensure_lmstudio_runtime_loaded(
|
||||
_destination_context_intent
|
||||
)
|
||||
if agent._lmstudio_load_was_unverified(_runtime_context_length):
|
||||
logger.warning(
|
||||
"LM Studio model activation was rejected or completed without a "
|
||||
"verifiable active context length during model switch; continuing "
|
||||
"with configured context"
|
||||
)
|
||||
_effective_context_length = agent._effective_lmstudio_context_length(
|
||||
_destination_context_intent,
|
||||
_runtime_context_length,
|
||||
)
|
||||
|
||||
# ── Re-evaluate prompt caching ──
|
||||
agent._use_prompt_caching, agent._use_native_cache_layout = (
|
||||
agent._anthropic_prompt_cache_policy(
|
||||
|
|
@ -2325,22 +2361,15 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
)
|
||||
)
|
||||
|
||||
# ── LM Studio: preload before probing context length ──
|
||||
agent._ensure_lmstudio_runtime_loaded()
|
||||
|
||||
# ── Update context compressor ──
|
||||
if hasattr(agent, "context_compressor") and agent.context_compressor:
|
||||
from agent.model_metadata import get_model_context_length
|
||||
# Re-read custom_providers from live config so per-model
|
||||
# context_length overrides are honored when switching to a
|
||||
# custom provider mid-session (closes #15779).
|
||||
_sm_custom_providers = None
|
||||
try:
|
||||
from hermes_cli.config import load_config, get_compatible_custom_providers
|
||||
_sm_cfg = load_config()
|
||||
_sm_custom_providers = get_compatible_custom_providers(_sm_cfg)
|
||||
except Exception:
|
||||
_sm_custom_providers = None
|
||||
if _sm_custom_providers is None:
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers, load_config
|
||||
_sm_custom_providers = get_compatible_custom_providers(load_config())
|
||||
except Exception:
|
||||
_sm_custom_providers = None
|
||||
# ``agent.api_key`` may be a callable (Azure Foundry Entra ID
|
||||
# token provider). ``get_model_context_length`` expects a
|
||||
# string for its live-probe paths; for Foundry the context
|
||||
|
|
@ -2352,7 +2381,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
|||
base_url=agent.base_url,
|
||||
api_key=_ctx_api_key,
|
||||
provider=agent.provider,
|
||||
config_context_length=getattr(agent, "_config_context_length", None),
|
||||
config_context_length=_effective_context_length,
|
||||
custom_providers=_sm_custom_providers,
|
||||
)
|
||||
agent.context_compressor.update_model(
|
||||
|
|
@ -2475,7 +2504,8 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
tool_call_id: Optional[str] = None, messages: list = None,
|
||||
pre_tool_block_checked: bool = False,
|
||||
skip_tool_request_middleware: bool = False,
|
||||
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None) -> str:
|
||||
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
skip_tool_execution_middleware: bool = False) -> str:
|
||||
"""Invoke a single tool and return the result string. No display logic.
|
||||
|
||||
Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
|
||||
|
|
@ -2654,8 +2684,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
||||
else:
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return _ra().handle_function_call(
|
||||
function_name, next_args, effective_task_id,
|
||||
dispatch_kwargs = dict(
|
||||
tool_call_id=tool_call_id,
|
||||
session_id=agent.session_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
|
|
@ -2667,6 +2696,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
tool_request_middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
if skip_tool_execution_middleware:
|
||||
dispatch_kwargs["skip_tool_execution_middleware"] = True
|
||||
return _ra().handle_function_call(
|
||||
function_name,
|
||||
next_args,
|
||||
effective_task_id,
|
||||
**dispatch_kwargs,
|
||||
)
|
||||
|
||||
if skip_tool_execution_middleware:
|
||||
return _execute(function_args)
|
||||
|
||||
from hermes_cli.middleware import run_tool_execution_middleware
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ Payment / credit exhaustion fallback:
|
|||
|
||||
import contextlib
|
||||
import contextvars
|
||||
import functools
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
|
|
@ -50,9 +51,10 @@ import os
|
|||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path # noqa: F401 — used by test mocks
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the
|
||||
|
|
@ -534,7 +536,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str:
|
|||
# plus providers we intentionally keep pinned here (e.g. Anthropic predates
|
||||
# profiles). New providers should set default_aux_model on their profile instead.
|
||||
_API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
|
||||
"gemini": "gemini-3-flash-preview",
|
||||
"gemini": "gemini-3.6-flash",
|
||||
"zai": "glm-4.5-flash",
|
||||
"kimi-coding": "kimi-k2-turbo-preview",
|
||||
"stepfun": "step-3.5-flash",
|
||||
|
|
@ -543,7 +545,7 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
|
|||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"opencode-zen": "gemini-3-flash",
|
||||
"opencode-go": "glm-5",
|
||||
"kilocode": "google/gemini-3-flash-preview",
|
||||
"kilocode": "google/gemini-3.6-flash",
|
||||
"ollama-cloud": "nemotron-3-nano:30b",
|
||||
"tencent-tokenhub": "hy3-preview",
|
||||
# NB: no "deepinfra" entry — its aux model lives on the ProviderProfile
|
||||
|
|
@ -758,8 +760,8 @@ NOUS_EXTRA_BODY = _nous_extra_body()
|
|||
auxiliary_is_nous: bool = False
|
||||
|
||||
# Default auxiliary models per provider
|
||||
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
|
||||
_NOUS_MODEL = "google/gemini-3-flash-preview"
|
||||
_OPENROUTER_MODEL = "google/gemini-3.6-flash"
|
||||
_NOUS_MODEL = "google/gemini-3.6-flash"
|
||||
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
|
||||
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
|
||||
|
|
@ -2492,6 +2494,167 @@ _RUNTIME_MAIN_AUTH_MODE: str = ""
|
|||
_RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
|
||||
contextvars.ContextVar("auxiliary_runtime_main", default=None)
|
||||
)
|
||||
|
||||
_RELAY_AUX_CALL_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
|
||||
contextvars.ContextVar("auxiliary_relay_call", default=None)
|
||||
)
|
||||
|
||||
|
||||
def _relay_auxiliary_call(callback):
|
||||
"""Give every physical retry in one auxiliary call a shared Relay identity."""
|
||||
|
||||
@functools.wraps(callback)
|
||||
def wrapped(*args, **kwargs):
|
||||
task = args[0] if args else kwargs.get("task")
|
||||
token = _RELAY_AUX_CALL_CONTEXT.set({
|
||||
"task": str(task or "unknown"),
|
||||
"request_id": f"aux-{uuid.uuid4().hex}",
|
||||
"attempt_count": 0,
|
||||
"provider": "",
|
||||
"model": "",
|
||||
"api_mode": "chat_completions",
|
||||
})
|
||||
try:
|
||||
return callback(*args, **kwargs)
|
||||
except BaseException:
|
||||
_fail_relay_auxiliary_call()
|
||||
raise
|
||||
finally:
|
||||
_RELAY_AUX_CALL_CONTEXT.reset(token)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _relay_auxiliary_call_async(callback):
|
||||
"""Async counterpart to :func:`_relay_auxiliary_call`."""
|
||||
|
||||
@functools.wraps(callback)
|
||||
async def wrapped(*args, **kwargs):
|
||||
task = args[0] if args else kwargs.get("task")
|
||||
token = _RELAY_AUX_CALL_CONTEXT.set({
|
||||
"task": str(task or "unknown"),
|
||||
"request_id": f"aux-{uuid.uuid4().hex}",
|
||||
"attempt_count": 0,
|
||||
"provider": "",
|
||||
"model": "",
|
||||
"api_mode": "chat_completions",
|
||||
})
|
||||
try:
|
||||
return await callback(*args, **kwargs)
|
||||
except BaseException:
|
||||
_fail_relay_auxiliary_call()
|
||||
raise
|
||||
finally:
|
||||
_RELAY_AUX_CALL_CONTEXT.reset(token)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _set_relay_auxiliary_route(
|
||||
provider: str | None,
|
||||
model: str | None,
|
||||
api_mode: str | None,
|
||||
) -> None:
|
||||
context = _RELAY_AUX_CALL_CONTEXT.get()
|
||||
if context is None:
|
||||
return
|
||||
context["provider"] = str(provider or "auxiliary")
|
||||
context["model"] = str(model or "unknown")
|
||||
context["api_mode"] = str(api_mode or "chat_completions")
|
||||
|
||||
|
||||
def _relay_auxiliary_metadata(
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> tuple[str, str, dict[str, Any]] | None:
|
||||
context = _RELAY_AUX_CALL_CONTEXT.get()
|
||||
if context is None:
|
||||
return None
|
||||
attempt_count = int(context.get("attempt_count") or 0)
|
||||
context["attempt_count"] = attempt_count + 1
|
||||
provider_name = str(provider or context.get("provider") or "auxiliary")
|
||||
model_name = str(context.get("model") or "unknown")
|
||||
return provider_name, model_name, {
|
||||
"api_mode": str(api_mode or context.get("api_mode") or "chat_completions"),
|
||||
"api_request_id": str(context["request_id"]),
|
||||
"call_role": f"auxiliary:{context['task']}",
|
||||
"retry_count": attempt_count,
|
||||
"auxiliary_task": str(context["task"]),
|
||||
}
|
||||
|
||||
|
||||
def _relay_sync_completion(
|
||||
client: Any,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
create: Callable[[dict[str, Any]], Any] | None = None,
|
||||
) -> Any:
|
||||
callback = create or (lambda request: client.chat.completions.create(**request))
|
||||
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
|
||||
if route is None:
|
||||
return callback(kwargs)
|
||||
provider_name, fallback_model, metadata = route
|
||||
from agent import relay_llm
|
||||
|
||||
return relay_llm.execute_current(
|
||||
kwargs,
|
||||
callback,
|
||||
name=provider_name,
|
||||
model_name=str(kwargs.get("model") or fallback_model),
|
||||
metadata=metadata,
|
||||
defer_logical_completion=True,
|
||||
)
|
||||
|
||||
|
||||
async def _relay_async_completion(
|
||||
client: Any,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
create: Callable[[dict[str, Any]], Any] | None = None,
|
||||
) -> Any:
|
||||
callback = create or (lambda request: client.chat.completions.create(**request))
|
||||
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
|
||||
if route is None:
|
||||
return await callback(kwargs)
|
||||
provider_name, fallback_model, metadata = route
|
||||
from agent import relay_llm
|
||||
|
||||
return await relay_llm.execute_current_async(
|
||||
kwargs,
|
||||
callback,
|
||||
name=provider_name,
|
||||
model_name=str(kwargs.get("model") or fallback_model),
|
||||
metadata=metadata,
|
||||
defer_logical_completion=True,
|
||||
)
|
||||
|
||||
|
||||
def _relay_sync_stream(
|
||||
client: Any,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> Any:
|
||||
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
|
||||
if route is None:
|
||||
return client.chat.completions.create(**kwargs)
|
||||
provider_name, fallback_model, metadata = route
|
||||
from agent import relay_llm
|
||||
|
||||
return relay_llm.stream_current(
|
||||
kwargs,
|
||||
lambda request: client.chat.completions.create(**request),
|
||||
name=provider_name,
|
||||
model_name=str(kwargs.get("model") or fallback_model),
|
||||
finalizer=dict,
|
||||
metadata=metadata,
|
||||
)
|
||||
_RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "")
|
||||
_RUNTIME_MAIN_COMPAT_LOCK = threading.Lock()
|
||||
|
||||
|
|
@ -3801,7 +3964,13 @@ def _retry_same_provider_sync(
|
|||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
_relay_sync_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
),
|
||||
task,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3866,7 +4035,13 @@ async def _retry_same_provider_async(
|
|||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
await _relay_async_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
),
|
||||
task,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -4071,7 +4246,7 @@ def _call_fallback_candidate_sync(
|
|||
base_url=fb_base, task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
_relay_sync_completion(fb_client, fb_kwargs, provider=fb_label), task)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
|
|
@ -4088,7 +4263,13 @@ def _call_fallback_candidate_sync(
|
|||
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
_relay_sync_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=fb_provider,
|
||||
),
|
||||
task,
|
||||
)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
|
|
@ -4137,7 +4318,13 @@ async def _call_fallback_candidate_async(
|
|||
base_url=fb_base, task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
await _relay_async_completion(
|
||||
fb_client,
|
||||
fb_kwargs,
|
||||
provider=fb_label,
|
||||
),
|
||||
task,
|
||||
)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
|
|
@ -4155,7 +4342,13 @@ async def _call_fallback_candidate_async(
|
|||
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
await _relay_async_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=fb_provider,
|
||||
),
|
||||
task,
|
||||
)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
|
|
@ -7270,6 +7463,7 @@ def _validate_llm_response(
|
|||
except (AttributeError, TypeError, IndexError) as exc:
|
||||
recovered = _recover_aux_response_message(response)
|
||||
if recovered is not None:
|
||||
_complete_relay_auxiliary_call()
|
||||
return recovered
|
||||
response_type = type(response).__name__
|
||||
response_preview = str(response)[:120]
|
||||
|
|
@ -7279,9 +7473,34 @@ def _validate_llm_response(
|
|||
f"Expected object with .choices[0].message — check provider "
|
||||
f"adapter or custom endpoint compatibility."
|
||||
) from exc
|
||||
_complete_relay_auxiliary_call()
|
||||
return response
|
||||
|
||||
|
||||
def _complete_relay_auxiliary_call(*, outcome: str = "success") -> None:
|
||||
"""Close one auxiliary logical call after acceptance or terminal failure."""
|
||||
context = _RELAY_AUX_CALL_CONTEXT.get()
|
||||
if context is None:
|
||||
return
|
||||
from agent import relay_llm
|
||||
|
||||
relay_llm.complete_logical_call(
|
||||
str(context.get("request_id") or ""),
|
||||
outcome=outcome,
|
||||
)
|
||||
|
||||
|
||||
def _fail_relay_auxiliary_call() -> None:
|
||||
"""Close a terminally failed call without replacing its original error."""
|
||||
try:
|
||||
_complete_relay_auxiliary_call(outcome="failed")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Relay auxiliary failure finalization failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _recover_aux_response_message(response: Any) -> Optional[Any]:
|
||||
"""Synthesize chat-completions shape from Responses-style text fields.
|
||||
|
||||
|
|
@ -7680,6 +7899,7 @@ async def _acreate_with_stream(
|
|||
)
|
||||
|
||||
|
||||
@_relay_auxiliary_call
|
||||
def call_llm(
|
||||
task: str = None,
|
||||
*,
|
||||
|
|
@ -7820,6 +8040,11 @@ def call_llm(
|
|||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
_set_relay_auxiliary_route(
|
||||
resolved_provider,
|
||||
final_model,
|
||||
resolved_api_mode,
|
||||
)
|
||||
|
||||
# Log what we're about to do — makes auxiliary operations visible
|
||||
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
|
||||
|
|
@ -7857,7 +8082,12 @@ def call_llm(
|
|||
kwargs["stream"] = True
|
||||
if stream_options:
|
||||
kwargs["stream_options"] = stream_options
|
||||
return client.chat.completions.create(**kwargs)
|
||||
return _relay_sync_stream(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
)
|
||||
|
||||
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
|
||||
# then payment fallback.
|
||||
|
|
@ -7880,10 +8110,18 @@ def call_llm(
|
|||
# for the transient retry every auxiliary task shares. (PR #16587)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
_create_with_progress(
|
||||
client, kwargs, task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider, _base_info or resolved_base_url,
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=lambda request: _create_with_progress(
|
||||
client,
|
||||
request,
|
||||
task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider, _base_info or resolved_base_url,
|
||||
),
|
||||
),
|
||||
),
|
||||
task,
|
||||
|
|
@ -7919,10 +8157,19 @@ def call_llm(
|
|||
time.sleep(_backoff)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
_create_with_progress(
|
||||
client, kwargs, task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider, _base_info or resolved_base_url,
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=lambda request: _create_with_progress(
|
||||
client,
|
||||
request,
|
||||
task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider,
|
||||
_base_info or resolved_base_url,
|
||||
),
|
||||
),
|
||||
),
|
||||
task)
|
||||
|
|
@ -7942,7 +8189,12 @@ def call_llm(
|
|||
)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**retry_kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
retry_err_str = str(retry_err)
|
||||
# If retry still fails, fall through to the max_tokens /
|
||||
|
|
@ -7980,7 +8232,12 @@ def call_llm(
|
|||
kwargs.pop("max_completion_tokens", None)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
# If the max_tokens retry also hits a payment or connection
|
||||
# error, fall through to the fallback chain below.
|
||||
|
|
@ -8010,7 +8267,12 @@ def call_llm(
|
|||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
|
|
@ -8043,7 +8305,12 @@ def call_llm(
|
|||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
|
|
@ -8071,7 +8338,12 @@ def call_llm(
|
|||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
return _validate_llm_response(
|
||||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
|
||||
# ── Auth refresh retry ───────────────────────────────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
|
|
@ -8121,7 +8393,12 @@ def call_llm(
|
|||
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
|
|
@ -8379,6 +8656,7 @@ def extract_content_or_reasoning(response) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
@_relay_auxiliary_call_async
|
||||
async def async_call_llm(
|
||||
task: str = None,
|
||||
*,
|
||||
|
|
@ -8470,6 +8748,11 @@ async def async_call_llm(
|
|||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
_set_relay_auxiliary_route(
|
||||
resolved_provider,
|
||||
final_model,
|
||||
resolved_api_mode,
|
||||
)
|
||||
|
||||
# Pass the client's actual base_url (not just resolved_base_url) so
|
||||
# endpoint-specific temperature overrides can distinguish
|
||||
|
|
@ -8508,7 +8791,14 @@ async def async_call_llm(
|
|||
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await _acreate(kwargs), task,
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=_acreate,
|
||||
),
|
||||
task,
|
||||
provider=resolved_provider, base_url=_client_base)
|
||||
except Exception as transient_err:
|
||||
if not _is_transient_transport_error(transient_err):
|
||||
|
|
@ -8529,7 +8819,14 @@ async def async_call_llm(
|
|||
task or "call", transient_err,
|
||||
)
|
||||
return _validate_llm_response(
|
||||
await _acreate(kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=_acreate,
|
||||
),
|
||||
task)
|
||||
except Exception as first_err:
|
||||
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
|
||||
retry_kwargs = dict(kwargs)
|
||||
|
|
@ -8540,7 +8837,12 @@ async def async_call_llm(
|
|||
)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**retry_kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
retry_err_str = str(retry_err)
|
||||
if not (
|
||||
|
|
@ -8574,7 +8876,12 @@ async def async_call_llm(
|
|||
kwargs.pop("max_completion_tokens", None)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
# If the max_tokens retry also hits a payment or connection
|
||||
# error, fall through to the fallback chain below.
|
||||
|
|
@ -8603,7 +8910,12 @@ async def async_call_llm(
|
|||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
|
|
@ -8635,7 +8947,12 @@ async def async_call_llm(
|
|||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
|
|
@ -8662,7 +8979,12 @@ async def async_call_llm(
|
|||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
return _validate_llm_response(
|
||||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
|
||||
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
|
|
@ -8706,7 +9028,12 @@ async def async_call_llm(
|
|||
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -191,6 +191,28 @@ def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
|
|||
return f"call_{digest}"
|
||||
|
||||
|
||||
def _clamp_responses_call_id(call_id: str) -> str:
|
||||
"""Keep a ``call_id`` within the Responses API's 64-char limit (#73492).
|
||||
|
||||
The codex app-server namespaces MCP tool call ids as
|
||||
``codex_mcp__<server>__<tool>_<codex_call_id>``; with an ``exec-<uuid>``
|
||||
component the built-in ``hermes-tools`` server already overflows 64 chars,
|
||||
and the Responses API rejects the whole payload with a non-retryable HTTP
|
||||
400 that then replays every turn — permanently bricking the session.
|
||||
|
||||
Sibling defect to #10788 (which clamped ``input[*].id``), applied here to
|
||||
``call_id``. The surrogate is a pure, deterministic function of the
|
||||
original, so the ``function_call`` and its matching ``function_call_output``
|
||||
— which carry the same original id — map to the same surrogate and stay
|
||||
paired without correlating the two items. Short ids pass through unchanged,
|
||||
preserving prompt-cache prefixes.
|
||||
"""
|
||||
if len(call_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
|
||||
return call_id
|
||||
digest = hashlib.sha256(call_id.encode("utf-8", errors="replace")).hexdigest()[:32]
|
||||
return f"call_{digest}"
|
||||
|
||||
|
||||
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Split a stored tool id into (call_id, response_item_id)."""
|
||||
if not isinstance(raw_id, str):
|
||||
|
|
@ -546,7 +568,7 @@ def _chat_messages_to_responses_input(
|
|||
|
||||
items.append({
|
||||
"type": "function_call",
|
||||
"call_id": call_id,
|
||||
"call_id": _clamp_responses_call_id(call_id),
|
||||
"name": fn_name,
|
||||
"arguments": arguments,
|
||||
})
|
||||
|
|
@ -589,7 +611,7 @@ def _chat_messages_to_responses_input(
|
|||
|
||||
items.append({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"call_id": _clamp_responses_call_id(call_id),
|
||||
"output": output_value,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,10 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
|||
try:
|
||||
if not agent._session_db_created:
|
||||
agent._ensure_db_session()
|
||||
agent._session_db.update_token_counts(
|
||||
# Enqueued for the SessionDB background writer — keeps the
|
||||
# per-call accounting write off the turn thread (see
|
||||
# conversation_loop's queue_token_counts call).
|
||||
agent._session_db.queue_token_counts(
|
||||
agent.session_id,
|
||||
model=agent.model,
|
||||
billing_provider=agent.provider,
|
||||
|
|
@ -154,7 +157,8 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
|||
try:
|
||||
if not agent._session_db_created:
|
||||
agent._ensure_db_session()
|
||||
agent._session_db.update_token_counts(
|
||||
# Enqueued for the SessionDB background writer (see above).
|
||||
agent._session_db.queue_token_counts(
|
||||
agent.session_id,
|
||||
input_tokens=canonical_usage.input_tokens,
|
||||
output_tokens=canonical_usage.output_tokens,
|
||||
|
|
@ -1236,6 +1240,8 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
|||
"""
|
||||
import httpx as _httpx
|
||||
|
||||
from agent import relay_llm
|
||||
|
||||
active_client = client or agent._ensure_primary_openai_client(reason="codex_stream_direct")
|
||||
max_stream_retries = 1
|
||||
# Accumulate streamed text so callers / compat shims can read it.
|
||||
|
|
@ -1260,48 +1266,88 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
|||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted before Codex stream retry")
|
||||
|
||||
stream_kwargs = dict(api_kwargs)
|
||||
stream_kwargs["stream"] = True
|
||||
intercepted_events = []
|
||||
writer_token = {"value": None}
|
||||
|
||||
def _open_codex_stream(next_api_kwargs: dict[str, Any]):
|
||||
stream_kwargs = dict(next_api_kwargs)
|
||||
stream_kwargs["stream"] = True
|
||||
return active_client.responses.create(**stream_kwargs)
|
||||
|
||||
def _codex_stream_created(_raw_stream: Any) -> None:
|
||||
# Claim the delta sink for THIS physical attempt. A newer attempt
|
||||
# supersedes this token and fences late deltas out of the turn.
|
||||
writer_token["value"] = claim_stream_writer(agent)
|
||||
|
||||
def _accept_codex_chunk(_chunk: Any) -> bool:
|
||||
token = writer_token["value"]
|
||||
if token is None or stream_writer_is_current(agent, token):
|
||||
return True
|
||||
logger.warning(
|
||||
"Codex streaming attempt superseded by a newer stream; "
|
||||
"stopping consumption to preserve the single-writer "
|
||||
"invariant (model=%s).",
|
||||
api_kwargs.get("model", "unknown"),
|
||||
)
|
||||
return False
|
||||
|
||||
def _finalize_codex_stream() -> Any:
|
||||
return _consume_codex_event_stream(
|
||||
list(intercepted_events),
|
||||
model=api_kwargs.get("model"),
|
||||
)
|
||||
|
||||
try:
|
||||
event_stream = active_client.responses.create(**stream_kwargs)
|
||||
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
|
||||
event_stream = relay_llm.stream(
|
||||
dict(api_kwargs),
|
||||
_open_codex_stream,
|
||||
session_id=str(getattr(agent, "session_id", "") or ""),
|
||||
name=str(getattr(agent, "provider", "") or "codex"),
|
||||
model_name=str(api_kwargs.get("model") or ""),
|
||||
finalizer=_finalize_codex_stream,
|
||||
on_stream_created=_codex_stream_created,
|
||||
on_chunk=intercepted_events.append,
|
||||
chunk_adapter=lambda chunk: chunk,
|
||||
accept_chunk=_accept_codex_chunk,
|
||||
completed_response_predicate=lambda response: bool(
|
||||
hasattr(response, "output") and not hasattr(response, "__iter__")
|
||||
),
|
||||
metadata={
|
||||
"api_mode": "codex_responses",
|
||||
"api_request_id": getattr(agent, "_current_api_request_id", None),
|
||||
"call_role": (
|
||||
"delegated"
|
||||
if getattr(agent, "is_subagent", False)
|
||||
else "fallback"
|
||||
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
|
||||
else "primary"
|
||||
),
|
||||
"retry_count": attempt,
|
||||
},
|
||||
defer_logical_completion=True,
|
||||
)
|
||||
except (
|
||||
_httpx.RemoteProtocolError,
|
||||
_httpx.ReadTimeout,
|
||||
_httpx.ConnectError,
|
||||
ConnectionError,
|
||||
) as exc:
|
||||
if attempt < max_stream_retries:
|
||||
logger.debug(
|
||||
"Codex Responses stream connect failed (attempt %s/%s); retrying. %s error=%s",
|
||||
attempt + 1, max_stream_retries + 1,
|
||||
agent._client_log_context(), exc,
|
||||
"Codex Responses stream connect failed (attempt %s/%s); "
|
||||
"retrying. %s error=%s",
|
||||
attempt + 1,
|
||||
max_stream_retries + 1,
|
||||
agent._client_log_context(),
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
raise
|
||||
|
||||
# Claim the delta sink for THIS attempt (#65991) — parity with the
|
||||
# chat_completions/anthropic/bedrock paths. If a prior attempt's
|
||||
# stream is somehow still alive, this claim supersedes it so its
|
||||
# late deltas are fenced out of the turn; conversely, a newer
|
||||
# attempt supersedes us and the interrupt_check below stops our
|
||||
# consumption immediately.
|
||||
_writer_token = claim_stream_writer(agent)
|
||||
|
||||
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
|
||||
if agent._interrupt_requested:
|
||||
return True
|
||||
if not stream_writer_is_current(agent, _tok):
|
||||
logger.warning(
|
||||
"Codex streaming attempt superseded by a newer stream; "
|
||||
"stopping consumption to preserve the single-writer "
|
||||
"invariant (model=%s).",
|
||||
api_kwargs.get("model", "unknown"),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
def _interrupt_or_superseded() -> bool:
|
||||
return bool(agent._interrupt_requested)
|
||||
|
||||
try:
|
||||
# Compatibility: some mocks/providers return a concrete response
|
||||
# instead of an iterable. Pass it straight through.
|
||||
if hasattr(event_stream, "output") and not hasattr(event_stream, "__iter__"):
|
||||
return event_stream
|
||||
|
||||
try:
|
||||
final = _consume_codex_event_stream(
|
||||
event_stream,
|
||||
|
|
@ -1320,6 +1366,12 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
|||
on_event=_on_event,
|
||||
interrupt_check=_interrupt_or_superseded,
|
||||
)
|
||||
# The terminal SSE frame is contractually last. Request the
|
||||
# end-of-stream marker so Relay can run its response finalizer
|
||||
# and close the physical attempt scope before Hermes returns.
|
||||
if not agent._interrupt_requested:
|
||||
for _ignored in event_stream:
|
||||
pass
|
||||
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
|
||||
if attempt < max_stream_retries:
|
||||
logger.debug(
|
||||
|
|
@ -1330,6 +1382,10 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
|||
)
|
||||
continue
|
||||
raise
|
||||
except RuntimeError:
|
||||
if event_stream.final_response is not None:
|
||||
return event_stream.final_response
|
||||
raise
|
||||
|
||||
if final.status in {"incomplete", "failed"}:
|
||||
logger.warning(
|
||||
|
|
@ -1347,7 +1403,20 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
|||
try:
|
||||
close_fn()
|
||||
except Exception:
|
||||
pass
|
||||
# A failed close can leave this response's connection
|
||||
# checked out of the httpx pool while the caller's finally
|
||||
# reports a reuse-reason close (e.g. interrupt_check broke
|
||||
# the event loop with collected output) — caching the
|
||||
# client with the leaked connection. Poison the slot so
|
||||
# that close really closes the pool (owner-thread abort;
|
||||
# mirrors the chat-streaming interrupt-break handling).
|
||||
# ``client is None`` means the shared primary client,
|
||||
# which is never reuse-cached and must not have its
|
||||
# sockets force-shut here.
|
||||
if client is not None:
|
||||
agent._abort_request_openai_client(
|
||||
active_client, reason="codex_stream_close_failed"
|
||||
)
|
||||
|
||||
|
||||
def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None):
|
||||
|
|
|
|||
|
|
@ -1354,6 +1354,125 @@ class ContextCompressor(ContextEngine):
|
|||
previous = telemetry.get("aux_call_duration_ms") or 0
|
||||
telemetry["aux_call_duration_ms"] = previous + max(0, int(duration_ms))
|
||||
|
||||
def _emit_init_summary_once(self) -> None:
|
||||
"""Emit the informative startup line once, on first resolution.
|
||||
|
||||
Deferred out of ``__init__`` (#32221): the line reports resolved token
|
||||
budgets, so emitting it there would force the synchronous
|
||||
``get_model_context_length()`` probe during construction. Reads via
|
||||
the properties below are safe here because
|
||||
``_resolved_context_length`` is already set.
|
||||
"""
|
||||
if not getattr(self, "_log_init_summary", False):
|
||||
return
|
||||
self._log_init_summary = False
|
||||
logger.info(
|
||||
"Context compressor initialized: model=%s context_length=%d "
|
||||
"threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d "
|
||||
"provider=%s base_url=%s",
|
||||
self.model, self._resolved_context_length, self.threshold_tokens,
|
||||
self.threshold_percent * 100, self.summary_target_ratio * 100,
|
||||
self.tail_token_budget,
|
||||
self.provider or "none", self.base_url or "none",
|
||||
)
|
||||
|
||||
def _resolve_context_length(self) -> int:
|
||||
"""Resolve and cache the model's context length on first access."""
|
||||
if self._resolved_context_length is None:
|
||||
self._resolved_context_length = get_model_context_length(
|
||||
self.model,
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
config_context_length=self._config_context_length,
|
||||
provider=self.provider,
|
||||
)
|
||||
# Small-context threshold floor: models under 512K trigger at
|
||||
# >=75% so compaction doesn't fire with half the window still
|
||||
# free. Raise-only; must run AFTER context_length is resolved
|
||||
# and BEFORE threshold_tokens is derived (deferred here from
|
||||
# __init__ along with the resolution itself, #32221).
|
||||
# _base_threshold_percent already has the per-model override
|
||||
# applied, so the floor stacks on top of it.
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
self._resolved_context_length, self._base_threshold_percent,
|
||||
)
|
||||
self._emit_init_summary_once()
|
||||
return self._resolved_context_length
|
||||
|
||||
@property
|
||||
def context_length(self) -> int:
|
||||
return self._resolve_context_length()
|
||||
|
||||
@context_length.setter
|
||||
def context_length(self, value: int) -> None:
|
||||
# No-op guard: repeated assignment of the SAME window (e.g. the codex
|
||||
# app-server usage callback re-reports the window on every response)
|
||||
# must not invalidate the derived budgets — that would wipe runtime
|
||||
# corrections applied directly to threshold_tokens/tail_token_budget
|
||||
# (see conversation_compression's aux-context threshold sync), which
|
||||
# persisted on main's eager-init behavior.
|
||||
if value == getattr(self, "_resolved_context_length", None):
|
||||
return
|
||||
self._resolved_context_length = value
|
||||
# Re-apply the small-context floor (raise-only) for the genuinely new
|
||||
# window so the invalidated budgets below recompute coherently —
|
||||
# percent and tokens must derive from the same window. Skipped on
|
||||
# bare test instances built via object.__new__ that never ran
|
||||
# __init__ (no _base_threshold_percent).
|
||||
_base = getattr(self, "_base_threshold_percent", None)
|
||||
if _base is not None:
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
value, _base,
|
||||
)
|
||||
self._threshold_tokens = None
|
||||
self._tail_token_budget = None
|
||||
self._max_summary_tokens = None
|
||||
self._emit_init_summary_once()
|
||||
|
||||
@property
|
||||
def threshold_tokens(self) -> int:
|
||||
if self._threshold_tokens is None:
|
||||
# Resolve the window FIRST (may apply the small-context floor to
|
||||
# threshold_percent as a side effect) so the percent read below
|
||||
# is the floored value regardless of argument evaluation order.
|
||||
_ctx = self.context_length
|
||||
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even
|
||||
# if the percentage would suggest a lower value (#14690 handles
|
||||
# the degenerate small-window case inside the helper).
|
||||
self._threshold_tokens = self._compute_threshold_tokens(
|
||||
_ctx, self.threshold_percent, self.max_tokens,
|
||||
)
|
||||
# Apply absolute token cap (compression.threshold_tokens) —
|
||||
# takes the lower of the ratio-based threshold and the cap.
|
||||
self._apply_threshold_tokens_cap()
|
||||
return self._threshold_tokens
|
||||
|
||||
@threshold_tokens.setter
|
||||
def threshold_tokens(self, value: int) -> None:
|
||||
self._threshold_tokens = value
|
||||
|
||||
@property
|
||||
def tail_token_budget(self) -> int:
|
||||
if self._tail_token_budget is None:
|
||||
self._tail_token_budget = int(self.threshold_tokens * self.summary_target_ratio)
|
||||
return self._tail_token_budget
|
||||
|
||||
@tail_token_budget.setter
|
||||
def tail_token_budget(self, value: int) -> None:
|
||||
self._tail_token_budget = value
|
||||
|
||||
@property
|
||||
def max_summary_tokens(self) -> int:
|
||||
if self._max_summary_tokens is None:
|
||||
self._max_summary_tokens = min(
|
||||
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
|
||||
)
|
||||
return self._max_summary_tokens
|
||||
|
||||
@max_summary_tokens.setter
|
||||
def max_summary_tokens(self, value: int) -> None:
|
||||
self._max_summary_tokens = value
|
||||
|
||||
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||
"""Clear all per-session compaction state at a real session boundary.
|
||||
|
||||
|
|
@ -1988,56 +2107,30 @@ class ContextCompressor(ContextEngine):
|
|||
# deterministic "summary unavailable" handoff and drop the middle window.
|
||||
self.abort_on_summary_failure = abort_on_summary_failure
|
||||
|
||||
self.context_length = get_model_context_length(
|
||||
model, base_url=base_url, api_key=api_key,
|
||||
config_context_length=config_context_length,
|
||||
provider=provider,
|
||||
)
|
||||
# Small-context threshold floor: models under 512K trigger at >=75%
|
||||
# so compaction doesn't fire with half the window still free (the
|
||||
# incompressible floor makes 50%-triggered compaction thrash on
|
||||
# 128K-262K models). Raise-only; must run AFTER context_length is
|
||||
# resolved and BEFORE threshold_tokens is derived. The pre-floor
|
||||
# value is kept so update_model() can re-derive for a new window
|
||||
# (switching small -> large must drop back to the configured value).
|
||||
# Note: _base_threshold_percent already has the per-model override
|
||||
# applied, so the floor stacks on top of any model-specific threshold.
|
||||
# Defer context-length resolution to first access (#32221):
|
||||
# get_model_context_length() can issue a synchronous /models HTTP
|
||||
# probe, which must not block AIAgent construction. The small-context
|
||||
# threshold floor and the absolute threshold cap both need the
|
||||
# resolved window, so they are applied on first resolution (see
|
||||
# _resolve_context_length / the threshold_tokens property) instead
|
||||
# of here. update_model() re-derives the floor for a new window from
|
||||
# _config_threshold_percent (the raw config value snapshotted above),
|
||||
# so switching small -> large correctly drops back to the configured
|
||||
# value.
|
||||
self._config_context_length = config_context_length
|
||||
self._configured_threshold_percent = self.threshold_percent
|
||||
self.threshold_percent = self._effective_threshold_percent(
|
||||
self.context_length, self._base_threshold_percent,
|
||||
)
|
||||
threshold_percent = self.threshold_percent
|
||||
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
|
||||
# the percentage would suggest a lower value. This prevents premature
|
||||
# compression on large-context models at 50% while keeping the % sane
|
||||
# for models right at the minimum. _compute_threshold_tokens also
|
||||
# guards the degenerate case where the floor would equal/exceed the
|
||||
# window (small models), so auto-compression can still fire (#14690).
|
||||
self.threshold_tokens = self._compute_threshold_tokens(
|
||||
self.context_length, threshold_percent, self.max_tokens,
|
||||
)
|
||||
# Apply absolute token cap (compression.threshold_tokens) — takes
|
||||
# the lower of the ratio-based threshold and the cap.
|
||||
self._apply_threshold_tokens_cap()
|
||||
self._resolved_context_length: int | None = None
|
||||
self._threshold_tokens: int | None = None
|
||||
self._tail_token_budget: int | None = None
|
||||
self._max_summary_tokens: int | None = None
|
||||
self.compression_count = 0
|
||||
|
||||
# Derive token budgets: ratio is relative to the threshold, not total context
|
||||
target_tokens = int(self.threshold_tokens * self.summary_target_ratio)
|
||||
self.tail_token_budget = target_tokens
|
||||
self.max_summary_tokens = min(
|
||||
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
|
||||
)
|
||||
|
||||
if not quiet_mode:
|
||||
logger.info(
|
||||
"Context compressor initialized: model=%s context_length=%d "
|
||||
"threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d "
|
||||
"provider=%s base_url=%s",
|
||||
model, self.context_length, self.threshold_tokens,
|
||||
threshold_percent * 100, self.summary_target_ratio * 100,
|
||||
self.tail_token_budget,
|
||||
provider or "none", base_url or "none",
|
||||
)
|
||||
# The "initialized" log reports resolved token budgets, which would
|
||||
# force the deferred get_model_context_length() probe to run inside
|
||||
# __init__ and re-introduce the exact synchronous blocking this change
|
||||
# removes (#32221). Emit it on first context-length resolution instead
|
||||
# so construction stays non-blocking on every path (not just quiet).
|
||||
self._log_init_summary = not quiet_mode
|
||||
self._context_probed = False # True after a step-down from context error
|
||||
|
||||
self.last_prompt_tokens = 0
|
||||
|
|
|
|||
|
|
@ -2721,16 +2721,28 @@ def try_shrink_image_parts_in_messages(
|
|||
media_type = "image/jpeg"
|
||||
return f"data:{media_type};base64,{data}"
|
||||
|
||||
def _write_data_url_to_source(source: dict, data_url: str) -> None:
|
||||
def _write_data_url_to_source(source: dict, data_url: str) -> dict:
|
||||
"""Return a NEW source dict carrying the re-encoded payload.
|
||||
|
||||
Copy-on-write: content parts on the per-call ``api_messages`` list may
|
||||
be shared references into the persistent conversation history (the
|
||||
per-message copy is shallow, and cache decoration only deep-copies the
|
||||
marked messages). Mutating the existing dict would rewrite the stored
|
||||
transcript with the degraded image — so the caller replaces the part,
|
||||
never edits it in place.
|
||||
"""
|
||||
header, _, data = data_url.partition(",")
|
||||
media_type = "image/jpeg"
|
||||
if header.startswith("data:"):
|
||||
candidate = header[len("data:"):].split(";", 1)[0].strip()
|
||||
if candidate.startswith("image/"):
|
||||
media_type = candidate
|
||||
source["type"] = "base64"
|
||||
source["media_type"] = media_type
|
||||
source["data"] = data
|
||||
return {
|
||||
**source,
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
for msg in api_messages:
|
||||
if not isinstance(msg, dict):
|
||||
|
|
@ -2738,7 +2750,13 @@ def try_shrink_image_parts_in_messages(
|
|||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for part in content:
|
||||
# Copy-on-write per message: never mutate part/source dicts in place —
|
||||
# they can alias the stored conversation history (see
|
||||
# _write_data_url_to_source). Build a replacement content list on the
|
||||
# first shrunken part and reassign msg["content"] (a top-level write on
|
||||
# the per-call message copy, which never reaches history).
|
||||
new_content: list | None = None
|
||||
for part_idx, part in enumerate(content):
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
|
|
@ -2747,7 +2765,12 @@ def try_shrink_image_parts_in_messages(
|
|||
url = _source_to_data_url(source)
|
||||
resized, unshrinkable = _shrink_data_url(url or "")
|
||||
if resized and isinstance(source, dict):
|
||||
_write_data_url_to_source(source, resized)
|
||||
if new_content is None:
|
||||
new_content = list(content)
|
||||
new_content[part_idx] = {
|
||||
**part,
|
||||
"source": _write_data_url_to_source(source, resized),
|
||||
}
|
||||
changed_count += 1
|
||||
elif unshrinkable:
|
||||
unshrinkable_oversized += 1
|
||||
|
|
@ -2761,17 +2784,26 @@ def try_shrink_image_parts_in_messages(
|
|||
url = image_value.get("url", "")
|
||||
resized, unshrinkable = _shrink_data_url(url)
|
||||
if resized:
|
||||
image_value["url"] = resized
|
||||
if new_content is None:
|
||||
new_content = list(content)
|
||||
new_content[part_idx] = {
|
||||
**part,
|
||||
"image_url": {**image_value, "url": resized},
|
||||
}
|
||||
changed_count += 1
|
||||
elif unshrinkable:
|
||||
unshrinkable_oversized += 1
|
||||
elif isinstance(image_value, str):
|
||||
resized, unshrinkable = _shrink_data_url(image_value)
|
||||
if resized:
|
||||
part["image_url"] = resized
|
||||
if new_content is None:
|
||||
new_content = list(content)
|
||||
new_content[part_idx] = {**part, "image_url": resized}
|
||||
changed_count += 1
|
||||
elif unshrinkable:
|
||||
unshrinkable_oversized += 1
|
||||
if new_content is not None:
|
||||
msg["content"] = new_content
|
||||
|
||||
if changed_count:
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
|||
# session is created (not on continuation). Plugins can use this
|
||||
# to initialise session-scoped state (e.g. warm a memory cache).
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"on_session_start",
|
||||
session_id=agent.session_id,
|
||||
|
|
@ -2100,7 +2100,7 @@ def run_conversation(
|
|||
_llm_middleware_trace = []
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import (
|
||||
from hermes_cli.lifecycle import (
|
||||
has_hook,
|
||||
invoke_hook as _invoke_hook,
|
||||
)
|
||||
|
|
@ -2141,6 +2141,7 @@ def run_conversation(
|
|||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
retry_count=retry_count,
|
||||
request_messages=list(request_messages)
|
||||
if isinstance(request_messages, list)
|
||||
else [],
|
||||
|
|
@ -2230,7 +2231,28 @@ def run_conversation(
|
|||
return agent._interruptible_streaming_api_call(
|
||||
next_api_kwargs, on_first_delta=_stop_spinner
|
||||
)
|
||||
return agent._interruptible_api_call(next_api_kwargs)
|
||||
from agent import relay_llm
|
||||
|
||||
return relay_llm.execute(
|
||||
next_api_kwargs,
|
||||
agent._interruptible_api_call,
|
||||
session_id=str(agent.session_id or ""),
|
||||
name=str(agent.provider or "provider"),
|
||||
model_name=str(agent.model or ""),
|
||||
metadata={
|
||||
"api_mode": agent.api_mode,
|
||||
"api_request_id": api_request_id,
|
||||
"call_role": (
|
||||
"delegated"
|
||||
if getattr(agent, "is_subagent", False)
|
||||
else "fallback"
|
||||
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
|
||||
else "primary"
|
||||
),
|
||||
"retry_count": retry_count,
|
||||
},
|
||||
defer_logical_completion=True,
|
||||
)
|
||||
|
||||
from hermes_cli.middleware import run_llm_execution_middleware
|
||||
|
||||
|
|
@ -3217,7 +3239,12 @@ def run_conversation(
|
|||
_cost_delta = (_cost_delta or 0.0) + float(_moa_ref_cost)
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pass
|
||||
agent._session_db.update_token_counts(
|
||||
# Enqueued, not written: the background writer
|
||||
# applies the delta off the turn thread (a cold
|
||||
# state.db UPDATE here stalled the tool loop for
|
||||
# up to hundreds of ms per API call). Drained at
|
||||
# turn finalize via _persist_session.
|
||||
agent._session_db.queue_token_counts(
|
||||
agent.session_id,
|
||||
input_tokens=canonical_usage.input_tokens,
|
||||
output_tokens=canonical_usage.output_tokens,
|
||||
|
|
@ -3283,6 +3310,12 @@ def run_conversation(
|
|||
clear_nous_rate_limit()
|
||||
except Exception:
|
||||
pass
|
||||
from agent import relay_llm
|
||||
|
||||
relay_llm.complete_logical_call(
|
||||
api_request_id,
|
||||
outcome="success",
|
||||
)
|
||||
agent._touch_activity(f"API call #{api_call_count} completed")
|
||||
break # Success, exit retry loop
|
||||
|
||||
|
|
@ -5427,7 +5460,7 @@ def run_conversation(
|
|||
assistant_message.content = str(raw)
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import (
|
||||
from hermes_cli.lifecycle import (
|
||||
has_hook,
|
||||
invoke_hook as _invoke_hook,
|
||||
)
|
||||
|
|
@ -6760,7 +6793,8 @@ def run_conversation(
|
|||
_attempt = getattr(agent, "_pre_verify_nudges", 0)
|
||||
try:
|
||||
from agent.verify_hooks import max_verify_nudges
|
||||
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
|
||||
from hermes_cli.lifecycle import has_hook
|
||||
from hermes_cli.plugins import get_pre_verify_continue_message
|
||||
|
||||
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
|
||||
# Posture is fixed for the session — resolve once + cache.
|
||||
|
|
|
|||
|
|
@ -170,6 +170,27 @@ CREDITS_USAGE_BANDS: tuple[tuple[float, str, int], ...] = (
|
|||
)
|
||||
CREDITS_USAGE_KEY = "credits.usage" # single key for the escalating usage notice
|
||||
|
||||
# Minimum subscription balance that counts as "grant not yet spent" for the
|
||||
# grant_spent crossing gate (see evaluate_credits_notices). 1¢: portal-seeded
|
||||
# states derive micros from float dollars and can carry sub-cent residue where
|
||||
# the inference headers report exactly 0 — without this floor such a seed
|
||||
# opens the gate and the first header re-creates the at-open nag.
|
||||
GRANT_UNSPENT_MIN_MICROS = 10_000
|
||||
|
||||
|
||||
def new_credits_latch() -> dict:
|
||||
"""Fresh notice latch in the shape :func:`evaluate_credits_notices` expects.
|
||||
|
||||
The policy owns this schema — every producer (agent build, lazy re-init,
|
||||
tests) must build the latch through here so a new gate key lands everywhere
|
||||
at once instead of drifting across hand-rolled literals."""
|
||||
return {
|
||||
"active": set(),
|
||||
"seen_below_90": False,
|
||||
"usage_band": None,
|
||||
"seen_grant_unspent": False,
|
||||
}
|
||||
|
||||
|
||||
# ── AgentNotice (out-of-band notice payload; driver-agnostic) ────────────────
|
||||
|
||||
|
|
@ -250,7 +271,8 @@ def evaluate_credits_notices(
|
|||
) -> tuple[list[AgentNotice], list[str]]:
|
||||
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
|
||||
|
||||
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
|
||||
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int],
|
||||
"seen_grant_unspent": bool}.
|
||||
|
||||
``model_is_free``: True when the session's active model is a Nous free-tier
|
||||
model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
|
||||
|
|
@ -277,6 +299,18 @@ def evaluate_credits_notices(
|
|||
if uf is not None and uf < _lowest_band:
|
||||
latch["seen_below_90"] = True # gate opened: usage-band notices may now fire
|
||||
|
||||
# Grant-spent crossing gate: grant_spent may fire only after this session
|
||||
# has OBSERVED the grant meaningfully unspent (≥1¢ left — see
|
||||
# GRANT_UNSPENT_MIN_MICROS). Opening at grant-spent is a steady STATE, not
|
||||
# an event — /usage carries it; only a live in-session crossing announces.
|
||||
# Unlike seen_below_90, seeds must NOT prime this gate.
|
||||
if (
|
||||
uf is not None
|
||||
and uf < 1.0
|
||||
and state.subscription_micros >= GRANT_UNSPENT_MIN_MICROS
|
||||
):
|
||||
latch["seen_grant_unspent"] = True
|
||||
|
||||
active = latch["active"]
|
||||
|
||||
# ── Conditions ───────────────────────────────────────────────────────────
|
||||
|
|
@ -341,7 +375,17 @@ def evaluate_credits_notices(
|
|||
latch["usage_band"] = target_band
|
||||
|
||||
# ── grant_spent ──────────────────────────────────────────────────────────
|
||||
if grant_cond and "credits.grant_spent" not in active:
|
||||
# The crossing gate guards only the SHOW and is CONSUMED by it — one
|
||||
# announcement per crossing. A header flicker (uf → None → back to 1.0)
|
||||
# clears the sticky line via grant_cond but cannot re-announce; only a
|
||||
# renewal that re-opens the gate (a fresh ≥1¢ observation) arms the next
|
||||
# announcement. .get(): default closed for any hand-built latch missing
|
||||
# the key, so a first observation can never fire this notice.
|
||||
if (
|
||||
grant_cond
|
||||
and "credits.grant_spent" not in active
|
||||
and latch.get("seen_grant_unspent", False)
|
||||
):
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text=f"• Grant spent · ${state.purchased_usd} top-up left",
|
||||
|
|
@ -352,6 +396,7 @@ def evaluate_credits_notices(
|
|||
)
|
||||
)
|
||||
active.add("credits.grant_spent")
|
||||
latch["seen_grant_unspent"] = False
|
||||
elif "credits.grant_spent" in active and not grant_cond:
|
||||
to_clear.append("credits.grant_spent")
|
||||
active.discard("credits.grant_spent")
|
||||
|
|
@ -627,7 +672,8 @@ _DEV_FIXTURES: dict[str, dict] = {
|
|||
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
|
||||
denominator_kind="subscription_cap", paid_access=True,
|
||||
),
|
||||
"grant_exhausted": dict( # used_fraction == 1.0 + purchased>0 → credits.grant_spent
|
||||
"grant_exhausted": dict( # uf == 1.0 + purchased>0 → SILENT at open (crossing-gated);
|
||||
# flip healthy → grant_exhausted via the fixture-file path to see credits.grant_spent
|
||||
remaining_micros=12_340_000, remaining_usd="12.34",
|
||||
subscription_micros=0, subscription_usd="0.00",
|
||||
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
|
||||
|
|
@ -741,6 +787,9 @@ def _hydrate_seed_state(agent, state) -> None:
|
|||
agent._credits_session_start_micros = state.remaining_micros
|
||||
_latch = getattr(agent, "_credits_latch", None)
|
||||
if isinstance(_latch, dict) and state.used_fraction is not None:
|
||||
# Prime ONLY seen_below_90 (open-high band warnings are wanted at open).
|
||||
# Never prime seen_grant_unspent here: a seed observing grant-spent is a
|
||||
# steady state, and priming it would revive the every-session nag.
|
||||
_latch["seen_below_90"] = True
|
||||
emit = getattr(agent, "_emit_credits_notices", None)
|
||||
if callable(emit):
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ def probe_gemini_tier(
|
|||
api_key: str,
|
||||
base_url: str = DEFAULT_GEMINI_BASE_URL,
|
||||
*,
|
||||
model: str = "gemini-2.5-flash",
|
||||
model: str = "gemini-3.6-flash",
|
||||
timeout: float = 10.0,
|
||||
) -> str:
|
||||
"""Probe a Google AI Studio API key and return its tier.
|
||||
|
|
@ -154,8 +154,8 @@ def is_free_tier_quota_error(error_message: str) -> bool:
|
|||
|
||||
|
||||
_FREE_TIER_GUIDANCE = (
|
||||
"\n\nYour Google API key is on the free tier (<= 250 requests/day for "
|
||||
"gemini-2.5-flash). Hermes typically makes 3-10 API calls per user turn, "
|
||||
"\n\nYour Google API key is on the free tier (a few hundred requests/day "
|
||||
"for Gemini Flash models). Hermes typically makes 3-10 API calls per user turn, "
|
||||
"so the free tier is exhausted in a handful of messages and cannot sustain "
|
||||
"an agent session. Enable billing on your Google Cloud project and "
|
||||
"regenerate the key in a billing-enabled project: "
|
||||
|
|
@ -321,9 +321,13 @@ def _translate_tool_result_to_gemini(
|
|||
) -> Dict[str, Any]:
|
||||
tool_name_by_call_id = tool_name_by_call_id or {}
|
||||
tool_call_id = str(message.get("tool_call_id") or "")
|
||||
# A tool result can carry the unwrapped internal tool name (for example,
|
||||
# an MCP tool invoked through the `tool_call` bridge). Gemini requires
|
||||
# functionResponse.name to echo the matching functionCall.name, so the
|
||||
# call-id mapping must take precedence over the internal result name.
|
||||
name = str(
|
||||
message.get("name")
|
||||
or tool_name_by_call_id.get(tool_call_id)
|
||||
tool_name_by_call_id.get(tool_call_id)
|
||||
or message.get("name")
|
||||
or tool_call_id
|
||||
or "tool"
|
||||
)
|
||||
|
|
@ -976,7 +980,7 @@ class GeminiNativeClient:
|
|||
def _create_chat_completion(
|
||||
self,
|
||||
*,
|
||||
model: str = "gemini-2.5-flash",
|
||||
model: str = "gemini-3.6-flash",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = False,
|
||||
tools: Any = None,
|
||||
|
|
|
|||
|
|
@ -113,6 +113,13 @@ class InsightsEngine:
|
|||
"""
|
||||
cutoff = time.time() - (days * 86400)
|
||||
|
||||
# Token/cost totals may still sit on the SessionDB's async
|
||||
# accounting queue; drain so the report reflects exact counters.
|
||||
# (self.db may be a raw sqlite3 connection in tests — guard.)
|
||||
flush = getattr(self.db, "flush_token_counts", None)
|
||||
if callable(flush):
|
||||
flush()
|
||||
|
||||
# Gather raw data
|
||||
sessions = self._get_sessions(cutoff, source)
|
||||
tool_usage = self._get_tool_usage(cutoff, source)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Tuple
|
||||
from typing import List, Tuple
|
||||
|
||||
# Dedicated logger name so the documented grep recipe survives a
|
||||
# ``logging.getLogger(__name__)`` rename of any internal module.
|
||||
|
|
@ -188,6 +188,25 @@ def log_spawn_failed(server_id: str, workspace_root: str, exc: BaseException) ->
|
|||
)
|
||||
|
||||
|
||||
def log_reaped(keys: List[Tuple[str, str]], idle_timeout: float) -> None:
|
||||
"""Idle clients were shut down by the reaper. INFO — one line per
|
||||
sweep so users can correlate memory drops with LSP activity.
|
||||
|
||||
Also clears the ``log_active`` announce cache for the reaped keys so
|
||||
a later respawn re-announces at INFO instead of logging a misleading
|
||||
DEBUG "reused client".
|
||||
"""
|
||||
with _announce_lock:
|
||||
for key in keys:
|
||||
_announced_active.discard(key)
|
||||
summary = ", ".join(f"{sid} ({root})" for sid, root in keys)
|
||||
_emit(
|
||||
"reaper",
|
||||
logging.INFO,
|
||||
f"reaped {len(keys)} idle client(s) after {idle_timeout:.0f}s: {summary}",
|
||||
)
|
||||
|
||||
|
||||
def reset_announce_caches() -> None:
|
||||
"""Test-only: clear the dedup caches. Production code never calls this."""
|
||||
with _announce_lock:
|
||||
|
|
@ -209,5 +228,6 @@ __all__ = [
|
|||
"log_timeout",
|
||||
"log_server_error",
|
||||
"log_spawn_failed",
|
||||
"log_reaped",
|
||||
"reset_announce_caches",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ from agent.lsp.workspace import (
|
|||
logger = logging.getLogger("agent.lsp.manager")
|
||||
|
||||
DEFAULT_IDLE_TIMEOUT = 600 # seconds; servers idle for >10min get reaped
|
||||
MIN_IDLE_TIMEOUT = 30 # floor for config values; must exceed any per-op wait budget
|
||||
|
||||
|
||||
class _BackgroundLoop:
|
||||
|
|
@ -176,6 +177,7 @@ class LSPService:
|
|||
self._spawning: Dict[Tuple[str, str], asyncio.Future] = {}
|
||||
self._last_used: Dict[Tuple[str, str], float] = {}
|
||||
self._state_lock = threading.Lock()
|
||||
self._idle_reaper_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Delta baseline: file path → snapshot of diagnostics taken
|
||||
# immediately before a write. ``get_diagnostics_sync`` filters
|
||||
|
|
@ -183,6 +185,9 @@ class LSPService:
|
|||
# introduced by the current edit.
|
||||
self._delta_baseline: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
if self._enabled and self._idle_timeout > 0:
|
||||
self._loop.run(self._start_idle_reaper(), timeout=2.0)
|
||||
|
||||
@classmethod
|
||||
def create_from_config(cls) -> Optional["LSPService"]:
|
||||
"""Build a service from ``hermes_cli.config`` settings.
|
||||
|
|
@ -205,6 +210,16 @@ class LSPService:
|
|||
wait_mode = lsp_cfg.get("wait_mode", "document")
|
||||
wait_timeout = float(lsp_cfg.get("wait_timeout", DIAGNOSTICS_DOCUMENT_WAIT))
|
||||
install_strategy = lsp_cfg.get("install_strategy", "auto")
|
||||
try:
|
||||
idle_timeout = float(lsp_cfg.get("idle_timeout", DEFAULT_IDLE_TIMEOUT))
|
||||
except (TypeError, ValueError):
|
||||
idle_timeout = DEFAULT_IDLE_TIMEOUT
|
||||
if 0 < idle_timeout < MIN_IDLE_TIMEOUT:
|
||||
# A timeout below the per-operation wait budget could reap a
|
||||
# client mid-flight; the resulting outer timeout would then
|
||||
# mark the (server, workspace) pair broken for the process
|
||||
# lifetime. Clamp to a safe floor (0 still disables).
|
||||
idle_timeout = MIN_IDLE_TIMEOUT
|
||||
servers_cfg = lsp_cfg.get("servers") or {}
|
||||
disabled = []
|
||||
binary_overrides: Dict[str, List[str]] = {}
|
||||
|
|
@ -235,6 +250,7 @@ class LSPService:
|
|||
env_overrides=env_overrides,
|
||||
init_overrides=init_overrides,
|
||||
disabled_servers=disabled,
|
||||
idle_timeout=idle_timeout,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -434,6 +450,7 @@ class LSPService:
|
|||
# ``_clients`` with a half-initialized state.
|
||||
with self._state_lock:
|
||||
client = self._clients.pop(key, None)
|
||||
self._last_used.pop(key, None)
|
||||
if client is not None:
|
||||
try:
|
||||
# Fire-and-forget shutdown — give it a second to cleanup,
|
||||
|
|
@ -470,7 +487,7 @@ class LSPService:
|
|||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("snapshot open/wait failed: %s", e)
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
self._touch(client)
|
||||
if not fresh:
|
||||
# No fresh data for the pre-edit content — an empty baseline
|
||||
# is safe: worst case the delta filter removes less, never
|
||||
|
|
@ -499,7 +516,7 @@ class LSPService:
|
|||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("open/wait failed for %s: %s", file_path, e)
|
||||
return None
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
self._touch(client)
|
||||
if not fresh:
|
||||
return None
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
|
|
@ -539,6 +556,7 @@ class LSPService:
|
|||
with self._state_lock:
|
||||
client = self._clients.get(key)
|
||||
if client is not None and client.is_running:
|
||||
self._last_used[key] = time.time()
|
||||
eventlog.log_active(srv.server_id, per_server_root)
|
||||
return client
|
||||
spawning = self._spawning.get(key)
|
||||
|
|
@ -589,7 +607,7 @@ class LSPService:
|
|||
return None
|
||||
with self._state_lock:
|
||||
self._clients[key] = client
|
||||
self._last_used[key] = time.time()
|
||||
self._last_used[key] = time.time()
|
||||
eventlog.log_active(srv.server_id, per_server_root)
|
||||
spawn_future.set_result(client)
|
||||
return client
|
||||
|
|
@ -597,7 +615,63 @@ class LSPService:
|
|||
with self._state_lock:
|
||||
self._spawning.pop(key, None)
|
||||
|
||||
async def _start_idle_reaper(self) -> None:
|
||||
self._idle_reaper_task = asyncio.create_task(self._idle_reaper_loop())
|
||||
|
||||
def _touch(self, client: LSPClient) -> None:
|
||||
"""Refresh the last-used timestamp for a client we just used.
|
||||
|
||||
Guarded on membership so a reaped-mid-operation client can't
|
||||
resurrect an orphan ``_last_used`` entry after the reaper popped
|
||||
the key. All writers and the reaper run on the background loop
|
||||
thread; the lock keeps this consistent with the reader anyway.
|
||||
"""
|
||||
key = (client.server_id, client.workspace_root)
|
||||
with self._state_lock:
|
||||
if key in self._clients:
|
||||
self._last_used[key] = time.time()
|
||||
|
||||
async def _idle_reaper_loop(self) -> None:
|
||||
interval = min(60.0, self._idle_timeout)
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await self._reap_idle_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
# A transient sweep error must not kill the reaper —
|
||||
# otherwise one bad shutdown permanently re-opens the
|
||||
# unbounded-accumulation leak this loop exists to fix.
|
||||
logger.debug("LSP idle reaper sweep error: %s", e)
|
||||
|
||||
async def _reap_idle_once(self) -> None:
|
||||
cutoff = time.time() - self._idle_timeout
|
||||
with self._state_lock:
|
||||
idle_keys = [
|
||||
key
|
||||
for key in self._clients
|
||||
if self._last_used.get(key, 0) < cutoff
|
||||
]
|
||||
clients = [self._clients.pop(key) for key in idle_keys]
|
||||
for key in idle_keys:
|
||||
self._last_used.pop(key, None)
|
||||
if clients:
|
||||
eventlog.log_reaped(
|
||||
[(c.server_id, c.workspace_root) for c in clients],
|
||||
self._idle_timeout,
|
||||
)
|
||||
await asyncio.gather(
|
||||
*(client.shutdown() for client in clients),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _shutdown_async(self) -> None:
|
||||
reaper = self._idle_reaper_task
|
||||
self._idle_reaper_task = None
|
||||
if reaper is not None:
|
||||
reaper.cancel()
|
||||
await asyncio.gather(reaper, return_exceptions=True)
|
||||
with self._state_lock:
|
||||
clients = list(self._clients.values())
|
||||
self._clients.clear()
|
||||
|
|
|
|||
|
|
@ -2429,21 +2429,27 @@ def get_model_context_length(
|
|||
if context_length is not None:
|
||||
return context_length
|
||||
if not _is_known_provider_base_url(base_url):
|
||||
# 2b. Ollama native /api/show — any URL might be an Ollama server
|
||||
# (local, cloud, or custom hosting). Non-Ollama servers return
|
||||
# 404/405 quickly. Fall through on failure.
|
||||
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
|
||||
if ctx is not None:
|
||||
if not _skip_persistent_context_cache(base_url, provider):
|
||||
save_context_length(model, base_url, ctx)
|
||||
return ctx
|
||||
# 3. Try querying local server directly
|
||||
# For local endpoints, run the probe that respects configured
|
||||
# Modelfile context values first. _query_local_context_length
|
||||
# prefers num_ctx from Modelfile, while _query_ollama_api_show
|
||||
# returns the GGUF training max first which can be larger and
|
||||
# would create a false-safe window for compression (#63122).
|
||||
# Non-local endpoints preserve the existing GGUF-first behavior.
|
||||
if is_local_endpoint(base_url):
|
||||
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
|
||||
if local_ctx and local_ctx > 0:
|
||||
if not _skip_persistent_context_cache(base_url, provider):
|
||||
_maybe_cache_local_context_length(model, base_url, local_ctx)
|
||||
return local_ctx
|
||||
# 2b. Ollama native /api/show — non-local endpoints preserve
|
||||
# the existing generic /api/show GGUF-first behavior.
|
||||
# Non-Ollama servers return 404/405 quickly.
|
||||
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
|
||||
if ctx is not None:
|
||||
if not _skip_persistent_context_cache(base_url, provider):
|
||||
save_context_length(model, base_url, ctx)
|
||||
return ctx
|
||||
# 3. Probe-down fallback after endpoint-specific detection failed
|
||||
logger.info(
|
||||
"Could not detect context length for model %r at %s — "
|
||||
"defaulting to %s tokens (probe-down). Set model.context_length "
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ of 4000+ models across 109+ providers. Provides:
|
|||
(reasoning, tools, vision, PDF, audio), modalities, knowledge cutoff,
|
||||
open-weights flag, family grouping, deprecation status
|
||||
|
||||
Data resolution order (like TypeScript OpenCode):
|
||||
1. Bundled snapshot (ships with the package — offline-first)
|
||||
2. Disk cache (~/.hermes/models_dev_cache.json)
|
||||
3. Network fetch (https://models.dev/api.json)
|
||||
4. Background refresh every 60 minutes
|
||||
Data resolution order:
|
||||
1. In-memory cache (fresh, or stale served immediately while a single
|
||||
background daemon thread refreshes)
|
||||
2. Disk cache (~/.hermes/models_dev_cache.json — any age; stale data is
|
||||
served rather than blocking callers on the network)
|
||||
3. Network fetch (https://models.dev/api.json) — only when no cache
|
||||
exists at all; failed refreshes back off for 5 minutes process-wide
|
||||
Latency-sensitive callers (gateway route-identity checks) pass
|
||||
``allow_network=False`` and never touch the network.
|
||||
|
||||
Other modules should import the dataclasses and query functions from here
|
||||
rather than parsing the raw JSON themselves.
|
||||
|
|
@ -20,6 +24,7 @@ rather than parsing the raw JSON themselves.
|
|||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -33,10 +38,15 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
MODELS_DEV_URL = "https://models.dev/api.json"
|
||||
_MODELS_DEV_CACHE_TTL = 3600 # 1 hour in-memory
|
||||
_MODELS_DEV_RETRY_DELAY = 300 # 5 minutes after a failed refresh
|
||||
|
||||
# In-memory cache
|
||||
_models_dev_cache: Dict[str, Any] = {}
|
||||
_models_dev_cache_time: float = 0
|
||||
_models_dev_retry_after: float = 0
|
||||
_models_dev_fetch_lock = threading.Lock()
|
||||
_models_dev_refresh_lock = threading.Lock()
|
||||
_models_dev_refresh_in_flight = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -237,27 +247,152 @@ def _save_disk_cache(data: Dict[str, Any]) -> None:
|
|||
logger.debug("Failed to save models.dev disk cache: %s", e)
|
||||
|
||||
|
||||
def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
|
||||
def _fetch_models_dev_from_network() -> Dict[str, Any]:
|
||||
"""Fetch the live models.dev registry without touching local caches.
|
||||
|
||||
Raises on network errors and on an empty/invalid registry payload.
|
||||
"""
|
||||
response = requests.get(MODELS_DEV_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict) or not data:
|
||||
raise ValueError("models.dev returned an empty or invalid registry")
|
||||
return data
|
||||
|
||||
|
||||
def _mark_stale_cache_grace() -> None:
|
||||
"""Give stale cache data a short in-memory grace before retrying refresh.
|
||||
|
||||
Only ever moves the timestamp forward: if a background refresh completed
|
||||
between the caller's staleness check and this call, the fresh timestamp
|
||||
is preserved instead of being rewound to a 5-minute grace.
|
||||
"""
|
||||
global _models_dev_cache_time
|
||||
grace_time = time.time() - _MODELS_DEV_CACHE_TTL + _MODELS_DEV_RETRY_DELAY
|
||||
if grace_time > _models_dev_cache_time:
|
||||
_models_dev_cache_time = grace_time
|
||||
|
||||
|
||||
def _commit_registry(data: Dict[str, Any], *, where: str) -> None:
|
||||
"""Persist a freshly fetched registry: disk + in-mem + clear backoff.
|
||||
|
||||
Callers must hold ``_models_dev_fetch_lock`` so a failing refresh on one
|
||||
path can never stomp the state a succeeding refresh on the other path
|
||||
just committed (e.g. a failing background worker re-arming the backoff
|
||||
immediately after a successful ``force_refresh``).
|
||||
"""
|
||||
global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after
|
||||
_save_disk_cache(data)
|
||||
_models_dev_cache = data
|
||||
_models_dev_cache_time = time.time()
|
||||
_models_dev_retry_after = 0
|
||||
logger.debug(
|
||||
"Refreshed models.dev registry (%s): %d providers, %d total models",
|
||||
where,
|
||||
len(data),
|
||||
sum(len(p.get("models", {})) for p in data.values() if isinstance(p, dict)),
|
||||
)
|
||||
|
||||
|
||||
def _note_refresh_failure(exc: Exception, *, where: str) -> None:
|
||||
"""Record a failed refresh: arm the process-wide 5-minute backoff.
|
||||
|
||||
Callers must hold ``_models_dev_fetch_lock`` (see ``_commit_registry``).
|
||||
"""
|
||||
global _models_dev_retry_after
|
||||
_models_dev_retry_after = time.time() + _MODELS_DEV_RETRY_DELAY
|
||||
logger.debug(
|
||||
"models.dev refresh failed (%s); retry suppressed for %ds: %s",
|
||||
where,
|
||||
_MODELS_DEV_RETRY_DELAY,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _background_refresh_models_dev() -> None:
|
||||
"""Best-effort refresh after serving stale cache data."""
|
||||
global _models_dev_refresh_in_flight
|
||||
try:
|
||||
data = _fetch_models_dev_from_network()
|
||||
with _models_dev_fetch_lock:
|
||||
_commit_registry(data, where="background")
|
||||
except Exception as e:
|
||||
with _models_dev_fetch_lock:
|
||||
_note_refresh_failure(e, where="background")
|
||||
finally:
|
||||
with _models_dev_refresh_lock:
|
||||
_models_dev_refresh_in_flight = False
|
||||
|
||||
|
||||
def _start_background_refresh_models_dev() -> None:
|
||||
"""Start one daemon refresh worker if none is already running.
|
||||
|
||||
Honors the process-wide failure backoff: after a failed refresh,
|
||||
no new background worker is spawned until ``_models_dev_retry_after``.
|
||||
"""
|
||||
global _models_dev_refresh_in_flight
|
||||
if time.time() < _models_dev_retry_after:
|
||||
return
|
||||
with _models_dev_refresh_lock:
|
||||
if _models_dev_refresh_in_flight:
|
||||
return
|
||||
_models_dev_refresh_in_flight = True
|
||||
thread = threading.Thread(
|
||||
target=_background_refresh_models_dev,
|
||||
name="models-dev-refresh",
|
||||
daemon=True,
|
||||
)
|
||||
try:
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
# Thread/fd exhaustion: clear the flag so refresh isn't disabled
|
||||
# for the rest of the process lifetime. Callers still get stale data.
|
||||
with _models_dev_refresh_lock:
|
||||
_models_dev_refresh_in_flight = False
|
||||
logger.debug("Failed to start models.dev refresh thread: %s", e)
|
||||
|
||||
|
||||
def fetch_models_dev(
|
||||
force_refresh: bool = False, *, allow_network: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Fetch models.dev registry. Cache hierarchy: in-mem → disk → network.
|
||||
|
||||
Returns the full registry dict keyed by provider ID, or empty dict on failure.
|
||||
|
||||
Cache hierarchy (when ``force_refresh=False``):
|
||||
1. In-memory cache, populated and < TTL old → return immediately.
|
||||
2. **Disk cache file < TTL old by mtime → load, populate in-mem, return.**
|
||||
No network call. Saves ~500 ms per cold-start agent construction;
|
||||
``models.dev`` only changes when providers add new models, so a
|
||||
1 hour staleness window is acceptable (same TTL as in-mem cache).
|
||||
3. Network fetch → on success, save to disk + in-mem and return.
|
||||
4. Network fails → fall back to ANY available disk cache (even stale)
|
||||
with a short 5 min in-mem grace period before retrying network.
|
||||
1. Fresh in-memory cache → return immediately.
|
||||
2. Stale in-memory cache → return immediately and refresh in a single
|
||||
background daemon thread. Callers never block on the network while
|
||||
any cache exists; ``models.dev`` only changes when providers add
|
||||
new models, so stale data is preferable to a foreground timeout.
|
||||
3. Disk cache file (any age) → load, populate in-mem, return
|
||||
immediately. Stale disk caches trigger the same background refresh.
|
||||
4. No cache at all → singleflight foreground network fetch. On
|
||||
success, save to disk + in-mem and return.
|
||||
5. Any failed refresh (foreground or background) suppresses further
|
||||
automatic refreshes for 5 minutes process-wide.
|
||||
|
||||
When ``force_refresh=True`` (used by ``hermes config refresh``, the
|
||||
\"refresh model catalog\" code path), stages 1 and 2 are skipped. The
|
||||
function always hits the network and only falls back to disk if the
|
||||
network call fails.
|
||||
\"refresh model catalog\" code path), cache fast paths and the failure
|
||||
backoff are bypassed; the function hits the network and only falls back
|
||||
to cached data if the call fails. When ``allow_network=False``, any
|
||||
memory or disk cache is returned regardless of age and no request is
|
||||
made — used by latency-sensitive paths (gateway route-identity checks)
|
||||
that must never wait on the network.
|
||||
"""
|
||||
global _models_dev_cache, _models_dev_cache_time
|
||||
global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after
|
||||
|
||||
if not allow_network:
|
||||
if _models_dev_cache:
|
||||
return _models_dev_cache
|
||||
disk_data = _load_disk_cache()
|
||||
if disk_data:
|
||||
_models_dev_cache = disk_data
|
||||
disk_age = _disk_cache_age_seconds()
|
||||
_models_dev_cache_time = (
|
||||
time.time() - disk_age if disk_age is not None else 0
|
||||
)
|
||||
return _models_dev_cache
|
||||
|
||||
# Stage 1: fresh in-memory cache wins. This is the hot path on
|
||||
# long-lived processes — no I/O, no system calls.
|
||||
|
|
@ -268,54 +403,82 @@ def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
|
|||
):
|
||||
return _models_dev_cache
|
||||
|
||||
# Stage 2: fresh-by-mtime disk cache short-circuits the network call.
|
||||
# Only kicks in on cold-start processes (in-mem cache is empty or
|
||||
# expired) and only when the user hasn't asked for a forced refresh.
|
||||
# Skipped if the disk cache file is missing, unreadable, or older
|
||||
# than _MODELS_DEV_CACHE_TTL.
|
||||
# Stage 2: stale in-memory cache is still better than blocking provider
|
||||
# resolution on a foreground network timeout. Refresh it in the background.
|
||||
if not force_refresh and _models_dev_cache:
|
||||
_mark_stale_cache_grace()
|
||||
_start_background_refresh_models_dev()
|
||||
logger.debug(
|
||||
"Using stale in-memory models.dev cache; refreshing in background"
|
||||
)
|
||||
return _models_dev_cache
|
||||
|
||||
# Stage 3: disk cache short-circuits the network call.
|
||||
# Only kicks in on cold-start processes (in-mem cache is empty) and only
|
||||
# when the user hasn't asked for a forced refresh. A stale disk cache is
|
||||
# deliberately usable: provider/model resolution should not hang just
|
||||
# because models.dev is unreachable.
|
||||
if not force_refresh:
|
||||
disk_age = _disk_cache_age_seconds()
|
||||
if disk_age is not None and disk_age < _MODELS_DEV_CACHE_TTL:
|
||||
if disk_age is not None:
|
||||
disk_data = _load_disk_cache()
|
||||
if disk_data:
|
||||
_models_dev_cache = disk_data
|
||||
# Anchor in-mem TTL to the disk file's age so we don't
|
||||
# extend an already-aging cache by another full hour.
|
||||
_models_dev_cache_time = time.time() - disk_age
|
||||
logger.debug(
|
||||
"Loaded models.dev from fresh disk cache "
|
||||
"(%d providers, age=%.0fs)", len(disk_data), disk_age,
|
||||
)
|
||||
if disk_age < _MODELS_DEV_CACHE_TTL:
|
||||
# Anchor in-mem TTL to the disk file's age so we don't
|
||||
# extend an already-aging cache by another full hour.
|
||||
_models_dev_cache_time = time.time() - disk_age
|
||||
logger.debug(
|
||||
"Loaded models.dev from fresh disk cache "
|
||||
"(%d providers, age=%.0fs)", len(disk_data), disk_age,
|
||||
)
|
||||
else:
|
||||
_mark_stale_cache_grace()
|
||||
_start_background_refresh_models_dev()
|
||||
logger.debug(
|
||||
"Using stale models.dev disk cache (age=%.0fs); "
|
||||
"refreshing in background",
|
||||
disk_age,
|
||||
)
|
||||
return _models_dev_cache
|
||||
|
||||
# Stage 3: network fetch.
|
||||
try:
|
||||
response = requests.get(MODELS_DEV_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, dict) and data:
|
||||
_models_dev_cache = data
|
||||
_models_dev_cache_time = time.time()
|
||||
_save_disk_cache(data)
|
||||
logger.debug(
|
||||
"Fetched models.dev registry: %d providers, %d total models",
|
||||
len(data),
|
||||
sum(len(p.get("models", {})) for p in data.values() if isinstance(p, dict)),
|
||||
)
|
||||
# Failed automatic refreshes are process-wide. Avoid making every caller
|
||||
# retry the same unreachable endpoint while no usable cache exists.
|
||||
if not force_refresh and time.time() < _models_dev_retry_after:
|
||||
return _models_dev_cache
|
||||
|
||||
# Stage 4: singleflight foreground network fetch — only reached when no
|
||||
# memory or disk cache exists (or on force_refresh). Recheck state after
|
||||
# acquiring the lock because another caller may have refreshed or
|
||||
# established backoff while we waited.
|
||||
with _models_dev_fetch_lock:
|
||||
now = time.time()
|
||||
if not force_refresh:
|
||||
if _models_dev_cache:
|
||||
return _models_dev_cache
|
||||
if now < _models_dev_retry_after:
|
||||
return _models_dev_cache
|
||||
|
||||
try:
|
||||
data = _fetch_models_dev_from_network()
|
||||
_commit_registry(data, where="foreground")
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch models.dev: %s", e)
|
||||
except Exception as e:
|
||||
_note_refresh_failure(e, where="foreground")
|
||||
|
||||
# Stage 4: network failed — fall back to whatever disk cache exists,
|
||||
# even if it's stale. Give it a short 5 min in-mem TTL so we retry
|
||||
# the network soon instead of serving stale data for a full hour.
|
||||
if not _models_dev_cache:
|
||||
_models_dev_cache = _load_disk_cache()
|
||||
if _models_dev_cache:
|
||||
_models_dev_cache_time = time.time() - _MODELS_DEV_CACHE_TTL + 300
|
||||
logger.debug("Loaded models.dev from disk cache (%d providers)", len(_models_dev_cache))
|
||||
# Stage 5: network failed — return any stale memory/disk cache. Cache
|
||||
# freshness remains expired; the retry-after timestamp controls when
|
||||
# the next automatic request is allowed.
|
||||
if not _models_dev_cache:
|
||||
_models_dev_cache = _load_disk_cache()
|
||||
_models_dev_cache_time = 0
|
||||
if _models_dev_cache:
|
||||
logger.debug(
|
||||
"Loaded stale models.dev disk cache (%d providers)",
|
||||
len(_models_dev_cache),
|
||||
)
|
||||
|
||||
return _models_dev_cache
|
||||
return _models_dev_cache
|
||||
|
||||
|
||||
def lookup_models_dev_context(provider: str, model: str) -> Optional[int]:
|
||||
|
|
@ -671,7 +834,9 @@ def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo:
|
|||
# Provider-level queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
|
||||
def get_provider_info(
|
||||
provider_id: str, *, allow_network: bool = True
|
||||
) -> Optional[ProviderInfo]:
|
||||
"""Get full provider metadata from models.dev.
|
||||
|
||||
Accepts either a Hermes provider ID (e.g. "kilocode") or a models.dev
|
||||
|
|
@ -680,7 +845,14 @@ def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
|
|||
# Resolve Hermes ID → models.dev ID
|
||||
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
|
||||
|
||||
data = fetch_models_dev()
|
||||
# NOTE: keep the zero-argument call on the default path. Dozens of test
|
||||
# sites monkeypatch fetch_models_dev with zero-arg lambdas; passing the
|
||||
# kwarg unconditionally would break them all (they raise TypeError).
|
||||
data = (
|
||||
fetch_models_dev()
|
||||
if allow_network
|
||||
else fetch_models_dev(allow_network=False)
|
||||
)
|
||||
raw = data.get(mdev_id)
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -187,17 +187,18 @@ def apply_anthropic_cache_control(
|
|||
is retained.
|
||||
|
||||
Returns:
|
||||
Deep copy of messages with cache_control breakpoints injected.
|
||||
Shallow copy of message list with selective deep copies of modified messages.
|
||||
"""
|
||||
messages = copy.deepcopy(api_messages)
|
||||
if not messages:
|
||||
return messages
|
||||
if not api_messages:
|
||||
return api_messages
|
||||
|
||||
messages = list(api_messages)
|
||||
marker = _build_marker(cache_ttl)
|
||||
|
||||
breakpoints_used = 0
|
||||
|
||||
if messages[0].get("role") == "system":
|
||||
messages[0] = copy.deepcopy(messages[0])
|
||||
breakpoints_used = _apply_system_cache_markers(
|
||||
messages[0],
|
||||
marker,
|
||||
|
|
@ -213,6 +214,7 @@ def apply_anthropic_cache_control(
|
|||
and _can_carry_marker(messages[i], native_anthropic=native_anthropic)
|
||||
]
|
||||
for idx in non_sys[-remaining:]:
|
||||
messages[idx] = copy.deepcopy(messages[idx])
|
||||
_apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)
|
||||
|
||||
return messages
|
||||
|
|
|
|||
|
|
@ -146,19 +146,18 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
|
|||
# so we accept that community forks inheriting the same prefix are
|
||||
# treated as reasoning models (a reasonable default — the upstream
|
||||
# gateway timing is the same).
|
||||
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
|
||||
|
||||
|
||||
def _get_pattern(slug: str) -> re.Pattern[str]:
|
||||
compiled = _PATTERN_CACHE.get(slug)
|
||||
if compiled is None:
|
||||
compiled = re.compile(
|
||||
r"^"
|
||||
+ re.escape(slug)
|
||||
+ r"(?:$|[\-._])"
|
||||
)
|
||||
_PATTERN_CACHE[slug] = compiled
|
||||
return compiled
|
||||
# Pre-compile all patterns at module load time to avoid per-call regex
|
||||
# compilation and thread-safety issues with the mutable _PATTERN_CACHE.
|
||||
# The list is built once at import and never mutated afterwards, so it is
|
||||
# safe for free-threaded Python 3.13+ without any locking. The slug is kept
|
||||
# in each entry for debuggability (log/inspection), even though _match_any
|
||||
# only consumes floor + pattern.
|
||||
_SORTED_REASONING_FLOORS: list[tuple[str, float, re.Pattern[str]]] = [
|
||||
(slug, floor, re.compile(r"^" + re.escape(slug) + r"(?:$|[\-._])"))
|
||||
for slug, floor in sorted(
|
||||
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _match_any(model_lower: str) -> Optional[float]:
|
||||
|
|
@ -169,13 +168,8 @@ def _match_any(model_lower: str) -> Optional[float]:
|
|||
order is irrelevant: longest slug wins (so ``o3-mini`` beats
|
||||
``o3`` on a model like ``openai/o3-mini``).
|
||||
"""
|
||||
# Sort by slug length descending so longer / more-specific slugs
|
||||
# win on shared prefixes (o3-mini beats o3).
|
||||
sorted_floors = sorted(
|
||||
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
|
||||
)
|
||||
for slug, floor in sorted_floors:
|
||||
if _get_pattern(slug).search(model_lower):
|
||||
for _slug, floor, pattern in _SORTED_REASONING_FLOORS:
|
||||
if pattern.search(model_lower):
|
||||
return float(floor)
|
||||
return None
|
||||
|
||||
|
|
|
|||
1130
agent/relay_llm.py
Normal file
1130
agent/relay_llm.py
Normal file
File diff suppressed because it is too large
Load diff
1002
agent/relay_runtime.py
Normal file
1002
agent/relay_runtime.py
Normal file
File diff suppressed because it is too large
Load diff
123
agent/relay_tools.py
Normal file
123
agent/relay_tools.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Core NeMo Relay adapter for Hermes tool execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from agent import relay_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
callback: Callable[[dict[str, Any]], Any],
|
||||
*,
|
||||
session_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> tuple[Any, dict[str, Any]]:
|
||||
"""Run one tool call through Relay and return its final arguments."""
|
||||
runtime, session, parent = relay_runtime.resolve_execution_context(session_id)
|
||||
if runtime is None or session is None or not runtime.managed_execution_enabled():
|
||||
return callback(args), args
|
||||
|
||||
observed_args = args
|
||||
raw_result: dict[str, Any] = {}
|
||||
callback_error: BaseException | None = None
|
||||
callback_context = contextvars.copy_context()
|
||||
|
||||
def invoke(next_args: Any) -> Any:
|
||||
nonlocal callback_error, observed_args
|
||||
observed_args = next_args if isinstance(next_args, dict) else args
|
||||
try:
|
||||
result = callback_context.copy().run(callback, observed_args)
|
||||
except BaseException as exc:
|
||||
callback_error = exc
|
||||
raise
|
||||
raw_result["value"] = result
|
||||
raw_result["json"] = _jsonable(result)
|
||||
return raw_result["json"]
|
||||
|
||||
try:
|
||||
managed = _run_awaitable(
|
||||
runtime.run_in_session_async(
|
||||
session,
|
||||
runtime.relay.tools.execute,
|
||||
tool_name,
|
||||
_jsonable(args),
|
||||
invoke,
|
||||
handle=parent,
|
||||
metadata=_jsonable(metadata or {}),
|
||||
)
|
||||
)
|
||||
except BaseException as exc:
|
||||
if (
|
||||
callback_error is not None
|
||||
and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error)
|
||||
):
|
||||
raise callback_error
|
||||
if (
|
||||
isinstance(exc, Exception)
|
||||
and callback_error is None
|
||||
and "value" in raw_result
|
||||
):
|
||||
logger.warning(
|
||||
"NeMo Relay tool post-processing failed after dispatch success; "
|
||||
"returning the Hermes tool result",
|
||||
exc_info=True,
|
||||
)
|
||||
return raw_result["value"], observed_args
|
||||
raise
|
||||
|
||||
if "value" in raw_result and _json_equal(managed, raw_result["json"]):
|
||||
return raw_result["value"], observed_args
|
||||
if isinstance(managed, str):
|
||||
return managed, observed_args
|
||||
return json.dumps(_jsonable(managed), ensure_ascii=False), observed_args
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_jsonable(item) for item in value]
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
try:
|
||||
return _jsonable(model_dump(mode="json"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return _jsonable(vars(value))
|
||||
except (TypeError, AttributeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_equal(left: Any, right: Any) -> bool:
|
||||
try:
|
||||
return json.dumps(
|
||||
_jsonable(left), sort_keys=True, separators=(",", ":")
|
||||
) == json.dumps(_jsonable(right), sort_keys=True, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
return left == right
|
||||
|
||||
|
||||
def _run_awaitable(value: Any) -> Any:
|
||||
if not inspect.isawaitable(value):
|
||||
return value
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(value)
|
||||
raise RuntimeError(
|
||||
"Synchronous Hermes Relay tool execution cannot run on an active event-loop thread"
|
||||
)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -438,7 +438,12 @@ def build_turn_context(
|
|||
# Generate unique task_id if not provided to isolate VMs between tasks.
|
||||
effective_task_id = task_id or str(uuid.uuid4())
|
||||
agent._current_task_id = effective_task_id
|
||||
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
|
||||
turn_id = str(getattr(agent, "_relay_pending_turn_id", "") or "")
|
||||
if not turn_id:
|
||||
turn_id = (
|
||||
f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
agent._relay_pending_turn_id = None
|
||||
agent._current_turn_id = turn_id
|
||||
agent._current_api_request_id = ""
|
||||
# Tripwire: warn (with both turn ids) when this turn starts before the
|
||||
|
|
@ -1045,7 +1050,7 @@ def build_turn_context(
|
|||
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
|
||||
plugin_user_context = ""
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
|
||||
_pre_results = _invoke_hook(
|
||||
"pre_llm_call",
|
||||
session_id=agent.session_id,
|
||||
|
|
@ -1056,6 +1061,7 @@ def build_turn_context(
|
|||
is_first_turn=(not bool(conversation_history)),
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
|
||||
sender_id=getattr(agent, "_user_id", None) or "",
|
||||
)
|
||||
_ctx_parts: list[str] = []
|
||||
|
|
|
|||
|
|
@ -488,7 +488,7 @@ def finalize_turn(
|
|||
# First hook to return a string wins; None/empty return leaves text unchanged.
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
|
||||
_transform_results = _invoke_hook(
|
||||
"transform_llm_output",
|
||||
response_text=final_response,
|
||||
|
|
@ -510,7 +510,7 @@ def finalize_turn(
|
|||
# to an external memory system).
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"post_llm_call",
|
||||
session_id=agent.session_id,
|
||||
|
|
@ -669,14 +669,16 @@ def finalize_turn(
|
|||
# Fired at the very end of every run_conversation call.
|
||||
# Plugins can use this for cleanup, flushing buffers, etc.
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"on_session_end",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
completed=completed,
|
||||
failed=failed,
|
||||
interrupted=interrupted,
|
||||
turn_exit_reason=_turn_exit_reason,
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -511,15 +511,93 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
|||
pricing_version="deepseek-pricing-2026-07",
|
||||
),
|
||||
# Google Gemini
|
||||
(
|
||||
"google",
|
||||
"gemini-3.6-flash",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.50"),
|
||||
output_cost_per_million=Decimal("7.50"),
|
||||
cache_read_cost_per_million=Decimal("0.15"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/gemini-api/docs/pricing",
|
||||
pricing_version="google-pricing-2026-07-28",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-3.5-flash",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.50"),
|
||||
output_cost_per_million=Decimal("9.00"),
|
||||
cache_read_cost_per_million=Decimal("0.15"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-3.5-flash-lite",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.30"),
|
||||
output_cost_per_million=Decimal("2.50"),
|
||||
cache_read_cost_per_million=Decimal("0.03"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/gemini-api/docs/pricing",
|
||||
pricing_version="google-pricing-2026-07-28",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-3.1-pro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.00"),
|
||||
output_cost_per_million=Decimal("12.00"),
|
||||
cache_read_cost_per_million=Decimal("0.20"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-3.1-flash-lite",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.25"),
|
||||
output_cost_per_million=Decimal("1.50"),
|
||||
cache_read_cost_per_million=Decimal("0.025"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-3-pro-preview",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("2.00"),
|
||||
output_cost_per_million=Decimal("12.00"),
|
||||
cache_read_cost_per_million=Decimal("0.20"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-3-flash-preview",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.50"),
|
||||
output_cost_per_million=Decimal("3.00"),
|
||||
cache_read_cost_per_million=Decimal("0.05"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
"gemini-2.5-pro",
|
||||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("1.25"),
|
||||
output_cost_per_million=Decimal("10.00"),
|
||||
cache_read_cost_per_million=Decimal("0.125"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-03-16",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
|
|
@ -527,9 +605,10 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
|||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.15"),
|
||||
output_cost_per_million=Decimal("0.60"),
|
||||
cache_read_cost_per_million=Decimal("0.015"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-03-16",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
(
|
||||
"google",
|
||||
|
|
@ -537,9 +616,10 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
|
|||
): PricingEntry(
|
||||
input_cost_per_million=Decimal("0.10"),
|
||||
output_cost_per_million=Decimal("0.40"),
|
||||
cache_read_cost_per_million=Decimal("0.01"),
|
||||
source="official_docs_snapshot",
|
||||
source_url="https://ai.google.dev/pricing",
|
||||
pricing_version="google-pricing-2026-03-16",
|
||||
pricing_version="google-pricing-2026-07-07",
|
||||
),
|
||||
# AWS Bedrock — pricing per the Bedrock pricing page.
|
||||
# Bedrock charges the same per-token rates as the model provider but
|
||||
|
|
@ -878,6 +958,18 @@ for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
|
|||
]
|
||||
del _base_56
|
||||
|
||||
# The direct Gemini provider currently exposes preview IDs for these two
|
||||
# models. Keep the official snapshot keyed by both their documented stable
|
||||
# names and the provider's emitted IDs so a catalog selection is billable.
|
||||
for _alias, _canonical in {
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro",
|
||||
"gemini-3.1-flash-lite-preview": "gemini-3.1-flash-lite",
|
||||
}.items():
|
||||
_OFFICIAL_DOCS_PRICING[("google", _alias)] = _OFFICIAL_DOCS_PRICING[
|
||||
("google", _canonical)
|
||||
]
|
||||
del _alias, _canonical
|
||||
|
||||
|
||||
def _to_decimal(value: Any) -> Optional[Decimal]:
|
||||
if value is None:
|
||||
|
|
@ -925,11 +1017,17 @@ def resolve_billing_route(
|
|||
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name in {"minimax", "minimax-cn"}:
|
||||
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
# Vertex AI hosts the same Gemini models as Google AI Studio; price them
|
||||
# off the gemini official-docs snapshot. Strip the "google/" vendor prefix
|
||||
# the OpenAI-compat endpoint requires so the pricing key matches.
|
||||
if provider_name == "vertex" or base_url_host_matches(base_url or "", "aiplatform.googleapis.com"):
|
||||
return BillingRoute(provider="gemini", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
# Google AI Studio (Gemini) and Vertex AI host the same Gemini models.
|
||||
# Price them off the official docs snapshot — the pricing keys are
|
||||
# keyed on provider='google', so normalize every Google-flavored
|
||||
# provider name/host onto it. Strip the "google/" vendor prefix the
|
||||
# Vertex OpenAI-compat endpoint requires so the pricing key matches.
|
||||
if (
|
||||
provider_name in {"google", "gemini", "vertex", "google-gemini", "google-ai-studio", "google-vertex", "vertex-ai"}
|
||||
or base_url_host_matches(base_url or "", "aiplatform.googleapis.com")
|
||||
or base_url_host_matches(base_url or "", "generativelanguage.googleapis.com")
|
||||
):
|
||||
return BillingRoute(provider="google", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||
if provider_name == "fireworks" or base_url_host_matches(base_url or "", "api.fireworks.ai"):
|
||||
# Fireworks model ids look like accounts/fireworks/models/<name>;
|
||||
# rsplit("/", 1)[-1] yields just <name> which is what the dict keys on.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import { test } from 'vitest'
|
|||
|
||||
import {
|
||||
canImportHermesCli,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
hermesRuntimeImportProbe,
|
||||
PROBE_TIMEOUT_MS,
|
||||
resolveProbeTimeoutMs,
|
||||
shouldTrustHermesOverride,
|
||||
verifyHermesCli
|
||||
} from './backend-probes'
|
||||
|
|
@ -100,9 +103,25 @@ test('verifyHermesCli returns true when --version exits 0', () => {
|
|||
})
|
||||
|
||||
test('verifyHermesCli swallows timeouts (does not throw)', () => {
|
||||
// We can't easily provoke a real 5s hang in CI without slowing the
|
||||
// We can't easily provoke a real hang in CI without slowing the
|
||||
// suite, but we CAN confirm that an invocation that DOES throw
|
||||
// (because the binary is missing) returns false rather than
|
||||
// propagating. Same code path the timeout case takes.
|
||||
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
|
||||
})
|
||||
|
||||
test('default probe timeout is 15s (not the old 5s death-loop value)', () => {
|
||||
assert.equal(DEFAULT_PROBE_TIMEOUT_MS, 15_000)
|
||||
// Module constant uses process.env at load time; with no override it
|
||||
// matches the default (tests run without HERMES_PROBE_TIMEOUT_MS).
|
||||
assert.equal(PROBE_TIMEOUT_MS, DEFAULT_PROBE_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
test('resolveProbeTimeoutMs honours HERMES_PROBE_TIMEOUT_MS', () => {
|
||||
assert.equal(resolveProbeTimeoutMs({}), DEFAULT_PROBE_TIMEOUT_MS)
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '30000' }), 30_000)
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '0' }), DEFAULT_PROBE_TIMEOUT_MS)
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: 'nope' }), DEFAULT_PROBE_TIMEOUT_MS)
|
||||
// Cap runaway values
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '999999' }), 120_000)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@
|
|||
* actually works.
|
||||
*
|
||||
* Both probes are deliberately fast and forgiving:
|
||||
* - 5s timeout (a hung interpreter beats forever, but we still give
|
||||
* slow disks / cold caches room to breathe)
|
||||
* - default 15s timeout (5s was too short on cold Windows disks / AV;
|
||||
* issue #61764 death-loop) with HERMES_PROBE_TIMEOUT_MS override
|
||||
* - one automatic retry after a timeout before declaring the runtime dead
|
||||
* - stdio ignored (we only care about exit code; stdout/stderr are
|
||||
* not surfaced to the user, just to recentHermesLog for forensics
|
||||
* via the caller's catch block if it chooses)
|
||||
|
|
@ -34,7 +35,82 @@
|
|||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000
|
||||
/** Default probe budget. 5s false-negativeed healthy Windows cold starts (#61764). */
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Resolve the backend probe timeout (ms).
|
||||
* Honours HERMES_PROBE_TIMEOUT_MS when it parses as a positive integer.
|
||||
*/
|
||||
function resolveProbeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env.HERMES_PROBE_TIMEOUT_MS
|
||||
|
||||
if (raw == null || raw === '') {
|
||||
return DEFAULT_PROBE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
const n = Number.parseInt(String(raw), 10)
|
||||
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return DEFAULT_PROBE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
// Clamp absurd values (ms) so a typo can't hang startup forever.
|
||||
return Math.min(n, 120_000)
|
||||
}
|
||||
|
||||
const PROBE_TIMEOUT_MS = resolveProbeTimeoutMs()
|
||||
|
||||
function isTimeoutError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const e = err as { code?: string; killed?: boolean; signal?: string }
|
||||
|
||||
if (e.killed === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (e.code === 'ETIMEDOUT') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Node marks timed-out execFileSync with SIGTERM on some platforms.
|
||||
if (e.signal === 'SIGTERM') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Run execFileSync; on timeout only, retry once before failing.
|
||||
* Non-timeout failures (ENOENT, non-zero exit) fail immediately.
|
||||
*/
|
||||
function execProbeSync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
stdio: 'ignore'
|
||||
timeout: number
|
||||
shell?: boolean
|
||||
windowsHide?: boolean
|
||||
}
|
||||
): void {
|
||||
try {
|
||||
execFileSync(command, args, options)
|
||||
} catch (err) {
|
||||
if (!isTimeoutError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// One cold-cache / AV miss should not force hermes-setup --update (#61764).
|
||||
execFileSync(command, args, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Python snippet used to verify Hermes can import far enough to
|
||||
|
|
@ -71,7 +147,7 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
|
|||
}
|
||||
|
||||
try {
|
||||
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
execProbeSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
|
|
@ -120,7 +196,7 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
|||
}
|
||||
|
||||
try {
|
||||
execFileSync(hermesCommand, ['--version'], {
|
||||
execProbeSync(hermesCommand, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: Boolean(opts?.shell),
|
||||
|
|
@ -133,4 +209,13 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
|||
}
|
||||
}
|
||||
|
||||
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli }
|
||||
export {
|
||||
canImportHermesCli,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
execProbeSync,
|
||||
hermesRuntimeImportProbe,
|
||||
PROBE_TIMEOUT_MS,
|
||||
resolveProbeTimeoutMs,
|
||||
shouldTrustHermesOverride,
|
||||
verifyHermesCli
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@
|
|||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@
|
|||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ import { pathToFileURL } from 'node:url'
|
|||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
dataUrlReadMaxBytesFromMb,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
readFileDataUrlForIpc,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
|
|
@ -24,6 +29,49 @@ async function rejectsWithCode(promise, code: string) {
|
|||
})
|
||||
}
|
||||
|
||||
test('clampDataUrlReadMaxMb defaults and bounds the attach size preference', () => {
|
||||
assert.equal(clampDataUrlReadMaxMb(undefined), DATA_URL_READ_DEFAULT_MAX_MB)
|
||||
assert.equal(clampDataUrlReadMaxMb(0), 1)
|
||||
assert.equal(clampDataUrlReadMaxMb(256), 256)
|
||||
assert.equal(clampDataUrlReadMaxMb(99999), 4096)
|
||||
assert.equal(dataUrlReadMaxBytesFromMb(16), 16 * 1024 * 1024)
|
||||
})
|
||||
|
||||
test('attachment upload cap is bounded above the preview default', () => {
|
||||
assert.equal(ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, 256 * 1024 * 1024)
|
||||
assert.ok(ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES > dataUrlReadMaxBytesFromMb(DATA_URL_READ_DEFAULT_MAX_MB))
|
||||
})
|
||||
|
||||
test('attachment data URL helper reads bytes above the preview default without changing that limit', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-large-attachment-'))
|
||||
const source = path.join(tempDir, 'large.bin')
|
||||
const previewLimit = dataUrlReadMaxBytesFromMb(DATA_URL_READ_DEFAULT_MAX_MB)
|
||||
const content = Buffer.alloc(previewLimit + 1024, 0x5a)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(source, content)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(source, {
|
||||
maxBytes: previewLimit,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
|
||||
const dataUrl = await readFileDataUrlForIpc(source, {
|
||||
maxBytes: ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
||||
mimeType: 'application/octet-stream',
|
||||
purpose: 'Attachment upload'
|
||||
})
|
||||
|
||||
assert.match(dataUrl, /^data:application\/octet-stream;base64,/)
|
||||
assert.deepEqual(Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'), content)
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,34 @@ import path from 'node:path'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
|
||||
// image preview, etc.). The whole file is base64-buffered in main, so this is
|
||||
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
|
||||
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
|
||||
// values can OOM / crash the app).
|
||||
const DATA_URL_READ_DEFAULT_MAX_MB = 16
|
||||
const DATA_URL_READ_MIN_MAX_MB = 1
|
||||
const DATA_URL_READ_MAX_MAX_MB = 4096
|
||||
// Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach
|
||||
// reader so the payload still fits uvicorn's raised ws_max_size (384 MiB)
|
||||
// after base64 + framing. Preview stays on the Settings-configurable path.
|
||||
const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024
|
||||
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
function clampDataUrlReadMaxMb(value) {
|
||||
const parsed = Number(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DATA_URL_READ_DEFAULT_MAX_MB
|
||||
}
|
||||
|
||||
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
|
||||
}
|
||||
|
||||
function dataUrlReadMaxBytesFromMb(maxMb) {
|
||||
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
|
||||
}
|
||||
|
||||
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
|
||||
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
||||
|
||||
|
|
@ -303,10 +328,34 @@ async function resolveReadableFileForIpc(
|
|||
return { realPath, resolvedPath, stat }
|
||||
}
|
||||
|
||||
async function readFileDataUrlForIpc(
|
||||
filePath,
|
||||
options: {
|
||||
purpose?: string
|
||||
baseDir?: fs.PathOrFileDescriptor
|
||||
fs?: typeof fs
|
||||
blockSensitive?: boolean
|
||||
maxBytes?: number
|
||||
mimeType: string
|
||||
}
|
||||
): Promise<string> {
|
||||
const fsImpl = options.fs || fs
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(filePath, options)
|
||||
const data = await fsImpl.promises.readFile(resolvedPath)
|
||||
|
||||
return `data:${options.mimeType};base64,${data.toString('base64')}`
|
||||
}
|
||||
|
||||
export {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
DATA_URL_READ_MAX_MAX_MB,
|
||||
DATA_URL_READ_MIN_MAX_MB,
|
||||
dataUrlReadMaxBytesFromMb,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
readFileDataUrlForIpc,
|
||||
rejectUnsafePathSyntax,
|
||||
resolveDirectoryForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,13 @@ import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
|||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { isReauthRequiredError, waitForHermesReady } from './backend-health'
|
||||
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import {
|
||||
canImportHermesCli,
|
||||
execProbeSync,
|
||||
PROBE_TIMEOUT_MS,
|
||||
shouldTrustHermesOverride,
|
||||
verifyHermesCli
|
||||
} from './backend-probes'
|
||||
import { waitForDashboardPortAnnouncement } from './backend-ready'
|
||||
import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure'
|
||||
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
|
||||
|
|
@ -115,9 +121,13 @@ import {
|
|||
switchBranch
|
||||
} from './git-worktree-ops'
|
||||
import {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
dataUrlReadMaxBytesFromMb,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret as encryptDesktopSecretStrict,
|
||||
readFileDataUrlForIpc,
|
||||
resolveReadableFileForIpc,
|
||||
resolveRequestedPathForIpc,
|
||||
resolveTimeoutMs,
|
||||
|
|
@ -175,6 +185,7 @@ import {
|
|||
} from './ssh-connection'
|
||||
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
|
||||
import { resolveBehindCount, shouldCountCommits } from './update-count'
|
||||
import { waitForUpdateClearance } from './update-gate'
|
||||
import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker'
|
||||
import { runRebuildWithRetry } from './update-rebuild'
|
||||
import {
|
||||
|
|
@ -188,6 +199,7 @@ import {
|
|||
} from './update-relaunch'
|
||||
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
|
||||
import { spawnUpdaterProcess } from './updater-process'
|
||||
import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan'
|
||||
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
|
||||
import {
|
||||
computeWindowOptions,
|
||||
|
|
@ -960,7 +972,7 @@ app.setAboutPanelOptions({
|
|||
|
||||
// Custom scheme for streaming local media (video/audio) into the renderer.
|
||||
// Reading large media through `readFileDataUrl` failed: it base64-loads the
|
||||
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
|
||||
// whole file into memory and is hard-capped (default 16 MB, Settings → Chat),
|
||||
// so any non-trivial video silently refused to load. Streaming via a protocol
|
||||
// handler removes the size cap and gives the <video> element seekable,
|
||||
// range-aware playback. Must be registered before the app is ready.
|
||||
|
|
@ -1725,30 +1737,48 @@ const UPDATE_WAIT_POLL_MS = 1000
|
|||
// updater's own progress window appears. (#50419)
|
||||
const UPDATE_HANDOFF_DWELL_MS = 2500
|
||||
|
||||
// Gate deps shared by the primary-window boot path and the pool-backend
|
||||
// spawn path. Consulting BOTH the on-disk marker and the in-process
|
||||
// updateInFlight flag is load-bearing (#73822): applyUpdates kills its own
|
||||
// backend BEFORE the Windows venv-blocker scan but only writes the marker
|
||||
// AFTER it, so a marker-only gate lets the renderer's ~1s reconnect respawn
|
||||
// a backend inside the update's own critical section — which the scan then
|
||||
// reports as a blocker, aborting every update attempt.
|
||||
function updateGateDeps() {
|
||||
return {
|
||||
hasLiveMarker: () => Boolean(readLiveUpdateMarker(HERMES_HOME)),
|
||||
isUpdateInFlight: () => updateInFlight
|
||||
}
|
||||
}
|
||||
|
||||
// Block until no live update is in progress (or we hit the wait timeout).
|
||||
// Emits a boot-progress phase so the renderer shows "Update in progress…"
|
||||
// rather than a frozen splash. Returns true if it parked at all.
|
||||
async function waitForUpdateToFinish() {
|
||||
let marker = readLiveUpdateMarker(HERMES_HOME)
|
||||
let announced = false
|
||||
|
||||
if (!marker) {
|
||||
const outcome = await waitForUpdateClearance(updateGateDeps(), {
|
||||
onWaitTick: async reason => {
|
||||
if (!announced) {
|
||||
announced = true
|
||||
rememberLog(`[updates] update in progress (${reason}); deferring backend start until it finishes`)
|
||||
}
|
||||
|
||||
await advanceBootProgress(
|
||||
'backend.update-wait',
|
||||
'An update is finishing — Hermes will start automatically when it completes…',
|
||||
12
|
||||
)
|
||||
},
|
||||
pollMs: UPDATE_WAIT_POLL_MS,
|
||||
timeoutMs: UPDATE_WAIT_TIMEOUT_MS
|
||||
})
|
||||
|
||||
if (outcome === 'clear') {
|
||||
return false
|
||||
}
|
||||
|
||||
rememberLog(`[updates] update in progress (pid=${marker.pid}); deferring backend start until it finishes`)
|
||||
const deadline = Date.now() + UPDATE_WAIT_TIMEOUT_MS
|
||||
|
||||
while (marker && Date.now() < deadline) {
|
||||
await advanceBootProgress(
|
||||
'backend.update-wait',
|
||||
'An update is finishing — Hermes will start automatically when it completes…',
|
||||
12
|
||||
)
|
||||
await new Promise(r => setTimeout(r, UPDATE_WAIT_POLL_MS))
|
||||
marker = readLiveUpdateMarker(HERMES_HOME)
|
||||
}
|
||||
|
||||
if (marker) {
|
||||
if (outcome === 'timeout') {
|
||||
rememberLog('[updates] update still in progress after wait timeout; starting backend anyway')
|
||||
} else {
|
||||
rememberLog('[updates] update finished; proceeding with backend start')
|
||||
|
|
@ -1862,11 +1892,22 @@ function backendSupportsServe(backend) {
|
|||
if (supported === null) {
|
||||
try {
|
||||
const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : []
|
||||
execFileSync(backend.command, [...prefix, 'serve', '--help'], {
|
||||
// Same cold-Windows Python-startup class as the runtime probes
|
||||
// (#61764/#72632/#72707): `serve --help` imports at least as much as
|
||||
// `hermes --version` (~10.5s measured cold), and a false negative here
|
||||
// is cached for the process lifetime, silently routing a modern
|
||||
// runtime through the legacy `dashboard` form. Share the probe budget
|
||||
// and its timeout-only retry instead of a thinner local bound.
|
||||
execProbeSync(backend.command, [...prefix, 'serve', '--help'], {
|
||||
cwd: backend.root || undefined,
|
||||
env: { ...process.env, HERMES_HOME, ...(backend.env || {}) },
|
||||
timeout: 15000,
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
// `.cmd`/`.bat` shim backends carry shell: true in their descriptor
|
||||
// (see resolveHermesBackend step 4); execFileSync of a .cmd without
|
||||
// shell throws EINVAL on modern Node, which the catch below would
|
||||
// mis-cache as "serve unsupported" for the process lifetime.
|
||||
shell: Boolean(backend.shell),
|
||||
windowsHide: true
|
||||
})
|
||||
supported = true
|
||||
|
|
@ -2023,7 +2064,10 @@ function findSystemPython() {
|
|||
const out = execFileSync(
|
||||
'reg',
|
||||
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
||||
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
// Registry reads are near-instant; the bound only exists so a
|
||||
// pathologically wedged reg.exe can't hang the synchronous boot
|
||||
// resolver forever (this ran unbounded before).
|
||||
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 })
|
||||
)
|
||||
|
||||
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
||||
|
|
@ -2078,7 +2122,12 @@ function findSystemPython() {
|
|||
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
|
||||
hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// Bare interpreter startup — much lighter than the hermes-import
|
||||
// probes, but still python.exe under cold cache / AV scan, so
|
||||
// share the probe budget rather than running unbounded (this
|
||||
// synchronous exec previously had no timeout at all).
|
||||
timeout: PROBE_TIMEOUT_MS
|
||||
})
|
||||
)
|
||||
|
||||
|
|
@ -2874,6 +2923,39 @@ async function applyUpdates(opts = {}) {
|
|||
return { ok: false, error: message }
|
||||
}
|
||||
|
||||
// Preflight: after releasing our own backends, check for remaining
|
||||
// Hermes processes running from this venv. The updater normally refuses
|
||||
// when it detects a holder, but because the updater is spawned detached
|
||||
// with stdio:ignore, the user never sees that refusal and the update
|
||||
// silently fails. This preflight detects holders early and gives the
|
||||
// user an actionable error. Windows-only; the .pyd lock hazard is a
|
||||
// Windows phenomenon. ALL failures (blocked, missing python, timeout,
|
||||
// malformed output, missing psutil) abort the handoff — never proceed
|
||||
// to the detached updater when the venv state is unknown.
|
||||
if (IS_WINDOWS) {
|
||||
const scanOutcome = await scanVenvBlockers(updateRoot)
|
||||
|
||||
if (scanOutcome.kind === 'blocked') {
|
||||
const message = formatBlockerMessage(scanOutcome.result)
|
||||
|
||||
rememberLog(`[updates] venv-blocked: ${scanOutcome.result.processes.length} process(es) hold the install`)
|
||||
emitUpdateProgress({ stage: 'error', message, percent: null })
|
||||
startHermes().catch(() => {})
|
||||
|
||||
return { ok: false, error: 'venv-blocked', message }
|
||||
}
|
||||
|
||||
if (scanOutcome.kind === 'probe-failure') {
|
||||
const message = formatProbeFailedMessage()
|
||||
|
||||
rememberLog(`[updates] venv-blocker probe failed: ${scanOutcome.error}`)
|
||||
emitUpdateProgress({ stage: 'error', message, percent: null })
|
||||
startHermes().catch(() => {})
|
||||
|
||||
return { ok: false, error: 'venv-probe-failed', message }
|
||||
}
|
||||
}
|
||||
|
||||
// Detached so the updater outlives this process — it needs us GONE before
|
||||
// `hermes update` will run (the venv shim is locked while we live).
|
||||
const child = spawnUpdaterProcess(updater, updaterArgs, {
|
||||
|
|
@ -3822,17 +3904,20 @@ function resolveHermesBackend(backendArgs) {
|
|||
// through to the install-script bootstrap if the optional probe times
|
||||
// out under load; the pinned backend is the only valid runtime there.
|
||||
if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
return (
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
command: hermesCommand,
|
||||
args: backendArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
kind: 'command',
|
||||
shell: shellForProbe
|
||||
}
|
||||
)
|
||||
// `unwrapped` above already answered "is this a Windows venv shim?" —
|
||||
// it was null (not a shim, or its import probe failed). Do NOT re-run
|
||||
// unwrapWindowsVenvHermesCommand here: the second call repeats the
|
||||
// same un-memoized import probe, costing up to another full probe
|
||||
// timeout on the boot path for an answer we already have.
|
||||
return {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
command: hermesCommand,
|
||||
args: backendArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
kind: 'command',
|
||||
shell: shellForProbe
|
||||
}
|
||||
}
|
||||
|
||||
rememberLog(
|
||||
|
|
@ -4891,6 +4976,45 @@ function closePreviewWatchers() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Watch a DIRECTORY for entry churn (folders appearing/vanishing) — the
|
||||
* disk-plugin door's "new plugin folder" signal, replacing the renderer's 5s
|
||||
* readdir poll. Same registry + change channel as the preview file watchers
|
||||
* (the renderer reconciles on any tick; per-file edits stay on their own
|
||||
* watches), so stopPreviewFileWatch/closePreviewWatchers manage these too. */
|
||||
function watchDirectory(rawDir) {
|
||||
const watchDir = path.resolve(String(rawDir || ''))
|
||||
|
||||
if (!fs.existsSync(watchDir) || !fs.statSync(watchDir).isDirectory()) {
|
||||
throw new Error(`Not a directory: ${watchDir}`)
|
||||
}
|
||||
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
let timer = null
|
||||
|
||||
const watcher = fs.watch(watchDir, () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
sendPreviewFileChanged({ id, path: watchDir, url: pathToFileURL(watchDir).toString() })
|
||||
}, PREVIEW_WATCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
previewWatchers.set(id, {
|
||||
close: () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
watcher.close()
|
||||
}
|
||||
})
|
||||
|
||||
return { id, path: watchDir }
|
||||
}
|
||||
|
||||
// Best-effort read of a gateway's advertised auth providers, cached per base
|
||||
// URL for the life of the process. Used by the oauth pre-flight guard to tell
|
||||
// a password-provider gateway (which cannot satisfy the bearer/cookie checks
|
||||
|
|
@ -5508,18 +5632,22 @@ function installContextMenu(window) {
|
|||
})
|
||||
}
|
||||
|
||||
// Microphone capture for the voice composer. The renderer drives mic access
|
||||
// Microphone and camera capture. The voice composer drives mic access and
|
||||
// renderer features (e.g. desktop plugins) can drive camera access, both
|
||||
// through getUserMedia, which Chromium gates behind these two session hooks.
|
||||
//
|
||||
// The naive `details.mediaTypes.includes('audio')` check works on macOS but
|
||||
// breaks on Windows: Chromium frequently fires the mic permission request with
|
||||
// an empty/undefined `mediaTypes`, so the strict check denies it and
|
||||
// getUserMedia throws NotAllowedError ("Microphone permission was denied").
|
||||
// We therefore treat an audio-capture request as allowed whenever it's the
|
||||
// 'media'/'audioCapture' permission AND mediaTypes either includes 'audio' OR
|
||||
// is empty/absent (the Windows case). Video is still denied.
|
||||
function isAudioCapturePermission(permission, details) {
|
||||
if (permission === 'audioCapture') {
|
||||
// breaks on Windows: Chromium frequently fires the request with an empty or
|
||||
// undefined `mediaTypes`, so a strict check denies it and getUserMedia throws
|
||||
// NotAllowedError. We therefore allow the capture permissions and treat absent
|
||||
// metadata as allowed.
|
||||
//
|
||||
// Granting here is not the last gate: the OS still applies its own capture
|
||||
// permission (macOS TCC prompts on first use, per the NSMicrophone/NSCamera
|
||||
// usage strings), so the user keeps a real allow/deny and can revoke it in
|
||||
// System Settings afterwards.
|
||||
function isMediaCapturePermission(permission, details) {
|
||||
if (permission === 'audioCapture' || permission === 'videoCapture') {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -5529,38 +5657,31 @@ function isAudioCapturePermission(permission, details) {
|
|||
|
||||
const mediaTypes = details?.mediaTypes
|
||||
|
||||
// Windows: mediaTypes is often empty for a capture request. Don't deny on
|
||||
// missing metadata.
|
||||
if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) {
|
||||
// Windows: mediaTypes is often empty for a mic request. Don't deny on
|
||||
// missing metadata. (A video request would carry mediaTypes:['video'].)
|
||||
return true
|
||||
}
|
||||
|
||||
return mediaTypes.includes('audio') && !mediaTypes.includes('video')
|
||||
return mediaTypes.includes('audio') || mediaTypes.includes('video')
|
||||
}
|
||||
|
||||
function installMediaPermissions() {
|
||||
// Async request handler: the prompt-style path (most platforms).
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
|
||||
callback(isAudioCapturePermission(permission, details))
|
||||
callback(isMediaCapturePermission(permission, details))
|
||||
})
|
||||
|
||||
// Synchronous check handler: Chromium consults this for getUserMedia on
|
||||
// Windows in addition to (or instead of) the request handler. Without it,
|
||||
// the check defaults to false and the mic is denied before the request
|
||||
// the check defaults to false and capture is denied before the request
|
||||
// handler ever runs.
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
|
||||
if (permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */) {
|
||||
// details.mediaType is a single string here (not the mediaTypes array).
|
||||
const mediaType = details?.mediaType
|
||||
|
||||
if (mediaType === 'video') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
return (
|
||||
permission === 'media' ||
|
||||
permission === ('audioCapture' as any) /* todo: is this needed? */ ||
|
||||
permission === ('videoCapture' as any)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -7933,6 +8054,27 @@ async function spawnPoolBackend(profile, entry) {
|
|||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
|
||||
// Same update mutual exclusion as the primary window's waitForLocalStart
|
||||
// (#73822): pool backends spawn from the same venv, so an ungated respawn
|
||||
// during applyUpdates' critical section re-locks the venv and trips the
|
||||
// venv-blocker preflight. No boot-progress UI here — pool backends boot
|
||||
// silently for background profiles — so we only log while parked.
|
||||
{
|
||||
let poolAnnounced = false
|
||||
|
||||
await waitForUpdateClearance(updateGateDeps(), {
|
||||
onWaitTick: reason => {
|
||||
if (!poolAnnounced) {
|
||||
poolAnnounced = true
|
||||
rememberLog(`[updates] update in progress (${reason}); deferring pool backend start for profile "${profile}"`)
|
||||
}
|
||||
},
|
||||
pollMs: UPDATE_WAIT_POLL_MS,
|
||||
timeoutMs: UPDATE_WAIT_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
|
|
@ -8021,6 +8163,17 @@ async function spawnPoolBackend(profile, entry) {
|
|||
|
||||
entry.token = authToken
|
||||
|
||||
// Verify the WebSocket session token before declaring backend ready.
|
||||
// HTTP /api/status can pass while WS auth fails (separate transport, separate guards).
|
||||
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
|
||||
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
|
||||
|
||||
if (!wsProbe.ok) {
|
||||
throw new Error(
|
||||
`Hermes backend for profile "${profile}" is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
|
|
@ -8028,7 +8181,7 @@ async function spawnPoolBackend(profile, entry) {
|
|||
authMode: 'token',
|
||||
token: authToken,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
wsUrl,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
|
|
@ -8346,6 +8499,16 @@ async function startHermes() {
|
|||
rememberLog
|
||||
})
|
||||
|
||||
// Verify the WebSocket session token before declaring backend ready.
|
||||
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
|
||||
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
|
||||
|
||||
if (!wsProbe.ok) {
|
||||
throw new Error(
|
||||
`Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
|
||||
)
|
||||
}
|
||||
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||
|
|
@ -8360,7 +8523,7 @@ async function startHermes() {
|
|||
source: 'local',
|
||||
authMode: 'token',
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
wsUrl,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
|
|
@ -9270,8 +9433,8 @@ ipcMain.handle('hermes:window:openInstance', async () => {
|
|||
// shortcuts and the View menu. Reads and writes target the asking window.
|
||||
ipcMain.handle('hermes:zoom:get', event => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const level =
|
||||
window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL
|
||||
|
||||
const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL
|
||||
|
||||
return { level, percent: zoomLevelToPercent(level) }
|
||||
})
|
||||
|
|
@ -10052,15 +10215,71 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
|||
return true
|
||||
})
|
||||
|
||||
// Data-URL file load cap (composer attach + local previews). Main owns the
|
||||
// persisted MB value so every IPC read honours Settings → Chat without the
|
||||
// renderer having to pass maxBytes on each call. Default is 16 MB; clamp
|
||||
// lives in hardening.ts.
|
||||
const DATA_URL_READ_MAX_CONFIG_PATH = path.join(app.getPath('userData'), 'data-url-read-max.json')
|
||||
|
||||
function readPersistedDataUrlReadMaxMb() {
|
||||
try {
|
||||
return clampDataUrlReadMaxMb(JSON.parse(fs.readFileSync(DATA_URL_READ_MAX_CONFIG_PATH, 'utf8')).maxMb)
|
||||
} catch {
|
||||
return DATA_URL_READ_DEFAULT_MAX_MB
|
||||
}
|
||||
}
|
||||
|
||||
let dataUrlReadMaxMb = readPersistedDataUrlReadMaxMb()
|
||||
|
||||
function persistDataUrlReadMaxMb(maxMb) {
|
||||
const next = clampDataUrlReadMaxMb(maxMb)
|
||||
dataUrlReadMaxMb = next
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DATA_URL_READ_MAX_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(DATA_URL_READ_MAX_CONFIG_PATH, JSON.stringify({ maxMb: next }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[data-url-read-max] write failed: ${error.message}`)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:data-url-read-max:get', () => ({
|
||||
maxMb: dataUrlReadMaxMb,
|
||||
// Keep the default bytes constant visible for tests / diagnostics.
|
||||
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb)
|
||||
}))
|
||||
|
||||
ipcMain.handle('hermes:data-url-read-max:set', (_event, maxMb) => {
|
||||
const next = persistDataUrlReadMaxMb(maxMb)
|
||||
|
||||
return {
|
||||
maxMb: next,
|
||||
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
maxBytes: dataUrlReadMaxBytesFromMb(next)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||||
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
|
||||
maxBytes: DATA_URL_READ_MAX_BYTES,
|
||||
return readFileDataUrlForIpc(filePath, {
|
||||
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb),
|
||||
mimeType: mimeTypeForPath(resolveRequestedPathForIpc(filePath, { purpose: 'File preview' })),
|
||||
purpose: 'File preview'
|
||||
})
|
||||
})
|
||||
|
||||
const data = await fs.promises.readFile(resolvedPath)
|
||||
|
||||
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
|
||||
// Remote attachment transfer is independent of the preview / Settings path.
|
||||
// Keep a finite cap so Electron + base64 memory stays bounded while archives
|
||||
// can exceed the default 16 MiB preview ceiling (and still fit the gateway
|
||||
// WebSocket frame limit after base64 expansion).
|
||||
ipcMain.handle('hermes:readFileDataUrlForAttach', async (_event, filePath) => {
|
||||
return readFileDataUrlForIpc(filePath, {
|
||||
maxBytes: ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
||||
mimeType: mimeTypeForPath(resolveRequestedPathForIpc(filePath, { purpose: 'Attachment upload' })),
|
||||
purpose: 'Attachment upload'
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
|
|
@ -10131,6 +10350,12 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
|||
return true
|
||||
})
|
||||
|
||||
// Paired reader for the GUI terminal's paste chord: the renderer's
|
||||
// navigator.clipboard.readText() throws "Document is not focused" whenever a
|
||||
// portaled overlay has focus, and there's no way to route a read through the
|
||||
// canvas. The main process has no such gate.
|
||||
ipcMain.handle('hermes:readClipboard', () => clipboard.readText())
|
||||
|
||||
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||||
|
|
@ -10172,6 +10397,8 @@ ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
|||
|
||||
ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:watchDirectory', (_event, dir) => watchDirectory(String(dir || '')))
|
||||
|
||||
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
|
||||
|
||||
// Each renderer reports the turns it has in flight; the quit guard reads the
|
||||
|
|
@ -10701,6 +10928,31 @@ ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
|
|||
}
|
||||
})
|
||||
|
||||
// The LOCAL Desktop runtime-plugin root: `<HERMES_HOME>/desktop-plugins`,
|
||||
// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from
|
||||
// the connected backend. A remote backend reports its own `hermes_home` over
|
||||
// the gateway, which is a path on the REMOTE box; deriving the plugin dir from
|
||||
// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the
|
||||
// on-disk plugin door silently breaks (#66899). Electron owns this resolution
|
||||
// so it stays valid in every connection mode. Created on demand, like openDir.
|
||||
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => {
|
||||
// Profile-aware: a named Desktop profile gets its own plugin root under
|
||||
// profiles/<name>/, matching the profile-scoped hermes_home the backend
|
||||
// reported before this resolver existed. 'default'/unset pins the global root.
|
||||
const profile = readActiveDesktopProfile()
|
||||
const base = profile && profile !== 'default' ? path.join(HERMES_HOME, 'profiles', profile) : HERMES_HOME
|
||||
const dir = path.join(base, 'desktop-plugins')
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
} catch {
|
||||
// Best-effort create; return the path regardless so the reveal action can
|
||||
// still surface a real openPath error and the scanner can retry later.
|
||||
}
|
||||
|
||||
return dir
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
|
|
|
|||
|
|
@ -98,9 +98,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
|
||||
readFileDataUrlForAttach: filePath => ipcRenderer.invoke('hermes:readFileDataUrlForAttach', filePath),
|
||||
dataUrlReadMax: {
|
||||
get: () => ipcRenderer.invoke('hermes:data-url-read-max:get'),
|
||||
set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb)
|
||||
},
|
||||
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
|
||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
|
||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
||||
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
|
||||
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
|
||||
|
|
@ -113,6 +119,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
},
|
||||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
watchDirectory: dir => ipcRenderer.invoke('hermes:watchDirectory', dir),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
|
|
@ -148,6 +155,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
|
||||
desktopPluginsRoot: () => ipcRenderer.invoke('hermes:fs:desktopPluginsRoot'),
|
||||
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
||||
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
|
||||
|
|
|
|||
|
|
@ -208,3 +208,13 @@ test('chatWindowWebPreferences passes the preload path through and keeps the har
|
|||
assert.equal(prefs.sandbox, true)
|
||||
assert.equal(prefs.nodeIntegration, false)
|
||||
})
|
||||
|
||||
test('chatWindowWebPreferences allows autoplay so wake-started voice speaks its first reply', () => {
|
||||
// Regression: Chromium's default autoplay policy suspends audio until a user
|
||||
// gesture. A wake-word-started voice conversation has no preceding click, so
|
||||
// the first reply's playback was rejected and only turn 2+ spoke. A native
|
||||
// app the user launched should not gate audio on a gesture.
|
||||
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
|
||||
|
||||
assert.equal(prefs.autoplayPolicy, 'no-user-gesture-required')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
|||
// occluded windows. A streaming chat app must keep painting in the
|
||||
// background, so every chat window opts out. The preload path is injected
|
||||
// because it depends on the Electron entry's __dirname.
|
||||
//
|
||||
// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice:
|
||||
// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play()
|
||||
// and AudioContext) until the user has interacted with the frame. A voice
|
||||
// conversation started by the "Hey Hermes" wake word has NO preceding click,
|
||||
// so the FIRST reply's audio playback was rejected (NotAllowedError, silently
|
||||
// swallowed) and only turn 2+ spoke — the very "first message in a new voice
|
||||
// session is silent" bug. Manual voice-start worked only because the button
|
||||
// click counted as the gesture. This is a native app the user deliberately
|
||||
// launched; there is no drive-by-autoplay concern to protect against.
|
||||
function chatWindowWebPreferences(preloadPath: string) {
|
||||
return {
|
||||
preload: preloadPath,
|
||||
|
|
@ -29,7 +39,8 @@ function chatWindowWebPreferences(preloadPath: string) {
|
|||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true,
|
||||
backgroundThrottling: false
|
||||
backgroundThrottling: false,
|
||||
autoplayPolicy: 'no-user-gesture-required' as const
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
144
apps/desktop/electron/update-gate.test.ts
Normal file
144
apps/desktop/electron/update-gate.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Tests for electron/update-gate.ts — the update mutual-exclusion gate that
|
||||
* parks local backend spawns while an in-app update is running.
|
||||
*
|
||||
* The regression this guards (#73822): applyUpdates kills its own backend
|
||||
* BEFORE the Windows venv-blocker scan but writes the on-disk marker AFTER
|
||||
* it. A marker-only gate therefore let the renderer's reconnect spawn a
|
||||
* fresh backend inside the update's own critical section, which the scan
|
||||
* reported as a blocker — aborting every Desktop update attempt on Windows.
|
||||
* The gate must consult the in-process updateInFlight flag as well.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { updateGateReason, waitForUpdateClearance } from './update-gate'
|
||||
|
||||
function deps(marker: boolean, inFlight: boolean) {
|
||||
return {
|
||||
hasLiveMarker: () => marker,
|
||||
isUpdateInFlight: () => inFlight
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateGateReason
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('gate open when neither marker nor flag is set', () => {
|
||||
assert.equal(updateGateReason(deps(false, false)), null)
|
||||
})
|
||||
|
||||
test('marker alone closes the gate', () => {
|
||||
assert.equal(updateGateReason(deps(true, false)), 'marker')
|
||||
})
|
||||
|
||||
test('updateInFlight alone closes the gate (#73822 — the pre-marker window)', () => {
|
||||
assert.equal(updateGateReason(deps(false, true)), 'update-in-flight')
|
||||
})
|
||||
|
||||
test('marker wins as the reported reason when both are set', () => {
|
||||
assert.equal(updateGateReason(deps(true, true)), 'marker')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// waitForUpdateClearance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('returns clear immediately without sleeping when the gate is open', async () => {
|
||||
let slept = 0
|
||||
|
||||
const outcome = await waitForUpdateClearance(deps(false, false), {
|
||||
pollMs: 10,
|
||||
sleep: async () => {
|
||||
slept += 1
|
||||
},
|
||||
timeoutMs: 1000
|
||||
})
|
||||
|
||||
assert.equal(outcome, 'clear')
|
||||
assert.equal(slept, 0)
|
||||
})
|
||||
|
||||
test('parks on the in-flight flag and finishes when it clears', async () => {
|
||||
// Simulates the #73822 sequence: the reconnect arrives while updateInFlight
|
||||
// is true and no marker exists yet; the flag clears (abort path finally)
|
||||
// and the waiter proceeds.
|
||||
let inFlight = true
|
||||
let ticks = 0
|
||||
|
||||
const outcome = await waitForUpdateClearance(
|
||||
{ hasLiveMarker: () => false, isUpdateInFlight: () => inFlight },
|
||||
{
|
||||
onWaitTick: reason => {
|
||||
ticks += 1
|
||||
assert.equal(reason, 'update-in-flight')
|
||||
|
||||
if (ticks >= 3) {
|
||||
inFlight = false
|
||||
}
|
||||
},
|
||||
pollMs: 1,
|
||||
sleep: async () => {},
|
||||
timeoutMs: 10_000
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(outcome, 'finished')
|
||||
assert.equal(ticks, 3)
|
||||
})
|
||||
|
||||
test('parks across the flag→marker handoff without a gap', async () => {
|
||||
// Success path: the marker is written (main.ts:2936) BEFORE applyUpdates'
|
||||
// finally clears the flag, so a waiter that arrived during the scan stays
|
||||
// parked through the transition instead of slipping through.
|
||||
let inFlight = true
|
||||
let marker = false
|
||||
let ticks = 0
|
||||
const reasons: string[] = []
|
||||
|
||||
const outcome = await waitForUpdateClearance(
|
||||
{ hasLiveMarker: () => marker, isUpdateInFlight: () => inFlight },
|
||||
{
|
||||
onWaitTick: reason => {
|
||||
ticks += 1
|
||||
reasons.push(reason)
|
||||
|
||||
if (ticks === 2) {
|
||||
marker = true // updater hand-off: marker written first…
|
||||
}
|
||||
|
||||
if (ticks === 3) {
|
||||
inFlight = false // …then the flag clears; marker still holds the gate
|
||||
}
|
||||
|
||||
if (ticks === 5) {
|
||||
marker = false // updater finished
|
||||
}
|
||||
},
|
||||
pollMs: 1,
|
||||
sleep: async () => {},
|
||||
timeoutMs: 10_000
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(outcome, 'finished')
|
||||
assert.deepEqual(reasons, ['update-in-flight', 'update-in-flight', 'marker', 'marker', 'marker'])
|
||||
})
|
||||
|
||||
test('returns timeout when the gate never opens', async () => {
|
||||
let clock = 0
|
||||
|
||||
const outcome = await waitForUpdateClearance(deps(true, false), {
|
||||
now: () => clock,
|
||||
pollMs: 10,
|
||||
sleep: async ms => {
|
||||
clock += ms
|
||||
},
|
||||
timeoutMs: 50
|
||||
})
|
||||
|
||||
assert.equal(outcome, 'timeout')
|
||||
})
|
||||
95
apps/desktop/electron/update-gate.ts
Normal file
95
apps/desktop/electron/update-gate.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
'use strict'
|
||||
|
||||
/**
|
||||
* update-gate.ts
|
||||
*
|
||||
* Pure, dependency-injected gate that parks local backend spawns while an
|
||||
* in-app update is running (#73822, #50238).
|
||||
*
|
||||
* Two independent signals mean "an update owns the venv right now":
|
||||
*
|
||||
* - the on-disk marker (`HERMES_HOME/.hermes-update-in-progress`), written
|
||||
* by the updater — and by the desktop itself just before hand-off — and
|
||||
* - the in-process `updateInFlight` flag, true for the whole
|
||||
* `applyUpdates()` critical section.
|
||||
*
|
||||
* The marker alone is NOT enough (#73822): `applyUpdates` kills its own
|
||||
* backend early (`releaseBackendLock`) but only writes the marker AFTER the
|
||||
* Windows venv-blocker scan. Killing the backend drops the renderer's
|
||||
* WebSocket, the renderer reconnects within ~1s, and a marker-only gate
|
||||
* happily spawns a fresh backend inside the update's own critical section —
|
||||
* which `scanVenvBlockers` then reports as a blocker, aborting every update
|
||||
* attempt forever. Consulting the flag closes that window. On the success
|
||||
* path the marker is written BEFORE the flag clears in `applyUpdates`'
|
||||
* `finally`, so there is no instant where both signals are false and a
|
||||
* waiter could slip through mid-update.
|
||||
*/
|
||||
|
||||
export type UpdateGateReason = 'marker' | 'update-in-flight' | null
|
||||
|
||||
export interface UpdateGateDeps {
|
||||
/** True when a live on-disk update marker exists (see update-marker.ts). */
|
||||
hasLiveMarker: () => boolean
|
||||
/** True while this process is inside applyUpdates()' critical section. */
|
||||
isUpdateInFlight: () => boolean
|
||||
}
|
||||
|
||||
/** Why the gate is closed right now, or null when it is open. */
|
||||
export function updateGateReason(deps: UpdateGateDeps): UpdateGateReason {
|
||||
if (deps.hasLiveMarker()) {
|
||||
return 'marker'
|
||||
}
|
||||
|
||||
if (deps.isUpdateInFlight()) {
|
||||
return 'update-in-flight'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type UpdateClearanceOutcome = 'clear' | 'finished' | 'timeout'
|
||||
|
||||
export interface WaitForUpdateClearanceOptions {
|
||||
timeoutMs: number
|
||||
pollMs: number
|
||||
/** Invoked once per poll while parked (boot progress / logging). */
|
||||
onWaitTick?: (reason: Exclude<UpdateGateReason, null>) => void | Promise<void>
|
||||
now?: () => number
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Park until no update signal remains, or the deadline passes.
|
||||
*
|
||||
* Returns 'clear' when the gate was already open (no wait happened),
|
||||
* 'finished' when it opened during the wait, and 'timeout' when the deadline
|
||||
* expired with the gate still closed (callers proceed anyway — matching the
|
||||
* long-standing marker-gate behavior, since a wedged updater must not brick
|
||||
* the app forever).
|
||||
*/
|
||||
export async function waitForUpdateClearance(
|
||||
deps: UpdateGateDeps,
|
||||
options: WaitForUpdateClearanceOptions
|
||||
): Promise<UpdateClearanceOutcome> {
|
||||
const now = options.now || Date.now
|
||||
const sleep = options.sleep || (ms => new Promise<void>(r => setTimeout(r, ms)))
|
||||
|
||||
let reason = updateGateReason(deps)
|
||||
|
||||
if (!reason) {
|
||||
return 'clear'
|
||||
}
|
||||
|
||||
const deadline = now() + options.timeoutMs
|
||||
|
||||
while (reason && now() < deadline) {
|
||||
if (options.onWaitTick) {
|
||||
await options.onWaitTick(reason)
|
||||
}
|
||||
|
||||
await sleep(options.pollMs)
|
||||
reason = updateGateReason(deps)
|
||||
}
|
||||
|
||||
return reason ? 'timeout' : 'finished'
|
||||
}
|
||||
218
apps/desktop/electron/venv-blocker-scan.test.ts
Normal file
218
apps/desktop/electron/venv-blocker-scan.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
'use strict'
|
||||
|
||||
/**
|
||||
* Tests for apps/desktop/electron/venv-blocker-scan.ts
|
||||
*
|
||||
* Run with: npx vitest run electron/venv-blocker-scan.test.ts
|
||||
* (from apps/desktop; wired into npm test:desktop:platforms)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatBlockerMessage,
|
||||
formatProbeFailedMessage,
|
||||
parseVenvBlockerScanOutput,
|
||||
resolveVenvPython,
|
||||
scanVenvBlockers
|
||||
} from './venv-blocker-scan'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveVenvPython
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveVenvPython', () => {
|
||||
it('returns a real path when a temp venv python file exists', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-vt-'))
|
||||
|
||||
try {
|
||||
const scriptsDir = process.platform === 'win32' ? 'Scripts' : 'bin'
|
||||
const pythonName = process.platform === 'win32' ? 'python.exe' : 'python3'
|
||||
const dir = path.join(sandbox, 'venv', scriptsDir)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const pyPath = path.join(dir, pythonName)
|
||||
fs.writeFileSync(pyPath, '', { mode: 0o755 })
|
||||
assert.equal(resolveVenvPython(sandbox), pyPath)
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for non-existent venv', () => {
|
||||
assert.equal(resolveVenvPython('/nonexistent'), null)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatBlockerMessage / formatProbeFailedMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatBlockerMessage', () => {
|
||||
it('includes PID, name, cmdline, remote-client warning, and retry suggestion', () => {
|
||||
const msg = formatBlockerMessage({
|
||||
blocked: true,
|
||||
processes: [{ pid: 101, name: 'python.exe', cmdline: 'serve --host 10.0.0.1' }]
|
||||
})
|
||||
|
||||
assert.ok(msg.includes('PID 101'))
|
||||
assert.ok(msg.includes('python.exe'))
|
||||
assert.ok(msg.includes('serve'))
|
||||
assert.ok(msg.includes('remote backend'))
|
||||
assert.ok(msg.includes('retry'))
|
||||
assert.ok(!msg.includes('force-venv'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatProbeFailedMessage', () => {
|
||||
it('suggests retry and hermes update', () => {
|
||||
const msg = formatProbeFailedMessage()
|
||||
assert.ok(msg.includes('hermes update'))
|
||||
assert.ok(msg.includes('retry'))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseVenvBlockerScanOutput — pure function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseVenvBlockerScanOutput', () => {
|
||||
const ok = (over: any = {}) => JSON.stringify({ ok: true, blocked: false, processes: [], ...over })
|
||||
|
||||
it('valid clear', () => {
|
||||
const o = parseVenvBlockerScanOutput(ok())
|
||||
assert.equal(o.kind, 'clear')
|
||||
})
|
||||
|
||||
it('valid blocked', () => {
|
||||
const o = parseVenvBlockerScanOutput(
|
||||
ok({
|
||||
blocked: true,
|
||||
processes: [{ pid: 1, name: 'p', cmdline: 'c' }]
|
||||
})
|
||||
)
|
||||
|
||||
assert.equal(o.kind, 'blocked')
|
||||
})
|
||||
|
||||
it('malformed JSON', () => {
|
||||
assert.equal(parseVenvBlockerScanOutput('not json').kind, 'probe-failure')
|
||||
})
|
||||
|
||||
it('ok=false is rejected', () => {
|
||||
assert.equal(
|
||||
parseVenvBlockerScanOutput(JSON.stringify({ ok: false, blocked: false, processes: [] })).kind,
|
||||
'probe-failure'
|
||||
)
|
||||
})
|
||||
|
||||
it('blocked must be boolean', () => {
|
||||
assert.equal(parseVenvBlockerScanOutput(ok({ blocked: 'false' })).kind, 'probe-failure')
|
||||
})
|
||||
|
||||
it('blocked=true with empty processes rejected', () => {
|
||||
assert.equal(parseVenvBlockerScanOutput(ok({ blocked: true, processes: [] })).kind, 'probe-failure')
|
||||
})
|
||||
|
||||
it('blocked=false with non-empty processes rejected', () => {
|
||||
assert.equal(
|
||||
parseVenvBlockerScanOutput(ok({ processes: [{ pid: 1, name: 'p', cmdline: 'c' }] })).kind,
|
||||
'probe-failure'
|
||||
)
|
||||
})
|
||||
|
||||
it('process pid must be positive integer', () => {
|
||||
assert.equal(
|
||||
parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 0, name: 'p', cmdline: 'c' }] })).kind,
|
||||
'probe-failure'
|
||||
)
|
||||
})
|
||||
|
||||
it('process name must be non-empty string', () => {
|
||||
assert.equal(
|
||||
parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: '', cmdline: 'c' }] })).kind,
|
||||
'probe-failure'
|
||||
)
|
||||
})
|
||||
|
||||
it('process missing cmdline is rejected', () => {
|
||||
assert.equal(
|
||||
parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: 'p' }] })).kind,
|
||||
'probe-failure'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scanVenvBlockers — subprocess with injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('scanVenvBlockers', () => {
|
||||
const stubVenv = () => '/fake/venv/python.exe'
|
||||
const okJson = JSON.stringify({ ok: true, blocked: false, processes: [] })
|
||||
|
||||
const blockedJson = JSON.stringify({
|
||||
ok: true,
|
||||
blocked: true,
|
||||
processes: [{ pid: 1, name: 'p', cmdline: 'c' }]
|
||||
})
|
||||
|
||||
function execReturn(json: string): any {
|
||||
return (async (...args: any[]) => ({ stdout: json, stderr: '' })) as any
|
||||
}
|
||||
|
||||
function execThrow(status: number, stderr: string): any {
|
||||
return (async (...args: any[]) => {
|
||||
const e: any = new Error()
|
||||
e.status = status
|
||||
e.stderr = Buffer.from(stderr)
|
||||
throw e
|
||||
}) as any
|
||||
}
|
||||
|
||||
it('clear scan returns clear', async () => {
|
||||
assert.equal((await scanVenvBlockers('/r', execReturn(okJson), stubVenv)).kind, 'clear')
|
||||
})
|
||||
|
||||
it('blocked scan returns blocked', async () => {
|
||||
assert.equal((await scanVenvBlockers('/r', execReturn(blockedJson), stubVenv)).kind, 'blocked')
|
||||
})
|
||||
|
||||
it('non-zero exit is probe-failure', async () => {
|
||||
const o = await scanVenvBlockers('/r', execThrow(2, 'ModuleNotFoundError'), stubVenv)
|
||||
assert.equal(o.kind, 'probe-failure')
|
||||
})
|
||||
|
||||
it('missing venv python is probe-failure', async () => {
|
||||
const o = await scanVenvBlockers('/r', execReturn(okJson), () => null)
|
||||
assert.equal(o.kind, 'probe-failure')
|
||||
})
|
||||
|
||||
it('malformed subprocess output is probe-failure', async () => {
|
||||
const o = await scanVenvBlockers('/r', execReturn('bad json'), stubVenv)
|
||||
assert.equal(o.kind, 'probe-failure')
|
||||
})
|
||||
|
||||
it('calls subprocess with correct args, cwd and timeout', async () => {
|
||||
const calls: any[] = []
|
||||
|
||||
const spy = (async (cmd: string, args: string[], opts: any) => {
|
||||
calls.push({ cmd, args, cwd: opts.cwd, timeout: opts.timeout })
|
||||
|
||||
return { stdout: okJson, stderr: '' }
|
||||
}) as any
|
||||
|
||||
await scanVenvBlockers('/update/root', spy, stubVenv)
|
||||
assert.equal(calls.length, 1)
|
||||
const c = calls[0]
|
||||
assert.ok(c.cmd.endsWith('python.exe'))
|
||||
assert.deepEqual(c.args, ['-m', 'hermes_cli._scan_venv_blockers'])
|
||||
assert.equal(c.cwd, '/update/root')
|
||||
assert.equal(typeof c.timeout, 'number')
|
||||
assert.ok(c.timeout > 0)
|
||||
})
|
||||
})
|
||||
214
apps/desktop/electron/venv-blocker-scan.ts
Normal file
214
apps/desktop/electron/venv-blocker-scan.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
'use strict'
|
||||
|
||||
/**
|
||||
* venv-blocker-scan.ts
|
||||
*
|
||||
* Thin helper that runs the Python venv-blocker scan as a subprocess and
|
||||
* returns a typed result for the Desktop update preflight.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VenvBlockerProcess {
|
||||
pid: number
|
||||
name: string
|
||||
cmdline: string
|
||||
}
|
||||
|
||||
export interface VenvBlockerScanResult {
|
||||
blocked: boolean
|
||||
processes: VenvBlockerProcess[]
|
||||
}
|
||||
|
||||
export type ScanOutcome =
|
||||
| { kind: 'clear'; result: VenvBlockerScanResult }
|
||||
| { kind: 'blocked'; result: VenvBlockerScanResult }
|
||||
| { kind: 'probe-failure'; error: string }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCAN_TIMEOUT_MS = 15000
|
||||
const SCAN_MODULE = 'hermes_cli._scan_venv_blockers'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Strictly validate and parse the JSON output from the venv-blocker scan.
|
||||
* Pure function — no side effects.
|
||||
*/
|
||||
export function parseVenvBlockerScanOutput(raw: string): ScanOutcome {
|
||||
let parsed: any
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return { kind: 'probe-failure', error: 'malformed JSON' }
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || parsed.ok !== true) {
|
||||
return { kind: 'probe-failure', error: 'missing or invalid ok field' }
|
||||
}
|
||||
|
||||
if (typeof parsed.blocked !== 'boolean') {
|
||||
return { kind: 'probe-failure', error: 'blocked must be a boolean' }
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed.processes)) {
|
||||
return { kind: 'probe-failure', error: 'processes must be an array' }
|
||||
}
|
||||
|
||||
const processes: VenvBlockerProcess[] = []
|
||||
|
||||
for (const entry of parsed.processes) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return { kind: 'probe-failure', error: 'process entry must be an object' }
|
||||
}
|
||||
|
||||
const { pid, name, cmdline } = entry
|
||||
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return { kind: 'probe-failure', error: 'process pid must be a positive integer' }
|
||||
}
|
||||
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
return { kind: 'probe-failure', error: 'process name must be a non-empty string' }
|
||||
}
|
||||
|
||||
if (typeof cmdline !== 'string') {
|
||||
return { kind: 'probe-failure', error: 'process cmdline must be a string' }
|
||||
}
|
||||
|
||||
processes.push({ pid, name, cmdline })
|
||||
}
|
||||
|
||||
// Reject inconsistent combinations
|
||||
if (parsed.blocked && processes.length === 0) {
|
||||
return { kind: 'probe-failure', error: 'blocked is true but process list is empty' }
|
||||
}
|
||||
|
||||
if (!parsed.blocked && processes.length > 0) {
|
||||
return { kind: 'probe-failure', error: 'blocked is false but process list is non-empty' }
|
||||
}
|
||||
|
||||
return parsed.blocked
|
||||
? { kind: 'blocked', result: { blocked: true, processes } }
|
||||
: { kind: 'clear', result: { blocked: false, processes } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the venv-blocker scan subprocess. Async so the Electron main-process
|
||||
* event loop is never blocked by the psutil process scan (up to 15s on a
|
||||
* loaded Windows box). Accepts optional overrides for testing (dependency
|
||||
* injection).
|
||||
*/
|
||||
export async function scanVenvBlockers(
|
||||
updateRoot: string,
|
||||
execOverride?: typeof execFileAsync,
|
||||
resolveOverride?: typeof resolveVenvPython
|
||||
): Promise<ScanOutcome> {
|
||||
const execFn = execOverride || execFileAsync
|
||||
const resolveFn = resolveOverride || resolveVenvPython
|
||||
const venvPython = resolveFn(updateRoot)
|
||||
|
||||
if (!venvPython) {
|
||||
return { kind: 'probe-failure', error: 'venv python not found' }
|
||||
}
|
||||
|
||||
let stdout: string
|
||||
|
||||
try {
|
||||
const proc = await execFn(venvPython, ['-m', SCAN_MODULE], {
|
||||
cwd: updateRoot,
|
||||
encoding: 'utf-8',
|
||||
timeout: SCAN_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
} as any)
|
||||
|
||||
stdout = String((proc as any).stdout ?? '')
|
||||
} catch (err: any) {
|
||||
const diag = [`exit code ${err.status ?? err.code ?? -1}`]
|
||||
|
||||
if (err.stderr) {
|
||||
diag.push(String(err.stderr).slice(0, 200))
|
||||
}
|
||||
|
||||
return { kind: 'probe-failure', error: diag.join('; ') }
|
||||
}
|
||||
|
||||
return parseVenvBlockerScanOutput(stdout)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve the venv python path. Returns null if the file does not exist. */
|
||||
export function resolveVenvPython(updateRoot: string): string | null {
|
||||
const isWindows = process.platform === 'win32'
|
||||
const pythonName = isWindows ? 'python.exe' : 'python3'
|
||||
const scriptsDir = isWindows ? 'Scripts' : 'bin'
|
||||
const candidate = path.join(updateRoot, 'venv', scriptsDir, pythonName)
|
||||
|
||||
try {
|
||||
fs.accessSync(candidate)
|
||||
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable error message from blocker scan results.
|
||||
* Does NOT recommend --force-venv.
|
||||
*/
|
||||
export function formatBlockerMessage(result: VenvBlockerScanResult): string {
|
||||
const lines = [
|
||||
'Update aborted: another Hermes process is using this installation.',
|
||||
'',
|
||||
'These processes must be stopped before updating:',
|
||||
''
|
||||
]
|
||||
|
||||
for (const proc of result.processes.slice(0, 10)) {
|
||||
lines.push(` PID ${proc.pid} ${proc.name} ${proc.cmdline}`)
|
||||
}
|
||||
|
||||
if (result.processes.length > 10) {
|
||||
lines.push(` ... and ${result.processes.length - 10} more`)
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'Close the terminal, app, or service owning that process. If it is a ' +
|
||||
'remote backend, stopping it will disconnect remote clients.'
|
||||
)
|
||||
lines.push('Then retry the update.')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a probe-failure error message.
|
||||
*/
|
||||
export function formatProbeFailedMessage(): string {
|
||||
return (
|
||||
'Update aborted: Desktop could not verify the Hermes installation is free.\n' +
|
||||
'\n' +
|
||||
'Close other Hermes windows and terminals, then retry. If the problem\n' +
|
||||
'persists, run `hermes update` in a terminal for detailed diagnostics.'
|
||||
)
|
||||
}
|
||||
|
|
@ -220,6 +220,7 @@
|
|||
"CFBundleExecutable": "Hermes",
|
||||
"CFBundleName": "Hermes",
|
||||
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
|
||||
"NSCameraUsageDescription": "Hermes uses the camera when a plugin or feature you enable requests it.",
|
||||
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
|
||||
},
|
||||
"gatekeeperAssess": false,
|
||||
|
|
|
|||
61
apps/desktop/scripts/diag-overlay-ab.mjs
Normal file
61
apps/desktop/scripts/diag-overlay-ab.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env node
|
||||
// Robust A/B: measure each surface N times, report median wasted renders.
|
||||
// Reduces timing noise from stream ticks landing on different clicks.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const RUNS = 3
|
||||
let msgId = 1
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = (data) => { const m = JSON.parse(data); if (m.id === id) { ws.off('message', h); m.error ? reject(m.error) : resolve(m.result) } }
|
||||
ws.on('message', h); ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async function ev(ws, e) { return (await send(ws, 'Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }))?.result?.value }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const median = arr => { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)] }
|
||||
|
||||
async function wsUrl() {
|
||||
const d = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
return d.find(t => t.type === 'page' && (t.url||'').includes('5174')).webSocketDebuggerUrl
|
||||
}
|
||||
|
||||
async function measureOnce(ws, setup, holdMs) {
|
||||
await setup(ws)
|
||||
await sleep(300)
|
||||
await ev(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await sleep(holdMs)
|
||||
const rep = JSON.parse(await ev(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
|
||||
return rep.reduce((s, c) => s + c.wasted, 0)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(await wsUrl())
|
||||
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
const surfaces = [
|
||||
['Artifacts', ws => ev(ws, `window.location.hash='#/artifacts'; true`), 2000],
|
||||
['Messaging', ws => ev(ws, `window.location.hash='#/messaging'; true`), 2000],
|
||||
['Cron', ws => ev(ws, `window.location.hash='#/cron'; true`), 2000],
|
||||
['Profiles', ws => ev(ws, `window.location.hash='#/profiles'; true`), 2000],
|
||||
['Agents', ws => ev(ws, `window.location.hash='#/agents'; true`), 2000],
|
||||
['Starmap', ws => ev(ws, `window.location.hash='#/starmap'; true`), 2000],
|
||||
['Webhooks', ws => ev(ws, `window.location.hash='#/webhooks'; true`), 2000],
|
||||
['CommandCenter/System', async ws => { await ev(ws, `window.location.hash='#/command-center'; true`); await sleep(400); await ev(ws, `Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.trim()==='System')?.click(); true`) }, 2000],
|
||||
]
|
||||
|
||||
const label = process.argv[2] || 'RUN'
|
||||
console.log(`\n=== ${label} (median of ${RUNS}, wasted renders, 2s idle) ===`)
|
||||
for (const [name, setup, hold] of surfaces) {
|
||||
const runs = []
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
runs.push(await measureOnce(ws, setup, hold))
|
||||
await ev(ws, `window.location.hash='#/'; true`); await sleep(300)
|
||||
}
|
||||
console.log(` ${name.padEnd(24)} median ${String(median(runs)).padStart(6)} (runs: ${runs.join(', ')})`)
|
||||
}
|
||||
ws.close()
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
161
apps/desktop/scripts/diag-overlay-churn.mjs
Normal file
161
apps/desktop/scripts/diag-overlay-churn.mjs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
#!/usr/bin/env node
|
||||
// CDP probe: measure render churn on overlay surfaces (cmdk, settings, command-center, skills)
|
||||
// against the user's live instance on :9222. Read-only — never closes or navigates away.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const CDP_URL = 'ws://127.0.0.1:9222'
|
||||
const TARGET_GLOB = '/devtools/page/'
|
||||
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async function getRendererTarget() {
|
||||
const resp = await fetch('http://127.0.0.1:9222/json/list')
|
||||
const targets = await resp.json()
|
||||
// Find the renderer target (not devtools)
|
||||
return targets.find(t => t.url?.startsWith('http://127.0.0.1:5174') || t.url?.includes('5174'))
|
||||
?? targets.find(t => t.type === 'page' && !t.url.startsWith('devtools'))
|
||||
}
|
||||
|
||||
async function evalInPage(ws, expr) {
|
||||
const result = await send(ws, 'Runtime.evaluate', {
|
||||
expression: expr,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})
|
||||
return result?.result?.value
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const target = await getRendererTarget()
|
||||
if (!target) {
|
||||
console.error('No renderer target found')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Target: ${target.url}`)
|
||||
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on('open', resolve)
|
||||
ws.on('error', reject)
|
||||
})
|
||||
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
// Check if __RENDER_COUNTS__ is available
|
||||
const hasCounter = await evalInPage(ws, 'typeof __RENDER_COUNTS__')
|
||||
console.log(`__RENDER_COUNTS__ available: ${hasCounter}`)
|
||||
|
||||
if (hasCounter !== 'object') {
|
||||
console.log('Render counter not loaded — checking perf-live...')
|
||||
const hasPerfLive = await evalInPage(ws, 'typeof __PERF_LIVE__')
|
||||
console.log(`__PERF_LIVE__ available: ${hasPerfLive}`)
|
||||
}
|
||||
|
||||
// 1. Baseline: open cmdk, type a few chars, measure
|
||||
console.log('\n=== CmdK Palette ===')
|
||||
await evalInPage(ws, `
|
||||
window.__renderBaseline = {}
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Open cmdk via keyboard shortcut
|
||||
const evt = new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })
|
||||
document.dispatchEvent(evt)
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
|
||||
// Type some characters
|
||||
for (const ch of ['s', 'e', 't', 't', 'i', 'n', 'g']) {
|
||||
await evalInPage(ws, `
|
||||
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[placeholder]')
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.value = el.value + '${ch}'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const cmdkCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('CmdK render counts after typing "setting":', JSON.stringify(cmdkCounts, null, 2))
|
||||
|
||||
// Close cmdk
|
||||
await evalInPage(ws, `new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); true`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// 2. Command Center — measure renders while on System tab
|
||||
console.log('\n=== Command Center (System tab) ===')
|
||||
await evalInPage(ws, `
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Navigate to command center system section
|
||||
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('System') || el.textContent?.includes('Command Center'))
|
||||
if (link) link.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
const ccCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('Command Center render counts (after 1s on System tab):', JSON.stringify(ccCounts, null, 2))
|
||||
|
||||
// 3. Settings page — measure renders on nav
|
||||
console.log('\n=== Settings Page ===')
|
||||
await evalInPage(ws, `
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Navigate to settings
|
||||
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('Settings'))
|
||||
if (link) link.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
// Click through a few nav items
|
||||
for (const label of ['Appearance', 'Gateway', 'Keys', 'About']) {
|
||||
await evalInPage(ws, `
|
||||
const el = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '${label}')
|
||||
if (el) el.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
}
|
||||
|
||||
const settingsCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('Settings render counts (after clicking 4 nav items):', JSON.stringify(settingsCounts, null, 2))
|
||||
|
||||
ws.close()
|
||||
console.log('\nDone.')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
201
apps/desktop/scripts/diag-overlay-full.mjs
Normal file
201
apps/desktop/scripts/diag-overlay-full.mjs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
#!/usr/bin/env node
|
||||
// Comprehensive overlay render-churn measurement on the live instance.
|
||||
// Measures: cmdk root + submenus, settings, command center, capabilities, system overlays.
|
||||
// Read-only — never closes the app, only navigates and clicks.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async function eval_(ws, expr) {
|
||||
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
return r?.result?.value
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function measure(ws, label, fn, settleMs = 400) {
|
||||
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await fn(ws)
|
||||
await sleep(settleMs)
|
||||
const report = await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`)
|
||||
const parsed = JSON.parse(report)
|
||||
const totalRenders = parsed.reduce((s, c) => s + c.renders, 0)
|
||||
const totalWasted = parsed.reduce((s, c) => s + c.wasted, 0)
|
||||
const totalMs = parsed.reduce((s, c) => s + c.totalMs, 0)
|
||||
const top5 = parsed.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 5)
|
||||
console.log(`\n${label}`)
|
||||
console.log(` total: ${totalRenders} renders, ${totalWasted} wasted, ${totalMs.toFixed(1)}ms`)
|
||||
if (top5.length > 0) {
|
||||
for (const c of top5) {
|
||||
console.log(` ${c.name}: ${c.renders} renders, ${c.wasted} wasted, ${c.totalMs.toFixed(1)}ms`)
|
||||
}
|
||||
} else {
|
||||
console.log(` (no wasted renders)`)
|
||||
}
|
||||
return { label, totalRenders, totalWasted, totalMs, top: top5 }
|
||||
}
|
||||
|
||||
async function clickByText(ws, text, tag = 'button') {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === '${text}')
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function clickByHref(ws, partial) {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('a,button')).find(e => e.getAttribute('href')?.includes('${partial}'))
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function typeInInput(ws, text) {
|
||||
for (const ch of text) {
|
||||
await eval_(ws, `
|
||||
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[type="text"]') || document.querySelector('input')
|
||||
if (el) { el.focus(); el.value = el.value + '${ch}'; el.dispatchEvent(new Event('input', {bubbles:true})) }
|
||||
true
|
||||
`)
|
||||
await sleep(50)
|
||||
}
|
||||
}
|
||||
|
||||
async function pressKey(ws, key, mods = {}) {
|
||||
await eval_(ws, `
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '${key}', ${Object.entries(mods).map(([k,v]) => `${k}:${v}`).join(', ')}, bubbles: true }))
|
||||
true
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(WS_URL)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on('open', resolve)
|
||||
ws.on('error', reject)
|
||||
})
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
const avail = await eval_(ws, `typeof __RENDER_COUNTS__`)
|
||||
console.log(`__RENDER_COUNTS__: ${avail}`)
|
||||
if (avail !== 'object') {
|
||||
console.error('Render counter not loaded. Reload the page.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const results = []
|
||||
|
||||
// === 1. CmdK root: open, type "setting", close ===
|
||||
results.push(await measure(ws, '1. CmdK root (type "setting")', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'setting')
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 2. CmdK submenu: open, navigate to theme picker, click a theme, back ===
|
||||
results.push(await measure(ws, '2. CmdK theme submenu', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'theme')
|
||||
await sleep(200)
|
||||
// Click first theme item
|
||||
await eval_(ws, `
|
||||
const item = document.querySelector('[cmdk-item]')
|
||||
if (item) item.click()
|
||||
true
|
||||
`)
|
||||
await sleep(300)
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 3. CmdK color-mode submenu ===
|
||||
results.push(await measure(ws, '3. CmdK color-mode submenu', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'color mode')
|
||||
await sleep(200)
|
||||
await eval_(ws, `const item = document.querySelector('[cmdk-item]'); if (item) item.click(); true`)
|
||||
await sleep(300)
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 4. Settings: open, click through nav items ===
|
||||
results.push(await measure(ws, '4. Settings (5 nav clicks)', async (ws) => {
|
||||
await clickByHref(ws, 'settings')
|
||||
await sleep(500)
|
||||
for (const label of ['Appearance', 'Gateway', 'Keys', 'Notifications', 'About']) {
|
||||
await clickByText(ws, label)
|
||||
await sleep(100)
|
||||
}
|
||||
}))
|
||||
|
||||
// === 5. Command Center: open to System tab, idle 2s ===
|
||||
results.push(await measure(ws, '5. Command Center (System tab, 2s idle)', async (ws) => {
|
||||
await clickByHref(ws, 'command-center')
|
||||
await sleep(500)
|
||||
await clickByText(ws, 'System')
|
||||
await sleep(2000)
|
||||
}, 100))
|
||||
|
||||
// === 6. Command Center: Sessions tab ===
|
||||
results.push(await measure(ws, '6. Command Center (Sessions tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'Sessions')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 7. Capabilities/Skills page ===
|
||||
results.push(await measure(ws, '7. Capabilities (Skills tab, 1s)', async (ws) => {
|
||||
await clickByHref(ws, 'skills')
|
||||
await sleep(800)
|
||||
// Click Skills tab if not already
|
||||
await clickByText(ws, 'Skills')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 8. Capabilities Toolsets tab ===
|
||||
results.push(await measure(ws, '8. Capabilities (Toolsets tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'Tools')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 9. Capabilities MCP tab ===
|
||||
results.push(await measure(ws, '9. Capabilities (MCP tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'MCP')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// Navigate back to chat
|
||||
await eval_(ws, `window.location.hash = '#/'; true`)
|
||||
await sleep(300)
|
||||
|
||||
// Summary table
|
||||
console.log('\n\n=== SUMMARY ===')
|
||||
console.log('Surface'.padEnd(45) + 'Renders'.padStart(10) + 'Wasted'.padStart(10) + 'ms'.padStart(10))
|
||||
console.log('-'.repeat(75))
|
||||
for (const r of results) {
|
||||
console.log(r.label.padEnd(45) + String(r.totalRenders).padStart(10) + String(r.totalWasted).padStart(10) + r.totalMs.toFixed(1).padStart(10))
|
||||
}
|
||||
|
||||
ws.close()
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
110
apps/desktop/scripts/diag-overlay-sweep.mjs
Normal file
110
apps/desktop/scripts/diag-overlay-sweep.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
#!/usr/bin/env node
|
||||
// Full surface sweep: every overlay, every settings sub-page, every system overlay.
|
||||
// Read-only — navigates and clicks, never closes the app.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = process.env.CDP_WS || 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async function eval_(ws, expr) {
|
||||
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
return r?.result?.value
|
||||
}
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function measure(ws, label, fn, settleMs = 300) {
|
||||
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await fn(ws)
|
||||
await sleep(settleMs)
|
||||
const report = JSON.parse(await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
|
||||
const totalRenders = report.reduce((s, c) => s + c.renders, 0)
|
||||
const totalWasted = report.reduce((s, c) => s + c.wasted, 0)
|
||||
const totalMs = report.reduce((s, c) => s + c.totalMs, 0)
|
||||
const top = report.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 6)
|
||||
console.log(`\n${label}`)
|
||||
console.log(` ${totalRenders} renders · ${totalWasted} wasted · ${totalMs.toFixed(1)}ms`)
|
||||
for (const c of top) console.log(` ${c.name}: ${c.renders}r / ${c.wasted}w / ${c.totalMs.toFixed(1)}ms`)
|
||||
if (top.length === 0) console.log(` (clean)`)
|
||||
return { label, totalRenders, totalWasted, totalMs }
|
||||
}
|
||||
|
||||
async function nav(ws, hash) {
|
||||
await eval_(ws, `window.location.hash = '#${hash}'; true`)
|
||||
}
|
||||
async function clickText(ws, text, tag='button') {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === ${JSON.stringify(text)})
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(WS_URL)
|
||||
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
|
||||
await send(ws, 'Runtime.enable')
|
||||
if (await eval_(ws, `typeof __RENDER_COUNTS__`) !== 'object') { console.error('counter not loaded'); process.exit(1) }
|
||||
|
||||
const results = []
|
||||
|
||||
// System overlays — open each, idle 2s (streaming underneath is the churn test)
|
||||
for (const [label, route] of [
|
||||
['Artifacts', '/artifacts'],
|
||||
['Messaging', '/messaging'],
|
||||
['Cron', '/cron'],
|
||||
['Profiles', '/profiles'],
|
||||
['Agents', '/agents'],
|
||||
['Starmap', '/starmap'],
|
||||
['Webhooks', '/webhooks'],
|
||||
]) {
|
||||
results.push(await measure(ws, `OVERLAY: ${label} (open + 2s idle)`, async (ws) => {
|
||||
await nav(ws, route)
|
||||
await sleep(2200)
|
||||
}, 100))
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
// Settings sub-pages — open settings, click each nav item, idle 1.5s on it
|
||||
await nav(ws, '/settings')
|
||||
await sleep(600)
|
||||
for (const label of ['Model','Session','Appearance','Notifications','Providers','Gateway','Keybinds','API Keys','Plugins','Archived Chats','About']) {
|
||||
results.push(await measure(ws, `SETTINGS: ${label} (1.5s idle)`, async (ws) => {
|
||||
await clickText(ws, label)
|
||||
await sleep(1500)
|
||||
}, 100))
|
||||
}
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
|
||||
// Messaging sub-tabs (platform detail) — click through if present
|
||||
await nav(ws, '/messaging')
|
||||
await sleep(800)
|
||||
results.push(await measure(ws, 'MESSAGING: idle 2s w/ platform list', async (ws) => {
|
||||
await sleep(2000)
|
||||
}, 100))
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
|
||||
console.log('\n\n=== SUMMARY (wasted renders) ===')
|
||||
console.log('Surface'.padEnd(48) + 'Renders'.padStart(9) + 'Wasted'.padStart(9) + 'ms'.padStart(9))
|
||||
console.log('-'.repeat(75))
|
||||
for (const r of results) {
|
||||
console.log(r.label.padEnd(48) + String(r.totalRenders).padStart(9) + String(r.totalWasted).padStart(9) + r.totalMs.toFixed(1).padStart(9))
|
||||
}
|
||||
ws.close()
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
|
|
@ -96,7 +96,7 @@ type CellCtx = {
|
|||
}
|
||||
|
||||
interface ArtifactColumn {
|
||||
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
|
||||
Cell: React.ComponentType<{ artifact: ArtifactRecord; ctx: CellCtx }>
|
||||
bodyClassName: string
|
||||
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
|
||||
id: 'location' | 'primary' | 'session'
|
||||
|
|
@ -278,10 +278,12 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
})
|
||||
}, [])
|
||||
|
||||
const cellCtx: CellCtx = {
|
||||
onOpen: openArtifact,
|
||||
onOpenChat: sessionId => openSession(sessionId, navigate)
|
||||
}
|
||||
// Stable ctx: recreating it (or its onOpenChat closure) every render made
|
||||
// every artifact cell re-render whenever the page did — and a link cell's
|
||||
// async title fetch re-rendered the page repeatedly. openArtifact is already
|
||||
// a useCallback; navigate is stable, so onOpenChat can be too.
|
||||
const openChat = useCallback((sessionId: string) => openSession(sessionId, navigate), [navigate])
|
||||
const cellCtx: CellCtx = useMemo(() => ({ onOpen: openArtifact, onOpenChat: openChat }), [openArtifact, openChat])
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
|
|
@ -549,7 +551,7 @@ function ArtifactCellAction({
|
|||
)
|
||||
}
|
||||
|
||||
function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const PrimaryCell = memo(function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const isLink = artifact.kind === 'link'
|
||||
const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null
|
||||
const Icon = brand ?? (isLink ? Link2 : FileText)
|
||||
|
|
@ -571,9 +573,9 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
|||
</span>
|
||||
</ArtifactCellAction>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const LocationCell = memo(function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const { t } = useI18n()
|
||||
const isLink = artifact.kind === 'link'
|
||||
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
|
||||
|
|
@ -602,9 +604,9 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
|
|||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const SessionCell = memo(function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
return (
|
||||
<ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
|
|
@ -615,7 +617,7 @@ function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
|||
</span>
|
||||
</ArtifactCellAction>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@
|
|||
* through the SAME registry schema as every other surface (statusbar, titlebar,
|
||||
* panes, layouts):
|
||||
*
|
||||
* render areas (`render`): composer.top — banner strip above the input
|
||||
* composer.bottom — row below the input grid
|
||||
* composer.leading — inline after the "+" menu
|
||||
* composer.actions — inline before the model pill
|
||||
* render areas (`render`): composer.top — banner strip above the input
|
||||
* composer.bottom — row below the input grid
|
||||
* composer.underside — floating strip BELOW the
|
||||
* whole composer (no chrome)
|
||||
* composer.leading — inline after the "+" menu
|
||||
* composer.actions — inline before the model pill
|
||||
*
|
||||
* data kinds (`data`): composer.middleware (ComposerMiddleware)
|
||||
* composer.attachments (ComposerAttachmentProvider)
|
||||
* data kinds (`data`): composer.middleware (ComposerMiddleware)
|
||||
* composer.attachments (ComposerAttachmentProvider)
|
||||
* composer.microActions (ComposerMicroActionProvider)
|
||||
*
|
||||
* Core keeps ownership of the transcript, input, and submit engine — these
|
||||
* seams AUGMENT the composer, they never replace it. Middleware runs as an
|
||||
|
|
@ -17,17 +20,23 @@
|
|||
* draft, pass it through, or cancel the send by returning null.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import type { TodoItem } from '@/lib/todos'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import type { ComposerAction } from '@/store/composer-actions'
|
||||
|
||||
export const COMPOSER_AREAS = {
|
||||
top: 'composer.top',
|
||||
bottom: 'composer.bottom',
|
||||
underside: 'composer.underside',
|
||||
leading: 'composer.leading',
|
||||
actions: 'composer.actions',
|
||||
middleware: 'composer.middleware',
|
||||
attachments: 'composer.attachments'
|
||||
attachments: 'composer.attachments',
|
||||
microActions: 'composer.microActions'
|
||||
} as const
|
||||
|
||||
export interface ComposerDraft {
|
||||
|
|
@ -92,3 +101,39 @@ export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvid
|
|||
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
|
||||
.filter(p => Boolean(p.label && p.run))
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of a `composer.microActions` data contribution — the pill strip at
|
||||
* the top of the composer's overlay lane.
|
||||
*
|
||||
* `resolve` is called with the live session context and returns the badges to
|
||||
* show right now, or `[]` for "nothing from me". Returning a list rather than
|
||||
* a static badge is what lets a provider be conditional ("only while idle",
|
||||
* "only with unfinished tasks") without a reactive `when()`, which the
|
||||
* registry deliberately doesn't offer.
|
||||
*/
|
||||
export interface ComposerMicroActionProvider {
|
||||
resolve: (ctx: ComposerMicroActionContext) => ComposerAction[]
|
||||
}
|
||||
|
||||
/** What a micro-action provider gets to branch on. Deliberately small: every
|
||||
* field here is a standing compatibility promise to the plugins using it. */
|
||||
export interface ComposerMicroActionContext {
|
||||
/** A turn is currently running in this session. */
|
||||
busy: boolean
|
||||
sessionId: string
|
||||
/** Live todo list for the session (empty when there is none). */
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
/** Micro-action providers, memoised against the registry's own stable
|
||||
* snapshot — the strip re-resolves on every composer render, so a fresh array
|
||||
* here would defeat that. */
|
||||
export function useComposerMicroActionProviders(): ComposerMicroActionProvider[] {
|
||||
const contributions = useContributions(COMPOSER_AREAS.microActions)
|
||||
|
||||
return useMemo(
|
||||
() => contributions.map(c => c.data as ComposerMicroActionProvider).filter(p => typeof p?.resolve === 'function'),
|
||||
[contributions]
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||
|
||||
import type { ChatBarState } from '@/app/chat/composer/types'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
|
||||
|
||||
import { ComposerControls } from './controls'
|
||||
|
||||
|
|
@ -77,3 +78,62 @@ describe('ComposerControls shortcut tooltips', () => {
|
|||
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
|
||||
})
|
||||
})
|
||||
|
||||
describe('wake-word ear visibility', () => {
|
||||
afterEach(() => {
|
||||
resetWakeWordState()
|
||||
})
|
||||
|
||||
it('stays mounted during a busy agent turn', () => {
|
||||
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
|
||||
renderControls({ busy: true, busyAction: 'stop' })
|
||||
|
||||
expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stays mounted (enabled in config) even when a start was refused', () => {
|
||||
applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' })
|
||||
// Transient refusal marks available false but enabled keeps it mounted.
|
||||
applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false })
|
||||
renderControls()
|
||||
|
||||
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stays visible (never hides) even when unavailable and not enabled', () => {
|
||||
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
|
||||
renderControls()
|
||||
|
||||
// The ear ALWAYS shows so the user can click to enable; a failed start
|
||||
// surfaces its reason in the tooltip rather than hiding the control.
|
||||
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces the backend refusal reason in the tooltip, still visible', () => {
|
||||
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
|
||||
applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false })
|
||||
renderControls()
|
||||
|
||||
const ear = screen.getByLabelText('Wake word: "hey hermes" — off')
|
||||
expect(ear).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a disabled paused ear inside the voice-conversation pill', () => {
|
||||
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
|
||||
renderControls({
|
||||
conversation: {
|
||||
active: true,
|
||||
level: 0,
|
||||
muted: false,
|
||||
onEnd: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onStopTurn: vi.fn(),
|
||||
onToggleMute: vi.fn(),
|
||||
status: 'listening'
|
||||
}
|
||||
})
|
||||
|
||||
const ear = screen.getByLabelText('Wake word: "hey hermes" — paused during voice chat')
|
||||
expect((ear as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,24 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
|
||||
import {
|
||||
AudioLines,
|
||||
Ear,
|
||||
EarOff,
|
||||
iconSize,
|
||||
Layers3,
|
||||
Loader2,
|
||||
Square,
|
||||
SteeringWheel,
|
||||
Volume2,
|
||||
VolumeX
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import { ModelPill } from './model-pill'
|
||||
|
|
@ -80,6 +94,7 @@ export function ComposerControls({
|
|||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
<WakeWordButton disabled={disabled} />
|
||||
{busyAction === 'steer' ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Button
|
||||
|
|
@ -181,6 +196,9 @@ function ConversationPill({
|
|||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
{/* Keep the ear visible during voice chat — shown paused, since the
|
||||
conversation holds the mic (the one time wake must not listen). */}
|
||||
<WakeWordButton disabled={disabled} pausedForVoice />
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Button
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
|
|
@ -294,6 +312,55 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
|
|||
)
|
||||
}
|
||||
|
||||
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
|
||||
// user must always be able to click it to turn passive listening on; if the
|
||||
// backend can't start (missing STT/TTS, deps still installing, no mic
|
||||
// permission, etc.) the click surfaces the reason in the tooltip and the
|
||||
// toggle stays off. States: listening (accent-highlighted), off (muted
|
||||
// ear-off), and paused-for-voice (disabled while a voice conversation holds
|
||||
// the mic — the one time wake genuinely must not listen). Backend refusals
|
||||
// ({started:false, reason}) keep the toggle off and put the reason/hint in
|
||||
// the tooltip.
|
||||
function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const wake = useStore($wakeWord)
|
||||
|
||||
const phrase = wake.phrase || 'hey hermes'
|
||||
|
||||
const label = pausedForVoice
|
||||
? c.wakeWordPausedVoice(phrase)
|
||||
: wake.listening
|
||||
? c.wakeWordListening(phrase)
|
||||
: c.wakeWordOff(phrase)
|
||||
|
||||
const tooltip = !pausedForVoice && wake.notice ? `${label} — ${wake.notice}` : label
|
||||
|
||||
return (
|
||||
<Tip label={tooltip}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={wake.listening && !pausedForVoice}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
wake.listening && !pausedForVoice && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
|
||||
)}
|
||||
disabled={disabled || pausedForVoice || wake.pending}
|
||||
onClick={() => {
|
||||
triggerHaptic(wake.listening ? 'close' : 'open')
|
||||
void toggleWakeWord()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wake.listening && !pausedForVoice ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
function DictationButton({
|
||||
disabled,
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { blurComposerInput } from './focus'
|
||||
import {
|
||||
blurComposerInput,
|
||||
getActiveComposer,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
releaseActiveComposer,
|
||||
requestComposerFocus
|
||||
} from './focus'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
/**
|
||||
|
|
@ -21,8 +28,23 @@ function mountInput(hidden = false) {
|
|||
return input
|
||||
}
|
||||
|
||||
/** A chat surface stamp — the same `data-composer-target` ChatView hangs. */
|
||||
function mountSurface(target: string, hidden = false) {
|
||||
const layer = document.createElement('div')
|
||||
layer.toggleAttribute('data-pane-hidden', hidden)
|
||||
const surface = document.createElement('div')
|
||||
surface.dataset.composerTarget = target
|
||||
layer.append(surface)
|
||||
document.body.append(layer)
|
||||
|
||||
return surface
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
// `activeTarget` is module-level — a case that leaves a stale claim behind
|
||||
// would otherwise decide the next one.
|
||||
markActiveComposer('main')
|
||||
})
|
||||
|
||||
describe('blurComposerInput', () => {
|
||||
|
|
@ -48,3 +70,149 @@ describe('blurComposerInput', () => {
|
|||
expect(document.activeElement).toBe(outside)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* `markActiveComposer` has four call sites and, unguarded, no counterpart: an
|
||||
* unmounting or keep-alive-buried composer left `activeTarget` pointing at
|
||||
* itself, so every `'active'`-routed request was delivered to a target with no
|
||||
* on-screen subscriber. Type-to-focus preventDefaults the keystroke BEFORE the
|
||||
* request, so a dead target swallows the character and focuses nothing.
|
||||
*/
|
||||
describe('releaseActiveComposer', () => {
|
||||
it('falls back to the main composer when the claimant releases', () => {
|
||||
const root = document.createElement('div')
|
||||
root.dataset.slot = 'aui_edit-composer-root'
|
||||
document.body.append(root)
|
||||
|
||||
markActiveComposer('edit')
|
||||
expect(getActiveComposer()).toBe('edit')
|
||||
|
||||
root.remove()
|
||||
releaseActiveComposer('edit')
|
||||
|
||||
expect(getActiveComposer()).toBe('main')
|
||||
})
|
||||
|
||||
it('leaves the key with the live claimant when a stale composer releases late', () => {
|
||||
markActiveComposer('edit')
|
||||
markActiveComposer('tile:abc')
|
||||
|
||||
releaseActiveComposer('edit')
|
||||
|
||||
expect(getActiveComposer()).toBe('tile:abc')
|
||||
})
|
||||
|
||||
it('prefers the visible chat surface over a hard main default', () => {
|
||||
const root = document.createElement('div')
|
||||
root.dataset.slot = 'aui_edit-composer-root'
|
||||
document.body.append(root)
|
||||
mountSurface('tile:visible')
|
||||
markActiveComposer('edit')
|
||||
|
||||
root.remove()
|
||||
releaseActiveComposer('edit')
|
||||
|
||||
expect(getActiveComposer()).toBe('tile:visible')
|
||||
})
|
||||
|
||||
it('routes an active-target request to the main composer once the edit composer closes', async () => {
|
||||
// Mirrors the per-composer filter in use-composer-draft / user-edit-composer:
|
||||
// a composer ignores any request not addressed to its own target.
|
||||
const mainComposerSaw: string[] = []
|
||||
|
||||
const off = onComposerFocusRequest(({ target }) => {
|
||||
if (target === 'main') {
|
||||
mainComposerSaw.push(target)
|
||||
}
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
root.dataset.slot = 'aui_edit-composer-root'
|
||||
document.body.append(root)
|
||||
markActiveComposer('edit')
|
||||
root.remove()
|
||||
releaseActiveComposer('edit')
|
||||
requestComposerFocus('active')
|
||||
|
||||
// `dispatch` defers to a macrotask so click/keydown handlers settle first.
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
off()
|
||||
|
||||
expect(mainComposerSaw).toEqual(['main'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveActive / keep-alive tab heal', () => {
|
||||
it('heals type-to-focus onto the visible main tab when a tile is buried', async () => {
|
||||
// Repro for the reported main-tab miss: user typed in a session tile, then
|
||||
// clicked the main/workspace tab without focusing its input. The tile stays
|
||||
// mounted under data-pane-hidden, so activeTarget still reads tile:… and
|
||||
// every type-to-focus request is dropped by the visible main composer.
|
||||
mountSurface('tile:buried', true)
|
||||
mountSurface('main')
|
||||
markActiveComposer('tile:buried')
|
||||
|
||||
expect(getActiveComposer()).toBe('main')
|
||||
|
||||
const mainSaw: string[] = []
|
||||
const tileSaw: string[] = []
|
||||
|
||||
const off = onComposerFocusRequest(({ target }) => {
|
||||
if (target === 'main') {
|
||||
mainSaw.push(target)
|
||||
}
|
||||
|
||||
if (target === 'tile:buried') {
|
||||
tileSaw.push(target)
|
||||
}
|
||||
})
|
||||
|
||||
requestComposerFocus('active', { typeChar: 'h' })
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
off()
|
||||
|
||||
expect(mainSaw).toEqual(['main'])
|
||||
expect(tileSaw).toEqual([])
|
||||
// Cache stays honest so dict/insert/Esc path all agree thereafter.
|
||||
expect(getActiveComposer()).toBe('main')
|
||||
})
|
||||
|
||||
it('keeps a live tile claim while that tile is the visible surface', () => {
|
||||
mountSurface('main', true)
|
||||
mountSurface('tile:front')
|
||||
markActiveComposer('tile:front')
|
||||
|
||||
expect(getActiveComposer()).toBe('tile:front')
|
||||
})
|
||||
|
||||
it('heals an edit claim once the edit root is gone (no release site needed)', async () => {
|
||||
mountSurface('main')
|
||||
markActiveComposer('edit')
|
||||
// No edit root in the document → claim is dead. getActiveComposer heals.
|
||||
expect(getActiveComposer()).toBe('main')
|
||||
|
||||
const mainSaw: string[] = []
|
||||
|
||||
const off = onComposerFocusRequest(({ target }) => {
|
||||
if (target === 'main') {
|
||||
mainSaw.push(target)
|
||||
}
|
||||
})
|
||||
|
||||
requestComposerFocus('active', { typeChar: 'a' })
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
off()
|
||||
|
||||
expect(mainSaw).toEqual(['main'])
|
||||
})
|
||||
|
||||
it('holds an edit claim while the edit composer root is mounted', () => {
|
||||
const root = document.createElement('div')
|
||||
root.dataset.slot = 'aui_edit-composer-root'
|
||||
document.body.append(root)
|
||||
mountSurface('main')
|
||||
markActiveComposer('edit')
|
||||
|
||||
expect(getActiveComposer()).toBe('edit')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,6 +43,21 @@ const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
|||
const SUBMIT_EVENT = 'hermes:composer-submit'
|
||||
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
|
||||
|
||||
/** Inline edit composer root — mounted only while a user bubble is being edited. */
|
||||
const EDIT_COMPOSER_ROOT = '[data-slot="aui_edit-composer-root"]'
|
||||
|
||||
/** Attribute-safe selector fragment. jsdom (vitest) does not ship `CSS.escape`. */
|
||||
const cssEscape = (value: string): string => {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(value)
|
||||
}
|
||||
|
||||
// Our targets are `'main'` / `'edit'` / `'tile:<id>'` — alphanumerics plus `:`
|
||||
// and `-`. Escape anything outside that set so a weird id cannot break the
|
||||
// attribute selector.
|
||||
return value.replace(/[^a-zA-Z0-9_:-]/g, ch => `\\${ch}`)
|
||||
}
|
||||
|
||||
interface SubmitDetail {
|
||||
target: ComposerTarget
|
||||
text: string
|
||||
|
|
@ -50,7 +65,76 @@ interface SubmitDetail {
|
|||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
|
||||
/**
|
||||
* The chat surface currently on screen (`data-composer-target` hung off each
|
||||
* ChatView). Inactive tabs stay mounted with `data-pane-hidden`, so this uses
|
||||
* the same visibility policy as every other document-wide surface lookup.
|
||||
*/
|
||||
const visibleChatTarget = (): ComposerTarget | null => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const surface = queryVisible<HTMLElement>('[data-composer-target]')
|
||||
const target = surface?.dataset.composerTarget
|
||||
|
||||
return target ? (target as ComposerTarget) : null
|
||||
}
|
||||
|
||||
/** True when `target` still has a live, on-screen subscriber. */
|
||||
const targetIsReachable = (target: ComposerTarget): boolean => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true
|
||||
}
|
||||
|
||||
// The edit composer is an in-thread overlay, not a chat surface — it never
|
||||
// stamps `data-composer-target`. While its root is mounted it still owns the
|
||||
// bus; once it tears down the claim is dead.
|
||||
if (target === 'edit') {
|
||||
return Boolean(document.querySelector(EDIT_COMPOSER_ROOT))
|
||||
}
|
||||
|
||||
// Exact match on a VISIBLE surface. Background keep-alive tabs carry the same
|
||||
// `data-composer-target` but sit under `data-pane-hidden`, so queryVisible
|
||||
// filters them out.
|
||||
if (queryVisible(`[data-composer-target="${cssEscape(target)}"]`)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// A different chat surface is on screen → this claim is buried or gone.
|
||||
// (A claim with zero stamped surfaces yet — first paint, pure-unit tests —
|
||||
// keeps the marked key until the DOM contradicts it.)
|
||||
if (queryVisible('[data-composer-target]')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer `'active'` should route to right now.
|
||||
*
|
||||
* The cached claim (`activeTarget`) wins while its surface is still on screen.
|
||||
* Tab stacks keep inactive panes mounted, so focusing a tile then clicking the
|
||||
* main tab leaves `activeTarget` pointing at a buried composer — with no
|
||||
* subscriber on the visible surface, every type-to-focus keystroke is
|
||||
* preventDefault'd and dropped. Heal to the visible chat surface (or main)
|
||||
* whenever the claim is off-screen or gone, and keep the cache honest so Esc /
|
||||
* voice / soft `/` agree with the keyboard path.
|
||||
*/
|
||||
const resolveActive = (): ComposerTarget => {
|
||||
if (targetIsReachable(activeTarget)) {
|
||||
return activeTarget
|
||||
}
|
||||
|
||||
const visible = visibleChatTarget() ?? 'main'
|
||||
|
||||
activeTarget = visible
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? resolveActive() : target)
|
||||
|
||||
const dispatch = <T>(name: string, detail: T) => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
|
@ -82,9 +166,33 @@ export const markActiveComposer = (target: ComposerTarget) => {
|
|||
activeTarget = target
|
||||
}
|
||||
|
||||
/** Hand the routing key back when a composer unmounts, so `'active'` can never
|
||||
* resolve to a composer that no longer has a subscriber — such a request is
|
||||
* dispatched and then dropped by every mounted composer's target filter, and
|
||||
* nothing re-marks the active composer on its own.
|
||||
*
|
||||
* Guarded on identity: a composer unmounting AFTER another one claimed the key
|
||||
* (closing a background tile, a deferred edit-close cleanup) must not steal it
|
||||
* from the live claimant. Falls through to {@link resolveActive} when the
|
||||
* caller's surface is buried rather than gone, so closing on a tab switch that
|
||||
* already re-fronted another chat surfaces there immediately. */
|
||||
export const releaseActiveComposer = (target: ComposerTarget) => {
|
||||
if (activeTarget !== target) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the visible chat surface over a hard `'main'` default — releasing a
|
||||
// closed tile while another tile is fronted should land there, not the
|
||||
// (possibly buried) workspace tab.
|
||||
activeTarget = visibleChatTarget() ?? 'main'
|
||||
}
|
||||
|
||||
/** The composer that last held focus — the target `'active'` resolves to.
|
||||
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
|
||||
export const getActiveComposer = (): ComposerTarget => activeTarget
|
||||
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one.
|
||||
* Heals a stale claim the same way {@link requestComposerFocus} does, so Esc
|
||||
* and type-to-focus never disagree after a tab switch left the bus pointing at
|
||||
* a keep-alive-mounted background composer. */
|
||||
export const getActiveComposer = (): ComposerTarget => resolveActive()
|
||||
|
||||
export const requestComposerFocus = (
|
||||
target: ComposerTarget | 'active' = 'active',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||
import { type ComposerAttachment, mainComposerScope, stashSessionDraft } from '@/store/composer'
|
||||
|
||||
import type { QueueEditState } from '../composer-utils'
|
||||
import { type ComposerTarget, getActiveComposer, markActiveComposer } from '../focus'
|
||||
import { type ComposerScope, ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from '../scope'
|
||||
|
||||
import { useComposerDraft } from './use-composer-draft'
|
||||
|
||||
|
|
@ -128,3 +130,48 @@ describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useComposerDraft — a closing composer hands the focus-bus key back', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
mainComposerScope.clear()
|
||||
markActiveComposer('main')
|
||||
})
|
||||
|
||||
function renderScoped(target: ComposerTarget) {
|
||||
const scope: ComposerScope = { ...MAIN_COMPOSER_SCOPE, target }
|
||||
|
||||
return render(
|
||||
<ComposerScopeProvider value={scope}>
|
||||
<ProbeHarness
|
||||
activeQueueSessionKey="session-tile"
|
||||
onLayoutSnapshot={() => undefined}
|
||||
sessionId="session-tile"
|
||||
/>
|
||||
</ComposerScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('stops `active` resolving to a session tile once the tile unmounts', () => {
|
||||
const { unmount } = renderScoped('tile:abc')
|
||||
|
||||
// Mounting claims the bus for this tile — the leak precondition.
|
||||
expect(getActiveComposer()).toBe('tile:abc')
|
||||
|
||||
unmount()
|
||||
|
||||
expect(getActiveComposer()).toBe('main')
|
||||
})
|
||||
|
||||
it('leaves the key alone when another composer claimed it before this one unmounted', () => {
|
||||
const { unmount } = renderScoped('tile:abc')
|
||||
expect(getActiveComposer()).toBe('tile:abc')
|
||||
|
||||
// The user clicks into a second tile, which claims the bus.
|
||||
markActiveComposer('tile:other')
|
||||
|
||||
unmount()
|
||||
|
||||
expect(getActiveComposer()).toBe('tile:other')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ import {
|
|||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest
|
||||
onComposerInsertRequest,
|
||||
releaseActiveComposer
|
||||
} from '../focus'
|
||||
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
|
||||
import { composerPlainText, placeCaretEnd, REF_RE, renderComposerContents } from '../rich-editor'
|
||||
|
|
@ -153,6 +154,15 @@ export function useComposerDraft({
|
|||
}
|
||||
}, [focusInput, focusKey, focusRequestId, inputDisabled])
|
||||
|
||||
// The mirror of the `markActiveComposer` above: give the key back when this
|
||||
// composer goes away (a session tile closing, a pane unmounting). Covers both
|
||||
// claim sites for this composer — `focusInput` here and ChatBar's `onFocus` —
|
||||
// since they mark the same scope target. Without it `'active'` keeps
|
||||
// resolving to a dead tile and every routed focus/insert request is dropped.
|
||||
// (Heal-to-visible in focus.ts covers the keep-alive-tab case where the pane
|
||||
// stays mounted behind the front tab; this covers true unmounts.)
|
||||
useEffect(() => () => releaseActiveComposer(target), [target])
|
||||
|
||||
useEffect(() => {
|
||||
if (inputDisabled) {
|
||||
return undefined
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useAuiState } from '@assistant-ui/react'
|
|||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
chatSurfaceRoot,
|
||||
clearSurfaceVar,
|
||||
COMPOSER_HEIGHT_VAR,
|
||||
COMPOSER_SURFACE_HEIGHT_VAR,
|
||||
|
|
@ -168,11 +169,16 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
|
|||
}, [poppedOut, syncComposerMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
const composer = composerRef.current
|
||||
// Resolve the owning surface while the composer is still attached; the
|
||||
// unmount cleanup runs after React detached the node, where closest()
|
||||
// can no longer find [data-chat-surface] and would clear the document
|
||||
// root instead of this surface (same class of bug as the status stack's
|
||||
// stale-clearance leak).
|
||||
const root = chatSurfaceRoot(composerRef.current)
|
||||
|
||||
return () => {
|
||||
clearSurfaceVar(composer, COMPOSER_HEIGHT_VAR)
|
||||
clearSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR)
|
||||
clearSurfaceVar(root, COMPOSER_HEIGHT_VAR)
|
||||
clearSurfaceVar(root, COMPOSER_SURFACE_HEIGHT_VAR)
|
||||
}
|
||||
}, [composerRef])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
|
||||
import { resetBrowseState } from '@/store/composer-input-history'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $autoSpeakReplies, $voiceStopPhrase, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { resumeWakeAfterVoice } from '@/store/wake-word'
|
||||
|
||||
import type { ComposerTarget } from '../focus'
|
||||
import { onComposerVoiceToggleRequest } from '../focus'
|
||||
|
|
@ -54,6 +58,7 @@ export function useComposerVoice({
|
|||
const { $messages } = useComposerScope()
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
const voiceStartRequest = useStore($voiceConversationStartRequest)
|
||||
|
||||
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
|
||||
focusInput,
|
||||
|
|
@ -111,14 +116,30 @@ export function useComposerVoice({
|
|||
await onSubmit(text)
|
||||
}
|
||||
|
||||
const wakePausedRef = useRef(false)
|
||||
// Resolves once the in-flight wake.pause round-trip completes (mic released by
|
||||
// the wake listener). The conversation awaits this before opening its own mic
|
||||
// so the two never contend for the device — on Windows especially, opening the
|
||||
// capture device while the wake listener still holds it makes getUserMedia
|
||||
// fail and the conversation never starts listening.
|
||||
const wakePauseBarrierRef = useRef<Promise<void> | null>(null)
|
||||
|
||||
const conversation = useVoiceConversation({
|
||||
busy,
|
||||
consumePendingResponse,
|
||||
enabled: voiceConversationActive,
|
||||
onFatalError: () => setVoiceConversationActive(false),
|
||||
// A spoken stop command ("stop", "never mind", "goodbye", …) ends the
|
||||
// hands-free conversation. Flipping the flag is the authoritative off
|
||||
// switch — the enabled=false prop + effect below drive conversation.end()
|
||||
// teardown (mic close, wake re-arm).
|
||||
onStopWord: () => setVoiceConversationActive(false),
|
||||
onSubmit: submitVoiceTurn,
|
||||
onTranscribeAudio,
|
||||
pendingResponse: pendingTurnResponse
|
||||
pendingResponse: pendingTurnResponse,
|
||||
// Before the conversation opens the mic, wait for any in-flight wake.pause
|
||||
// to finish releasing the capture device (see wakePauseBarrierRef).
|
||||
beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined
|
||||
})
|
||||
|
||||
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
|
||||
|
|
@ -142,6 +163,73 @@ export function useComposerVoice({
|
|||
[target, toggleVoiceConversation]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) {
|
||||
setVoiceConversationActive(true)
|
||||
}
|
||||
}, [disabled, target, voiceConversationActive, voiceStartRequest])
|
||||
|
||||
const resumeWakeIfPaused = useCallback(() => {
|
||||
if (!wakePausedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
wakePausedRef.current = false
|
||||
wakePauseBarrierRef.current = null
|
||||
// Reconcile, don't just resume: the wake word is a persistent setting, so
|
||||
// ending a voice chat must re-arm the listener whenever config says
|
||||
// enabled — including when the raw resume loses the mic-release race.
|
||||
void resumeWakeAfterVoice()
|
||||
}, [])
|
||||
|
||||
// The ref is a request token (did WE issue wake.pause?), not an atom mirror —
|
||||
// it guards resumeWakeIfPaused from resuming a detector another surface owns.
|
||||
const pauseWakeForVoice = useCallback(() => {
|
||||
wakePausedRef.current = true
|
||||
|
||||
const barrier = (async () => {
|
||||
try {
|
||||
await $gateway.get()?.request('wake.pause', {})
|
||||
} catch {
|
||||
// No wake listener / older backend — nothing held the mic.
|
||||
}
|
||||
})()
|
||||
|
||||
wakePauseBarrierRef.current = barrier
|
||||
|
||||
return barrier
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceConversationActive) {
|
||||
pauseWakeForVoice()
|
||||
} else {
|
||||
resumeWakeIfPaused()
|
||||
}
|
||||
}, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive])
|
||||
|
||||
// 'Say "stop" to end the voice chat.' notice when the conversation starts.
|
||||
// Phrase comes from voice.stop_phrases (first entry) so a custom phrase
|
||||
// renders correctly; a null phrase (stop_phrases: []) shows no notice.
|
||||
useEffect(() => {
|
||||
if (!voiceConversationActive) {
|
||||
return
|
||||
}
|
||||
|
||||
const phrase = $voiceStopPhrase.get()
|
||||
|
||||
if (phrase) {
|
||||
notify({
|
||||
id: 'voice-stop-hint',
|
||||
kind: 'info',
|
||||
icon: 'mic',
|
||||
message: t.notifications.voice.sayStopToEnd(phrase)
|
||||
})
|
||||
}
|
||||
}, [t, voiceConversationActive])
|
||||
|
||||
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
|
||||
|
||||
// Explicit start/end for the on-screen conversation controls (the hotkey uses
|
||||
// the gated toggle above).
|
||||
const startConversation = useCallback(() => setVoiceConversationActive(true), [])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { setComposerActions } from '@/store/composer-actions'
|
||||
import { $todosBySession } from '@/store/todos'
|
||||
|
||||
import { type ComposerMicroActionContext, useComposerMicroActionProviders } from '../contrib'
|
||||
|
||||
/**
|
||||
* Resolve every registered micro-action provider for this session and publish
|
||||
* the result to `$composerActionsBySession`, which the pill strip renders.
|
||||
*
|
||||
* Core registers nothing, so the strip stays empty until something contributes
|
||||
* to `composer.microActions`. Providers are pure functions of the session
|
||||
* context and the set is recomputed rather than mutated, so there are no
|
||||
* ordering games between registrars and a provider that stops returning a
|
||||
* badge withdraws it. One that throws is skipped, so a broken plugin loses
|
||||
* only its own badge.
|
||||
*/
|
||||
export function useComposerMicroActions(sessionId: null | string, busy: boolean) {
|
||||
const todos = useSessionSlice($todosBySession, sessionId)
|
||||
const providers = useComposerMicroActionProviders()
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const ctx: ComposerMicroActionContext = { busy, sessionId, todos }
|
||||
|
||||
setComposerActions(
|
||||
sessionId,
|
||||
providers.flatMap(provider => {
|
||||
try {
|
||||
return provider.resolve(ctx) ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
)
|
||||
}, [busy, providers, sessionId, todos])
|
||||
|
||||
// Withdraw on unmount / session switch ONLY. Clearing in the resolve effect's
|
||||
// cleanup would publish an empty set before every republish — two store
|
||||
// writes and two stack re-renders for what is usually a no-op.
|
||||
useEffect(() => (sessionId ? () => setComposerActions(sessionId, []) : undefined), [sessionId])
|
||||
}
|
||||
|
|
@ -49,7 +49,15 @@ function gestureTargetOk(target: EventTarget | null) {
|
|||
return false
|
||||
}
|
||||
|
||||
return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]')
|
||||
// `composer-no-drag`: chrome that lives inside the composer root but isn't
|
||||
// part of the draggable frame — the floating pill strips. The pills are
|
||||
// `button`s and already excluded, but the strip's own box (the gaps between
|
||||
// pills) isn't, so without this a press landing between two badges still
|
||||
// drags. The strips are `w-fit`, so this costs the grab band only the width
|
||||
// of the badges themselves.
|
||||
return !target.closest(
|
||||
'button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper], [data-slot="composer-no-drag"]'
|
||||
)
|
||||
}
|
||||
|
||||
/** Floating composer's 5px outer frame — grab here to drag without long-press. */
|
||||
|
|
|
|||
|
|
@ -18,6 +18,29 @@ const CATALOG = {
|
|||
]
|
||||
}
|
||||
|
||||
// A catalog shaped like a real install: a couple of skills the user lives in,
|
||||
// a bundled one they have never opened, and one of their own they haven't
|
||||
// either.
|
||||
const RANKED_CATALOG = {
|
||||
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
|
||||
pairs: [
|
||||
['/new', 'Start a new session'],
|
||||
['/docx', 'Edit Word documents'],
|
||||
['/research', 'Look it up before answering'],
|
||||
['/research-paper-writing', 'Write an academic paper'],
|
||||
['/work', 'Kick off a task in a fresh worktree']
|
||||
],
|
||||
skills: {
|
||||
'/docx': { usage: 0, origin: 'local' },
|
||||
'/research': { usage: 60, origin: 'local' },
|
||||
'/research-paper-writing': { usage: 0, origin: 'bundled' },
|
||||
'/work': { usage: 172, origin: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
const commandsOf = (items: readonly Unstable_TriggerItem[]) =>
|
||||
items.map(item => (item.metadata as { command?: string })?.command)
|
||||
|
||||
function harness(gateway: HermesGateway) {
|
||||
const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {}
|
||||
|
||||
|
|
@ -95,4 +118,40 @@ describe('useSlashCompletions', () => {
|
|||
|
||||
expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work'])
|
||||
})
|
||||
|
||||
// An alphabetical `/` menu buries the skills someone runs daily under the
|
||||
// ones that shipped with Hermes and were never opened.
|
||||
it('orders skills by use and hides never-used built-ins on a bare slash', async () => {
|
||||
const request = vi.fn().mockResolvedValue(RANKED_CATALOG)
|
||||
const api = harness({ request } as unknown as HermesGateway)
|
||||
|
||||
const skills = commandsOf((await completions(api, '')).filter(isSkillItem))
|
||||
|
||||
expect(skills).toEqual(['/work', '/research', '/docx'])
|
||||
})
|
||||
|
||||
// Typing is a search, and a search that hides a match is broken — the
|
||||
// never-used built-in still shows, just below the one she actually uses.
|
||||
it('ranks a typed query by use without hiding anything', async () => {
|
||||
const request = vi.fn().mockImplementation((method: string) =>
|
||||
Promise.resolve(
|
||||
method === 'commands.catalog'
|
||||
? RANKED_CATALOG
|
||||
: {
|
||||
items: [
|
||||
{ text: '/research-paper-writing', display: '/research-paper-writing', meta: 'Write a paper' },
|
||||
{ text: '/research', display: '/research', meta: 'Look it up' }
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const api = harness({ request } as unknown as HermesGateway)
|
||||
|
||||
// Warm the catalog first: the popover always opens on a bare `/` before a
|
||||
// query is typed, which is where the usage map comes from.
|
||||
await completions(api, '')
|
||||
|
||||
expect(commandsOf(await completions(api, 'research'))).toEqual(['/research', '/research-paper-writing'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ import {
|
|||
type DesktopThemeCommandOption,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
isDesktopSlashSuggestion,
|
||||
rankSkillCommands
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache'
|
||||
import {
|
||||
$slashCompletionsEpoch,
|
||||
cachedSlashCompletion,
|
||||
hasCachedSlashCompletion,
|
||||
peekCachedSlashCompletion
|
||||
} from '@/lib/slash-completion-cache'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
|
|
@ -145,7 +151,7 @@ export function useSlashCompletions(options: {
|
|||
// backend didn't categorize.
|
||||
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const items = sections.flatMap(section =>
|
||||
const items = sections.flatMap<CompletionEntry>(section =>
|
||||
section.pairs.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
|
|
@ -161,13 +167,19 @@ export function useSlashCompletions(options: {
|
|||
// Re-add the leftovers under one Skills header (which also gives them
|
||||
// the skill pill accent and makes them offerable mid-message).
|
||||
const categorized = new Set(items.map(item => item.text.toLowerCase()))
|
||||
const skillRows: CompletionEntry[] = []
|
||||
|
||||
for (const [command, meta] of catalog.pairs ?? []) {
|
||||
if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) {
|
||||
items.push({ text: command, display: command, group: 'Skills', meta })
|
||||
skillRows.push({ text: command, display: command, group: 'Skills', meta })
|
||||
}
|
||||
}
|
||||
|
||||
// Browsing, not searching: rank the skills the user actually reaches
|
||||
// for to the top and drop never-used built-ins entirely. Typing a
|
||||
// query takes the other branch, where nothing is hidden.
|
||||
items.push(...rankSkillCommands(skillRows, catalog.skills, { pruneUnusedBuiltins: true }))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
|
|
@ -210,9 +222,27 @@ export function useSlashCompletions(options: {
|
|||
// Skills (stable within a group, preserving backend relevance order).
|
||||
const groupOrder = ['Commands', 'Skills', 'Options']
|
||||
|
||||
const items = isArgCompletion
|
||||
? decorated
|
||||
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
if (isArgCompletion) {
|
||||
return { items: decorated, query }
|
||||
}
|
||||
|
||||
// Rank the matched skills by use — `/re` should lead with the /research
|
||||
// the user lives in, not the /research-paper-writing they've never
|
||||
// opened. Nothing is pruned here: a typed query is a search, and a
|
||||
// search that hides a match is broken. Usage rides along on the catalog
|
||||
// response, which the popover has already fetched by the time anyone
|
||||
// types; if it somehow hasn't, order falls back to the backend's.
|
||||
const catalogSkills = peekCachedSlashCompletion<CommandsCatalogLike>('catalog')?.skills
|
||||
|
||||
const ranked = [
|
||||
...decorated.filter(item => item.group !== 'Skills'),
|
||||
...rankSkillCommands(
|
||||
decorated.filter(item => item.group === 'Skills'),
|
||||
catalogSkills
|
||||
)
|
||||
]
|
||||
|
||||
const items = [...ranked].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,26 +1,37 @@
|
|||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { $composerActionsBySession } from '@/store/composer-actions'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
|
||||
/** Structural view of the three per-session feeds — they hold different item
|
||||
* types, and all this hook needs from each is "does this key have rows". */
|
||||
interface PresenceFeed {
|
||||
get(): Record<string, undefined | unknown[]>
|
||||
listen(listener: () => void): () => void
|
||||
}
|
||||
|
||||
const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const offItems = $statusItemsBySession.listen(onChange)
|
||||
const offPreviews = $previewStatusBySession.listen(onChange)
|
||||
const offs = FEEDS.map(feed => feed.listen(onChange))
|
||||
|
||||
return () => {
|
||||
offItems()
|
||||
offPreviews()
|
||||
for (const off of offs) {
|
||||
off()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session has any status items or previews, as a coarse *edge*: the
|
||||
* boolean only flips when the stack appears/disappears. ChatBar uses it to
|
||||
* toggle a styling data-attr — subscribing to the whole `$statusItemsBySession`
|
||||
* (a `computed` that rebuilds the entire map) / `$previewStatusBySession` maps
|
||||
* re-rendered the ~1.4k ChatBar on every per-item mutation (a subagent tick, a
|
||||
* 5s background poll) and on churn in OTHER sessions. The boolean snapshot bails
|
||||
* out of all of that, re-rendering only on the actual show/hide transition.
|
||||
* Whether a session has any status items, micro actions, or previews, as a
|
||||
* coarse *edge*: the boolean only flips when the stack appears/disappears.
|
||||
* ChatBar uses it to toggle a styling data-attr — subscribing to the whole
|
||||
* `$statusItemsBySession` (a `computed` that rebuilds the entire map) /
|
||||
* `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every
|
||||
* per-item mutation (a subagent tick, a 5s background poll) and on churn in
|
||||
* OTHER sessions. The boolean snapshot bails out of all of that, re-rendering
|
||||
* only on the actual show/hide transition.
|
||||
*/
|
||||
export function useSessionStatusPresence(sessionId: string | null): boolean {
|
||||
return useSyncExternalStore(subscribe, () => {
|
||||
|
|
@ -28,9 +39,6 @@ export function useSessionStatusPresence(sessionId: string | null): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 ||
|
||||
($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0
|
||||
)
|
||||
return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { startThinkingSound, stopThinkingSound } from '@/lib/thinking-sound'
|
||||
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
|
||||
import {
|
||||
markVoicePlaybackInterrupted,
|
||||
|
|
@ -9,7 +10,9 @@ import {
|
|||
startSpeechStream,
|
||||
stopVoicePlayback
|
||||
} from '@/lib/voice-playback'
|
||||
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
import { useMicRecorder } from './use-mic-recorder'
|
||||
|
||||
|
|
@ -25,20 +28,26 @@ interface VoiceConversationOptions {
|
|||
busy: boolean
|
||||
enabled: boolean
|
||||
onFatalError?: () => void
|
||||
onStopWord?: () => void
|
||||
onSubmit: (text: string) => Promise<void> | void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
pendingResponse: () => PendingVoiceResponse | null
|
||||
consumePendingResponse: () => void
|
||||
/** Awaited right before the mic is opened. Used to let the wake-word listener
|
||||
* fully release the capture device first, so the two never contend. */
|
||||
beforeMicOpen?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export function useVoiceConversation({
|
||||
busy,
|
||||
enabled,
|
||||
onFatalError,
|
||||
onStopWord,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
pendingResponse,
|
||||
consumePendingResponse
|
||||
consumePendingResponse,
|
||||
beforeMicOpen
|
||||
}: VoiceConversationOptions) {
|
||||
const { t } = useI18n()
|
||||
const voiceCopy = t.notifications.voice
|
||||
|
|
@ -54,11 +63,25 @@ export function useVoiceConversation({
|
|||
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
|
||||
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
|
||||
const bargeCapturePendingRef = useRef(false)
|
||||
const speechStartSequenceRef = useRef(0)
|
||||
const enabledRef = useRef(enabled)
|
||||
const mutedRef = useRef(muted)
|
||||
const busyRef = useRef(busy)
|
||||
const statusRef = useRef<ConversationStatus>('idle')
|
||||
const wasEnabledRef = useRef(enabled)
|
||||
const onStopWordRef = useRef(onStopWord)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
onStopWordRef.current = onStopWord
|
||||
}, [onStopWord])
|
||||
|
||||
const beforeMicOpenRef = useRef(beforeMicOpen)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
beforeMicOpenRef.current = beforeMicOpen
|
||||
}, [beforeMicOpen])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
|
|
@ -132,6 +155,18 @@ export function useVoiceConversation({
|
|||
return
|
||||
}
|
||||
|
||||
// A spoken "stop" (or "never mind", "goodbye", …) ends the
|
||||
// conversation instead of being submitted as a turn. Only whole-
|
||||
// utterance stop commands match, so "stop the container" still goes
|
||||
// through as a real request.
|
||||
if (isVoiceStopCommand(transcript)) {
|
||||
dropSpeechSession()
|
||||
setStatus('idle')
|
||||
onStopWordRef.current?.()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
dropSpeechSession()
|
||||
await onSubmit(transcript)
|
||||
|
|
@ -167,6 +202,20 @@ export function useVoiceConversation({
|
|||
return
|
||||
}
|
||||
|
||||
// Let the wake-word listener fully release the capture device before we
|
||||
// open ours — opening the mic while wake still holds it makes getUserMedia
|
||||
// fail (the "clicked voice but it never starts listening" bug).
|
||||
try {
|
||||
await beforeMicOpenRef.current?.()
|
||||
} catch {
|
||||
// A pause failure shouldn't block the user's explicit start.
|
||||
}
|
||||
|
||||
// enabled/muted/busy or an interleaved turn may have changed while we waited.
|
||||
if (!enabledRef.current || mutedRef.current || busyRef.current || statusRef.current !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI.
|
||||
await handle.start({
|
||||
|
|
@ -181,6 +230,12 @@ export function useVoiceConversation({
|
|||
onSilence: () => void handleTurn()
|
||||
})
|
||||
setStatus('listening')
|
||||
// Clear any prior turn-timeout before arming a fresh one. Each listen
|
||||
// cycle reassigns turnTimeoutRef; without clearing first, a stale 60s
|
||||
// timer from an earlier cycle survives and later fires handleTurn() in
|
||||
// the middle of a new listen, cutting it short (or, after enough idle
|
||||
// re-listens, wedging the loop into a state it doesn't re-arm from).
|
||||
clearTurnTimeout()
|
||||
turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.couldNotStartSession)
|
||||
|
|
@ -211,7 +266,15 @@ export function useVoiceConversation({
|
|||
|
||||
dropSpeechSession()
|
||||
|
||||
if (enabledRef.current) {
|
||||
// If stopVoicePlayback() was called externally (Stop button, end), the
|
||||
// voice-playback sequence has advanced past what we captured at speech
|
||||
// start — don't auto-start the next sentence, the user chose to stop.
|
||||
const stoppedByUser =
|
||||
speechStartSequenceRef.current > 0 && $voicePlayback.get().sequence > speechStartSequenceRef.current
|
||||
|
||||
speechStartSequenceRef.current = 0
|
||||
|
||||
if (enabledRef.current && !stoppedByUser) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
|
|
@ -341,6 +404,8 @@ export function useVoiceConversation({
|
|||
barged = true
|
||||
})
|
||||
|
||||
speechStartSequenceRef.current = $voicePlayback.get().sequence
|
||||
|
||||
void playSpeechText(response.text, { source: 'voice-conversation' })
|
||||
.catch(error => notifyError(error, voiceCopy.playbackFailed))
|
||||
.finally(() => {
|
||||
|
|
@ -365,6 +430,7 @@ export function useVoiceConversation({
|
|||
(responseId: string) => {
|
||||
responseIdRef.current = responseId
|
||||
spokenSourceLengthRef.current = 0
|
||||
speechStartSequenceRef.current = $voicePlayback.get().sequence
|
||||
setStatus('speaking')
|
||||
|
||||
let barged = false
|
||||
|
|
@ -509,6 +575,22 @@ export function useVoiceConversation({
|
|||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [enabled, stopTurn])
|
||||
|
||||
// Ambient "thinking" sound: while the agent works (status 'thinking') no
|
||||
// audio flows, which reads as dead air mid-conversation. Calm bubble blips
|
||||
// fill the gap; they stop the INSTANT speech starts, the mic re-arms, or the
|
||||
// conversation ends. Gated by voice.thinking_sound + the shared sound mute.
|
||||
useEffect(() => {
|
||||
if (enabled && !muted && status === 'thinking') {
|
||||
startThinkingSound()
|
||||
|
||||
return stopThinkingSound
|
||||
}
|
||||
|
||||
stopThinkingSound()
|
||||
|
||||
return undefined
|
||||
}, [enabled, muted, status])
|
||||
|
||||
// Drive the loop: when a voice-submitted reply appears, open a live speech
|
||||
// session (which feeds itself from then on). Otherwise start listening when
|
||||
// idle between turns.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ComposerPrimitive } from '@assistant-ui/react'
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { composerFill, composerFloatingStrip, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slot as ContribSlot } from '@/contrib/react/slot'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
|
@ -11,6 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
|||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { interceptsTypedVoiceStop } from '@/lib/voice-stop-word'
|
||||
import { sessionCompacting } from '@/store/compaction'
|
||||
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
|
||||
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
|
||||
|
|
@ -48,6 +49,7 @@ import { useComposerTrigger } from './hooks/use-composer-trigger'
|
|||
import { useComposerUndo } from './hooks/use-composer-undo'
|
||||
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
|
||||
import { useComposerVoice } from './hooks/use-composer-voice'
|
||||
import { useComposerMicroActions } from './hooks/use-micro-actions'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useSessionStatusPresence } from './hooks/use-status-presence'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
|
||||
|
|
@ -94,11 +96,32 @@ export function ChatBar({
|
|||
onSubmit: onSubmitProp,
|
||||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
// Typed stop phrase during an active voice conversation ends it — same
|
||||
// semantics as SAYING "stop" (voice-stop-word.ts) or clicking the pill's
|
||||
// end control. Populated after useComposerVoice below (the submit wrapper
|
||||
// is created first); render-time assignment keeps the ref current.
|
||||
const voiceStopRef = useRef<{ active: boolean; end: () => void }>({ active: false, end: () => {} })
|
||||
|
||||
// Every send (typed, queued, voice) passes through the contributed
|
||||
// middleware chain first — rewrite / pass-through / cancel. Empty chain =
|
||||
// exact pass-through, so surfaces without contributions are byte-identical.
|
||||
const onSubmit = useCallback<ChatBarProps['onSubmit']>(
|
||||
async (value, options) => {
|
||||
// Bare stop phrase typed while the voice conversation is live: end the
|
||||
// conversation (mic off, pill dismissed) instead of sending "stop" to
|
||||
// the agent. Spoken transcripts are already stop-checked inside
|
||||
// use-voice-conversation, so this only catches typed/queued sends.
|
||||
// Outside a voice conversation, typed "stop" is a normal message.
|
||||
const voiceStop = voiceStopRef.current
|
||||
|
||||
if (interceptsTypedVoiceStop(voiceStop.active, value, options?.attachments?.length ?? 0)) {
|
||||
voiceStop.end()
|
||||
|
||||
// Consumed (not rejected): report accepted so the submit engine
|
||||
// clears the draft instead of restoring "stop" into the composer.
|
||||
return true
|
||||
}
|
||||
|
||||
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
|
||||
|
||||
if (!draft) {
|
||||
|
|
@ -133,6 +156,10 @@ export function ChatBar({
|
|||
// every per-item status mutation or other sessions' churn (see the hook).
|
||||
const statusPresent = useSessionStatusPresence(statusSessionId)
|
||||
|
||||
// Publishes contributed micro actions for this session; the status stack
|
||||
// renders them as the pill strip at the top of the overlay lane.
|
||||
useComposerMicroActions(statusSessionId, busy)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
|
|
@ -835,6 +862,11 @@ export function ChatBar({
|
|||
target: scope.target
|
||||
})
|
||||
|
||||
// Keep the typed-stop interceptor (see onSubmit above) in sync with the
|
||||
// live conversation state. Render-time ref assignment, same pattern as
|
||||
// dispatchSubmitRef — no effect needed for a plain mirror.
|
||||
voiceStopRef.current = { active: voiceConversationActive, end: endConversation }
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onInsertText={insertText}
|
||||
|
|
@ -1076,7 +1108,14 @@ export function ChatBar({
|
|||
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
|
||||
data-dragging={dragging ? '' : undefined}
|
||||
data-slot="composer-drag-region"
|
||||
onDoubleClick={handleComposerToggle}
|
||||
onDoubleClick={event => {
|
||||
// The pill strips paint above this region; a double-click that
|
||||
// lands on one must not float the composer. onPointerDown goes
|
||||
// through gestureTargetOk, but this handler doesn't.
|
||||
if (!(event.target as Element).closest('[data-slot="composer-no-drag"]')) {
|
||||
handleComposerToggle()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
|
|
@ -1168,6 +1207,17 @@ export function ChatBar({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Underside: a floating strip BELOW the whole composer surface.
|
||||
Chrome-free by design — contributions bring their own pill/skin,
|
||||
like the micro-action strip above. In flow (the root is
|
||||
bottom-anchored, so this grows the composer upward and stays on
|
||||
screen) but OUTSIDE the surface, so it escapes the surface's
|
||||
clipping, border, and scroll fade. Shares the micro-action
|
||||
strip's grid so the two bracket the composer on one vertical
|
||||
line. Renders nothing until something contributes. */}
|
||||
<div className={cn(composerFloatingStrip, 'pt-1.5 empty:hidden')} data-slot="composer-no-drag">
|
||||
<ContribSlot area={COMPOSER_AREAS.underside} />
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { memo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAction } from '@/store/composer-actions'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* Floating pill — the treatment the thread's jump/approval button uses for a
|
||||
* control that sits over scrolling content: full radius, hairline border, the
|
||||
* shared composer fill behind a blur so thread text never bleeds through.
|
||||
* Sized against the composer's own control height so a row of pills lines up
|
||||
* with the chrome it floats above.
|
||||
*
|
||||
* NEVER `pointer-events-none`, not even when disabled. The pop-out drag region
|
||||
* is an `absolute` sibling behind these pills, so a pill that stops taking
|
||||
* pointer events hands the hit test straight to it — and a dead-looking badge
|
||||
* becomes a grab handle that floats the composer.
|
||||
*/
|
||||
const PILL = cn(
|
||||
'inline-flex h-(--composer-control-size) max-w-56 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5',
|
||||
'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
'text-xs font-normal text-(--ui-text-secondary) transition-colors',
|
||||
'hover:bg-(--chrome-action-hover) hover:text-foreground',
|
||||
'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)',
|
||||
'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50'
|
||||
)
|
||||
|
||||
/**
|
||||
* The micro-action pills. Layout-free on purpose — the composer owns the strip
|
||||
* (`composerFloatingStrip`), this owns only the pills, so the strip above the
|
||||
* surface and the `composer.underside` strip below it can't drift apart.
|
||||
*/
|
||||
export const ActionBadges = memo(function ActionBadges({
|
||||
actions,
|
||||
sessionId
|
||||
}: {
|
||||
actions: ComposerAction[]
|
||||
sessionId: string
|
||||
}) {
|
||||
// A pill can kick off async work (a gateway call, a submit). Track which one
|
||||
// is in flight so it can spin and lock instead of double-firing.
|
||||
const [runningId, setRunningId] = useState<null | string>(null)
|
||||
|
||||
const run = async (action: ComposerAction) => {
|
||||
if (runningId) {
|
||||
return
|
||||
}
|
||||
|
||||
setRunningId(action.id)
|
||||
|
||||
try {
|
||||
await action.run(sessionId)
|
||||
} catch (error) {
|
||||
notifyError(error, action.label)
|
||||
} finally {
|
||||
setRunningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return actions.map(action => {
|
||||
const running = runningId === action.id
|
||||
const glyph = running ? 'loading' : action.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
className={PILL}
|
||||
disabled={action.disabled || Boolean(runningId)}
|
||||
key={action.id}
|
||||
onClick={() => void run(action)}
|
||||
type="button"
|
||||
>
|
||||
{glyph && <Codicon className="shrink-0 opacity-70" name={glyph} size="0.75rem" spinning={running} />}
|
||||
<span className="truncate">{action.label}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -3,10 +3,10 @@ import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'rea
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars'
|
||||
import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import { BillingBanner } from '@/components/billing-banner'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { composerDockCard, composerFloatingStrip } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -15,6 +15,7 @@ import { type Translations, useI18n } from '@/i18n'
|
|||
import { useSessionSlice } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $billingBlock } from '@/store/billing-block'
|
||||
import { $composerActionsBySession } from '@/store/composer-actions'
|
||||
import {
|
||||
$statusItemsBySession,
|
||||
type ComposerStatusItem,
|
||||
|
|
@ -29,6 +30,7 @@ import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview
|
|||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { ActionBadges } from './action-badges'
|
||||
import { PreviewStatusRow } from './preview-row'
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
|
|
@ -93,6 +95,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
// items actually changed.
|
||||
const items = useSessionSlice($statusItemsBySession, sessionId)
|
||||
const previews = useSessionSlice($previewStatusBySession, sessionId)
|
||||
const actions = useSessionSlice($composerActionsBySession, sessionId)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const billing = useStore($billingBlock)
|
||||
|
||||
|
|
@ -151,6 +154,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
|
||||
}
|
||||
|
||||
// Micro actions ride at the top of the stack — the one block you press
|
||||
// rather than read. Rendered OUTSIDE the card (see `actionStrip`) so the
|
||||
// pills float; a blocked account still gets the billing wall above them.
|
||||
|
||||
for (const group of groups) {
|
||||
sections.push({
|
||||
key: group.type,
|
||||
|
|
@ -208,7 +215,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
|
||||
const visible = sections.length > 0
|
||||
// Micro actions are the TOP-MOST thing in the whole overlay lane — above the
|
||||
// status card, above the billing wall, above everything. They're the only
|
||||
// rows up here you press instead of read, so nothing may ever stack on top
|
||||
// of them. Rendered outside the card (below) so the pills float.
|
||||
const actionStrip = actions.length > 0 && sessionId ? <ActionBadges actions={actions} sessionId={sessionId} /> : null
|
||||
|
||||
const visible = sections.length > 0 || Boolean(actionStrip)
|
||||
const stackRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// The stack is out of flow (overlays the thread), so the composer's measured
|
||||
|
|
@ -220,11 +233,16 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
const el = stackRef.current
|
||||
|
||||
if (!visible || !el) {
|
||||
clearSurfaceVar(el, STATUS_STACK_VAR)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the owning surface NOW, while the node is attached. The cleanup
|
||||
// below runs after the stack collapsed and React removed the div, so
|
||||
// closest() from the detached node misses [data-chat-surface] and would
|
||||
// clear the document root instead — leaving the stale height on the
|
||||
// surface, which keeps inflating the thread's bottom clearance until the
|
||||
// next publish.
|
||||
const root = chatSurfaceRoot(el)
|
||||
let last = -1
|
||||
|
||||
const sync = () => {
|
||||
|
|
@ -242,7 +260,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
clearSurfaceVar(el, STATUS_STACK_VAR)
|
||||
clearSurfaceVar(root, STATUS_STACK_VAR)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
|
|
@ -255,29 +273,52 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
// Sits in the overlay lane above the composer. The composer root has pt-2
|
||||
// before the actual surface; translate by that amount so the stack returns
|
||||
// to its original attachment point without intruding into the repo strip.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
|
||||
// pl matches the surface's own left edge: `inset-x-0` resolves against the
|
||||
// root's PADDING box, while the surface and the underside strip sit in its
|
||||
// CONTENT box, so without it the lane hangs 5px further left than both.
|
||||
className="absolute inset-x-0 bottom-full z-3 flex max-h-[40vh] flex-col translate-y-2 pl-[0.3125rem]"
|
||||
onPointerDownCapture={() => blurComposerInput()}
|
||||
ref={stackRef}
|
||||
>
|
||||
{/* The card paints the shared --composer-fill (rest / scrolled / focused
|
||||
all match the composer surface by construction); on scroll we only
|
||||
ghost the CONTENT — element opacity on the card would kill the blur.
|
||||
Rounded top, square bottom; the bottom border is TRANSPARENT — the
|
||||
composer surface's visible top border (which sits at a higher z) is the
|
||||
single shared seam, so the two read as one fused capsule. */}
|
||||
<div
|
||||
className={cn(
|
||||
composerDockCard('top'),
|
||||
// Inset (mx-2) so the stack reads slightly narrower than the composer
|
||||
// surface below it — the original look.
|
||||
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
{/* FIRST in the lane and OUTSIDE the scroller, so nothing can ever sit
|
||||
above the pills — not the status card, not the billing wall — and a
|
||||
long todo list can't scroll them out of view. Outside the card too:
|
||||
they carry their own fill, so they must not paint on its background. */}
|
||||
{actionStrip && (
|
||||
<div
|
||||
className={cn(
|
||||
composerFloatingStrip,
|
||||
'shrink-0 pb-1.5 transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{actionStrip}
|
||||
</div>
|
||||
)}
|
||||
{/* Everything else scrolls under them. */}
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
{/* The card paints the shared --composer-fill (rest / scrolled / focused
|
||||
all match the composer surface by construction); on scroll we only
|
||||
ghost the CONTENT — element opacity on the card would kill the blur.
|
||||
Rounded top, square bottom; the bottom border is TRANSPARENT — the
|
||||
composer surface's visible top border (which sits at a higher z) is the
|
||||
single shared seam, so the two read as one fused capsule. */}
|
||||
{sections.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
composerDockCard('top'),
|
||||
// Inset (mx-2) so the stack reads slightly narrower than the composer
|
||||
// surface below it — the original look.
|
||||
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { STATUS_STACK_VAR } from '@/app/chat/surface-vars'
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import { $goalsBySession, type SessionGoal } from '@/store/goals'
|
||||
|
||||
import { ComposerStatusStack } from './index'
|
||||
|
||||
// The stack measures itself into a surface var — jsdom has no ResizeObserver.
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
|
||||
const SID = 'sess-height-1'
|
||||
|
||||
const goal = (): SessionGoal => ({ status: 'active', title: 'ship the feature', updatedAt: Date.now() })
|
||||
|
||||
/**
|
||||
* Regression: when the stack collapses (its last item finishes), React removes
|
||||
* the stack div BEFORE the layout-effect cleanup runs. Resolving the surface
|
||||
* from the ref at cleanup time then walks a DETACHED node, misses
|
||||
* [data-chat-surface], and clears the document root instead — the stale height
|
||||
* stays on the surface and keeps inflating the thread's bottom clearance
|
||||
* (`--thread-last-message-clearance`) until the next publish. The effect must
|
||||
* capture its surface root while the node is still attached.
|
||||
*/
|
||||
describe('ComposerStatusStack surface-var lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
$goalsBySession.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$goalsBySession.set({})
|
||||
document.documentElement.style.removeProperty(STATUS_STACK_VAR)
|
||||
})
|
||||
|
||||
function renderOnSurface() {
|
||||
const surface = document.createElement('div')
|
||||
surface.setAttribute('data-chat-surface', '')
|
||||
document.body.append(surface)
|
||||
|
||||
const view = render(
|
||||
<MemoryRouter>
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerStatusStack queue={null} sessionId={SID} />
|
||||
</I18nProvider>
|
||||
</MemoryRouter>,
|
||||
{ container: surface }
|
||||
)
|
||||
|
||||
return { surface, view }
|
||||
}
|
||||
|
||||
it('publishes its measured height onto the owning surface while visible', () => {
|
||||
$goalsBySession.set({ [SID]: goal() })
|
||||
|
||||
const { surface } = renderOnSurface()
|
||||
|
||||
// jsdom measures 0 — the value is irrelevant, the target element is not.
|
||||
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
|
||||
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
|
||||
})
|
||||
|
||||
it('clears the surface var when the stack collapses to nothing', () => {
|
||||
$goalsBySession.set({ [SID]: goal() })
|
||||
|
||||
const { surface } = renderOnSurface()
|
||||
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
|
||||
|
||||
// Last status item goes away → the component renders null and React
|
||||
// detaches the stack div before the cleanup runs.
|
||||
act(() => $goalsBySession.set({}))
|
||||
|
||||
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
|
||||
})
|
||||
|
||||
it('clears the surface var on unmount', () => {
|
||||
$goalsBySession.set({ [SID]: goal() })
|
||||
|
||||
const { surface, view } = renderOnSurface()
|
||||
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
|
||||
|
||||
view.unmount()
|
||||
|
||||
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
|
|
@ -111,11 +112,15 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
|||
useStore($sessionColorById)
|
||||
const color = sessionColorFor(session) ?? null
|
||||
|
||||
const needsInput = useStore($attentionSessionIds).includes(storedSessionId)
|
||||
const isWorking = useStore($workingSessionIds).includes(storedSessionId)
|
||||
const isStalled = useStore($stalledSessionIds).includes(storedSessionId)
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId)
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId)
|
||||
// Per-session membership as booleans via useStoreSelector: these arrays tick
|
||||
// on every stream delta (any session working/stalled/etc changes the array
|
||||
// reference), but a given dot only repaints when ITS OWN membership flips.
|
||||
// A plain useStore(array).includes(id) re-rendered every dot on every tick.
|
||||
const needsInput = useStoreSelector($attentionSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isWorking = useStoreSelector($workingSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isStalled = useStoreSelector($stalledSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isUnread = useStoreSelector($unreadFinishedSessionIds, ids => ids.includes(storedSessionId))
|
||||
const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId))
|
||||
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
|
|||
}
|
||||
|
||||
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||
const next = await uploadComposerAttachment(attachment, {
|
||||
backendCwd: readState()?.cwd,
|
||||
remote,
|
||||
requestGateway,
|
||||
sessionId
|
||||
})
|
||||
|
||||
if (options.updateComposerAttachments ?? true) {
|
||||
scope.attachments.update(next)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useI18n } from '@/i18n'
|
|||
import { fmtDayTime, relativeTime } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { updateCronJobs } from '@/store/cron'
|
||||
import { $changeEventsAvailable, $cronChangeTick } from '@/store/live-sync'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $selectedStoredSessionId } from '@/store/session'
|
||||
import type { CronJob } from '@/types/hermes'
|
||||
|
|
@ -27,9 +28,11 @@ const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
|||
// without turning the sidebar into the full Cron page.
|
||||
const PEEK_RUN_LIMIT = 5
|
||||
|
||||
// Runs are written by the background scheduler tick (no UI signal), so poll the
|
||||
// open peek so a freshly-fired run shows up within a few seconds.
|
||||
// Runs are written by the background scheduler tick. cron.changed reloads the
|
||||
// open peek immediately on event-capable backends (poll drops to a backstop);
|
||||
// older backends keep the legacy cadence.
|
||||
const PEEK_POLL_INTERVAL_MS = 8000
|
||||
const PEEK_BACKSTOP_INTERVAL_MS = 60_000
|
||||
|
||||
// Keep the section compact: show a few jobs up front, reveal more in larger
|
||||
// steps on demand (mirrors the messaging sections in the sidebar).
|
||||
|
|
@ -322,6 +325,8 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
|||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const changeEventsAvailable = useStore($changeEventsAvailable)
|
||||
const cronChangeTick = useStore($cronChangeTick)
|
||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -342,17 +347,21 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
|||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load()
|
||||
}
|
||||
}, PEEK_POLL_INTERVAL_MS)
|
||||
const intervalId = window.setInterval(
|
||||
() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load()
|
||||
}
|
||||
},
|
||||
changeEventsAvailable ? PEEK_BACKSTOP_INTERVAL_MS : PEEK_POLL_INTERVAL_MS
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}, [jobId])
|
||||
// cronChangeTick: a fired run reloads the peek immediately.
|
||||
}, [changeEventsAvailable, cronChangeTick, jobId])
|
||||
|
||||
return (
|
||||
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
|
||||
|
|
|
|||
|
|
@ -384,11 +384,10 @@ export function ChatSidebar({
|
|||
[sessions, showAllProfiles, profileScope]
|
||||
)
|
||||
|
||||
// Agent session order is pinned to creation time (started_at), NOT activity —
|
||||
// a new message must never float a session to the top. Position only changes
|
||||
// for a brand-new session or an explicit manual drag (agentOrderIds).
|
||||
// Recents by activity (last_active || started_at). User send stamps
|
||||
// last_active immediately; manual drag order still wins below.
|
||||
const sortedSessions = useMemo(
|
||||
() => [...visibleSessions].sort((a, b) => (b.started_at || 0) - (a.started_at || 0)),
|
||||
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
|
||||
[visibleSessions]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { memo } from 'react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { ProfileTag } from '@/app/chat/profile-tag'
|
||||
|
|
@ -54,7 +55,7 @@ function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
|||
return unit === 'second' ? r.ageNow : `${value}${r[AGE_KEY[unit]]}`
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
function SidebarSessionRowImpl({
|
||||
session,
|
||||
branchStem,
|
||||
isPinned,
|
||||
|
|
@ -251,3 +252,32 @@ export function SidebarSessionRow({
|
|||
</SessionContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// The sidebar re-renders on every stream tick ($sessions/$workingSessionIds
|
||||
// churn), and it stays mounted beneath every overlay — so an unmemoized row
|
||||
// re-rendered the whole list (and its Codicon/label/status-dot subtree) on each
|
||||
// delta, bleeding churn into Settings, Cron, Profiles, Artifacts, etc.
|
||||
//
|
||||
// The callback props (onArchive/onResume/…) are fresh closures every render by
|
||||
// design (they close over the row's session id), so a default memo never bails.
|
||||
// They're pure id-forwarders, though — identical behavior for a given row — so
|
||||
// the comparator deliberately ignores them and compares only the DATA that
|
||||
// changes what the row paints. A row whose session/selection/working/pin state
|
||||
// is unchanged now bails out, even while a sibling session streams.
|
||||
function rowPropsEqual(a: SidebarSessionRowProps, b: SidebarSessionRowProps): boolean {
|
||||
return (
|
||||
a.session === b.session &&
|
||||
a.isPinned === b.isPinned &&
|
||||
a.isSelected === b.isSelected &&
|
||||
a.isWorking === b.isWorking &&
|
||||
a.branchStem === b.branchStem &&
|
||||
a.reorderable === b.reorderable &&
|
||||
a.dragging === b.dragging &&
|
||||
a.showProfile === b.showProfile &&
|
||||
a.dragHandleProps === b.dragHandleProps &&
|
||||
a.className === b.className &&
|
||||
a.style === b.style
|
||||
)
|
||||
}
|
||||
|
||||
export const SidebarSessionRow = memo(SidebarSessionRowImpl, rowPropsEqual)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { LogTail } from '@/components/chat/log-tail'
|
||||
|
|
@ -26,6 +25,7 @@ import {
|
|||
} from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { fmtDateTime } from '@/lib/time'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
|
|
@ -48,6 +48,12 @@ const LOG_LEVELS = ['ALL', 'INFO', 'WARNING', 'ERROR'] as const
|
|||
const USAGE_PERIODS = [7, 30, 90] as const
|
||||
type UsagePeriod = (typeof USAGE_PERIODS)[number]
|
||||
|
||||
// Stable empty arrays so the selector returns the same reference when we're
|
||||
// not on the Sessions tab — useStoreSelector bails out on Object.is, so the
|
||||
// component never re-renders from $sessions ticks while on System/Usage/etc.
|
||||
const EMPTY_SESSIONS: readonly never[] = []
|
||||
const EMPTY_PINNED: readonly string[] = []
|
||||
|
||||
interface CommandCenterViewProps {
|
||||
initialSection?: CommandCenterSection
|
||||
onClose: () => void
|
||||
|
|
@ -129,10 +135,12 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
|
|||
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
|
||||
const { t } = useI18n()
|
||||
const cc = t.commandCenter
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
// $sessions ticks on every streaming token (title updates, new sessions),
|
||||
// but we only need the data on the Sessions tab. Subscribe conditionally so
|
||||
// the System/Usage/Maintenance tabs don't re-render on every stream delta.
|
||||
const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
|
||||
const sessions = useStoreSelector($sessions, s => (section === 'sessions' ? s : EMPTY_SESSIONS))
|
||||
const pinnedSessionIds = useStoreSelector($pinnedSessionIds, s => (section === 'sessions' ? s : EMPTY_PINNED))
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null)
|
||||
|
|
@ -294,25 +302,29 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
|||
[cc, refreshSystem]
|
||||
)
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
})),
|
||||
[cc, section, setSection]
|
||||
)
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={cc.close} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlayNav
|
||||
groups={SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
}))}
|
||||
/>
|
||||
<OverlayNav groups={navGroups} />
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4 flex items-center justify-between gap-3 max-[47.5rem]:mb-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
|
|
@ -49,6 +49,7 @@ import {
|
|||
} from '@/lib/icons'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveVersionStatus } from '@/lib/version-status'
|
||||
import { $repoWorktrees } from '@/store/coding-status'
|
||||
import {
|
||||
$commandPaletteOpen,
|
||||
|
|
@ -59,8 +60,16 @@ import {
|
|||
import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { requestStartWorkSession } from '@/store/projects'
|
||||
import { $connection } from '@/store/session'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { applyBackendUpdate } from '@/store/updates'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateStatus,
|
||||
requestActiveUpdate
|
||||
} from '@/store/updates'
|
||||
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
|
|
@ -93,6 +102,8 @@ interface PaletteItem {
|
|||
action?: string
|
||||
/** Renders a trailing check: this row IS the current setting (theme, mode). */
|
||||
active?: boolean
|
||||
/** Muted text beside the label — state the row acts on (a version, a count). */
|
||||
detail?: string
|
||||
icon: IconComponent
|
||||
id: string
|
||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||
|
|
@ -218,6 +229,38 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
|
|||
// theme lists under both Light and Dark). The id suffix disambiguates.
|
||||
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
|
||||
|
||||
const PaletteRow = memo(function PaletteRow({
|
||||
bindings,
|
||||
item,
|
||||
onSelectMods,
|
||||
onSelectItem
|
||||
}: {
|
||||
bindings: Record<string, string[]>
|
||||
item: PaletteItem
|
||||
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
|
||||
onSelectItem: (item: PaletteItem) => void
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
keywords={item.keywords}
|
||||
onMouseDown={onSelectMods}
|
||||
onSelect={() => onSelectItem(item)}
|
||||
value={paletteValue(item)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
|
||||
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
|
||||
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
|
||||
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
|
||||
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
|
|
@ -314,6 +357,35 @@ export function CommandPalette() {
|
|||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState<string | null>(null)
|
||||
|
||||
// The Update row names the same install the statusbar names — same target
|
||||
// selection, same resolver. Reduced to the label string: an in-flight apply
|
||||
// rewrites these stores on every progress line, and only a changed string
|
||||
// should rebuild the palette's groups.
|
||||
const connection = useStore($connection)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
const clientStatus = useStore($updateStatus)
|
||||
const clientApply = useStore($updateApply)
|
||||
const backendStatus = useStore($backendUpdateStatus)
|
||||
const backendApply = useStore($backendUpdateApply)
|
||||
|
||||
const updateVersionLabel = useMemo(() => {
|
||||
const backend = connection?.mode === 'remote'
|
||||
const apply = backend ? backendApply : clientApply
|
||||
const status = backend ? backendStatus : clientStatus
|
||||
|
||||
return resolveVersionStatus({
|
||||
applying: apply.applying || apply.stage === 'restart',
|
||||
behind: status?.behind ?? 0,
|
||||
copy: t.shell.statusbar,
|
||||
remote: backend,
|
||||
restarting: apply.stage === 'restart',
|
||||
sha: status?.currentSha?.slice(0, 7) ?? null,
|
||||
target: backend ? 'backend' : 'client',
|
||||
updateAvailable: status?.updateAvailable,
|
||||
version: backend ? status?.currentVersion : desktopVersion?.appVersion
|
||||
}).label
|
||||
}, [backendApply, backendStatus, clientApply, clientStatus, connection?.mode, desktopVersion?.appVersion, t])
|
||||
|
||||
// cmdk's onSelect doesn't forward the triggering event — keep the last
|
||||
// click/keydown modifiers so session rows can honour ⌘-Enter / ⌘-click.
|
||||
const lastSelectMods = useRef<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }>({
|
||||
|
|
@ -551,11 +623,12 @@ export function CommandPalette() {
|
|||
run: () => void runGatewayRestart()
|
||||
},
|
||||
{
|
||||
detail: updateVersionLabel,
|
||||
icon: Download,
|
||||
id: 'cc-update-hermes',
|
||||
keywords: ['update', 'upgrade', 'hermes', 'version', 'system', 'restart'],
|
||||
label: cc.updateHermes,
|
||||
run: () => void applyBackendUpdate()
|
||||
run: () => requestActiveUpdate()
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -632,7 +705,7 @@ export function CommandPalette() {
|
|||
]
|
||||
: [])
|
||||
]
|
||||
}, [contributedItems, go, settingsSectionLabel, t, worktrees])
|
||||
}, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
|
|
@ -996,35 +1069,15 @@ export function CommandPalette() {
|
|||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onMouseDown={noteSelectMods}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={paletteValue(item)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
{item.active && (
|
||||
<Check
|
||||
className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{group.items.map(item => (
|
||||
<PaletteRow
|
||||
bindings={bindings}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onSelectItem={handleSelect}
|
||||
onSelectMods={noteSelectMods}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
|
||||
import { refreshActiveProfile } from '@/store/profile'
|
||||
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
|
||||
import {
|
||||
|
|
@ -14,10 +16,15 @@ import type { GatewayRequester } from '../types'
|
|||
|
||||
// Cron sessions are written by a background scheduler tick, messaging turns by
|
||||
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
|
||||
// desktop websocket, so poll the bounded lists while the app is visible.
|
||||
// desktop websocket directly. Backends with the change watcher broadcast
|
||||
// `cron.changed` / `sessions.changed` when those on-disk writes land, so the
|
||||
// timers below become slow safety-net backstops; against an older backend
|
||||
// (no `change_events` on gateway.ready) they stay at the legacy cadence.
|
||||
const CRON_POLL_INTERVAL_MS = 30_000
|
||||
const CRON_BACKSTOP_INTERVAL_MS = 5 * 60_000
|
||||
const MESSAGING_POLL_INTERVAL_MS = 10_000
|
||||
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
|
||||
const ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS = 30_000
|
||||
// Match the TUI's live-session refresh cadence. Auto-compression can rotate a
|
||||
// stored session id while its turn keeps running; until the next snapshot the
|
||||
// sidebar row points at the new id while the renderer still knows the old one.
|
||||
|
|
@ -25,6 +32,15 @@ const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
|
|||
// alarming (and clicking the row appeared to "fix" it by touching the live
|
||||
// session). This snapshot is small and already polled at 1.5s by the TUI.
|
||||
const LIVE_SESSION_STATUS_POLL_INTERVAL_MS = 1_500
|
||||
// With change events the snapshot re-pulls on every sessions.changed tick, so
|
||||
// the interval only covers the degraded-socket edge the stream can't replay
|
||||
// (see rehydrateLiveSessionStatuses) — 30s is plenty for that.
|
||||
const LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS = 30_000
|
||||
// Coalesce tick-driven sidebar list refreshes: sessions.changed fires (floored
|
||||
// to 2s server-side) on every state.db write during a streaming turn, and the
|
||||
// full list refresh is heavier than the active_list snapshot. Trailing-edge
|
||||
// scheduled, so the burst's last write always lands.
|
||||
const SESSIONS_LIST_TICK_GAP_MS = 10_000
|
||||
|
||||
interface LiveSessionStatusItem {
|
||||
id?: string
|
||||
|
|
@ -201,6 +217,10 @@ export function useBackgroundSync({
|
|||
refreshSessions,
|
||||
requestGateway
|
||||
}: BackgroundSyncParams): void {
|
||||
const changeEventsAvailable = useStore($changeEventsAvailable)
|
||||
const cronChangeTick = useStore($cronChangeTick)
|
||||
const sessionsChangeTick = useStore($sessionsChangeTick)
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
|
|
@ -229,8 +249,9 @@ export function useBackgroundSync({
|
|||
|
||||
// A reconnect loses renderer-only working/attention atoms while the backend
|
||||
// keeps the actual turns alive. Re-seed from the gateway's in-memory session
|
||||
// registry immediately, then cheaply poll while visible so a profile switch
|
||||
// or missed reconnect edge cannot leave running rows dark until clicked.
|
||||
// registry immediately, then re-pull on every sessions.changed broadcast; a
|
||||
// slow visible poll remains as the backstop for the degraded-socket edge the
|
||||
// stream cannot replay (legacy cadence against older backends).
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
|
|
@ -260,7 +281,10 @@ export function useBackgroundSync({
|
|||
}
|
||||
}
|
||||
|
||||
const dispose = visiblePoll(LIVE_SESSION_STATUS_POLL_INTERVAL_MS, () => void refreshLiveStatuses())
|
||||
const dispose = visiblePoll(
|
||||
changeEventsAvailable ? LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS : LIVE_SESSION_STATUS_POLL_INTERVAL_MS,
|
||||
() => void refreshLiveStatuses()
|
||||
)
|
||||
|
||||
void refreshLiveStatuses()
|
||||
|
||||
|
|
@ -268,44 +292,98 @@ export function useBackgroundSync({
|
|||
cancelled = true
|
||||
dispose()
|
||||
}
|
||||
}, [activeGatewayProfile, gatewayState, requestGateway])
|
||||
// sessionsChangeTick: each sessions.changed broadcast re-seeds immediately
|
||||
// via the effect re-run (already coalesced to 2s server-side).
|
||||
}, [activeGatewayProfile, changeEventsAvailable, gatewayState, requestGateway, sessionsChangeTick])
|
||||
|
||||
// sessions.changed also means the *stored* list may have new rows (a cron
|
||||
// run's session, an inbound messaging turn creating a thread). The full list
|
||||
// refresh is heavier than the active_list snapshot, so trail it on a gap
|
||||
// instead of firing per tick. Direct atom subscription: the throttle state
|
||||
// lives in the effect closure, not in refs synced from renders.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || !changeEventsAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastRunAt = 0
|
||||
let timer: null | number = null
|
||||
|
||||
const run = () => {
|
||||
lastRunAt = Date.now()
|
||||
void refreshSessions()
|
||||
void refreshMessagingSessions()
|
||||
}
|
||||
|
||||
const unsubscribe = $sessionsChangeTick.listen(() => {
|
||||
const since = Date.now() - lastRunAt
|
||||
|
||||
if (since >= SESSIONS_LIST_TICK_GAP_MS) {
|
||||
run()
|
||||
} else if (timer === null) {
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null
|
||||
run()
|
||||
}, SESSIONS_LIST_TICK_GAP_MS - since)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [changeEventsAvailable, gatewayState, refreshMessagingSessions, refreshSessions])
|
||||
|
||||
// Keep the cron-jobs section live without a user action (scheduler ticks in
|
||||
// the background); re-check on tab re-focus too.
|
||||
// the background). cron.changed (jobs.json moved: CRUD or a scheduler tick's
|
||||
// bookkeeping) drives the refresh; the visible poll is the backstop.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs())
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
// Keep the messaging-platform session lists live (inbound turns are written
|
||||
// by the gateway, not the desktop websocket).
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
if (cronChangeTick > 0) {
|
||||
void refreshCronJobs()
|
||||
}
|
||||
|
||||
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
|
||||
}, [gatewayState, refreshMessagingSessions])
|
||||
return visiblePoll(
|
||||
changeEventsAvailable ? CRON_BACKSTOP_INTERVAL_MS : CRON_POLL_INTERVAL_MS,
|
||||
() => void refreshCronJobs()
|
||||
)
|
||||
}, [changeEventsAvailable, cronChangeTick, gatewayState, refreshCronJobs])
|
||||
|
||||
// Only the open messaging transcript needs its own poll — local chats are
|
||||
// live over the websocket already.
|
||||
// Only the open messaging transcript needs its own cadence — local chats are
|
||||
// live over the websocket already. sessions.changed re-pulls it via the tick
|
||||
// dep; the visible poll is the backstop.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || !activeIsMessaging) {
|
||||
return
|
||||
}
|
||||
|
||||
const dispose = visiblePoll(
|
||||
ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
|
||||
changeEventsAvailable ? ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS : ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
|
||||
() => void refreshActiveMessagingTranscript()
|
||||
)
|
||||
|
||||
void refreshActiveMessagingTranscript()
|
||||
|
||||
return dispose
|
||||
}, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript])
|
||||
// sessionsChangeTick: an inbound turn re-pulls the open transcript.
|
||||
}, [activeIsMessaging, changeEventsAvailable, gatewayState, refreshActiveMessagingTranscript, sessionsChangeTick])
|
||||
|
||||
// Messaging session lists against an older backend: no sessions.changed, so
|
||||
// keep the legacy visible poll. (Event-capable backends fold this into the
|
||||
// trailing sessions.changed refresh above.)
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open' || changeEventsAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
|
||||
}, [changeEventsAvailable, gatewayState, refreshMessagingSessions])
|
||||
|
||||
// A fresh new-session draft (gateway open, no active session) re-pulls the
|
||||
// model + config so the composer pill reflects the profile default.
|
||||
|
|
|
|||
|
|
@ -29,11 +29,21 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
|
|||
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
||||
import { isMessagingSource } from '@/lib/session-source'
|
||||
import { latestSessionTodos } from '@/lib/todos'
|
||||
import { playWakeSound } from '@/lib/wake-sound'
|
||||
import { $billingSettingsRequest } from '@/store/billing-block'
|
||||
import { requestVoiceConversationStart } from '@/store/composer'
|
||||
import { setCronFocusJobId } from '@/store/cron'
|
||||
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$freshSessionRequest,
|
||||
$profileScope,
|
||||
ensureGatewayProfile,
|
||||
newSessionInProfile,
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile
|
||||
} from '@/store/profile'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$activeSessionId,
|
||||
|
|
@ -54,6 +64,7 @@ import {
|
|||
setMessages
|
||||
} from '@/store/session'
|
||||
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
|
||||
import { armWakeWord } from '@/store/wake-word'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
|
|
@ -662,9 +673,39 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
const handleGatewayEventWithPlugins = useCallback(
|
||||
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
|
||||
emitGatewayEvent(event)
|
||||
|
||||
if (event.type === 'wake.detected') {
|
||||
const payload = event.payload as { profile?: null | string; start_new_session?: boolean } | undefined
|
||||
|
||||
// Audible confirmation that the wake registered, before voice capture
|
||||
// starts. Gated by the shared sound-mute toggle.
|
||||
playWakeSound()
|
||||
|
||||
// Multi-profile routing: a wake phrase enrolled by another profile
|
||||
// re-homes the gateway to that profile first (live swap — same path
|
||||
// as clicking it in the profile rail), then opens the fresh session
|
||||
// and starts voice there.
|
||||
const targetProfile = payload?.profile?.trim()
|
||||
const activeProfile = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (targetProfile && normalizeProfileKey(targetProfile) !== activeProfile) {
|
||||
if (payload?.start_new_session !== false) {
|
||||
newSessionInProfile(targetProfile)
|
||||
} else {
|
||||
void ensureGatewayProfile(normalizeProfileKey(targetProfile))
|
||||
}
|
||||
} else if (payload?.start_new_session !== false) {
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
|
||||
requestVoiceConversationStart()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
handleDesktopGatewayEvent(event)
|
||||
},
|
||||
[handleDesktopGatewayEvent]
|
||||
[handleDesktopGatewayEvent, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
useGatewayBoot({
|
||||
|
|
@ -685,6 +726,14 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
refreshSessions
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open') {
|
||||
// Status-then-arm, syncing $wakeWord so the composer toggle reflects the
|
||||
// same listener this auto-arm claims.
|
||||
void armWakeWord(requestGateway)
|
||||
}
|
||||
}, [gatewayState, requestGateway])
|
||||
|
||||
// Only the open messaging transcript needs its own poll — local chats are
|
||||
// live over the websocket already.
|
||||
const activeIsMessaging =
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { AlertTriangle } from '@/lib/icons'
|
|||
import { requestModelOptions } from '@/lib/model-options'
|
||||
import { asText } from '@/lib/text'
|
||||
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
|
||||
import { $changeEventsAvailable, $cronChangeTick } from '@/store/live-sync'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profileScope, ALL_PROFILES } from '@/store/profile'
|
||||
|
||||
|
|
@ -672,10 +673,12 @@ function formatRunTime(seconds?: null | number): string {
|
|||
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString()
|
||||
}
|
||||
|
||||
// Runs are produced by the background scheduler tick (no UI signal), so poll
|
||||
// while the panel is open + on tab re-focus so a fired run shows up within a few
|
||||
// seconds instead of waiting for a reload.
|
||||
// Runs are produced by the background scheduler tick. cron.changed /
|
||||
// sessions.changed broadcasts re-load immediately on event-capable backends
|
||||
// (the tick dep below), so the poll drops to a slow backstop there; older
|
||||
// backends keep the legacy cadence.
|
||||
const RUNS_POLL_INTERVAL_MS = 8000
|
||||
const RUNS_BACKSTOP_INTERVAL_MS = 60_000
|
||||
|
||||
function CronJobRuns({
|
||||
c,
|
||||
|
|
@ -687,6 +690,8 @@ function CronJobRuns({
|
|||
onOpenSession?: (sessionId: string) => void
|
||||
}) {
|
||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||
const changeEventsAvailable = useStore($changeEventsAvailable)
|
||||
const cronChangeTick = useStore($cronChangeTick)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -706,11 +711,14 @@ function CronJobRuns({
|
|||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load()
|
||||
}
|
||||
}, RUNS_POLL_INTERVAL_MS)
|
||||
const intervalId = window.setInterval(
|
||||
() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load()
|
||||
}
|
||||
},
|
||||
changeEventsAvailable ? RUNS_BACKSTOP_INTERVAL_MS : RUNS_POLL_INTERVAL_MS
|
||||
)
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
|
|
@ -725,7 +733,8 @@ function CronJobRuns({
|
|||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [jobId])
|
||||
// cronChangeTick: a fired run moves jobs.json bookkeeping → reload now.
|
||||
}, [changeEventsAvailable, cronChangeTick, jobId])
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -13,11 +13,27 @@ import {
|
|||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
import type { ComponentPropsWithoutRef, ComponentType, SVGProps } from 'react'
|
||||
import { forwardRef } from 'react'
|
||||
import { forwardRef, memo } from 'react'
|
||||
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Photon brand icon — three diagonal rounded bars (the Photon logo mark).
|
||||
// Rendered at ~14 px inside the PlatformAvatar so the bars are kept thick
|
||||
// enough to stay legible. At small sizes the bars blend into a distinctive
|
||||
// silhouette; the wide triangular spacing preserves the logo's identity.
|
||||
// ---------------------------------------------------------------------------
|
||||
function PhotonIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<rect height="10" rx="1.25" transform="rotate(15 14 7.5)" width="2.5" x="12.75" y="2.5" />
|
||||
<rect height="10" rx="1.25" transform="rotate(15 8 13)" width="2.5" x="6.75" y="8" />
|
||||
<rect height="10" rx="1.25" transform="rotate(15 16 18)" width="2.5" x="14.75" y="13" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// We render simpleicons.org brand glyphs for platforms whose owners publish a
|
||||
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
|
||||
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
|
||||
|
|
@ -45,6 +61,7 @@ const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
|
|||
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
|
||||
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
|
||||
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
|
||||
photon: { Icon: PhotonIcon, color: '#6366F1', kind: 'brand' },
|
||||
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
|
||||
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
|
||||
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
|
||||
|
|
@ -65,48 +82,50 @@ interface PlatformAvatarProps extends Omit<ComponentPropsWithoutRef<'span'>, 'ch
|
|||
// component and injects a ref plus pointer/focus/aria handlers onto it. A
|
||||
// plain function component with no ref/rest forwarding drops all of that
|
||||
// silently — the tooltip renders but never opens (#67500).
|
||||
export const PlatformAvatar = forwardRef<HTMLSpanElement, PlatformAvatarProps>(function PlatformAvatar(
|
||||
{ className, platformId, platformName, style, ...rest },
|
||||
ref
|
||||
) {
|
||||
const spec = PLATFORM_ICONS[platformId]
|
||||
export const PlatformAvatar = memo(
|
||||
forwardRef<HTMLSpanElement, PlatformAvatarProps>(function PlatformAvatar(
|
||||
{ className, platformId, platformName, style, ...rest },
|
||||
ref
|
||||
) {
|
||||
const spec = PLATFORM_ICONS[platformId]
|
||||
|
||||
const baseClass = cn(
|
||||
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
className
|
||||
)
|
||||
const baseClass = cn(
|
||||
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
className
|
||||
)
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const { Icon, color } = spec
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
className={baseClass}
|
||||
ref={ref}
|
||||
style={style}
|
||||
style={{
|
||||
// 16% tint of the brand color so the glyph reads against any surface
|
||||
// without the avatar dominating the row.
|
||||
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
|
||||
color,
|
||||
...style
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const { Icon, color } = spec
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={baseClass}
|
||||
ref={ref}
|
||||
style={{
|
||||
// 16% tint of the brand color so the glyph reads against any surface
|
||||
// without the avatar dominating the row.
|
||||
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
|
||||
color,
|
||||
...style
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, type ReactNode } from 'react'
|
||||
import { Fragment, memo, type ReactNode } from 'react'
|
||||
|
||||
import { TabDropdown } from '@/components/ui/tab-dropdown'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
|
|
@ -94,7 +94,14 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
export const OverlayNavItem = memo(function OverlayNavItem({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
nested,
|
||||
onClick,
|
||||
trailing
|
||||
}: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
|
|
@ -121,7 +128,7 @@ export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, tra
|
|||
{trailing}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export interface OverlayNavLink {
|
||||
active: boolean
|
||||
|
|
|
|||
|
|
@ -82,6 +82,33 @@ describe('readProjectDir', () => {
|
|||
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
|
||||
})
|
||||
|
||||
it('filters gitignored entries when Windows path casing differs across IPC results', async () => {
|
||||
gitRoot.mockResolvedValue('C:\\Repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === 'c:\\repo\\src') {
|
||||
return ok([
|
||||
{ name: 'debug.log', path: 'c:\\repo\\src\\debug.log', isDirectory: false },
|
||||
{ name: 'keep.ts', path: 'c:\\repo\\src\\keep.ts', isDirectory: false }
|
||||
])
|
||||
}
|
||||
|
||||
if (path === 'C:/Repo') {
|
||||
return ok([{ name: '.gitignore', path: 'C:/Repo/.gitignore', isDirectory: false }])
|
||||
}
|
||||
|
||||
if (path === 'C:/Repo/src') {
|
||||
return ok([])
|
||||
}
|
||||
|
||||
return ok([])
|
||||
})
|
||||
readFileDataUrl.mockResolvedValue(dataUrl('src/*.log\n'))
|
||||
|
||||
const result = await readProjectDir('c:\\repo\\src', 'c:\\repo')
|
||||
|
||||
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
|
||||
})
|
||||
|
||||
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
|
||||
gitRoot.mockResolvedValue('/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
|
|
|
|||
|
|
@ -32,16 +32,25 @@ function clean(path: string) {
|
|||
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
// Windows path identity is case-insensitive. Fold only comparison keys so the
|
||||
// relative path returned below keeps the filesystem's original spelling;
|
||||
// POSIX paths remain case-sensitive.
|
||||
function comparisonPath(path: string) {
|
||||
return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//') ? path.toLowerCase() : path
|
||||
}
|
||||
|
||||
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
|
||||
function relativeTo(root: string, child: string) {
|
||||
const r = clean(root)
|
||||
const c = clean(child)
|
||||
const rKey = comparisonPath(r)
|
||||
const cKey = comparisonPath(c)
|
||||
|
||||
if (c === r) {
|
||||
if (cKey === rKey) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
|
||||
return cKey.startsWith(`${rKey}/`) ? c.slice(r.length + 1) : null
|
||||
}
|
||||
|
||||
/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
||||
|
||||
const key = (init: Partial<KeyboardEvent> & { key: string }) =>
|
||||
({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, type: 'keydown', ...init }) as KeyboardEvent
|
||||
|
||||
describe('terminalClipboardIntent', () => {
|
||||
it('never claims a bare Ctrl+C with nothing selected, on either platform', () => {
|
||||
for (const isMac of [true, false]) {
|
||||
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: false, isMac })).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('copies on Ctrl+C when text is selected, so a selection is never lost to SIGINT', () => {
|
||||
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: false })).toBe('copy')
|
||||
})
|
||||
|
||||
it('reserves plain Ctrl+C for the shell on macOS, where ⌘C is the copy chord', () => {
|
||||
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: true })).toBeNull()
|
||||
expect(terminalClipboardIntent(key({ key: 'c', metaKey: true }), { hasSelection: true, isMac: true })).toBe('copy')
|
||||
})
|
||||
|
||||
it('only claims copy when there is something to copy', () => {
|
||||
expect(terminalClipboardIntent(key({ key: 'c', metaKey: true }), { hasSelection: false, isMac: true })).toBeNull()
|
||||
expect(
|
||||
terminalClipboardIntent(key({ ctrlKey: true, key: 'c', shiftKey: true }), { hasSelection: false, isMac: false })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('claims paste regardless of selection, since paste has nothing to do with one', () => {
|
||||
expect(terminalClipboardIntent(key({ key: 'v', metaKey: true }), { hasSelection: false, isMac: true })).toBe(
|
||||
'paste'
|
||||
)
|
||||
expect(
|
||||
terminalClipboardIntent(key({ ctrlKey: true, key: 'v', shiftKey: true }), { hasSelection: false, isMac: false })
|
||||
).toBe('paste')
|
||||
})
|
||||
|
||||
it('leaves shell chords alone: bare Ctrl+V, Alt combos, and keyup', () => {
|
||||
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'v' }), { hasSelection: false, isMac: false })).toBeNull()
|
||||
expect(
|
||||
terminalClipboardIntent(key({ altKey: true, ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: false })
|
||||
).toBeNull()
|
||||
expect(
|
||||
terminalClipboardIntent(key({ key: 'c', metaKey: true, type: 'keyup' }), { hasSelection: true, isMac: true })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mirrorSelection', () => {
|
||||
const host = () => {
|
||||
const el = document.createElement('div')
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.className = 'xterm-helper-textarea'
|
||||
el.appendChild(textarea)
|
||||
|
||||
return { el, textarea }
|
||||
}
|
||||
|
||||
it('puts the selection where the OS copy command can find it', () => {
|
||||
const { el, textarea } = host()
|
||||
mirrorSelection(el, 'npm run check')
|
||||
|
||||
expect(textarea.value).toBe('npm run check')
|
||||
})
|
||||
|
||||
it('clears the mirror when the selection goes away', () => {
|
||||
const { el, textarea } = host()
|
||||
mirrorSelection(el, 'something')
|
||||
mirrorSelection(el, '')
|
||||
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
|
||||
it('is a no-op before xterm has mounted its textarea', () => {
|
||||
expect(() => mirrorSelection(document.createElement('div'), 'text')).not.toThrow()
|
||||
})
|
||||
})
|
||||
69
apps/desktop/src/app/right-sidebar/terminal/clipboard.ts
Normal file
69
apps/desktop/src/app/right-sidebar/terminal/clipboard.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Clipboard keybindings for the GUI terminal.
|
||||
//
|
||||
// xterm renders to a canvas, so its selection is not a DOM selection and the
|
||||
// platform's own copy command has nothing to grab. Two mechanisms fix that:
|
||||
// this key map (explicit chords) and `mirrorSelection` below (which hands the
|
||||
// selection to the OS through xterm's hidden helper textarea, so the Edit menu,
|
||||
// ⌘C on macOS — swallowed by the menu before the renderer sees it — and the
|
||||
// right-click menu all work).
|
||||
//
|
||||
// The chords follow VS Code (terminal.clipboard.contribution.ts): ⌘C/⌘V on
|
||||
// macOS, Ctrl+Shift+C/V elsewhere, plus plain Ctrl+C as copy ONLY when text is
|
||||
// selected — the "intelligent Ctrl-C" of Windows Terminal and Tabby. With no
|
||||
// selection Ctrl+C stays SIGINT, so interrupting a process never breaks.
|
||||
|
||||
export type TerminalClipboardIntent = 'copy' | 'paste' | null
|
||||
|
||||
export function terminalClipboardIntent(
|
||||
event: KeyboardEvent,
|
||||
{ hasSelection, isMac }: { hasSelection: boolean; isMac: boolean }
|
||||
): TerminalClipboardIntent {
|
||||
if (event.type !== 'keydown' || event.altKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (isMac) {
|
||||
if (!event.metaKey || event.ctrlKey || event.shiftKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
// ⌘C with nothing selected falls through to the shell (⌘ isn't a terminal
|
||||
// modifier, so it's a no-op there rather than a lost keystroke).
|
||||
return key === 'c' ? (hasSelection ? 'copy' : null) : key === 'v' ? 'paste' : null
|
||||
}
|
||||
|
||||
if (!event.ctrlKey || event.metaKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
return key === 'c' ? (hasSelection ? 'copy' : null) : key === 'v' ? 'paste' : null
|
||||
}
|
||||
|
||||
// Bare Ctrl+C: copy only when there's a selection to copy, else SIGINT.
|
||||
return key === 'c' && hasSelection ? 'copy' : null
|
||||
}
|
||||
|
||||
// Hand the terminal's selection to the OS by mirroring it into xterm's hidden
|
||||
// helper textarea (the same trick xterm uses for Linux middle-click paste,
|
||||
// CoreBrowserTerminal.ts:531). Without it `webContents.copy()` — what the Edit
|
||||
// menu, ⌘C, and the right-click Copy item all call — finds no DOM selection and
|
||||
// copies nothing.
|
||||
export function mirrorSelection(host: HTMLElement, text: string) {
|
||||
const textarea = host.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
|
||||
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
textarea.value = ''
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
textarea.value = text
|
||||
textarea.select()
|
||||
}
|
||||
|
|
@ -5,11 +5,14 @@ import { WebglAddon } from '@xterm/addon-webgl'
|
|||
import { Terminal } from '@xterm/xterm'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { registerAgentTerminalWriter } from './agent-terminal-stream'
|
||||
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
||||
import { resolveSurfaceColor, terminalTheme } from './selection'
|
||||
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
||||
import { isMacPlatform, resolveSurfaceColor, terminalTheme } from './selection'
|
||||
|
||||
// Read-only terminal for an agent background process: a write-only xterm (no PTY,
|
||||
// no input) fed live by the backend output stream, keyed by process id. Shares
|
||||
|
|
@ -61,6 +64,30 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
|||
term.open(host)
|
||||
termRef.current = term
|
||||
|
||||
// Read-only mirror, but the output is exactly what people want to copy.
|
||||
// No paste path: this terminal has no PTY to paste into.
|
||||
const selectionDisposable = term.onSelectionChange(() => mirrorSelection(host, term.getSelection()))
|
||||
|
||||
term.attachCustomKeyEventHandler(event => {
|
||||
const intent = terminalClipboardIntent(event, {
|
||||
hasSelection: Boolean(term.getSelection()),
|
||||
isMac: isMacPlatform()
|
||||
})
|
||||
|
||||
if (intent !== 'copy') {
|
||||
return true
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void writeClipboardText(term.getSelection()).catch(() => {
|
||||
// Clipboard unavailable — leave the selection so the user can retry.
|
||||
})
|
||||
term.clearSelection()
|
||||
triggerHaptic('selection')
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
fitRef.current = () => {
|
||||
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
||||
try {
|
||||
|
|
@ -94,6 +121,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
|
|||
return () => {
|
||||
unregister()
|
||||
unregisterReader()
|
||||
selectionDisposable.dispose()
|
||||
observer.disconnect()
|
||||
term.dispose()
|
||||
termRef.current = null
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Terminal } from '@xterm/xterm'
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
|
@ -14,8 +15,10 @@ import { useTheme } from '@/themes/context'
|
|||
import { $terminalInjection } from '../store'
|
||||
|
||||
import { makeTerminalReader, registerTerminalReader } from './buffer'
|
||||
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
|
||||
import {
|
||||
isAddSelectionShortcut,
|
||||
isMacPlatform,
|
||||
resolveSurfaceColor,
|
||||
terminalSelectionAnchor,
|
||||
terminalSelectionLabel,
|
||||
|
|
@ -792,12 +795,55 @@ export function useTerminalSession({
|
|||
const next = term.getSelection()
|
||||
selectionRef.current = next
|
||||
selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : ''
|
||||
// Mirror into xterm's helper textarea so the OS sees a real selection —
|
||||
// that's what makes the Edit menu, ⌘C, and right-click Copy work over a
|
||||
// canvas that has no DOM selection of its own.
|
||||
mirrorSelection(host, next)
|
||||
setSelection(next)
|
||||
setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null)
|
||||
})
|
||||
|
||||
cleanup.push(() => selectionDisposable.dispose())
|
||||
|
||||
// Copy/paste chords. Returning false stops xterm from also sending the key
|
||||
// to the PTY; every path that doesn't copy or paste returns true, so plain
|
||||
// Ctrl+C with no selection still interrupts the running process.
|
||||
term.attachCustomKeyEventHandler(event => {
|
||||
const intent = terminalClipboardIntent(event, {
|
||||
hasSelection: Boolean(term.getSelection()),
|
||||
isMac: isMacPlatform()
|
||||
})
|
||||
|
||||
if (!intent) {
|
||||
return true
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (intent === 'copy') {
|
||||
const text = term.getSelection()
|
||||
// Write through the main process: the renderer's clipboard API throws
|
||||
// "Write permission denied" whenever the document isn't focused.
|
||||
void writeClipboardText(text).catch(() => {
|
||||
// Clipboard unavailable — the selection stays put so the user can retry.
|
||||
})
|
||||
term.clearSelection()
|
||||
triggerHaptic('selection')
|
||||
|
||||
return false
|
||||
}
|
||||
void (async () => {
|
||||
const text = (await window.hermesDesktop?.readClipboard?.()) ?? ''
|
||||
|
||||
if (text) {
|
||||
hasSessionActivityRef.current = true
|
||||
term.paste(text)
|
||||
}
|
||||
})()
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const startSession = () =>
|
||||
void terminalApi
|
||||
// Prefer the prior session's last cwd so a reopened tab lands where the
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
setDefaultReasoningEffort,
|
||||
setIntroPersonality
|
||||
} from '@/store/session'
|
||||
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
|
||||
import { applyAutoSpeakFromConfig, applyThinkingSoundFromConfig, applyVoiceStopPhraseFromConfig } from '@/store/voice-prefs'
|
||||
|
||||
const DEFAULT_VOICE_SECONDS = 120
|
||||
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
|
||||
|
|
@ -105,6 +105,8 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
|
|||
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
applyAutoSpeakFromConfig(config)
|
||||
applyVoiceStopPhraseFromConfig(config)
|
||||
applyThinkingSoundFromConfig(config)
|
||||
} catch {
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ import { setSessionCompacting } from '@/store/compaction'
|
|||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { applyGoalStatusText } from '@/store/goals'
|
||||
import {
|
||||
notifyCronChanged,
|
||||
notifyPetChanged,
|
||||
notifySessionsChanged,
|
||||
type PetChangeMeta,
|
||||
setChangeEventsAvailable
|
||||
} from '@/store/live-sync'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
|
|
@ -275,6 +282,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
// Seed the active skin into the desktop theme registry without applying,
|
||||
// so a fresh connect never overrides the user's persisted desktop theme.
|
||||
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
|
||||
// Backends with the change watcher broadcast pet/cron/sessions change
|
||||
// events; consumers demote their legacy polls to slow backstops.
|
||||
setChangeEventsAvailable(Boolean((payload as { change_events?: boolean } | undefined)?.change_events))
|
||||
|
||||
return
|
||||
} else if (event.type === 'skin.changed') {
|
||||
|
|
@ -287,6 +297,25 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
|
||||
}
|
||||
|
||||
return
|
||||
} else if (event.type === 'pet.changed' || event.type === 'cron.changed' || event.type === 'sessions.changed') {
|
||||
// Change-watcher broadcasts (server._broadcast_watched_changes): the
|
||||
// backend's on-disk signature moved. Route to the live-sync ticks the
|
||||
// former pollers now subscribe to. Only the active profile's changes
|
||||
// apply — background profile sockets watch their own homes.
|
||||
const fromActiveChangeProfile =
|
||||
!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (fromActiveChangeProfile) {
|
||||
if (event.type === 'pet.changed') {
|
||||
notifyPetChanged(payload as PetChangeMeta | undefined)
|
||||
} else if (event.type === 'cron.changed') {
|
||||
notifyCronChanged()
|
||||
} else {
|
||||
notifySessionsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
} else if (event.type === 'session.info') {
|
||||
// Apply session-scoped fields when the event targets the active
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { $notifications, clearNotifications } from '@/store/notifications'
|
|||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$currentUsage,
|
||||
$messages,
|
||||
$sessions,
|
||||
|
|
@ -1624,6 +1625,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
getRuntimeIdForStoredSession={storedId => (storedId === 'stored-session-a' ? 'rt-session-a' : null)}
|
||||
onReady={h => (handle = h)}
|
||||
onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}
|
||||
refreshSessions={async () => undefined}
|
||||
|
|
@ -1655,6 +1657,140 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||
expect($busy.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('a fromQueue drain carrying a stale runtime id re-homes via session.resume instead of landing in the foreground session', async () => {
|
||||
// The session-switch window this guards: the composer's queue key has
|
||||
// already flipped to session B (route-driven) while the foreground runtime
|
||||
// id prop still reads session A (resume-driven, one settle behind). Without
|
||||
// the central-binding check, prompt.submit fires with session_id=A and B's
|
||||
// queued prompt — plus its whole answer turn — lands inside A. With no
|
||||
// binding recorded for B yet, the stale id must be dropped and the drain
|
||||
// re-homed through the stored-session resume path.
|
||||
const updates: { sessionId: string; state: Record<string, unknown>; storedSessionId: null | string | undefined }[] =
|
||||
[]
|
||||
|
||||
const requestGateway = vi.fn(
|
||||
async (method: string, _params?: Record<string, unknown>) =>
|
||||
(method === 'session.resume' ? { session_id: 'rt-session-b' } : {}) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('queued for B mid-switch', {
|
||||
fromQueue: true,
|
||||
sessionId: 'rt-session-a',
|
||||
storedSessionId: 'stored-session-b'
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
|
||||
session_id: 'stored-session-b',
|
||||
source: 'desktop'
|
||||
})
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
{
|
||||
queued: true,
|
||||
session_id: 'rt-session-b',
|
||||
text: 'queued for B mid-switch'
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
// The invariant: the stale foreground runtime never receives the prompt.
|
||||
expect(
|
||||
requestGateway.mock.calls.every(
|
||||
([method, params]) =>
|
||||
method !== 'prompt.submit' || (params as { session_id?: string }).session_id !== 'rt-session-a'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
updates.some(update => update.sessionId === 'rt-session-b' && update.storedSessionId === 'stored-session-b')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('a fromQueue drain rebinds to the centrally recorded runtime when its explicit id is stale', async () => {
|
||||
// Same window, but B's runtime binding is already known centrally — the
|
||||
// drain should adopt the authoritative binding directly (no resume
|
||||
// round-trip) rather than trusting the leftover foreground id.
|
||||
const requestGateway = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
getRuntimeIdForStoredSession={storedId => (storedId === 'stored-session-b' ? 'rt-session-b-live' : null)}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('queued for B, B already re-bound', {
|
||||
fromQueue: true,
|
||||
sessionId: 'rt-session-a',
|
||||
storedSessionId: 'stored-session-b'
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
{
|
||||
queued: true,
|
||||
session_id: 'rt-session-b-live',
|
||||
text: 'queued for B, B already re-bound'
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything())
|
||||
expect(
|
||||
requestGateway.mock.calls.every(
|
||||
([method, params]) =>
|
||||
method !== 'prompt.submit' || (params as { session_id?: string }).session_id !== 'rt-session-a'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('a NON-queue explicit target keeps its runtime id even with no central binding recorded', async () => {
|
||||
// The scoping invariant for the check above. A slash skill dispatch into a
|
||||
// fresh ⌘T tab passes the same shape a stale drain does — sessionId and
|
||||
// storedSessionId differ, and the tab has no central binding yet — but its
|
||||
// two ids were resolved in the same tick, so the explicit target IS
|
||||
// authoritative. Validating this caller against the (empty) binding would
|
||||
// null the target and silently drop the kickoff into nowhere.
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
getRuntimeIdForStoredSession={() => null}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('kickoff for the tab', {
|
||||
sessionId: 'rt-tab',
|
||||
storedSessionId: 'stored-tab'
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: 'rt-tab',
|
||||
text: 'kickoff for the tab'
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
})
|
||||
|
||||
it('a fromQueue drain with null runtime id does NOT land in the foreground session (cross-session leak guard)', async () => {
|
||||
// The cross-session leak: a background drain fires with sessionId=null
|
||||
// (the stored session's runtime was reaped by the gateway). Without the
|
||||
|
|
@ -2128,6 +2264,7 @@ describe('usePromptActions file attachment sync', () => {
|
|||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
$currentCwd.set('')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
|
|
@ -2190,6 +2327,100 @@ describe('usePromptActions file attachment sync', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('uploads Windows file bytes when local mode fronts a POSIX WSL/Docker backend', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
$currentCwd.set('/root')
|
||||
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl }
|
||||
})
|
||||
|
||||
const attachment: ComposerAttachment = {
|
||||
...fileAttachment(),
|
||||
path: 'C:\\Users\\alice\\Downloads\\report.txt',
|
||||
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
|
||||
}
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'file.attach') {
|
||||
return {
|
||||
attached: true,
|
||||
path: '/root/.hermes/desktop-attachments/report.txt',
|
||||
ref_text: '@file:.hermes/desktop-attachments/report.txt',
|
||||
uploaded: true
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('summarize', { attachments: [attachment] })).toBe(true)
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Downloads\\report.txt')
|
||||
expect(calls[0]).toEqual({
|
||||
method: 'file.attach',
|
||||
params: {
|
||||
data_url: 'data:text/plain;base64,aGVsbG8=',
|
||||
name: 'report.txt',
|
||||
path: 'C:\\Users\\alice\\Downloads\\report.txt',
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
}
|
||||
})
|
||||
expect(calls[1]).toEqual({
|
||||
method: 'prompt.submit',
|
||||
params: { session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nsummarize' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses image.attach_bytes for a Windows image when the local backend cwd is POSIX', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl }
|
||||
})
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'image.attach_bytes') {
|
||||
return { attached: true, path: '/root/tmp/photo.jpg' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
const uploaded = await uploadComposerAttachment(
|
||||
{
|
||||
id: 'image:photo.jpg',
|
||||
kind: 'image',
|
||||
label: 'photo.jpg',
|
||||
path: 'C:\\Users\\alice\\Pictures\\photo.jpg'
|
||||
},
|
||||
{
|
||||
backendCwd: '/root',
|
||||
remote: false,
|
||||
requestGateway,
|
||||
sessionId: RUNTIME_SESSION_ID
|
||||
}
|
||||
)
|
||||
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Pictures\\photo.jpg')
|
||||
expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', {
|
||||
content_base64: 'aGVsbG8=',
|
||||
filename: 'photo.jpg',
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('image.attach', expect.anything())
|
||||
expect(uploaded.path).toBe('/root/tmp/photo.jpg')
|
||||
})
|
||||
|
||||
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
|
||||
// Submit-layer contract: only attachments that carry a `path` are upload
|
||||
// candidates. A path-less ref (an @-mention/context ref or pasted text)
|
||||
|
|
@ -2237,8 +2468,20 @@ describe('usePromptActions file attachment sync', () => {
|
|||
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
|
||||
})
|
||||
|
||||
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
|
||||
it('passes a Windows path directly for a native Windows local backend', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
$currentCwd.set('C:\\Users\\alice\\project')
|
||||
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,c2hvdWxkLW5vdC1iZS1yZWFk')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl }
|
||||
})
|
||||
|
||||
const attachment: ComposerAttachment = {
|
||||
...fileAttachment(),
|
||||
path: 'C:\\Users\\alice\\Downloads\\report.txt',
|
||||
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
|
||||
}
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
|
||||
|
|
@ -2257,11 +2500,12 @@ describe('usePromptActions file attachment sync', () => {
|
|||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
|
||||
const ok = await handle!.submitText('summarize', { attachments: [attachment] })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls[0]?.method).toBe('file.attach')
|
||||
// Local mode sends no data_url — the gateway shares this disk.
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
// Native Windows local mode shares the same path namespace.
|
||||
expect(calls[0]?.params).not.toHaveProperty('data_url')
|
||||
expect(calls[1]).toEqual({
|
||||
method: 'prompt.submit',
|
||||
|
|
@ -2510,6 +2754,13 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
// The central binding is stale in lockstep with the caller here: the
|
||||
// sleep/wake reaper only clears the GATEWAY's in-memory session, so
|
||||
// client-side state still swears by the old runtime id. That is what
|
||||
// routes this case to the reactive 404→resume→retry path instead of
|
||||
// the proactive binding check (covered by the cross-session drain
|
||||
// tests above).
|
||||
getRuntimeIdForStoredSession={storedId => (storedId === STORED_SESSION_ID ? 'rt-background-stale' : null)}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
|
|
@ -3844,7 +4095,7 @@ describe('uploadComposerAttachment remote read failures', () => {
|
|||
|
||||
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
|
||||
// electron/hardening.ts rejects the readFileDataUrl IPC with this exact
|
||||
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
|
||||
// shape when a file exceeds the configured data-URL read cap.
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { clearAllPrompts } from '@/store/prompts'
|
|||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$messages,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
|
|
@ -76,26 +77,38 @@ interface HandoffResult {
|
|||
error?: string
|
||||
}
|
||||
|
||||
const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/
|
||||
const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/
|
||||
|
||||
// `mode: local` means the gateway was launched locally, not necessarily that
|
||||
// Electron and the gateway share a filesystem. Windows Desktop can front a
|
||||
// WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the
|
||||
// boundary as bytes just like a remote attachment.
|
||||
function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean {
|
||||
return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage one file/image attachment into the session workspace and return the
|
||||
* attachment rewritten with the gateway-side ref. Images upload their bytes in
|
||||
* remote mode (so vision works) and pass the path locally; non-image files
|
||||
* upload bytes remotely and pass the path locally. Throws on failure so callers
|
||||
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
|
||||
* and the message-edit composer drop — keep them in lockstep.
|
||||
* attachment rewritten with the gateway-side ref. Attachments upload their
|
||||
* bytes for remote gateways and local cross-filesystem backends; otherwise the
|
||||
* gateway receives the shared local path. Throws on failure so callers can
|
||||
* surface an error. Shared by submit-time sync, the eager drop-time upload, and
|
||||
* the message-edit composer drop — keep them in lockstep.
|
||||
*/
|
||||
export async function uploadComposerAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
|
||||
opts: { backendCwd?: null | string; remote: boolean; requestGateway: GatewayRequest; sessionId: string }
|
||||
): Promise<ComposerAttachment> {
|
||||
const { remote, requestGateway, sessionId } = opts
|
||||
const { backendCwd, remote, requestGateway, sessionId } = opts
|
||||
const path = attachment.path ?? ''
|
||||
const label = attachment.label || pathLabel(path)
|
||||
const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd)
|
||||
|
||||
if (attachment.kind === 'image') {
|
||||
let result: ImageAttachResponse
|
||||
|
||||
if (remote) {
|
||||
if (uploadBytes) {
|
||||
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
|
||||
|
||||
try {
|
||||
|
|
@ -138,7 +151,7 @@ export async function uploadComposerAttachment(
|
|||
// Non-image file.
|
||||
let dataUrl: string | null = null
|
||||
|
||||
if (remote) {
|
||||
if (uploadBytes) {
|
||||
try {
|
||||
dataUrl = await readFileDataUrlForAttach(path)
|
||||
} catch (err) {
|
||||
|
|
@ -317,7 +330,12 @@ export function usePromptActions({
|
|||
}
|
||||
|
||||
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||
const nextAttachment = await uploadComposerAttachment(attachment, {
|
||||
backendCwd: $currentCwd.get(),
|
||||
remote,
|
||||
requestGateway,
|
||||
sessionId
|
||||
})
|
||||
|
||||
// Update-only: never resurrect a chip the user removed mid-upload.
|
||||
if (updateComposerAttachments) {
|
||||
|
|
@ -356,7 +374,14 @@ export function usePromptActions({
|
|||
try {
|
||||
// Update-only: if the user removed the chip while this was uploading,
|
||||
// don't resurrect it — just drop the staged result on the floor.
|
||||
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
|
||||
updateComposerAttachment(
|
||||
await uploadComposerAttachment(attachment, {
|
||||
backendCwd: $currentCwd.get(),
|
||||
remote,
|
||||
requestGateway,
|
||||
sessionId
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
// Leave the chip in place so submit-time sync can retry (or the user can
|
||||
// remove it) and flag the card; also toast so a hard failure (unreadable
|
||||
|
|
|
|||
|
|
@ -20,7 +20,14 @@ 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 {
|
||||
$sessions,
|
||||
resolveComposerSessionKey,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
touchSessionActivity
|
||||
} from '@/store/session'
|
||||
import { $sessionStates } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
|
|
@ -191,6 +198,40 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
|
||||
let sessionId: null | string = options?.sessionId ?? (isBackgroundQueueDrain ? null : activeSessionIdRef.current)
|
||||
|
||||
// A QUEUED runtime id is authoritative ONLY while it still belongs to its
|
||||
// stored session. On a session switch the composer's queue key flips with
|
||||
// the route while the foreground runtime id lags a resume behind, so a
|
||||
// drain can fire with storedSessionId=B but sessionId=A-runtime — and the
|
||||
// prompt.submit below would land B's queued prompt (and its whole answer
|
||||
// turn) inside A. Verify the pair against the central binding and drop a
|
||||
// stale queued id: the targetStoredSessionId resume path below then
|
||||
// rebinds the right runtime, exactly as a background drain with an
|
||||
// unknown binding does.
|
||||
//
|
||||
// Scoped to fromQueue on purpose. Only a drain pairs identifiers from two
|
||||
// different clocks; every other explicit-target caller resolves both ids
|
||||
// in the same tick and is authoritative by construction. A slash skill
|
||||
// dispatch into a fresh ⌘T tab (slash.ts) passes exactly this shape —
|
||||
// sessionId=tab-runtime, storedSessionId=tab-stored, no central binding
|
||||
// recorded yet — so an unscoped check would null the target and silently
|
||||
// drop the kickoff.
|
||||
//
|
||||
// The identity pair (storedSessionId === sessionId) is the fresh-chat
|
||||
// fallback — an unpersisted conversation's queue key IS its runtime id,
|
||||
// so it has no central binding to check against and is left untouched.
|
||||
if (
|
||||
options?.fromQueue &&
|
||||
options.sessionId &&
|
||||
options.storedSessionId &&
|
||||
options.storedSessionId !== options.sessionId
|
||||
) {
|
||||
const boundRuntimeId = getRuntimeIdForStoredSession(options.storedSessionId)
|
||||
|
||||
if (boundRuntimeId !== options.sessionId) {
|
||||
sessionId = boundRuntimeId
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the foreground session context for the whole async submit pipeline.
|
||||
// Without this, a fast session switch during session.resume / file.attach
|
||||
// can redirect the user's text into a different chat (#54527). Mutable —
|
||||
|
|
@ -293,7 +334,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
|
||||
// Idempotent optimistic insert — re-running with the resolved sessionId
|
||||
// after createBackendSessionForSend just overwrites with the same id.
|
||||
const seedOptimistic = (sid: string) =>
|
||||
const seedOptimistic = (sid: string) => {
|
||||
// Recents jump on send — not stream start, not turn resolve.
|
||||
const activity = bubbleText.trim() ? { preview: bubbleText.trim() } : undefined
|
||||
touchSessionActivity(sid, activity)
|
||||
|
||||
if (targetStoredSessionId && targetStoredSessionId !== sid) {
|
||||
touchSessionActivity(targetStoredSessionId, activity)
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
sid,
|
||||
state => ({
|
||||
|
|
@ -312,6 +361,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
|||
}),
|
||||
targetStoredSessionId
|
||||
)
|
||||
}
|
||||
|
||||
// After sync rewrites refs, refresh the optimistic message in place so the
|
||||
// transcript shows the resolved @file: ref rather than the local path.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AppendMessage } from '@assistant-ui/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
isSessionBusyError,
|
||||
isSessionIdCandidate,
|
||||
isSessionNotFoundError,
|
||||
readFileDataUrlForAttach,
|
||||
renderRpcResult,
|
||||
slashStatusText,
|
||||
visibleUserIndexAtOrdinal,
|
||||
|
|
@ -86,6 +87,32 @@ describe('friendlyRemoteAttachError', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('readFileDataUrlForAttach', () => {
|
||||
it('prefers the attachment-specific desktop reader over the preview reader', async () => {
|
||||
const previewReader = vi.fn(async () => 'preview')
|
||||
const attachmentReader = vi.fn(async () => 'data:application/zip;base64,UEs=')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl: previewReader, readFileDataUrlForAttach: attachmentReader }
|
||||
})
|
||||
|
||||
await expect(readFileDataUrlForAttach('/tmp/archive.zip')).resolves.toBe('data:application/zip;base64,UEs=')
|
||||
expect(attachmentReader).toHaveBeenCalledWith('/tmp/archive.zip')
|
||||
expect(previewReader).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the preview reader on older shells', async () => {
|
||||
const previewReader = vi.fn(async () => 'data:text/plain;base64,YQ==')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl: previewReader }
|
||||
})
|
||||
|
||||
await expect(readFileDataUrlForAttach('/tmp/note.txt')).resolves.toBe('data:text/plain;base64,YQ==')
|
||||
expect(previewReader).toHaveBeenCalledWith('/tmp/note.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('slashStatusText', () => {
|
||||
it('joins command and trimmed output', () => {
|
||||
expect(slashStatusText('/model', ' gpt ')).toBe('slash:/model\ngpt')
|
||||
|
|
|
|||
|
|
@ -155,8 +155,10 @@ export async function readImageForRemoteAttach(
|
|||
|
||||
// Read a non-image file as a data URL for upload via file.attach. Returns null
|
||||
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
|
||||
// Prefer the attach-specific IPC (256 MiB) so remote uploads are not stuck on
|
||||
// the preview/Settings default; fall back for older Electron shells.
|
||||
export async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
|
||||
const reader = window.hermesDesktop?.readFileDataUrl
|
||||
const reader = window.hermesDesktop?.readFileDataUrlForAttach ?? window.hermesDesktop?.readFileDataUrl
|
||||
|
||||
if (!reader) {
|
||||
return null
|
||||
|
|
@ -167,13 +169,12 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string
|
|||
return dataUrl || null
|
||||
}
|
||||
|
||||
// The readFileDataUrl IPC base64-loads the whole file into memory and is
|
||||
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which
|
||||
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
|
||||
// remote mode every attachment's bytes go through that read, so a big file
|
||||
// surfaces that internal message verbatim in the failure toast. Translate it
|
||||
// into a friendly "too large to upload to the remote gateway" line, parsing the
|
||||
// limit out of the message so it tracks the real cap. Non-cap errors pass
|
||||
// The attach/preview IPC base64-loads the whole file into memory and rejects
|
||||
// with a raw "file is too large (N bytes; limit M bytes)" string when over
|
||||
// cap. In remote mode every attachment's bytes go through that read, so a big
|
||||
// file surfaces that internal message verbatim in the failure toast. Translate
|
||||
// it into a friendly "too large to upload to the remote gateway" line, parsing
|
||||
// the limit out of the message so it tracks the real cap. Non-cap errors pass
|
||||
// through unchanged.
|
||||
export function friendlyRemoteAttachError(err: unknown, label: string): Error {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
|
|||
|
|
@ -558,6 +558,7 @@ export function useSessionActions({
|
|||
resetViewSync()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
|
||||
// A session is EITHER the main thread OR a tile — never both. openSessionTile
|
||||
// enforces this from the tile side (it refuses to tile the selected session);
|
||||
// this enforces it from the main side. Loading an existing session into main
|
||||
|
|
@ -570,6 +571,7 @@ export function useSessionActions({
|
|||
if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
|
||||
closeSessionTile(storedSessionId)
|
||||
}
|
||||
|
||||
// Optimistically clear any prior resume-failure latch for this session:
|
||||
// we're attempting a fresh resume, so the self-heal in use-route-resume
|
||||
// must not keep treating it as stranded. It's re-armed below only if THIS
|
||||
|
|
|
|||
114
apps/desktop/src/app/settings/combobox-input.tsx
Normal file
114
apps/desktop/src/app/settings/combobox-input.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useRef, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Free-input combobox for open-world fields (voice/model names): a plain
|
||||
* Input the user can type anything into, plus a dropdown listing ALL known
|
||||
* options.
|
||||
*
|
||||
* Replaces the old `<Input list="…">` + `<datalist>` rendering for
|
||||
* FREE_INPUT_KEYS: native datalists filter by the field's current value, so a
|
||||
* field already holding a valid option (e.g. `gpt-4o-mini-tts`) suggested
|
||||
* only that one entry — users couldn't discover the other models/voices at
|
||||
* all (and on some platforms datalists barely render). Suggestions filter by
|
||||
* substring while typing, but an exact-match value shows the full list so an
|
||||
* already-configured field still exposes every alternative.
|
||||
*/
|
||||
export function ComboboxInput({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
optionLabels,
|
||||
placeholder,
|
||||
className
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: string[]
|
||||
optionLabels?: Record<string, string>
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const query = value.trim().toLowerCase()
|
||||
const isExact = options.some(option => option.toLowerCase() === query)
|
||||
|
||||
const visible = query && !isExact ? options.filter(option => option.toLowerCase().includes(query)) : options
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
className="w-full pr-7"
|
||||
onChange={e => {
|
||||
onChange(e.target.value)
|
||||
|
||||
if (!open) {
|
||||
setOpen(true)
|
||||
}
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'Tab') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<button
|
||||
aria-label="Show options"
|
||||
className="absolute inset-y-0 right-1.5 flex items-center text-muted-foreground"
|
||||
onClick={() => {
|
||||
setOpen(current => !current)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
onOpenAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
{visible.length > 0 && (
|
||||
<CommandGroup>
|
||||
{visible.map(option => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
onSelect={() => {
|
||||
onChange(option)
|
||||
setOpen(false)
|
||||
}}
|
||||
value={option}
|
||||
>
|
||||
<Codicon
|
||||
className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')}
|
||||
name="check"
|
||||
/>
|
||||
<span className="truncate">{optionLabels?.[option] ?? option}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,10 +9,12 @@ import { prettyName } from '@/lib/text'
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { ConfigFieldSchema } from '@/types/hermes'
|
||||
|
||||
import { ComboboxInput } from './combobox-input'
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FREE_INPUT_KEYS } from './constants'
|
||||
import { FallbackModelsField } from './fallback-models-field'
|
||||
import { fieldCopyForSchemaKey } from './field-copy'
|
||||
import { ListRow } from './primitives'
|
||||
import { SearchableSelect } from './searchable-select'
|
||||
|
||||
/**
|
||||
* One generic config row: label + description resolved from the i18n field
|
||||
|
|
@ -93,29 +95,38 @@ export function ConfigField({
|
|||
|
||||
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
|
||||
|
||||
// Large closed-world lists (e.g. ~590 IANA timezones) get a searchable
|
||||
// Popover + cmdk combobox instead of a closed Select dropdown. The schema
|
||||
// opt-in via `searchable: true` keeps this deterministic — no field
|
||||
// accidentally triggers based on dynamic option count.
|
||||
if (selectOptions && schema.searchable) {
|
||||
return row(
|
||||
<SearchableSelect
|
||||
clearLabel={schema.clearable ? c.systemDefault : undefined}
|
||||
emptyMessage={c.noResults}
|
||||
onChange={next => onChange(next)}
|
||||
options={selectOptions.filter(o => o !== '')}
|
||||
placeholder={c.searchPlaceholder}
|
||||
value={String(value ?? '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Voice/model name fields are open-world (custom voice IDs, cloned voices,
|
||||
// brand-new model names) — render a free-input combobox where the known
|
||||
// options are datalist suggestions instead of a closed Select gate.
|
||||
// options are dropdown suggestions instead of a closed Select gate. The old
|
||||
// native <datalist> filtered by the current value, so a field already set
|
||||
// to a valid option showed only that single suggestion.
|
||||
if (selectOptions && FREE_INPUT_KEYS.has(schemaKey)) {
|
||||
const datalistId = `config-field-options-${schemaKey.replace(/\./g, '-')}`
|
||||
|
||||
return row(
|
||||
<>
|
||||
<Input
|
||||
className={CONTROL_TEXT}
|
||||
list={datalistId}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={c.notSet}
|
||||
value={String(value ?? '')}
|
||||
/>
|
||||
<datalist id={datalistId}>
|
||||
{selectOptions
|
||||
.filter(option => option !== '')
|
||||
.map(option => (
|
||||
<option key={option} label={optionLabels?.[option]} value={option} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
<ComboboxInput
|
||||
className={CONTROL_TEXT}
|
||||
onChange={onChange}
|
||||
optionLabels={optionLabels}
|
||||
options={selectOptions.filter(o => o !== '')}
|
||||
placeholder={c.notSet}
|
||||
value={String(value ?? '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,19 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
$dataUrlReadMaxMb,
|
||||
clampDataUrlReadMaxMb,
|
||||
DATA_URL_READ_DEFAULT_MAX_MB,
|
||||
DATA_URL_READ_MAX_MAX_MB,
|
||||
DATA_URL_READ_MIN_MAX_MB,
|
||||
refreshDataUrlReadMaxMb,
|
||||
setDataUrlReadMaxMb
|
||||
} from '@/store/data-url-read-max'
|
||||
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
|
||||
|
|
@ -21,7 +32,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
|
|||
import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
import { QuickEntrySettings } from './quick-entry-settings'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
|
|
@ -305,9 +316,13 @@ export function ConfigSettings({
|
|||
<QuickEntrySettings />
|
||||
</>
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
{/* Device-local attach/preview byte cap (main-process IPC guard). Chat is
|
||||
where image-attachment behavior already lives, so this sits above the
|
||||
schema fields for that section. */}
|
||||
{activeSectionId === 'chat' ? <AttachmentSizeSetting /> : null}
|
||||
{visibleFields.length === 0 && activeSectionId !== 'chat' ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
) : (
|
||||
) : visibleFields.length === 0 ? null : (
|
||||
<div className="grid gap-1">
|
||||
{visibleFields.map(([key, field]) => (
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
|
|
@ -345,3 +360,73 @@ export function ConfigSettings({
|
|||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
/** Free-form MB cap for Desktop's data-URL attach/preview path (main-process). */
|
||||
function AttachmentSizeSetting() {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
const stored = useStore($dataUrlReadMaxMb)
|
||||
const [draft, setDraft] = useState(String(stored))
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDataUrlReadMaxMb()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(String(stored))
|
||||
}, [stored])
|
||||
|
||||
const commit = () => {
|
||||
// An empty draft means "reset to the default", not the 1 MB floor
|
||||
// (Number('') === 0 would otherwise clamp down to the floor).
|
||||
const applied = draft.trim() === '' ? DATA_URL_READ_DEFAULT_MAX_MB : clampDataUrlReadMaxMb(draft)
|
||||
|
||||
// Unchanged: snap the draft back to the stored value and skip the
|
||||
// pointless IPC write + haptic.
|
||||
if (applied === stored) {
|
||||
setDraft(String(stored))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void setDataUrlReadMaxMb(applied).then(next => {
|
||||
setDraft(String(next))
|
||||
|
||||
// On a bridge write failure the store keeps the old value; only
|
||||
// celebrate when the new cap actually landed.
|
||||
if (next === applied) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label={c.attachmentSizeLabel}
|
||||
className="w-20"
|
||||
inputMode="numeric"
|
||||
max={DATA_URL_READ_MAX_MAX_MB}
|
||||
min={DATA_URL_READ_MIN_MAX_MB}
|
||||
onBlur={commit}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
value={draft}
|
||||
/>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{c.attachmentSizeUnit}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={c.attachmentSizeDesc}
|
||||
title={c.attachmentSizeTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,8 +263,10 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
|||
// Speech-to-text backends — kept in sync with the stt block in
|
||||
// hermes_cli/config.py (local/groq/openai/mistral/elevenlabs).
|
||||
'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'],
|
||||
// gpt-4o-mini-tts voice set (the tts-1 era stopped at shimmer). Free-input
|
||||
// field — the list is suggestions, not a gate (see FREE_INPUT_KEYS).
|
||||
// OpenAI TTS voices — the union across models (per the OpenAI TTS API
|
||||
// docs). Model-specific narrowing happens in enumOptionsFor():
|
||||
// tts-1 / tts-1-hd support 9 voices; gpt-4o-mini-tts supports all 13.
|
||||
// Free-input field — the list is suggestions, not a gate (FREE_INPUT_KEYS).
|
||||
'tts.openai.voice': [
|
||||
'alloy',
|
||||
'ash',
|
||||
|
|
@ -349,7 +351,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
|||
'kittentts',
|
||||
'piper'
|
||||
],
|
||||
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe'],
|
||||
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe', 'gpt-transcribe'],
|
||||
'stt.mistral.model': ['voxtral-mini-latest', 'voxtral-mini-2602'],
|
||||
'tts.openai.model': ['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd'],
|
||||
'tts.elevenlabs.model_id': ['eleven_multilingual_v2', 'eleven_turbo_v2_5', 'eleven_flash_v2_5'],
|
||||
|
|
@ -564,7 +566,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
|
|||
repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.',
|
||||
repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.'
|
||||
},
|
||||
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
|
||||
timezone: 'IANA timezone identifier. Blank uses the system timezone.',
|
||||
agent: {
|
||||
imageInputMode: 'Controls how image attachments are sent to the model.',
|
||||
maxTurns: 'Upper bound for tool-calling turns before Hermes stops a run.'
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue