diff --git a/tests/gateway/test_session_list_allowed_sources.py b/tests/gateway/test_session_list_allowed_sources.py new file mode 100644 index 0000000000..bd6791ff40 --- /dev/null +++ b/tests/gateway/test_session_list_allowed_sources.py @@ -0,0 +1,76 @@ +"""Regression tests for the TUI gateway's ``session.list`` handler. + +Reported during TUI v2 blitz retest: the ``/resume`` modal inside a TUI +session only surfaced ``tui``/``cli`` rows, hiding telegram sessions users +could still resume directly via ``hermes --tui --resume ``. + +The fix widens the picker to a curated allowlist of user-facing sources +(tui/cli + chat adapters) while still filtering internal/system sources. +""" + +from __future__ import annotations + +from tui_gateway import server + + +class _StubDB: + def __init__(self, rows): + self.rows = rows + self.calls: list[dict] = [] + + def list_sessions_rich(self, **kwargs): + self.calls.append(kwargs) + return list(self.rows) + + +def _call(limit: int = 20): + return server.handle_request({ + "id": "1", + "method": "session.list", + "params": {"limit": limit}, + }) + + +def test_session_list_includes_telegram_but_filters_internal_sources(monkeypatch): + rows = [ + {"id": "tui-1", "source": "tui", "started_at": 9}, + {"id": "tool-1", "source": "tool", "started_at": 8}, + {"id": "tg-1", "source": "telegram", "started_at": 7}, + {"id": "acp-1", "source": "acp", "started_at": 6}, + {"id": "cli-1", "source": "cli", "started_at": 5}, + ] + db = _StubDB(rows) + monkeypatch.setattr(server, "_get_db", lambda: db) + + resp = _call(limit=10) + sessions = resp["result"]["sessions"] + ids = [s["id"] for s in sessions] + + assert "tg-1" in ids and "tui-1" in ids and "cli-1" in ids, ids + assert "tool-1" not in ids and "acp-1" not in ids, ids + + +def test_session_list_fetches_wider_window_before_filtering(monkeypatch): + db = _StubDB([{"id": "x", "source": "cli", "started_at": 1}]) + monkeypatch.setattr(server, "_get_db", lambda: db) + + _call(limit=10) + + assert len(db.calls) == 1 + assert db.calls[0].get("source") is None, db.calls[0] + assert db.calls[0].get("limit") == 100, db.calls[0] + + +def test_session_list_preserves_ordering_after_filter(monkeypatch): + rows = [ + {"id": "newest", "source": "telegram", "started_at": 5}, + {"id": "internal", "source": "tool", "started_at": 4}, + {"id": "middle", "source": "tui", "started_at": 3}, + {"id": "oldest", "source": "discord", "started_at": 1}, + ] + monkeypatch.setattr(server, "_get_db", lambda: _StubDB(rows)) + + resp = _call() + ids = [s["id"] for s in resp["result"]["sessions"]] + + assert ids == ["newest", "middle", "oldest"] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 73bc39ffb2..1397e9b04d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1231,12 +1231,34 @@ def _(rid, params: dict) -> dict: @method("session.list") def _(rid, params: dict) -> dict: try: - db = _get_db() - # Show both TUI and CLI sessions — TUI is the successor to the CLI, - # so users expect to resume their old CLI sessions here too. - tui = db.list_sessions_rich(source="tui", limit=params.get("limit", 20)) - cli = db.list_sessions_rich(source="cli", limit=params.get("limit", 20)) - rows = sorted(tui + cli, key=lambda s: s.get("started_at") or 0, reverse=True)[:params.get("limit", 20)] + # Resume picker should include human conversation surfaces beyond + # tui/cli (notably telegram from blitz row #7), but avoid internal + # sources that clutter the modal (tool/acp/etc). + allow = frozenset( + { + "cli", + "tui", + "telegram", + "discord", + "slack", + "whatsapp", + "wecom", + "weixin", + "feishu", + "signal", + "mattermost", + "matrix", + "qq", + } + ) + + limit = int(params.get("limit", 20) or 20) + fetch_limit = max(limit * 5, 100) + rows = [ + s + for s in _get_db().list_sessions_rich(source=None, limit=fetch_limit) + if (s.get("source") or "").strip().lower() in allow + ][:limit] return _ok(rid, {"sessions": [ {"id": s["id"], "title": s.get("title") or "", "preview": s.get("preview") or "", "started_at": s.get("started_at") or 0, "message_count": s.get("message_count") or 0,