diff --git a/api/agent_sessions.py b/api/agent_sessions.py index c4d4dca1..955b4418 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -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: diff --git a/api/models.py b/api/models.py index a358e4c4..077dee2a 100644 --- a/api/models.py +++ b/api/models.py @@ -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", diff --git a/api/routes.py b/api/routes.py index 6b6ddcbb..c87d9403 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6188,10 +6188,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 diff --git a/tests/test_claude_code_session_import.py b/tests/test_claude_code_session_import.py index 4e1d294f..bbf9b080 100644 --- a/tests/test_claude_code_session_import.py +++ b/tests/test_claude_code_session_import.py @@ -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"))) diff --git a/tests/test_cli_session_tool_metadata.py b/tests/test_cli_session_tool_metadata.py index ceb8a367..93c2afb9 100644 --- a/tests/test_cli_session_tool_metadata.py +++ b/tests/test_cli_session_tool_metadata.py @@ -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}) diff --git a/tests/test_issue3585_cron_session_overflow.py b/tests/test_issue3585_cron_session_overflow.py index 9881e3fe..48716af5 100644 --- a/tests/test_issue3585_cron_session_overflow.py +++ b/tests/test_issue3585_cron_session_overflow.py @@ -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): diff --git a/tests/test_issue3930_source_filter_pushdown.py b/tests/test_issue3930_source_filter_pushdown.py new file mode 100644 index 00000000..bdf85728 --- /dev/null +++ b/tests/test_issue3930_source_filter_pushdown.py @@ -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", + } diff --git a/tests/test_issue634.py b/tests/test_issue634.py index d3d5907f..daa8c477 100644 --- a/tests/test_issue634.py +++ b/tests/test_issue634.py @@ -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] diff --git a/tests/test_session_import_cli_fallback_model.py b/tests/test_session_import_cli_fallback_model.py index 0da61158..73c276ad 100644 --- a/tests/test_session_import_cli_fallback_model.py +++ b/tests/test_session_import_cli_fallback_model.py @@ -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"))