Merge pull request #4140 from nesquena/stage-4011
Some checks failed
Release & Docker / release (push) Has been cancelled

Release NH (v0.51.395): push source filters into agent session scans (#3930)
This commit is contained in:
nesquena-hermes
2026-06-13 13:33:24 -07:00
committed by GitHub
10 changed files with 397 additions and 35 deletions

View File

@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.395] — 2026-06-13 — Release NH (push source filters into agent session scans, #3930)
### Fixed
- **The sidebar source filter (WebUI / CLI / cron) is now applied inside the session scan instead of projecting every row and filtering after (#3930).** A `claude_code`-only or `cron`-only filter now early-returns out of the unrelated side scans (Claude-Code import scan / cron-session scan) rather than building the full cross-source list and discarding most of it, cutting work on installs with large CLI or cron histories. The filter pushdown is correctness-preserving — no source's sessions are wrongly dropped from the list. (#3930)
## [v0.51.394] — 2026-06-13 — Release NG (document-title attention badge for pending prompts, #4121)
### Added

View File

@@ -396,6 +396,7 @@ def read_importable_agent_session_rows(
limit: int | None = 200,
log=None,
exclude_sources: tuple[str, ...] | None = ("cron", "webui"),
include_sources: tuple[str, ...] | None = None,
) -> list[dict]:
"""Return agent sessions projected as importable conversations.
@@ -409,7 +410,9 @@ def read_importable_agent_session_rows(
sidebar. This mirrors Hermes Agent CLI's session-list behaviour: interactive
views should stay focused on user-facing conversations, while callers that
need a source-specific diagnostic view can opt out by passing
``exclude_sources=None``.
``exclude_sources=None``. ``include_sources`` is an additional narrowing
filter; callers that want an include-only query should explicitly pass
``exclude_sources=None`` so the default exclusions do not also apply.
"""
db_path = Path(db_path)
if not db_path.exists():
@@ -517,6 +520,12 @@ def read_importable_agent_session_rows(
where_clauses = ["s.source IS NOT NULL"]
params: list[object] = []
if include_sources:
included = tuple(str(source) for source in include_sources if source)
if included:
placeholders = ", ".join("?" for _ in included)
where_clauses.append(f"s.source IN ({placeholders})")
params.extend(included)
if exclude_sources:
excluded = tuple(str(source) for source in exclude_sources if source)
if excluded:

View File

@@ -3473,6 +3473,15 @@ CLAUDE_CODE_MAX_MESSAGES_PER_FILE = 1000
CLAUDE_CODE_MAX_CONTENT_CHARS = 200_000
def _normalize_cli_session_source_filter(source_filter) -> str | None:
normalized = str(source_filter or '').strip().lower()
if not normalized or normalized in {'all', 'any', '*'}:
return None
if normalized == 'claude-code':
return CLAUDE_CODE_SOURCE
return normalized
def _default_claude_code_projects_dir() -> Path | None:
"""Resolve the Claude Code projects directory without touching real home in tests."""
override = os.getenv('HERMES_WEBUI_CLAUDE_PROJECTS_DIR')
@@ -3733,7 +3742,7 @@ def _sqlite_file_stat_cache_key(db_path: Path):
)
def _resolve_cli_sessions_context():
def _resolve_cli_sessions_context(source_filter=None):
# Use the active WebUI profile's HERMES_HOME to find state.db.
# The active profile is determined by what the user has selected in the UI
# (stored in the server's runtime config). This means:
@@ -3760,6 +3769,7 @@ def _resolve_cli_sessions_context():
str(hermes_home),
str(cli_profile or ''),
str(db_path),
str(source_filter or ''),
_sqlite_file_stat_cache_key(db_path),
_path_cache_key(projects_dir),
_path_stat_cache_key(projects_dir),
@@ -3768,12 +3778,21 @@ def _resolve_cli_sessions_context():
return hermes_home, db_path, cli_profile, cache_key
def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile) -> list:
def _load_cli_sessions_uncached(
hermes_home: Path,
db_path: Path,
_cli_profile,
source_filter=None,
) -> list:
cli_sessions = []
try:
cli_sessions.extend(get_claude_code_sessions())
except Exception:
logger.debug("Claude Code session scan failed", exc_info=True)
if source_filter in (None, CLAUDE_CODE_SOURCE):
try:
cli_sessions.extend(get_claude_code_sessions())
except Exception:
logger.debug("Claude Code session scan failed", exc_info=True)
if source_filter == CLAUDE_CODE_SOURCE:
return cli_sessions
if not db_path.exists():
return cli_sessions
@@ -3789,9 +3808,10 @@ def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile)
for row in read_importable_agent_session_rows(
db_path,
limit=CLI_VISIBLE_SESSION_LIMIT,
limit=CRON_PROJECT_CHIP_LIMIT if source_filter == 'cron' else CLI_VISIBLE_SESSION_LIMIT,
log=logger,
exclude_sources=("cron",),
exclude_sources=("cron",) if source_filter is None else None,
include_sources=None if source_filter is None else (source_filter,),
):
sid = row['id']
raw_ts = row['last_activity'] or row['started_at']
@@ -3865,6 +3885,9 @@ def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile)
'is_cli_session': is_cli_session_row(row),
})
if source_filter is not None:
return cli_sessions
# --- Second pass: fetch cron sessions that may have been squeezed out
# of the default window by more-recent non-cron sessions.
# The normal sidebar query caps at CLI_VISIBLE_SESSION_LIMIT (20) rows;
@@ -3874,14 +3897,12 @@ def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile)
# stay addressable under their project chip.
existing_sids = {s['session_id'] for s in cli_sessions}
try:
cron_excluded = tuple(
s for s in ('webui', 'claude-code') # keep only 'cron'
)
for row in read_importable_agent_session_rows(
db_path,
limit=CRON_PROJECT_CHIP_LIMIT,
log=logger,
exclude_sources=cron_excluded,
exclude_sources=None,
include_sources=("cron",),
):
sid = row['id']
if sid in existing_sids:
@@ -3955,14 +3976,15 @@ def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile)
return cli_sessions
def get_cli_sessions() -> list:
def get_cli_sessions(source_filter=None) -> list:
"""Read CLI sessions from the agent's SQLite store and return them as
dicts in a format the WebUI sidebar can render alongside local sessions.
Returns empty list if the SQLite DB is missing or any error occurs -- the
bridge is purely additive and never crashes the WebUI.
"""
hermes_home, db_path, cli_profile, cache_key = _resolve_cli_sessions_context()
source_filter = _normalize_cli_session_source_filter(source_filter)
hermes_home, db_path, cli_profile, cache_key = _resolve_cli_sessions_context(source_filter)
ttl = _cli_sessions_cache_ttl_seconds()
now = time.monotonic()
@@ -3975,7 +3997,12 @@ def get_cli_sessions() -> list:
return _copy_cli_sessions(cached_sessions)
_CLI_SESSIONS_CACHE.pop(cache_key, None)
try:
sessions = _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
sessions = _load_cli_sessions_uncached(
hermes_home,
db_path,
cli_profile,
source_filter=source_filter,
)
except Exception as _cli_err:
logger.warning(
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
@@ -3989,7 +4016,12 @@ def get_cli_sessions() -> list:
return _copy_cli_sessions(sessions)
try:
return _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
return _load_cli_sessions_uncached(
hermes_home,
db_path,
cli_profile,
source_filter=source_filter,
)
except Exception as _cli_err:
logger.warning(
"get_cli_sessions() failed — check state.db schema or path (%s): %s",

View File

@@ -6376,10 +6376,11 @@ def handle_get(handler, parsed) -> bool:
diag.stage("load_settings")
settings = load_settings()
show_cli_sessions = bool(settings.get("show_cli_sessions"))
agent_session_source_filter = settings.get("agent_session_source_filter")
webui_sessions = [_normalize_sidebar_source_flags(s) for s in webui_sessions]
if show_cli_sessions:
diag.stage("get_cli_sessions")
cli = get_cli_sessions()
cli = get_cli_sessions(source_filter=agent_session_source_filter)
diag.stage("merge_cli_sessions")
cli_by_id = {s["session_id"]: s for s in cli}
# #3238: reconcile orphaned imported-CLI sidecars. When a CLI

View File

@@ -199,7 +199,7 @@ def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, t
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: messages if _sid == sid else [])
monkeypatch.setattr(routes, "get_cli_sessions", lambda: [meta])
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None: [meta])
monkeypatch.setattr(routes, "get_last_workspace", lambda: tmp_path / "workspace")
monkeypatch.setattr(routes, "import_cli_session", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("read-only import must not persist")))

View File

@@ -142,7 +142,7 @@ def test_existing_cli_import_refreshes_same_length_tool_metadata(monkeypatch):
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda sid: enriched if sid == session_id else [])
monkeypatch.setattr(routes, "get_cli_sessions", lambda: [{"session_id": session_id, "source_tag": "cli", "raw_source": "cli", "session_source": "cli", "source_label": "CLI"}])
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None: [{"session_id": session_id, "source_tag": "cli", "raw_source": "cli", "session_source": "cli", "source_label": "CLI"}])
response = routes._handle_session_import_cli(object(), {"session_id": session_id})

View File

@@ -123,6 +123,10 @@ def test_cron_sessions_recovered_by_second_pass(tmp_path):
_make_state_db(db, cron_count=15, discord_count=5)
with (
mock.patch(
"api.models.read_importable_agent_session_rows",
wraps=models.read_importable_agent_session_rows,
) as read_rows,
mock.patch("api.models.get_claude_code_sessions", return_value=[]),
mock.patch("api.models.get_last_workspace", return_value=tmp_path),
mock.patch("api.models.ensure_cron_project", return_value="cron-project-id"),
@@ -132,6 +136,9 @@ def test_cron_sessions_recovered_by_second_pass(tmp_path):
cron_sessions = [s for s in result if s["source_tag"] == "cron"]
assert len(cron_sessions) > 0, "Cron sessions should be recovered by the second pass"
assert len(read_rows.call_args_list) == 2
assert read_rows.call_args_list[1].kwargs["exclude_sources"] is None
assert read_rows.call_args_list[1].kwargs["include_sources"] == ("cron",)
def test_webui_sidecarless_sessions_not_excluded(tmp_path):

View File

@@ -0,0 +1,306 @@
import io
import json
import sqlite3
from pathlib import Path
from urllib.parse import urlparse
import api.agent_sessions as agent_sessions
import api.models as models
import api.profiles as profiles
import api.routes as routes
class _FakeHandler:
def __init__(self):
self.status = None
self.headers = {}
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.headers[key] = value
def end_headers(self):
pass
def json_body(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
class _RecordingCursor:
def __init__(self, cursor, executed):
self._cursor = cursor
self._executed = executed
def execute(self, sql, params=()):
self._executed.append((sql, tuple(params)))
return self._cursor.execute(sql, params)
def fetchall(self):
return self._cursor.fetchall()
def fetchone(self):
return self._cursor.fetchone()
def __iter__(self):
return iter(self._cursor)
def __getattr__(self, name):
return getattr(self._cursor, name)
class _RecordingConnection:
def __init__(self, connection, executed):
self._connection = connection
self._executed = executed
def cursor(self):
return _RecordingCursor(self._connection.cursor(), self._executed)
def close(self):
return self._connection.close()
def commit(self):
return self._connection.commit()
@property
def row_factory(self):
return self._connection.row_factory
@row_factory.setter
def row_factory(self, value):
self._connection.row_factory = value
def __getattr__(self, name):
return getattr(self._connection, name)
def _make_state_db(path: Path) -> None:
conn = sqlite3.connect(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
);
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
"""
)
rows = [
("tui_session", "tui", "tui", "TUI Session", 10.0),
("cron_session", "cron", "cron", "Cron Session", 20.0),
("webui_session", "webui", "webui", "WebUI Session", 30.0),
]
for sid, source, session_source, title, started_at in rows:
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count,
parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, ?, 'test-model', ?, 1, NULL, NULL, NULL)
""",
(sid, source, session_source, title, started_at),
)
conn.execute(
"""
INSERT INTO messages (id, session_id, role, content, timestamp)
VALUES (?, ?, 'user', 'message', ?)
""",
(f"{sid}_msg", sid, started_at),
)
conn.commit()
conn.close()
def test_read_importable_agent_session_rows_uses_parameterized_include_filter(monkeypatch, tmp_path):
db = tmp_path / "state.db"
_make_state_db(db)
executed = []
real_connect = agent_sessions.sqlite3.connect
def recording_connect(*args, **kwargs):
return _RecordingConnection(real_connect(*args, **kwargs), executed)
monkeypatch.setattr(agent_sessions.sqlite3, "connect", recording_connect)
rows = agent_sessions.read_importable_agent_session_rows(
db,
limit=None,
exclude_sources=None,
include_sources=("tui", "cron"),
)
assert {row["id"] for row in rows} == {"tui_session", "cron_session"}
select_calls = [
(sql, params)
for sql, params in executed
if "FROM sessions s" in sql and "s.source IN (?, ?)" in sql
]
assert select_calls, "Expected the projection SQL to use a parameterized IN clause"
assert select_calls[-1][1][:2] == ("tui", "cron")
def test_get_cli_sessions_source_filter_uses_distinct_cache_key(monkeypatch, tmp_path):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: str(hermes_home))
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
monkeypatch.setattr(models, "_CLI_SESSIONS_CACHE_TTL_SECONDS", 60.0, raising=False)
models.clear_cli_sessions_cache()
seen = []
def fake_loader(_hermes_home, _db_path, _cli_profile, source_filter=None):
seen.append(source_filter)
return [{"session_id": f"session-{source_filter or 'all'}", "title": "cached"}]
monkeypatch.setattr(models, "_load_cli_sessions_uncached", fake_loader)
first = models.get_cli_sessions()
filtered = models.get_cli_sessions(source_filter="tui")
filtered_again = models.get_cli_sessions(source_filter="tui")
assert seen == [None, "tui"]
assert first[0]["session_id"] == "session-all"
assert filtered[0]["session_id"] == "session-tui"
assert filtered_again[0]["session_id"] == "session-tui"
def test_load_cli_sessions_uncached_pushes_specific_source_into_state_db_scan(monkeypatch, tmp_path):
db = tmp_path / "state.db"
db.write_text("", encoding="utf-8")
calls = []
claude_calls = []
def fake_read_rows(_db_path, **kwargs):
calls.append(kwargs)
return [
{
"id": "tui_session",
"title": "TUI Session",
"model": "test-model",
"source": "tui",
"raw_source": "tui",
"message_count": 2,
"actual_message_count": 2,
"actual_user_message_count": 1,
"last_activity": 10.0,
"started_at": 9.0,
}
]
monkeypatch.setattr(models, "get_claude_code_sessions", lambda: claude_calls.append(True) or [])
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path)
monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id")
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
result = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, source_filter="tui")
assert claude_calls == []
assert calls == [
{
"limit": models.CLI_VISIBLE_SESSION_LIMIT,
"log": models.logger,
"exclude_sources": None,
"include_sources": ("tui",),
}
]
assert [row["source_tag"] for row in result] == ["tui"]
def test_cron_source_filter_uses_cron_rescue_limit(monkeypatch, tmp_path):
db = tmp_path / "state.db"
db.write_text("", encoding="utf-8")
calls = []
def fake_read_rows(_db_path, **kwargs):
calls.append(kwargs)
return [
{
"id": "cron_session",
"title": "Cron Session",
"model": "test-model",
"source": "cron",
"raw_source": "cron",
"message_count": 1,
"actual_message_count": 1,
"actual_user_message_count": 1,
"last_activity": 10.0,
"started_at": 9.0,
}
]
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path)
monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id")
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
result = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, source_filter="cron")
assert calls == [
{
"limit": models.CRON_PROJECT_CHIP_LIMIT,
"log": models.logger,
"exclude_sources": None,
"include_sources": ("cron",),
}
]
assert [row["source_tag"] for row in result] == ["cron"]
def test_api_sessions_passes_source_filter_only_on_sidebar_path(monkeypatch):
captured = []
def fake_get_cli_sessions(source_filter=None):
captured.append(source_filter)
return []
monkeypatch.setattr(routes, "all_sessions", lambda diag=None: [])
monkeypatch.setattr(
routes,
"load_settings",
lambda: {
"show_cli_sessions": True,
"agent_session_source_filter": " TUI ",
},
)
monkeypatch.setattr(routes, "get_cli_sessions", fake_get_cli_sessions)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
handler = _FakeHandler()
routes.handle_get(handler, urlparse("http://example.com/api/sessions"))
assert handler.status == 200
assert handler.json_body()["sessions"] == []
assert captured == [" TUI "]
def test_non_sidebar_cli_session_callers_keep_default_get_cli_sessions_signature(monkeypatch):
monkeypatch.setattr(
routes,
"get_cli_sessions",
lambda: [{"session_id": "cli-session", "title": "CLI Session"}],
)
assert routes._lookup_cli_session_metadata("cli-session") == {
"session_id": "cli-session",
"title": "CLI Session",
}

View File

@@ -20,6 +20,14 @@ agent_src = AGENT_SESSIONS_PY.read_text(encoding='utf-8')
combined_src = src + "\n" + agent_src
def _get_cli_sessions_source() -> str:
match = re.search(r"^def get_cli_sessions\(", src, re.M)
assert match is not None, "get_cli_sessions() definition not found"
func_start = match.start()
func_end = src.find("\ndef ", func_start + 1)
return src[func_start:func_end] if func_end != -1 else src[func_start:]
class TestCliSessionsErrorSurface:
"""get_cli_sessions() must log warnings instead of silently returning []."""
@@ -38,18 +46,13 @@ class TestCliSessionsErrorSurface:
def test_exception_path_logs_warning(self):
"""The except clause must call logger.warning, not silently pass."""
# Find the exception handler in get_cli_sessions
func_start = src.find("def get_cli_sessions()")
func_end = src.find("\ndef ", func_start + 1)
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
func_body = _get_cli_sessions_source()
assert "warning(" in func_body, \
"get_cli_sessions() exception handler must call logging.warning()"
def test_exception_path_includes_db_path(self):
"""The warning must include the db_path for diagnosability."""
func_start = src.find("def get_cli_sessions()")
func_end = src.find("\ndef ", func_start + 1)
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
func_body = _get_cli_sessions_source()
# db_path should appear in the warning call
warning_pos = func_body.find("warning(")
warning_block = func_body[warning_pos:warning_pos + 300]
@@ -59,9 +62,7 @@ class TestCliSessionsErrorSurface:
def test_still_returns_empty_on_error(self):
"""Function must still return [] after logging (graceful degradation)."""
# After the warning, it should return cli_sessions (the empty list) not raise
func_start = src.find("def get_cli_sessions()")
func_end = src.find("\ndef ", func_start + 1)
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
func_body = _get_cli_sessions_source()
# Must have a 'return' after the warning call
warning_pos = func_body.find("_cli_err:")
after_warning = func_body[warning_pos:warning_pos + 400]

View File

@@ -133,7 +133,7 @@ def test_session_import_cli_refresh_matches_messages_despite_timestamp_type_diff
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda sid: fresh if sid == session_id else [])
monkeypatch.setattr(routes, "get_cli_sessions", lambda: [{"session_id": session_id, "source_tag": "weixin", "raw_source": "weixin", "session_source": "messaging", "source_label": "WeChat"}])
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None: [{"session_id": session_id, "source_tag": "weixin", "raw_source": "weixin", "session_source": "messaging", "source_label": "WeChat"}])
response = routes._handle_session_import_cli(object(), {"session_id": session_id})
@@ -185,7 +185,7 @@ def test_session_import_cli_refresh_rejects_prefix_if_non_timing_content_diverge
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda sid: fresh if sid == session_id else [])
monkeypatch.setattr(routes, "get_cli_sessions", lambda: [{"session_id": session_id, "source_tag": "telegram", "raw_source": "telegram", "session_source": "messaging", "source_label": "Telegram"}])
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None: [{"session_id": session_id, "source_tag": "telegram", "raw_source": "telegram", "session_source": "messaging", "source_label": "Telegram"}])
response = routes._handle_session_import_cli(object(), {"session_id": session_id})
@@ -228,7 +228,7 @@ def test_session_import_cli_preserves_parent_metadata_on_existing_import(monkeyp
monkeypatch.setattr(
routes,
"get_cli_sessions",
lambda: [{
lambda source_filter=None: [{
"session_id": session_id,
"source_tag": "telegram",
"raw_source": "telegram",
@@ -262,7 +262,7 @@ def test_read_only_import_payload_includes_parent_session_id(monkeypatch):
monkeypatch.setattr(
routes,
"get_cli_sessions",
lambda: [{
lambda source_filter=None: [{
"session_id": session_id,
"title": "Read-only child",
"model": "test-model",
@@ -377,7 +377,7 @@ def test_sessions_endpoint_suppresses_duplicate_webui_state_projection(monkeypat
}
monkeypatch.setattr(routes, "all_sessions", lambda diag=None: [webui_row])
monkeypatch.setattr(routes, "get_cli_sessions", lambda: [duplicate_webui_projection, external_projection])
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None: [duplicate_webui_projection, external_projection])
handler = _FakeHandler()
routes.handle_get(handler, urlparse("http://example.com/api/sessions"))