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:
nesquena-hermes 2026-05-29 16:55:24 -07:00 committed by GitHub
commit c8afb37ae7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 512 additions and 28 deletions

View file

@ -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

View file

@ -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).

View file

@ -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

View file

@ -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:

View 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()

View file

@ -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 — "

View file

@ -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",