fix(tui): narrow /resume sources to human adapters

Follow-up on #13724: showing literally every source was too noisy.\n\n now fetches a wider window (, larger limit) and then filters to a curated allowlist of human-facing sources (tui/cli plus chat adapters like telegram/discord/slack/whatsapp/etc). This keeps row #7 fixed (telegram sessions visible in /resume) without surfacing internal source kinds such as tool/acp.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 18:52:26 -05:00
parent 0dfb7b8a0d
commit bd046220b3
3 changed files with 104 additions and 90 deletions

View file

@ -1,84 +0,0 @@
"""Regression test for the TUI gateway's ``session.list`` handler.
Reported during the TUI v2 blitz retest: the ``/resume`` modal inside a
TUI session only surfaced ``tui``/``cli`` rows telegram/discord/whatsapp
sessions stayed hidden even though the user could still paste the id
directly into ``hermes --tui --resume <id>`` and get a working session.
The fix removes the adapter-kind filter so every session the DB surfaces
appears in the picker, sorted by ``started_at`` like before.
"""
from __future__ import annotations
import types
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_does_not_filter_by_source(monkeypatch):
rows = [
{"id": "tui-1", "source": "tui", "title": "a", "preview": "", "started_at": 3, "message_count": 1},
{"id": "tg-1", "source": "telegram", "title": "b", "preview": "", "started_at": 2, "message_count": 1},
{"id": "cli-1", "source": "cli", "title": "c", "preview": "", "started_at": 1, "message_count": 1},
]
db = _StubDB(rows)
monkeypatch.setattr(server, "_get_db", lambda: db)
resp = _call(limit=10)
assert "result" in resp, resp
assert len(db.calls) == 1
assert db.calls[0].get("source") is None, db.calls[0]
assert db.calls[0].get("limit") == 10
kinds = [s["source"] for s in resp["result"]["sessions"]]
assert "telegram" in kinds and "tui" in kinds and "cli" in kinds, kinds
def test_session_list_preserves_ordering(monkeypatch):
rows = [
{"id": "newest", "source": "telegram", "title": "", "preview": "", "started_at": 5, "message_count": 1},
{"id": "middle", "source": "tui", "title": "", "preview": "", "started_at": 3, "message_count": 1},
{"id": "oldest", "source": "discord", "title": "", "preview": "", "started_at": 1, "message_count": 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"]
def test_session_list_surfaces_missing_fields_as_empty(monkeypatch):
rows = [{"id": "bare", "source": "whatsapp"}]
monkeypatch.setattr(server, "_get_db", lambda: _StubDB(rows))
sess = _call()["result"]["sessions"][0]
assert sess == {
"id": "bare",
"title": "",
"preview": "",
"started_at": 0,
"message_count": 0,
"source": "whatsapp",
}

View file

@ -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 <id>``.
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"]

View file

@ -1231,12 +1231,34 @@ def _(rid, params: dict) -> dict:
@method("session.list")
def _(rid, params: dict) -> dict:
try:
# Show sessions from every adapter — users resume telegram/discord/etc
# sessions by pasting the id directly, so the picker should surface them
# too. Children (subagents/compression runs) stay filtered out via the
# hermes_state default.
limit = params.get("limit", 20)
rows = _get_db().list_sessions_rich(source=None, limit=limit)
# 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,