Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions nerve/agent/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,21 @@ async def list_sessions(
limit=limit, include_archived=include_archived,
)

async def list_interactive_sessions(
self,
limit: int,
offset: int = 0,
sources: tuple[str, ...] = ("telegram", "web"),
current_id: str | None = None,
) -> list[dict]:
"""Paginated switchable sessions (current → starred → recent) for the
channel /sessions keyboards. See
:meth:`nerve.db.sessions.SessionStore.list_interactive_sessions`.
"""
return await self.db.list_interactive_sessions(
limit=limit, offset=offset, sources=sources, current_id=current_id,
)

async def set_starred(self, session_id: str, starred: bool) -> bool:
"""Star or unstar a session.

Expand Down
15 changes: 11 additions & 4 deletions nerve/channels/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,14 +430,21 @@ async def list_sessions(self, limit: int = 20) -> list[dict[str, Any]]:
"""List sessions, most recently updated first."""
return await self.engine.sessions.list_sessions(limit=limit)

async def list_interactive_sessions(
self, limit: int, offset: int = 0, current_id: str | None = None,
) -> list[dict[str, Any]]:
"""Switchable sessions for the /sessions keyboard: interactive sources
only, current → starred → recent, paginated. See
:meth:`nerve.agent.sessions.SessionManager.list_interactive_sessions`.
"""
return await self.engine.sessions.list_interactive_sessions(
limit=limit, offset=offset, current_id=current_id,
)

async def set_session_starred(self, session_id: str, starred: bool) -> bool:
"""Star/unstar a session. Starred sessions are never auto-archived."""
return await self.engine.sessions.set_starred(session_id, starred)

async def toggle_session_starred(self, session_id: str) -> bool:
"""Toggle a session's starred flag. Returns the new state."""
return await self.engine.sessions.toggle_starred(session_id)

async def get_session(self, session_id: str) -> dict[str, Any] | None:
"""Fetch a session row (title/status/…), or None if it is gone."""
return await self.engine.db.get_session(session_id)
Expand Down
125 changes: 70 additions & 55 deletions nerve/channels/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def _format_reply_context(message: Any) -> str:


# Inline-keyboard /sessions rendering ------------------------------------- #
_SESSIONS_BUTTON_LIMIT = 8 # keep the keyboard thumb-friendly on mobile
_SESSIONS_PAGE_SIZE = 10 # sessions shown per /sessions page; ⬅️/➡️ page the rest
_SESSION_LABEL_MAX = 40 # Telegram wraps long button labels poorly


Expand All @@ -240,42 +240,63 @@ def _session_label(session: dict, current_id: str | None) -> str:


def build_sessions_view(
sessions: list[dict], current_id: str | None,
sessions: list[dict],
current_id: str | None,
*,
offset: int = 0,
has_prev: bool = False,
has_next: bool = False,
) -> "tuple[str, InlineKeyboardMarkup]":
"""Render the /sessions inline keyboard (pure, sync — unit-testable).

One tap-to-switch button per session (the current one marked ✓), with the
session id carried in ``callback_data`` as ``sess:<id>`` — so switching
routing takes a single tap and never requires copy-pasting an id on mobile.
A trailing ``➕ New session`` button (``sess:new``) starts a fresh one.
Returns ``(message_text, keyboard)``.
``sessions`` is one already-ordered page (the caller pins the current
session first and starred ones ahead of merely-recent ones); ``⬅️``/``➡️``
page through the rest via ``sess:page:<offset>`` when ``has_prev`` /
``has_next`` report more exist. A trailing ``➕ New session`` button
(``sess:new``) starts a fresh one. Returns ``(message_text, keyboard)``.
"""
rows: list[list[InlineKeyboardButton]] = []
for s in sessions[:_SESSIONS_BUTTON_LIMIT]:
for s in sessions[:_SESSIONS_PAGE_SIZE]:
sid = s.get("id")
cb = f"sess:{sid}"
# callback_data is capped at 64 bytes by Telegram; interactive session
# ids are short, but guard defensively so a button always round-trips.
if sid is None or len(cb.encode("utf-8")) > 64:
continue
row = [InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)]
# Per-session star toggle: ⭐ = kept alive (never auto-closed),
# ☆ = normal. Add it only when its callback also round-trips.
star_cb = f"sessstar:{sid}"
if len(star_cb.encode("utf-8")) <= 64:
row.append(
InlineKeyboardButton(
"⭐" if s.get("starred") else "☆", callback_data=star_cb,
)
rows.append(
[InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)]
)
shown = len(rows)

# Pager row — only the arrows that lead somewhere, so no tap is a dead end.
nav: list[InlineKeyboardButton] = []
if has_prev:
nav.append(
InlineKeyboardButton(
"⬅️ Prev",
callback_data=f"sess:page:{max(0, offset - _SESSIONS_PAGE_SIZE)}",
)
rows.append(row)
)
if has_next:
nav.append(
InlineKeyboardButton(
"➡️ More",
callback_data=f"sess:page:{offset + _SESSIONS_PAGE_SIZE}",
)
)
if nav:
rows.append(nav)

rows.append([InlineKeyboardButton("➕ New session", callback_data="sess:new")])

# The New-session button switches routing to a fresh session WITHOUT
# stopping the current one (unlike /new). A session switched away from
# keeps running any in-flight turn and still delivers its result to the
# chat (output is bound per-session, not to the channel's current map).
if rows[:-1]:
if shown:
current_title = next(
(
(s.get("title") or s.get("id"))
Expand All @@ -287,8 +308,12 @@ def build_sessions_view(
text = "🗂 Sessions — tap to switch."
if current_title:
text += f"\nCurrent: {current_title}"
if offset or has_next:
text += f"\nPage {offset // _SESSIONS_PAGE_SIZE + 1}"
text += "\n➕ New session keeps the current one running."
text += "\n⭐ keeps a session alive (never auto-closed); tap ☆/⭐ to toggle."
text += "\n⭐ = kept alive (never auto-closed); /star or /unstar the current session."
elif has_prev:
text = "No more sessions — tap ⬅️ Prev to go back."
else:
text = "No sessions yet — tap ➕ to start one."
return text, InlineKeyboardMarkup(rows)
Expand Down Expand Up @@ -1104,29 +1129,28 @@ async def _handle_session(self, update: Update, context: Any) -> None:
await update.message.reply_text(str(e))

async def _sessions_view_for(
self, channel_key: str,
self, channel_key: str, offset: int = 0,
) -> "tuple[str, InlineKeyboardMarkup]":
"""Build the inline-keyboard sessions view for a channel.

Shared by /sessions and the button-press handler so both render the
same thing. Only interactive (telegram/web) sessions are offered as
switch targets — cron/automation sessions are never listed.
same page. Only interactive (telegram/web) sessions that have history
are switch targets — cron/automation sessions are never listed — and the
store pins the current session first with starred (kept-alive) ones
ahead of the rest, so an idle starred session is never buried. Filtering
and ordering happen in SQL; we fetch one extra row to learn whether a
further page exists.
"""
offset = max(0, offset)
current = await self.router.get_last_session(channel_key)
sessions = await self.router.list_sessions(limit=30)
# Keep the most-recent interactive sessions that have history: empty
# (0-message) sessions are useless to switch to, and cron/automation
# sessions are never switch targets. Stop once the keyboard is full so
# we don't count every session.
non_empty: list[dict] = []
for s in sessions:
if s.get("source") not in ("telegram", "web"):
continue
if await self.router.count_session_messages(s["id"]) > 0:
non_empty.append(s)
if len(non_empty) >= _SESSIONS_BUTTON_LIMIT:
break
return build_sessions_view(non_empty, current)
page = await self.router.list_interactive_sessions(
limit=_SESSIONS_PAGE_SIZE + 1, offset=offset, current_id=current,
)
has_next = len(page) > _SESSIONS_PAGE_SIZE
return build_sessions_view(
page[:_SESSIONS_PAGE_SIZE], current,
offset=offset, has_prev=offset > 0, has_next=has_next,
)

async def _handle_sessions(self, update: Update, context: Any) -> None:
"""Handle /sessions — native inline keyboard to switch session routing.
Expand Down Expand Up @@ -1832,11 +1856,11 @@ async def _handle_session_button(self, query: Any) -> None:
"""Handle /sessions inline-keyboard presses.

callback_data forms:
``sess:list`` — (re)show the session switch list
``sess:list`` — (re)show the session switch list (first page)
``sess:page:<offset>`` — show the switch list at a pagination offset
``sess:new`` — create a fresh session (current keeps running)
``sess:<id>`` — switch routing to <id>, then show its history
``sesstail:<id>:<win>`` — widen the catch-up window (informational only)
``sessstar:<id>`` — toggle the session's starred (kept-alive) flag

After a switch/create the card is replaced by that session's recent
history (native order, oldest→newest) so the user can catch up — which
Expand All @@ -1855,6 +1879,16 @@ async def _handle_session_button(self, query: Any) -> None:
await self._safe_edit(query, text, markup)
return

if data.startswith("sess:page:"):
try:
page_offset = max(0, int(data.rsplit(":", 1)[1]))
except (IndexError, ValueError):
page_offset = 0
await query.answer()
text, markup = await self._sessions_view_for(channel_key, page_offset)
await self._safe_edit(query, text, markup)
return

if data.startswith("sesstail:"):
parts = data.split(":")
sid = parts[1] if len(parts) > 1 else ""
Expand All @@ -1866,24 +1900,6 @@ async def _handle_session_button(self, query: Any) -> None:
await self._edit_session_tail(query, sid, window)
return

if data.startswith("sessstar:"):
sid = data.split(":", 1)[1]
try:
now_starred = await self.router.toggle_session_starred(sid)
except ValueError:
await query.answer(
"That session is no longer available", show_alert=True,
)
else:
await query.answer(
"⭐ Kept alive — won't auto-close"
if now_starred
else "☆ Normal — may auto-close when idle"
)
text, markup = await self._sessions_view_for(channel_key)
await self._safe_edit(query, text, markup)
return

# sess:new / sess:<id>
target = data.split(":", 1)[1]
sid = target
Expand Down Expand Up @@ -1920,7 +1936,6 @@ async def _handle_callback_query(self, update: Update, context: Any) -> None:
if (
query.data.startswith("sess:")
or query.data.startswith("sesstail:")
or query.data.startswith("sessstar:")
):
await self._handle_session_button(query)
return
Expand Down
41 changes: 41 additions & 0 deletions nerve/db/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,47 @@ async def list_sessions(
async with self.db.execute(query, (limit,)) as cursor:
return [dict(row) async for row in cursor]

async def list_interactive_sessions(
self,
limit: int,
offset: int = 0,
sources: tuple[str, ...] = ("telegram", "web"),
current_id: str | None = None,
) -> list[dict]:
"""Page through switchable sessions for a channel's /sessions keyboard.

Returns non-archived sessions whose source is interactive (``sources``,
default telegram + web) and which have at least one message, ordered so
the switcher stays useful:

1. the current session (``current_id``) first, so you can always see
where you are however the rest is sorted;
2. then starred (kept-alive) sessions — they exist precisely to
survive going idle, so recency must not bury them;
3. then everything else, most recently updated first.

Filtering source and non-emptiness in SQL — rather than post-filtering a
plain recency fetch — is what stops constantly-updating cron/automation
sessions from filling the window and starving the interactive rows a
human actually switches between. Paginate with ``limit``/``offset``;
fetch ``page_size + 1`` to learn whether a further page exists.
"""
if not sources:
return []
placeholders = ",".join("?" for _ in sources)
query = (
f"SELECT * FROM sessions AS s "
f"WHERE s.status != 'archived' "
f"AND s.source IN ({placeholders}) "
f"AND EXISTS (SELECT 1 FROM messages AS m WHERE m.session_id = s.id) "
f"ORDER BY (s.id = ?) DESC, COALESCE(s.starred, 0) DESC, "
f"s.updated_at DESC "
f"LIMIT ? OFFSET ?"
)
params = (*sources, current_id or "", limit, offset)
async with self.db.execute(query, params) as cursor:
return [dict(row) async for row in cursor]

async def count_sessions(self, include_archived: bool = False) -> int:
"""Count sessions without loading them. Used by diagnostics."""
if include_archived:
Expand Down
83 changes: 83 additions & 0 deletions tests/test_list_interactive_sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Tests for SessionStore.list_interactive_sessions — the switchable-session
query behind the channel /sessions keyboards (current → starred → recent,
interactive sources only, non-empty, paginated)."""

import pytest

from nerve.db import Database


async def _make(db, sid, source, updated_at, *, starred=False, msg=True,
status="idle"):
await db.create_session(sid, title=sid, source=source, status=status)
if msg:
await db.add_message(sid, "user", "hi") # bumps updated_at → override below
if starred:
await db.update_session_fields(sid, {"starred": 1})
# updated_at is not settable via update_session_fields (it means "last
# message activity"); set it directly, after add_message, to pin recency.
await db._write(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (updated_at, sid),
)


async def _seed(db):
# Interactive, non-empty — the rows that should appear.
await _make(db, "cur", "telegram", "2026-01-02T00:00:00+00:00") # current, oldest
await _make(db, "starOld", "web", "2026-01-01T00:00:00+00:00", starred=True)
await _make(db, "starNew", "telegram", "2026-03-01T00:00:00+00:00", starred=True)
await _make(db, "recent1", "web", "2026-04-01T00:00:00+00:00") # newest non-starred
await _make(db, "recent2", "telegram", "2026-02-01T00:00:00+00:00")
# Rows that must be excluded.
await _make(db, "cronjob", "cron", "2026-05-01T00:00:00+00:00") # cron source
await _make(db, "emptyone", "web", "2026-04-15T00:00:00+00:00", msg=False) # 0 messages
await _make(db, "arch", "web", "2026-04-20T00:00:00+00:00",
starred=True, status="archived") # archived (even if starred)


@pytest.mark.asyncio
async def test_current_first_then_starred_then_recent(db: Database):
await _seed(db)
rows = await db.list_interactive_sessions(limit=50, current_id="cur")
ids = [r["id"] for r in rows]
# current pinned, then starred by recency, then recent non-starred by recency
assert ids == ["cur", "starNew", "starOld", "recent1", "recent2"]


@pytest.mark.asyncio
async def test_excludes_cron_empty_and_archived(db: Database):
await _seed(db)
ids = {r["id"] for r in await db.list_interactive_sessions(limit=50)}
assert "cronjob" not in ids # cron/automation source never listed
assert "emptyone" not in ids # 0-message sessions are useless to switch to
assert "arch" not in ids # archived excluded even when starred


@pytest.mark.asyncio
async def test_starred_lead_when_no_current(db: Database):
await _seed(db)
ids = [r["id"] for r in await db.list_interactive_sessions(limit=50)]
# No current pin: starred lead (by recency), then recent — including the
# (non-starred, old) "cur" which now sinks to the bottom.
assert ids == ["starNew", "starOld", "recent1", "recent2", "cur"]


@pytest.mark.asyncio
async def test_pagination_via_limit_offset(db: Database):
await _seed(db)
order = ["cur", "starNew", "starOld", "recent1", "recent2"]
p1 = [r["id"] for r in await db.list_interactive_sessions(limit=2, offset=0, current_id="cur")]
p2 = [r["id"] for r in await db.list_interactive_sessions(limit=2, offset=2, current_id="cur")]
p3 = [r["id"] for r in await db.list_interactive_sessions(limit=2, offset=4, current_id="cur")]
assert p1 == order[0:2]
assert p2 == order[2:4]
assert p3 == order[4:5] # last partial page


@pytest.mark.asyncio
async def test_next_page_probe_detects_more(db: Database):
await _seed(db)
# The channel view fetches page_size + 1 to learn whether a further page
# exists; with 5 rows and page_size 2, page 1's probe must return 3.
probe = await db.list_interactive_sessions(limit=3, offset=0, current_id="cur")
assert len(probe) == 3
Loading
Loading