diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py index 655a3cc87..e4a09b32b 100644 --- a/helpers/integration_commands.py +++ b/helpers/integration_commands.py @@ -35,6 +35,12 @@ COMMAND_REGISTRY: tuple[IntegrationCommandDef, ...] = ( "Info", ), IntegrationCommandDef("new", "Start a fresh chat context.", "Session"), + IntegrationCommandDef( + "sessions", + "Show or switch recent chat sessions.", + "Session", + aliases=("session",), + ), IntegrationCommandDef("clear", "Reset the current chat context.", "Session", aliases=("reset",)), IntegrationCommandDef( "queue", @@ -168,6 +174,8 @@ def try_handle_command(context: "AgentContext", text: str) -> str | None: return help_text(full=True) if command == "/status": return _handle_status(context) + if command == "/sessions": + return _handle_sessions(context) if command in {"/new", "/clear"}: return _handle_clear(context, new_chat=(command == "/new")) if command == "/send": @@ -251,6 +259,24 @@ def _handle_status(context: "AgentContext") -> str: ) +def _handle_sessions(context: "AgentContext") -> str: + from agent import AgentContext + + contexts = sorted( + AgentContext.all(), + key=lambda item: str(item.output().get("last_message") or ""), + reverse=True, + ) + lines = ["Recent sessions:"] + for item in contexts[:4]: + marker = " (current)" if item.id == context.id else "" + running = " - running" if item.is_running() else "" + lines.append(f"- {item.name or item.id}{marker}{running}") + if len(contexts) > 4: + lines.append(f"And {len(contexts) - 4} more. Use Telegram buttons to page through them.") + return "\n".join(lines) + + def _handle_clear(context: "AgentContext", *, new_chat: bool) -> str: context.reset() mq.remove(context) diff --git a/plugins/_telegram_integration/helpers/command_ui.py b/plugins/_telegram_integration/helpers/command_ui.py index 0ce9a5ae6..a0f108d0f 100644 --- a/plugins/_telegram_integration/helpers/command_ui.py +++ b/plugins/_telegram_integration/helpers/command_ui.py @@ -1,9 +1,10 @@ from __future__ import annotations +import json from dataclasses import dataclass from agent import AgentContext -from helpers import projects, subagents +from helpers import files, projects, subagents from helpers import integration_commands from helpers.persist_chat import save_tmp_chat from helpers.state_monitor_integration import mark_dirty_for_context @@ -12,9 +13,16 @@ from plugins._telegram_integration.helpers import telegram_client as tc from plugins._telegram_integration.helpers.constants import ( CTX_TG_STREAM_ENABLED, CTX_TG_TOOLS_ENABLED, + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_CHAT_TYPE, + CTX_TG_USER_ID, + CTX_TG_USERNAME, + STATE_FILE, ) PAGE_SIZE = 8 +SESSION_PAGE_SIZE = 4 CALLBACK_PREFIX = "tg" @@ -24,6 +32,14 @@ class PickerItem: label: str +@dataclass(frozen=True) +class SessionItem: + context: AgentContext + label: str + last_message: str + running: bool + + async def handle_command( context: AgentContext, token: str, @@ -44,6 +60,9 @@ async def handle_command( if command in {"/agent", "/profile"} and not args: await send_agent_picker(context, token, chat_id, reply_to_message_id, 0) return True + if command in {"/sessions", "/session"} and not args: + await send_session_picker(context, token, chat_id, reply_to_message_id, 0) + return True if command == "/stream": await send_toggle_picker( context, @@ -91,6 +110,8 @@ async def handle_callback( await edit_project_picker(context, token, chat_id, message_id, page) elif kind == "agent": await edit_agent_picker(context, token, chat_id, message_id, page) + elif kind == "session": + await edit_session_picker(context, token, chat_id, message_id, page) return True if kind == "model" and action in {"set", "clear"}: await _select_model(context, _safe_int(value), clear=(action == "clear")) @@ -104,6 +125,10 @@ async def handle_callback( await _select_agent(context, _safe_int(value)) await edit_agent_picker(context, token, chat_id, message_id, 0, selected=True) return True + if kind == "session" and action == "set": + selected_context = await _select_session(context, _safe_int(value)) + await edit_session_picker(selected_context or context, token, chat_id, message_id, 0, selected=bool(selected_context)) + return True if kind in {"stream", "tools"} and action in {"on", "off"}: key = CTX_TG_STREAM_ENABLED if kind == "stream" else CTX_TG_TOOLS_ENABLED label = "Response streaming" if kind == "stream" else "Tool progress" @@ -217,6 +242,30 @@ async def edit_toggle_picker( await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) +async def send_session_picker( + context: AgentContext, + token: str, + chat_id: int, + reply_to_message_id: int | None, + page: int, +) -> None: + text, markup = _session_view(context, page) + await tc.raw_send_text(token, chat_id, text, reply_to_message_id, "HTML", markup) + + +async def edit_session_picker( + context: AgentContext, + token: str, + chat_id: int, + message_id: int, + page: int, + *, + selected: bool = False, +) -> None: + text, markup = _session_view(context, page, selected=selected) + await tc.raw_edit_text(token, chat_id, message_id, text, "HTML", markup) + + def _model_view( context: AgentContext, page: int, @@ -295,6 +344,48 @@ def _toggle_view(context: AgentContext, key: str, label: str) -> tuple[str, dict return text, {"inline_keyboard": rows} +def _session_view( + context: AgentContext, + page: int, + *, + selected: bool = False, +) -> tuple[str, dict | None]: + items = _session_items() + current_label = _session_label(context) + status = f"Current session: {_html(current_label)}" + if context.is_running(): + status += "\nSession switching is available after the current run finishes." + elif selected: + status = "Session switched.\n" + status + if not items: + return status + "\nNo sessions were found.", None + + total = len(items) + page = _clamp_page(page, total, SESSION_PAGE_SIZE) + start = page * SESSION_PAGE_SIZE + end = min(start + SESSION_PAGE_SIZE, total) + rows: list[list[dict[str, str]]] = [] + for index, item in enumerate(items[start:end], start=start): + marker = "• " if item.context.id == context.id else "" + suffix = " (running)" if item.running else "" + action = "noop" if context.is_running() or item.running else "set" + rows.append([{ + "text": f"{marker}{item.label}{suffix}"[:64], + "callback_data": f"tg:session:{action}:{index}", + }]) + + nav: list[dict[str, str]] = [] + if page > 0: + nav.append({"text": "Prev", "callback_data": f"tg:session:page:{page - 1}"}) + if end < total: + nav.append({"text": "Next", "callback_data": f"tg:session:page:{page + 1}"}) + if nav: + rows.append(nav) + + range_text = f"\nShowing {start + 1}-{end} of {total}." + return status + range_text, {"inline_keyboard": rows} + + async def _select_model(context: AgentContext, index: int, *, clear: bool = False) -> None: if not model_config.is_chat_override_allowed(context.agent0): return @@ -339,6 +430,86 @@ async def _select_agent(context: AgentContext, index: int) -> None: mark_dirty_for_context(context.id, reason="telegram.agent_select") +async def _select_session(context: AgentContext, index: int) -> AgentContext | None: + if context.is_running(): + return None + items = _session_items() + if index < 0 or index >= len(items): + return None + target = items[index].context + if target.is_running(): + return None + + _copy_telegram_binding(context, target) + _set_session_mapping(target) + save_tmp_chat(target) + mark_dirty_for_context(context.id, reason="telegram.session_unselect") + mark_dirty_for_context(target.id, reason="telegram.session_select") + return target + + +def _session_items() -> list[SessionItem]: + contexts = sorted( + AgentContext.all(), + key=lambda item: str(item.output().get("last_message") or ""), + reverse=True, + ) + return [ + SessionItem( + context=item, + label=_session_label(item), + last_message=str(item.output().get("last_message") or ""), + running=item.is_running(), + ) + for item in contexts + ] + + +def _session_label(context: AgentContext) -> str: + return str(context.name or context.id or "Session") + + +def _copy_telegram_binding(source: AgentContext, target: AgentContext) -> None: + for key in ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_CHAT_TYPE, + CTX_TG_USER_ID, + CTX_TG_USERNAME, + ): + if key in source.data: + target.data[key] = source.data[key] + + +def _set_session_mapping(context: AgentContext) -> None: + bot_name = str(context.data.get(CTX_TG_BOT) or "") + user_id = context.data.get(CTX_TG_USER_ID) + chat_id = context.data.get(CTX_TG_CHAT_ID) + if not bot_name or user_id is None or chat_id is None: + return + key = f"{bot_name}:{int(user_id)}:{int(chat_id)}" + state = _load_telegram_state() + chats = state.setdefault("chats", {}) + chats[key] = context.id + _save_telegram_state(state) + + +def _load_telegram_state() -> dict: + path = files.get_abs_path(STATE_FILE) + if not files.exists(path): + return {} + try: + return json.loads(files.read_file(path)) + except Exception: + return {} + + +def _save_telegram_state(state: dict) -> None: + path = files.get_abs_path(STATE_FILE) + files.make_dirs(path) + files.write_file(path, json.dumps(state)) + + def _paged_buttons( kind: str, items: list[PickerItem], @@ -390,6 +561,11 @@ def _safe_int(value: str) -> int: return 0 +def _clamp_page(page: int, total_items: int, page_size: int) -> int: + total_pages = max(1, (total_items + page_size - 1) // page_size) + return min(max(0, page), total_pages - 1) + + def _label_for(items: list[PickerItem], key: str) -> str: for item in items: if item.key == key: diff --git a/tests/test_telegram_sessions_picker.py b/tests/test_telegram_sessions_picker.py new file mode 100644 index 000000000..588ecbc7e --- /dev/null +++ b/tests/test_telegram_sessions_picker.py @@ -0,0 +1,78 @@ +import asyncio +from types import SimpleNamespace + +from plugins._telegram_integration.helpers import command_ui +from plugins._telegram_integration.helpers.constants import ( + CTX_TG_BOT, + CTX_TG_CHAT_ID, + CTX_TG_USER_ID, + CTX_TG_USERNAME, +) + + +class FakeContext: + def __init__(self, id, name, last_message, *, running=False): + self.id = id + self.name = name + self.data = {} + self._last_message = last_message + self._running = running + + def output(self): + return {"last_message": self._last_message} + + def is_running(self): + return self._running + + +def test_session_picker_shows_four_recent_sessions(monkeypatch): + contexts = [ + FakeContext("ctx-1", "One", "2026-05-01T10:00:00"), + FakeContext("ctx-2", "Two", "2026-05-02T10:00:00"), + FakeContext("ctx-3", "Three", "2026-05-03T10:00:00"), + FakeContext("ctx-4", "Four", "2026-05-04T10:00:00"), + FakeContext("ctx-5", "Five", "2026-05-05T10:00:00"), + ] + current = contexts[4] + monkeypatch.setattr(command_ui, "AgentContext", SimpleNamespace(all=lambda: contexts)) + + text, markup = command_ui._session_view(current, 0) + + assert "Current session: Five" in text + assert "Showing 1-4 of 5." in text + rows = markup["inline_keyboard"] + assert [row[0]["text"] for row in rows[:4]] == ["• Five", "Four", "Three", "Two"] + assert rows[-1] == [{"text": "Next", "callback_data": "tg:session:page:1"}] + + +def test_select_session_remaps_telegram_chat(monkeypatch): + current = FakeContext("ctx-current", "Current", "2026-05-03T10:00:00") + target = FakeContext("ctx-target", "Target", "2026-05-04T10:00:00") + current.data.update( + { + CTX_TG_BOT: "main", + CTX_TG_CHAT_ID: 123, + CTX_TG_USER_ID: 456, + CTX_TG_USERNAME: "anmol", + } + ) + saved = [] + dirty = [] + state_out = {} + + monkeypatch.setattr(command_ui, "AgentContext", SimpleNamespace(all=lambda: [current, target])) + monkeypatch.setattr(command_ui, "save_tmp_chat", lambda context: saved.append(context.id)) + monkeypatch.setattr(command_ui, "mark_dirty_for_context", lambda context_id, *, reason=None: dirty.append((context_id, reason))) + monkeypatch.setattr(command_ui, "_load_telegram_state", lambda: {"chats": {}}) + monkeypatch.setattr(command_ui, "_save_telegram_state", lambda state: state_out.update(state)) + + selected = asyncio.run(command_ui._select_session(current, 0)) + + assert selected is target + assert target.data[CTX_TG_BOT] == "main" + assert target.data[CTX_TG_CHAT_ID] == 123 + assert target.data[CTX_TG_USER_ID] == 456 + assert target.data[CTX_TG_USERNAME] == "anmol" + assert state_out["chats"]["main:456:123"] == "ctx-target" + assert saved == ["ctx-target"] + assert ("ctx-target", "telegram.session_select") in dirty