diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f81547f..22fdcfdc 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/api/config.py b/api/config.py index e8b37b81..85c47dde 100644 --- a/api/config.py +++ b/api/config.py @@ -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). diff --git a/api/models.py b/api/models.py index 0be70f1f..aa80bfcf 100644 --- a/api/models.py +++ b/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 diff --git a/api/routes.py b/api/routes.py index 3c025f40..e03ec1b9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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: diff --git a/tests/test_issue3066_disabled_read_profile.py b/tests/test_issue3066_disabled_read_profile.py new file mode 100644 index 00000000..c32502a8 --- /dev/null +++ b/tests/test_issue3066_disabled_read_profile.py @@ -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() diff --git a/tests/test_opencode_providers.py b/tests/test_opencode_providers.py index f7f6e720..7f111016 100644 --- a/tests/test_opencode_providers.py +++ b/tests/test_opencode_providers.py @@ -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 — " diff --git a/tests/test_session_index.py b/tests/test_session_index.py index bb71ed3f..f4889e75 100644 --- a/tests/test_session_index.py +++ b/tests/test_session_index.py @@ -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",