Merge pull request #3146 from nesquena/release/stage-batch42
Release v0.51.160 — Release EF (stage-batch42: 3-PR low-risk cleanup)
This commit is contained in:
commit
c8afb37ae7
7 changed files with 512 additions and 28 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -3,6 +3,17 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.160] — 2026-05-29 — Release EF (stage-batch42 — 3-PR low-risk cleanup: OpenCode shared-key detection + skills-panel profile-aware disabled read + session-index metadata refresh perf)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Detect a shared `OPENCODE_API_KEY` as enabling both OpenCode Zen and OpenCode Go provider groups, matching Hermes Agent bridge environments that expose one OpenCode credential.
|
||||
- The skills panel now reads each skill's disabled state from the active WebUI profile's `config.yaml` (checking `skills.platform_disabled.webui` then falling back to `skills.disabled`) instead of the process-global `HERMES_HOME`, so non-default profiles show the correct enabled/disabled state and stay consistent with the skill-toggle write path.
|
||||
|
||||
### Changed
|
||||
|
||||
- WebUI session-sidebar metadata refresh is faster on large session directories: the persisted session-id listing is cached by directory mtime instead of re-globbing under the sessions lock on every `/api/sessions` poll, metadata-only loads skip the per-row session-index read when the sidecar already carries an authoritative `message_count`, and only runtime/lineage-shaped rows are overlaid with fuller sidecar metadata (historical transcripts are no longer scanned on every refresh).
|
||||
|
||||
## [v0.51.159] — 2026-05-29 — Release EE (stage-batch41 — 5-PR low-risk cleanup: Gateway tool-progress forwarding + shutdown diagnostics + CLI snippet-limit parity + numpad-Enter submit + sync-chat notes guardrail)
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -3437,6 +3437,7 @@ def get_available_models() -> dict:
|
|||
"XIAOMI_API_KEY",
|
||||
"OPENCODE_ZEN_API_KEY",
|
||||
"OPENCODE_GO_API_KEY",
|
||||
"OPENCODE_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
"XAI_API_KEY",
|
||||
|
|
@ -3478,9 +3479,9 @@ def get_available_models() -> dict:
|
|||
detected_providers.add("x-ai")
|
||||
if all_env.get("MISTRAL_API_KEY"):
|
||||
detected_providers.add("mistralai")
|
||||
if all_env.get("OPENCODE_ZEN_API_KEY"):
|
||||
if all_env.get("OPENCODE_ZEN_API_KEY") or all_env.get("OPENCODE_API_KEY"):
|
||||
detected_providers.add("opencode-zen")
|
||||
if all_env.get("OPENCODE_GO_API_KEY"):
|
||||
if all_env.get("OPENCODE_GO_API_KEY") or all_env.get("OPENCODE_API_KEY"):
|
||||
detected_providers.add("opencode-go")
|
||||
# AWS Bedrock uses IAM credentials rather than a single API key.
|
||||
# Detect when both access key and secret are available (#2720).
|
||||
|
|
|
|||
144
api/models.py
144
api/models.py
|
|
@ -99,6 +99,39 @@ def _cleanup_stale_tmp_files() -> None:
|
|||
pass # SESSION_DIR may not exist yet; that's fine
|
||||
|
||||
|
||||
_PERSISTED_SESSION_IDS_CACHE: tuple[Path | None, int | None, frozenset[str]] = (None, None, frozenset())
|
||||
|
||||
|
||||
def _persisted_session_ids_snapshot() -> frozenset[str]:
|
||||
"""Return persisted session ids, caching the directory snapshot by mtime.
|
||||
|
||||
`/api/sessions` and incremental index writes may run every few seconds. A
|
||||
full `SESSION_DIR.glob('*.json')` on a large session directory is expensive,
|
||||
and doing that scan while request threads contend on LOCK makes the sidebar
|
||||
look like it was designed by a committee of glaciers. Cache the listing until
|
||||
the directory mtime changes, and let callers take the snapshot before
|
||||
entering critical sections.
|
||||
"""
|
||||
global _PERSISTED_SESSION_IDS_CACHE
|
||||
try:
|
||||
dir_mtime_ns = SESSION_DIR.stat().st_mtime_ns
|
||||
except Exception:
|
||||
dir_mtime_ns = None
|
||||
cached_dir, cached_mtime_ns, cached_ids = _PERSISTED_SESSION_IDS_CACHE
|
||||
if cached_dir == SESSION_DIR and cached_mtime_ns == dir_mtime_ns:
|
||||
return cached_ids
|
||||
try:
|
||||
ids = frozenset(
|
||||
p.stem
|
||||
for p in SESSION_DIR.glob('*.json')
|
||||
if not p.name.startswith('_')
|
||||
)
|
||||
except Exception:
|
||||
ids = frozenset()
|
||||
_PERSISTED_SESSION_IDS_CACHE = (SESSION_DIR, dir_mtime_ns, ids)
|
||||
return ids
|
||||
|
||||
|
||||
def _rebuild_session_index_background() -> None:
|
||||
try:
|
||||
_write_session_index(updates=None)
|
||||
|
|
@ -210,18 +243,13 @@ def _write_session_index(updates=None):
|
|||
# This avoids loading every session file on every single save().
|
||||
_fallback = False
|
||||
try:
|
||||
# Avoid N filesystem exists() checks under LOCK by collecting
|
||||
# on-disk IDs once before entering the critical section.
|
||||
on_disk_ids = _persisted_session_ids_snapshot()
|
||||
with LOCK:
|
||||
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
|
||||
# Avoid N filesystem exists() checks under LOCK by collecting
|
||||
# on-disk IDs once.
|
||||
on_disk_ids = {
|
||||
p.stem
|
||||
for p in SESSION_DIR.glob('*.json')
|
||||
if not p.name.startswith('_')
|
||||
}
|
||||
|
||||
existing = [
|
||||
e for e in existing
|
||||
if (e.get('session_id') in in_memory_ids or e.get('session_id') in on_disk_ids)
|
||||
|
|
@ -786,11 +814,16 @@ class Session:
|
|||
parsed['messages'] = []
|
||||
parsed['tool_calls'] = []
|
||||
session = cls(**parsed)
|
||||
index_message_count = _lookup_index_message_count(sid)
|
||||
sidecar_message_count = _parse_nonnegative_int(parsed.get('message_count'))
|
||||
# The sidebar index is a cache and can lag behind external sidecar
|
||||
# appends/backfills. Prefer the largest known count so metadata-only
|
||||
# active-session polls do not miss real remote updates.
|
||||
index_message_count = None
|
||||
if sidecar_message_count is None:
|
||||
index_message_count = _lookup_index_message_count(sid)
|
||||
# Modern sidecars carry an accurate message_count, so it is the
|
||||
# source of truth and we skip the per-row _index.json read in the
|
||||
# common case. The sidebar index is only a cache (it can lag behind
|
||||
# external sidecar appends/backfills), so consult it solely as a
|
||||
# fallback when the sidecar has no count. When both are present we
|
||||
# still take the largest known count as a defensive measure.
|
||||
known_counts = [
|
||||
count for count in (index_message_count, sidecar_message_count)
|
||||
if count is not None
|
||||
|
|
@ -2530,6 +2563,77 @@ def _strip_sidebar_internal_flags(sessions: list[dict]) -> None:
|
|||
session.pop('_show_pre_compression_snapshot', None)
|
||||
|
||||
|
||||
def _row_may_need_sidecar_metadata_refresh(session: dict) -> bool:
|
||||
"""Return True when a row needs canonical sidecar runtime/snapshot metadata.
|
||||
|
||||
Compression lineage fields are enriched from state.db in one batched query
|
||||
later in all_sessions(). Loading hundreds of lineage sidecars on every
|
||||
/api/sessions poll turns the sidebar into molasses, so keep this refresh
|
||||
limited to the few rows whose transient runtime or snapshot state is not
|
||||
cheaply available from state.db.
|
||||
"""
|
||||
is_runtime_row = bool(
|
||||
session.get('active_stream_id')
|
||||
or session.get('has_pending_user_message')
|
||||
or session.get('pending_user_message')
|
||||
)
|
||||
snapshot_missing_sidebar_metadata = bool(
|
||||
session.get('pre_compression_snapshot')
|
||||
and (
|
||||
session.get('message_count') is None
|
||||
or session.get('last_message_at') is None
|
||||
)
|
||||
)
|
||||
return is_runtime_row or snapshot_missing_sidebar_metadata
|
||||
|
||||
|
||||
def _refresh_index_rows_from_sidecar_metadata(sessions: list[dict]) -> list[dict]:
|
||||
"""Overlay fuller sidecar metadata onto stale sidebar index rows.
|
||||
|
||||
``_index.json`` is a cache and can lag behind the canonical session sidecar
|
||||
during compression/continuation writes. Keep this read-only and limited to
|
||||
lineage/runtime-shaped rows so ordinary sidebar refreshes do not scan every
|
||||
historical transcript.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for session in sessions:
|
||||
if not _row_may_need_sidecar_metadata_refresh(session):
|
||||
out.append(session)
|
||||
continue
|
||||
sid = session.get('session_id')
|
||||
if not sid:
|
||||
out.append(session)
|
||||
continue
|
||||
sidecar = Session.load_metadata_only(sid)
|
||||
if not sidecar:
|
||||
out.append(session)
|
||||
continue
|
||||
compact = sidecar.compact(include_runtime=True)
|
||||
refreshed = dict(session)
|
||||
for key in (
|
||||
'message_count', 'updated_at', 'last_message_at', 'title', 'workspace',
|
||||
'model', 'model_provider', 'created_at', 'pinned', 'archived', 'project_id',
|
||||
'profile', 'pre_compression_snapshot', 'parent_session_id', 'source_tag',
|
||||
'raw_source', 'session_source', 'source_label', 'active_stream_id',
|
||||
'has_pending_user_message', 'pending_user_message', 'pending_started_at',
|
||||
):
|
||||
value = compact.get(key)
|
||||
if value is not None:
|
||||
refreshed[key] = value
|
||||
try:
|
||||
refreshed['message_count'] = max(
|
||||
int(session.get('message_count') or 0),
|
||||
int(compact.get('message_count') or 0),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if _session_sort_timestamp(compact) > _session_sort_timestamp(session):
|
||||
refreshed['updated_at'] = compact.get('updated_at', refreshed.get('updated_at'))
|
||||
refreshed['last_message_at'] = compact.get('last_message_at', refreshed.get('last_message_at'))
|
||||
out.append(refreshed)
|
||||
return out
|
||||
|
||||
|
||||
def _active_state_db_path() -> Path:
|
||||
"""Return state.db for the active Hermes profile, degrading to HERMES_HOME."""
|
||||
try:
|
||||
|
|
@ -2607,14 +2711,7 @@ def all_sessions(diag=None):
|
|||
_diag_stage(diag, "all_sessions.prune_index")
|
||||
with LOCK:
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
try:
|
||||
persisted_ids = {
|
||||
p.stem
|
||||
for p in SESSION_DIR.glob('*.json')
|
||||
if not p.name.startswith('_')
|
||||
}
|
||||
except Exception:
|
||||
persisted_ids = None
|
||||
persisted_ids = _persisted_session_ids_snapshot()
|
||||
index = [
|
||||
s for s in index
|
||||
if (
|
||||
|
|
@ -2658,6 +2755,13 @@ def all_sessions(diag=None):
|
|||
include_runtime=True,
|
||||
active_stream_ids=active_stream_ids,
|
||||
)
|
||||
_diag_stage(diag, "all_sessions.refresh_sidecar_metadata")
|
||||
refreshed_index_rows = _refresh_index_rows_from_sidecar_metadata(list(index_map.values()))
|
||||
index_map = {
|
||||
row['session_id']: row
|
||||
for row in refreshed_index_rows
|
||||
if row.get('session_id')
|
||||
}
|
||||
_diag_stage(diag, "all_sessions.sort_filter")
|
||||
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), _session_sort_timestamp(s)), reverse=True)
|
||||
# Hide empty Untitled sessions from the UI entirely — they are ephemeral
|
||||
|
|
|
|||
|
|
@ -212,6 +212,43 @@ def _worktree_retained_payload_for_session_id(sid: str) -> dict:
|
|||
return {}
|
||||
|
||||
|
||||
def _get_disabled_skill_names_for_profile() -> set:
|
||||
"""Read disabled skill names from the active profile's config.yaml.
|
||||
|
||||
Unlike ``tools.skills_tool._get_disabled_skill_names`` which reads from
|
||||
the process-global ``HERMES_HOME``, this uses ``_get_config_path()`` which
|
||||
resolves against the WebUI's active profile. Checks
|
||||
``skills.platform_disabled.webui`` first, falling back to
|
||||
``skills.disabled``.
|
||||
"""
|
||||
config_path = _get_config_path()
|
||||
if not config_path.exists():
|
||||
return set()
|
||||
try:
|
||||
cfg = _load_yaml_config_file(config_path)
|
||||
except Exception:
|
||||
return set()
|
||||
if not isinstance(cfg, dict):
|
||||
return set()
|
||||
skills_cfg = cfg.get("skills")
|
||||
if not isinstance(skills_cfg, dict):
|
||||
return set()
|
||||
# Check platform_disabled.webui first (mirrors agent platform resolution)
|
||||
platform_disabled = skills_cfg.get("platform_disabled")
|
||||
if isinstance(platform_disabled, dict) and "webui" in platform_disabled:
|
||||
return _normalize_disabled_set(platform_disabled["webui"])
|
||||
return _normalize_disabled_set(skills_cfg.get("disabled"))
|
||||
|
||||
|
||||
def _normalize_disabled_set(values) -> set:
|
||||
"""Normalize a YAML disabled list into a set of stripped strings."""
|
||||
if values is None:
|
||||
return set()
|
||||
if isinstance(values, str):
|
||||
values = [values]
|
||||
return {str(v).strip() for v in values if str(v).strip()}
|
||||
|
||||
|
||||
def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict:
|
||||
"""List skills using an explicit local skills directory.
|
||||
|
||||
|
|
@ -223,7 +260,6 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict
|
|||
from tools.skills_tool import (
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
_EXCLUDED_SKILL_DIRS,
|
||||
_get_disabled_skill_names,
|
||||
_parse_frontmatter,
|
||||
_sort_skills,
|
||||
skill_matches_platform,
|
||||
|
|
@ -240,7 +276,7 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict
|
|||
|
||||
all_skills = []
|
||||
seen_names: set[str] = set()
|
||||
disabled = _get_disabled_skill_names()
|
||||
disabled = _get_disabled_skill_names_for_profile()
|
||||
search_dirs = _active_skill_search_dirs(skills_dir)
|
||||
|
||||
for scan_dir in search_dirs:
|
||||
|
|
|
|||
163
tests/test_issue3066_disabled_read_profile.py
Normal file
163
tests/test_issue3066_disabled_read_profile.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Regression tests for issue #3066: skills panel disabled-state read path
|
||||
must resolve against the active WebUI profile, not the process-global
|
||||
HERMES_HOME.
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from tests.conftest import requires_agent_modules
|
||||
|
||||
|
||||
def _write_config(config_path: Path, data: dict) -> None:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(yaml.dump(data), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_skill(skills_dir: Path, name: str) -> None:
|
||||
skill_dir = skills_dir / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: A test skill\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_read_uses_active_profile_config(tmp_path, monkeypatch):
|
||||
"""_get_disabled_skill_names_for_profile reads from the active profile's
|
||||
config.yaml (via _get_config_path), not from HERMES_HOME."""
|
||||
from api import routes
|
||||
|
||||
profile_home = tmp_path / "profiles" / "work"
|
||||
config_path = profile_home / "config.yaml"
|
||||
_write_config(config_path, {"skills": {"disabled": ["skill-x", "skill-y"]}})
|
||||
|
||||
# Point _get_config_path at the profile config
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
|
||||
result = routes._get_disabled_skill_names_for_profile()
|
||||
assert result == {"skill-x", "skill-y"}
|
||||
|
||||
|
||||
def test_disabled_read_prefers_platform_disabled_webui(tmp_path, monkeypatch):
|
||||
"""When platform_disabled.webui exists, it takes precedence over the
|
||||
global disabled list."""
|
||||
from api import routes
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, {
|
||||
"skills": {
|
||||
"disabled": ["global-disabled"],
|
||||
"platform_disabled": {
|
||||
"webui": ["webui-disabled-a", "webui-disabled-b"],
|
||||
"telegram": ["telegram-disabled"],
|
||||
},
|
||||
}
|
||||
})
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
|
||||
result = routes._get_disabled_skill_names_for_profile()
|
||||
assert result == {"webui-disabled-a", "webui-disabled-b"}
|
||||
assert "global-disabled" not in result
|
||||
|
||||
|
||||
def test_disabled_read_falls_back_to_global_disabled(tmp_path, monkeypatch):
|
||||
"""When platform_disabled.webui is absent, falls back to skills.disabled."""
|
||||
from api import routes
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, {"skills": {"disabled": ["fallback-skill"]}})
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
|
||||
result = routes._get_disabled_skill_names_for_profile()
|
||||
assert result == {"fallback-skill"}
|
||||
|
||||
|
||||
def test_disabled_read_empty_when_no_config(tmp_path, monkeypatch):
|
||||
"""Returns empty set when config.yaml does not exist."""
|
||||
from api import routes
|
||||
|
||||
config_path = tmp_path / "nonexistent" / "config.yaml"
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
|
||||
result = routes._get_disabled_skill_names_for_profile()
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_disabled_read_empty_when_no_skills_section(tmp_path, monkeypatch):
|
||||
"""Returns empty set when config has no skills section."""
|
||||
from api import routes
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, {"model": "gpt-4"})
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
|
||||
result = routes._get_disabled_skill_names_for_profile()
|
||||
assert result == set()
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_skills_list_disabled_reflects_active_profile(tmp_path, monkeypatch):
|
||||
"""_skills_list_from_dir marks skills as disabled based on the active
|
||||
profile's config, not the process-global HERMES_HOME."""
|
||||
from api import routes
|
||||
|
||||
# Set up skills directory with two skills
|
||||
skills_dir = tmp_path / "skills"
|
||||
_write_skill(skills_dir, "skill-a")
|
||||
_write_skill(skills_dir, "skill-b")
|
||||
|
||||
# Profile config disables only skill-a
|
||||
config_path = tmp_path / "config.yaml"
|
||||
_write_config(config_path, {"skills": {"disabled": ["skill-a"]}})
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
|
||||
monkeypatch.setattr("api.routes._active_skills_dir", lambda: skills_dir)
|
||||
|
||||
result = routes._skills_list_from_dir(skills_dir)
|
||||
skills = {s["name"]: s for s in result["skills"]}
|
||||
|
||||
assert skills["skill-a"]["disabled"] is True
|
||||
assert skills["skill-b"]["disabled"] is False
|
||||
|
||||
|
||||
@requires_agent_modules
|
||||
def test_profile_switch_changes_disabled_state(tmp_path, monkeypatch):
|
||||
"""Simulates switching profiles: disabled state should follow the active
|
||||
profile's config, not remain stuck on the original profile."""
|
||||
from api import routes
|
||||
|
||||
# Two profile configs with different disabled lists
|
||||
profile_a_config = tmp_path / "profile-a" / "config.yaml"
|
||||
profile_b_config = tmp_path / "profile-b" / "config.yaml"
|
||||
_write_config(profile_a_config, {"skills": {"disabled": ["skill-x"]}})
|
||||
_write_config(profile_b_config, {"skills": {"disabled": ["skill-y"]}})
|
||||
|
||||
# Skills dir with both skills
|
||||
skills_dir = tmp_path / "skills"
|
||||
_write_skill(skills_dir, "skill-x")
|
||||
_write_skill(skills_dir, "skill-y")
|
||||
monkeypatch.setattr("api.routes._active_skills_dir", lambda: skills_dir)
|
||||
|
||||
# "Active" profile A
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: profile_a_config)
|
||||
result_a = routes._skills_list_from_dir(skills_dir)
|
||||
skills_a = {s["name"]: s for s in result_a["skills"]}
|
||||
assert skills_a["skill-x"]["disabled"] is True
|
||||
assert skills_a["skill-y"]["disabled"] is False
|
||||
|
||||
# "Switch" to profile B
|
||||
monkeypatch.setattr("api.routes._get_config_path", lambda: profile_b_config)
|
||||
result_b = routes._skills_list_from_dir(skills_dir)
|
||||
skills_b = {s["name"]: s for s in result_b["skills"]}
|
||||
assert skills_b["skill-x"]["disabled"] is False
|
||||
assert skills_b["skill-y"]["disabled"] is True
|
||||
|
||||
|
||||
def test_normalize_disabled_set_handles_edge_cases():
|
||||
"""_normalize_disabled_set handles None, str, list, and whitespace."""
|
||||
from api.routes import _normalize_disabled_set
|
||||
|
||||
assert _normalize_disabled_set(None) == set()
|
||||
assert _normalize_disabled_set("single") == {"single"}
|
||||
assert _normalize_disabled_set(["a", "b"]) == {"a", "b"}
|
||||
assert _normalize_disabled_set([" spaced ", " "]) == {"spaced"}
|
||||
assert _normalize_disabled_set([]) == set()
|
||||
|
|
@ -3,7 +3,6 @@ Tests for OpenCode Zen and OpenCode Go provider support.
|
|||
Verifies provider registration in display/model catalogs and
|
||||
env-var fallback detection.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
|
|
@ -96,6 +95,29 @@ def test_opencode_go_detected_via_env_key(monkeypatch):
|
|||
_models_with_env_key(monkeypatch, "OPENCODE_GO_API_KEY", "OpenCode Go")
|
||||
|
||||
|
||||
def test_shared_opencode_api_key_detects_zen_and_go(monkeypatch):
|
||||
"""A shared OpenCode bridge key should enable both OpenCode surfaces."""
|
||||
fake_mod = types.ModuleType("hermes_cli.models")
|
||||
fake_mod.list_available_providers = None
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_mod)
|
||||
monkeypatch.delattr(fake_mod, "list_available_providers")
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
config.cfg["model"] = {}
|
||||
config.cfg.pop("custom_providers", None)
|
||||
monkeypatch.delenv("OPENCODE_ZEN_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENCODE_GO_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENCODE_API_KEY", "test-key")
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
providers = {g["provider"] for g in result["groups"]}
|
||||
assert "OpenCode Zen" in providers
|
||||
assert "OpenCode Go" in providers
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
|
||||
|
||||
def test_openai_codex_model_catalog_includes_gpt54():
|
||||
"""openai-codex catalog must include gpt-5.4 and the standard Codex lineup."""
|
||||
assert "openai-codex" in config._PROVIDER_MODELS
|
||||
|
|
@ -117,7 +139,9 @@ def test_live_models_handler_delegates_to_provider_model_ids():
|
|||
rather than maintain its own per-provider fetch logic.
|
||||
"""
|
||||
import pathlib
|
||||
routes_src = (pathlib.Path(__file__).parent.parent / "api" / "routes.py").read_text()
|
||||
routes_src = (
|
||||
pathlib.Path(__file__).parent.parent / "api" / "routes.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "provider_model_ids" in routes_src, (
|
||||
"_handle_live_models must call hermes_cli.models.provider_model_ids() "
|
||||
"to delegate all provider-specific live-fetch logic to the agent"
|
||||
|
|
@ -139,7 +163,9 @@ def test_live_models_ui_no_longer_skips_any_provider():
|
|||
handles them all (with graceful fallback to static lists).
|
||||
"""
|
||||
import pathlib
|
||||
ui_src = (pathlib.Path(__file__).parent.parent / "static" / "ui.js").read_text()
|
||||
ui_src = (
|
||||
pathlib.Path(__file__).parent.parent / "static" / "ui.js"
|
||||
).read_text(encoding="utf-8")
|
||||
# The old exclusion list must be gone
|
||||
assert "includes(provider)" not in ui_src or "anthropic" not in ui_src[:ui_src.find("includes(provider)")+100], (
|
||||
"_fetchLiveModels must not skip anthropic, google, or gemini — "
|
||||
|
|
|
|||
|
|
@ -37,12 +37,16 @@ def _isolate_session_dir(tmp_path, monkeypatch):
|
|||
# Also patch the module-level references that Session uses
|
||||
monkeypatch.setattr(models.Session, "__module__", models.__name__)
|
||||
|
||||
# Clear the in-memory SESSIONS cache to avoid bleed
|
||||
# Clear the in-memory SESSIONS and persisted-id caches to avoid bleed.
|
||||
models.SESSIONS.clear()
|
||||
if hasattr(models, "_PERSISTED_SESSION_IDS_CACHE"):
|
||||
models._PERSISTED_SESSION_IDS_CACHE = (None, None, frozenset())
|
||||
|
||||
yield session_dir, index_file
|
||||
|
||||
models.SESSIONS.clear()
|
||||
if hasattr(models, "_PERSISTED_SESSION_IDS_CACHE"):
|
||||
models._PERSISTED_SESSION_IDS_CACHE = (None, None, frozenset())
|
||||
|
||||
|
||||
def _make_session(session_id, title="Untitled", updated_at=None):
|
||||
|
|
@ -515,6 +519,145 @@ def test_newer_continuation_beats_older_fuller_snapshot(monkeypatch):
|
|||
assert rows[0]["message_count"] == 2
|
||||
|
||||
|
||||
def test_all_sessions_uses_sidecar_metadata_for_runtime_rows_when_index_message_count_is_stale(monkeypatch):
|
||||
"""Runtime-shaped rows may refresh from fuller sidecar metadata."""
|
||||
session = Session(
|
||||
session_id="stale_index_sid",
|
||||
title="Reachy Mini Integration",
|
||||
messages=[
|
||||
{"role": "user", "content": "one", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "two", "timestamp": 101.0},
|
||||
{"role": "user", "content": "three", "timestamp": 102.0},
|
||||
],
|
||||
updated_at=102.0,
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "stale_index_sid",
|
||||
"title": "Reachy Mini Integration",
|
||||
"message_count": 1,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 100.0,
|
||||
"last_message_at": 100.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"active_stream_id": "stream-stale-index",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert rows[0]["session_id"] == "stale_index_sid"
|
||||
assert rows[0]["message_count"] == 3
|
||||
assert rows[0]["last_message_at"] == 102.0
|
||||
|
||||
|
||||
def test_all_sessions_sidecar_refresh_stays_metadata_only(monkeypatch):
|
||||
"""Refreshing runtime sidebar rows must not hydrate large sidecar messages."""
|
||||
session = Session(
|
||||
session_id="metadata_refresh_sid",
|
||||
title="Metadata Refresh",
|
||||
messages=[
|
||||
{"role": "user", "content": "one", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "x" * 200_000, "timestamp": 101.0},
|
||||
{"role": "user", "content": "three", "timestamp": 102.0},
|
||||
],
|
||||
parent_session_id="parent_sid",
|
||||
updated_at=102.0,
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "metadata_refresh_sid",
|
||||
"title": "Metadata Refresh",
|
||||
"message_count": 1,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 100.0,
|
||||
"last_message_at": 100.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"active_stream_id": "stream-metadata-refresh",
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
with patch.object(Session, "load", side_effect=AssertionError("full sidecar load should not run")):
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert rows[0]["session_id"] == "metadata_refresh_sid"
|
||||
assert rows[0]["message_count"] == 3
|
||||
assert rows[0]["last_message_at"] == 102.0
|
||||
|
||||
|
||||
def test_all_sessions_does_not_refresh_lineage_rows_from_sidecars(monkeypatch):
|
||||
"""Lineage rows are enriched from state.db; do not read every sidecar per poll."""
|
||||
_write_index_file(
|
||||
models.SESSION_INDEX_FILE,
|
||||
[
|
||||
{
|
||||
"session_id": "lineage_sid",
|
||||
"title": "Lineage Row",
|
||||
"message_count": 7,
|
||||
"created_at": 100.0,
|
||||
"updated_at": 101.0,
|
||||
"last_message_at": 101.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
"parent_session_id": "parent_sid",
|
||||
"_lineage_root_id": "root_sid",
|
||||
"_compression_segment_count": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
(models.SESSION_DIR / "lineage_sid.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": "lineage_sid",
|
||||
"title": "Lineage Row",
|
||||
"messages": [{"role": "user", "content": "sidecar"}],
|
||||
"message_count": 99,
|
||||
"updated_at": 200.0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
with patch.object(Session, "load_metadata_only", side_effect=AssertionError("lineage rows must not refresh sidecars")):
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert rows[0]["session_id"] == "lineage_sid"
|
||||
assert rows[0]["message_count"] == 7
|
||||
|
||||
|
||||
def test_load_metadata_only_skips_index_read_when_sidecar_has_message_count(monkeypatch):
|
||||
"""Modern sidecars already carry message_count; avoid an _index.json read per row."""
|
||||
session = Session(
|
||||
session_id="metadata_count_sid",
|
||||
title="Metadata Count",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
session.save(touch_updated_at=False)
|
||||
|
||||
def _fail_index_lookup(_sid):
|
||||
raise AssertionError("message_count sidecar should not need index lookup")
|
||||
|
||||
monkeypatch.setattr(models, "_lookup_index_message_count", _fail_index_lookup)
|
||||
|
||||
meta = Session.load_metadata_only("metadata_count_sid")
|
||||
|
||||
assert meta is not None
|
||||
assert meta.compact()["message_count"] == 1
|
||||
|
||||
|
||||
def test_session_save_does_not_persist_metadata_message_count_hint():
|
||||
s = Session(
|
||||
session_id="sess_private_hint",
|
||||
|
|
|
|||
Loading…
Reference in a new issue