From 606883b478c10cf30ee428a22f07fe334fba2740 Mon Sep 17 00:00:00 2001 From: Alexey Leshchenko Date: Tue, 26 May 2026 13:15:06 +0300 Subject: [PATCH 1/6] fix(telegram): preserve auto-titled sessions and bind chat on switch Label drift compared the full title to the default DM template on every message, reverting auto-titled names after the second message (issue #121). Introduce session_resolve::should_refresh_label and prefer chat_sessions map on ingest after /sessions switch. Adds Rust tests, a Python TDD simulator (pytest -k production shows red), and validate-hermes.sh for post-deploy checks. Fixes #121 --- src/channels/telegram/handler.rs | 66 ++++--- src/channels/telegram/mod.rs | 1 + src/channels/telegram/session_resolve.rs | 109 ++++++++++++ src/tests/mod.rs | 1 + src/tests/telegram_session_resolve_test.rs | 100 +++++++++++ tools/telegram_session_sim/.gitignore | 1 + tools/telegram_session_sim/README.md | 60 +++++++ tools/telegram_session_sim/__init__.py | 15 ++ tools/telegram_session_sim/helpers.py | 105 +++++++++++ tools/telegram_session_sim/pyproject.toml | 8 + tools/telegram_session_sim/pytest.ini | 3 + tools/telegram_session_sim/router.py | 167 ++++++++++++++++++ tools/telegram_session_sim/sim.py | 57 ++++++ .../telegram_session_sim/test_session_sim.py | 133 ++++++++++++++ tools/telegram_session_sim/validate-hermes.sh | 26 +++ 15 files changed, 830 insertions(+), 22 deletions(-) create mode 100644 src/channels/telegram/session_resolve.rs create mode 100644 src/tests/telegram_session_resolve_test.rs create mode 100644 tools/telegram_session_sim/.gitignore create mode 100644 tools/telegram_session_sim/README.md create mode 100644 tools/telegram_session_sim/__init__.py create mode 100644 tools/telegram_session_sim/helpers.py create mode 100644 tools/telegram_session_sim/pyproject.toml create mode 100644 tools/telegram_session_sim/pytest.ini create mode 100644 tools/telegram_session_sim/router.py create mode 100644 tools/telegram_session_sim/sim.py create mode 100644 tools/telegram_session_sim/test_session_sim.py create mode 100755 tools/telegram_session_sim/validate-hermes.sh diff --git a/src/channels/telegram/handler.rs b/src/channels/telegram/handler.rs index 03d87941..60d220d8 100644 --- a/src/channels/telegram/handler.rs +++ b/src/channels/telegram/handler.rs @@ -880,25 +880,46 @@ pub(crate) async fn handle_message( // 2026-04-25: a "πŸ¦€ KRAB-INCEPTION πŸ¦€" group renamed to "πŸ¦€ HEY IOLO // BUILD πŸ¦€" produced two distinct DB rows under the old title-only // lookup. The chat_id suffix prevents that. - let chat_id_suffix = format!("[chat:{}]", msg.chat.id.0); - let session_title = if is_dm { - format!( - "Telegram: DM {} ({}) {}", - user.first_name, user_id, chat_id_suffix - ) - } else { - format!("Telegram: {} {}", chat_title, chat_id_suffix) - }; + let chat_id = msg.chat.id.0; + let chat_id_suffix = session_resolve::chat_id_suffix(chat_id); + let session_title = session_resolve::build_session_title( + is_dm, + &user.first_name, + user_id, + &chat_title, + chat_id, + ); // Legacy title format used before the chat_id suffix was added. - // Kept so the first message after the upgrade matches the existing - // row instead of orphaning it. - let legacy_title = if is_dm { - format!("Telegram: DM {} ({})", user.first_name, user_id) - } else { - format!("Telegram: {}", chat_title) - }; + let legacy_title = + session_resolve::build_legacy_session_title(is_dm, &user.first_name, user_id, &chat_title); let session_id = { + // 0) Explicit chatβ†’session binding from /sessions switch or prior message. + if let Some(bound_id) = telegram_state.chat_session(chat_id).await + && let Ok(Some(bound)) = session_svc.get_session(bound_id).await + && !bound.is_archived() + { + if session_resolve::should_refresh_label( + bound.title.as_deref().unwrap_or(""), + &session_title, + ) { + let mut renamed = bound.clone(); + renamed.title = Some(session_title.clone()); + if let Err(e) = session_svc.update_session(&renamed).await { + tracing::warn!( + "Telegram: failed to refresh session {} label: {}", + bound_id, + e + ); + } + } + tracing::debug!( + "Telegram: using chat-bound session {} for chat_id={}", + bound_id, + chat_id + ); + bound_id + } else { // 1) Stable lookup: any session whose title ends with the chat_id // suffix is THIS chat regardless of how the label has changed. // 2) Legacy fallback: pre-suffix sessions match the bare title. @@ -963,12 +984,12 @@ pub(crate) async fn handle_message( } } } else { - // Label drift: the chat was renamed since this session - // was created. The chat_id suffix kept us pointing at - // the right row, but the stored title still shows the - // old label. Update it so /sessions etc. show the - // user's current name for the chat. - if session.title.as_deref() != Some(session_title.as_str()) { + // Label drift: refresh display label when appropriate (issue #121: + // do not revert auto-titled DM sessions to the default template). + if session_resolve::should_refresh_label( + session.title.as_deref().unwrap_or(""), + &session_title, + ) { let mut renamed = session.clone(); let prev_title = renamed.title.clone().unwrap_or_default(); renamed.title = Some(session_title.clone()); @@ -1019,6 +1040,7 @@ pub(crate) async fn handle_message( } } } + } }; tracing::info!( diff --git a/src/channels/telegram/mod.rs b/src/channels/telegram/mod.rs index 24e7ce64..c75f8ad2 100644 --- a/src/channels/telegram/mod.rs +++ b/src/channels/telegram/mod.rs @@ -6,6 +6,7 @@ mod agent; pub(crate) mod follow_up_question; pub(crate) mod handler; +pub(crate) mod session_resolve; pub use agent::TelegramAgent; diff --git a/src/channels/telegram/session_resolve.rs b/src/channels/telegram/session_resolve.rs new file mode 100644 index 00000000..97eae1a5 --- /dev/null +++ b/src/channels/telegram/session_resolve.rs @@ -0,0 +1,109 @@ +//! Pure Telegram session title + label-drift helpers (testable without teloxide). +//! +//! Issue #121: naive full-title comparison reverted auto-titled DM sessions back +//! to the default `Telegram: DM …` template on every subsequent message. + +/// Build the canonical session title for a Telegram chat. +pub fn build_session_title( + is_dm: bool, + user_name: &str, + user_id: i64, + chat_title: &str, + chat_id: i64, +) -> String { + let chat_id_suffix = format!("[chat:{chat_id}]"); + if is_dm { + format!("Telegram: DM {user_name} ({user_id}) {chat_id_suffix}") + } else { + format!("Telegram: {chat_title} {chat_id_suffix}") + } +} + +/// Legacy title format (pre suffix) for migration lookups. +pub fn build_legacy_session_title(is_dm: bool, user_name: &str, user_id: i64, chat_title: &str) -> String { + if is_dm { + format!("Telegram: DM {user_name} ({user_id})") + } else { + format!("Telegram: {chat_title}") + } +} + +pub fn chat_id_suffix(chat_id: i64) -> String { + format!("[chat:{chat_id}]") +} + +/// Whether to overwrite a stored session title with the freshly built template. +/// +/// - Default DM titles: refresh when the template default changed (display name). +/// - Auto-titled / custom DM titles: never clobber (issue #121). +/// - Telegram groups: refresh when the visible group label changed (suffix stable). +pub fn should_refresh_label(stored: &str, template: &str) -> bool { + if stored == template { + return false; + } + + if crate::brain::agent::service::AgentService::is_default_channel_title(stored) { + return crate::brain::agent::service::AgentService::is_default_channel_title(template) + && stored != template; + } + + if is_telegram_group_session_title(stored) && is_telegram_group_session_title(template) { + return telegram_middle_label(stored) != telegram_middle_label(template); + } + + false +} + +fn is_telegram_group_session_title(title: &str) -> bool { + let Some(rest) = title.strip_prefix("Telegram: ") else { + return false; + }; + !rest.starts_with("DM ") && title.contains("[chat:") +} + +fn telegram_middle_label(title: &str) -> String { + let body = title + .strip_prefix("Telegram: ") + .unwrap_or(title) + .trim(); + let suffix = crate::brain::agent::service::AgentService::extract_chat_id_suffix(title); + if suffix.is_empty() { + return body.to_string(); + } + body.strip_suffix(suffix) + .unwrap_or(body) + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dm_template_format() { + let t = build_session_title(true, "Alice", 123, "", 456); + assert_eq!(t, "Telegram: DM Alice (123) [chat:456]"); + } + + #[test] + fn should_not_clobber_auto_titled_dm() { + let auto = "Telegram: Fix deploy [chat:133526395]"; + let template = build_session_title(true, "Alexey", 133526395, "", 133526395); + assert!(!should_refresh_label(auto, template)); + } + + #[test] + fn should_refresh_group_rename() { + let old = "Telegram: Old Group [chat:-1]"; + let new = "Telegram: New Group [chat:-1]"; + assert!(should_refresh_label(old, new)); + } + + #[test] + fn default_dm_still_refreshes_on_name_change() { + let old = build_session_title(true, "Alice", 1, "", 99); + let new = build_session_title(true, "Bob", 1, "", 99); + assert!(should_refresh_label(&old, &new)); + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 65684351..5cf761f2 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -122,6 +122,7 @@ pub mod slash_autocomplete_dimensions_test; pub mod split_pane_test; pub mod subagent_test; pub mod telegram_resume_test; +pub mod telegram_session_resolve_test; pub mod token_tracking_test; pub mod tool_execution_repo_test; pub mod tool_loop_helpers_test; diff --git a/src/tests/telegram_session_resolve_test.rs b/src/tests/telegram_session_resolve_test.rs new file mode 100644 index 00000000..be7d6b1a --- /dev/null +++ b/src/tests/telegram_session_resolve_test.rs @@ -0,0 +1,100 @@ +//! Integration tests for Telegram session title + label drift (issue #121). + +use crate::channels::telegram::session_resolve::{ + build_session_title, chat_id_suffix, should_refresh_label, +}; +use crate::db::Database; +use crate::db::models::Session; +use crate::db::repository::SessionRepository; +use crate::services::{ServiceContext, SessionService}; + +async fn fresh_repo() -> (Database, SessionRepository) { + let db = Database::connect_in_memory() + .await + .expect("in-memory DB connect"); + db.run_migrations().await.expect("migrations"); + let repo = SessionRepository::new(db.pool().clone()); + (db, repo) +} + +#[test] +fn should_not_clobber_auto_titled_dm_title() { + let auto = "Telegram: Fix deploy pipeline [chat:133526395]"; + let template = build_session_title(true, "Alexey", 133526395, "", 133526395); + assert!( + !should_refresh_label(auto, &template), + "auto-titled DM must not revert to default template" + ); +} + +#[test] +fn group_rename_still_refreshes() { + let old = "Telegram: Old Group [chat:-5246593256]"; + let new = "Telegram: New Group [chat:-5246593256]"; + assert!(should_refresh_label(old, new)); +} + +#[tokio::test] +async fn suffix_lookup_after_switch_touch_picks_switched_row() { + let (_db, repo) = fresh_repo().await; + let chat_id = 42_i64; + let suffix = chat_id_suffix(chat_id); + let title = build_session_title(true, "U", 1, "", chat_id); + + let older = Session::new(Some(title.clone()), None, None); + repo.create(&older).await.expect("create older"); + + let mut newer = Session::new(Some(title), None, None); + newer.updated_at = older.updated_at + chrono::Duration::seconds(1); + repo.create(&newer).await.expect("create newer"); + + // Simulate /sessions switch to older session (touch updated_at) + let mut switched = older.clone(); + switched.updated_at = newer.updated_at + chrono::Duration::seconds(1); + repo.update(&switched).await.expect("touch older"); + + let hit = repo + .find_by_title_suffix(&suffix) + .await + .expect("query") + .expect("hit"); + assert_eq!(hit.id, older.id); +} + +#[tokio::test] +async fn auto_titled_title_survives_should_refresh_check() { + let template = build_session_title(true, "Alice", 1, "", 99); + let auto_titled = format!( + "Telegram: Deploy fix {}", + chat_id_suffix(99) + ); + assert!(!should_refresh_label(&auto_titled, &template)); +} + +#[tokio::test] +async fn service_update_session_title_preserves_suffix() { + let db = Database::connect_in_memory() + .await + .expect("connect"); + db.run_migrations().await.expect("migrations"); + let ctx = ServiceContext::new(db.pool().clone()); + let svc = SessionService::new(ctx); + + let title = build_session_title(true, "U", 1, "", 77); + let session = svc + .create_session(Some(title.clone())) + .await + .expect("create"); + + let new_title = format!("Telegram: Custom topic {}", chat_id_suffix(77)); + svc.update_session_title(session.id, Some(new_title.clone())) + .await + .expect("rename"); + + let loaded = svc.get_session(session.id).await.expect("get").expect("row"); + assert_eq!(loaded.title.as_deref(), Some(new_title.as_str())); + assert!( + loaded.title.as_ref().unwrap().ends_with("[chat:77]"), + "suffix must remain for lookup" + ); +} diff --git a/tools/telegram_session_sim/.gitignore b/tools/telegram_session_sim/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/tools/telegram_session_sim/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/tools/telegram_session_sim/README.md b/tools/telegram_session_sim/README.md new file mode 100644 index 00000000..eb0f3d13 --- /dev/null +++ b/tools/telegram_session_sim/README.md @@ -0,0 +1,60 @@ +# Telegram session simulator + +Fast TDD harness for OpenCrabs Telegram session routing ([issue #121](https://github.com/adolfousier/opencrabs/issues/121)) without compiling Rust locally. + +## Run (proper TDD) + +Contract tests assert **correct** behavior and are parametrized as `[production]` vs `[fixed]`: + +```bash +cd tools/telegram_session_sim + +# Full suite: production params FAIL (red), fixed params PASS (green), units PASS +python3 -m pytest -q + +# While developing the fix β€” only the green path +python3 -m pytest -q -k fixed + +# Prove production still has the bug (all red on contract tests) +python3 -m pytest -q -k production +``` + +Expected on current code before upstream handler matches `fixed`: + +| Command | Result | +|---------|--------| +| `pytest -q` | 3 failed `[production]`, 3 passed `[fixed]`, 6 unit tests passed | +| `pytest -q -k fixed` | all green | +| `pytest -q -k production` | 3 failed (intentional red) | + +When the Rust fix is ported and the sim’s `production` resolver is updated to match, `pytest -q` goes fully green. + +## What it models + +- Suffix lookup (`[chat:ID]` β†’ newest `updated_at`) +- Label drift (buggy: full-title compare; fixed: `should_refresh_label`) +- Auto-title composition (preserve `[chat:ID]`) +- `/sessions` switch via `chat_sessions` map (fixed resolver only) + +## Resolvers + +| Param id | `use_fixed_resolver` | Role in TDD | +|----------|----------------------|-------------| +| `production` | `False` | Red until bug fixed (`resolve_suffix_only` + naive label drift) | +| `fixed` | `True` | Green target (`resolve_with_chat_map` + `should_refresh_label`) | + +Upstream Rust tests: `src/tests/telegram_session_resolve_test.rs` and `src/channels/telegram/session_resolve.rs`. + +## Hermes validation (manual) + +After deploying a build with the fix: + +1. `/new` β†’ send a message β†’ wait ~10s for auto-title. +2. Send a **second** message β€” title in `/sessions` must stay non-default. +3. `/sessions` β†’ switch an older session β†’ send `ping` β€” reply should use that session's context. + +```bash +ssh hermes 'sqlite3 ~/.opencrabs/profiles/ops/opencrabs.db \ + "SELECT substr(title,1,60), auto_title_attempted, updated_at FROM sessions \ + WHERE title LIKE \"%Telegram%\" ORDER BY updated_at DESC LIMIT 5;"' +``` diff --git a/tools/telegram_session_sim/__init__.py b/tools/telegram_session_sim/__init__.py new file mode 100644 index 00000000..8c98301c --- /dev/null +++ b/tools/telegram_session_sim/__init__.py @@ -0,0 +1,15 @@ +"""Telegram session routing simulator for OpenCrabs issue #121.""" + +from .helpers import ( + compose_auto_title, + is_default_channel_title, + should_refresh_label, +) +from .sim import TelegramSessionSim + +__all__ = [ + "TelegramSessionSim", + "compose_auto_title", + "is_default_channel_title", + "should_refresh_label", +] diff --git a/tools/telegram_session_sim/helpers.py b/tools/telegram_session_sim/helpers.py new file mode 100644 index 00000000..c1e6cba4 --- /dev/null +++ b/tools/telegram_session_sim/helpers.py @@ -0,0 +1,105 @@ +"""Pure helpers ported from opencrabs src/brain/agent/service/types.rs.""" + +from __future__ import annotations + +CHANNEL_PREFIXES = ( + "Telegram: ", + "Discord: ", + "Slack: ", + "WhatsApp: ", + "Trello: ", +) + + +def clean_auto_title(raw: str) -> str: + trimmed = raw.strip().strip('"').strip("'") + if not trimmed: + return "" + if len(trimmed) > 60: + return trimmed[:60] + return trimmed + + +def is_default_channel_title(title: str) -> bool: + if title == "New Chat": + return True + if title.startswith("Telegram: "): + rest = title[len("Telegram: ") :] + return rest.startswith("DM ") and "(" in rest and ")" in rest + if title.startswith("Discord: "): + return title[len("Discord: ") :].startswith("#") + if title.startswith("Slack: "): + return title[len("Slack: ") :].startswith("#") + return False + + +def extract_channel_prefix(title: str) -> str: + for prefix in CHANNEL_PREFIXES: + if title.startswith(prefix): + return prefix + return "" + + +def extract_chat_id_suffix(title: str) -> str: + pos = title.rfind("[chat:") + if pos == -1: + return "" + suffix = title[pos:] + if suffix.endswith("]"): + return suffix + return "" + + +def compose_auto_title(old_title: str, llm_title: str) -> str: + clean = clean_auto_title(llm_title) + if not clean: + return old_title + prefix = extract_channel_prefix(old_title) + chat_suffix = extract_chat_id_suffix(old_title) + if not prefix: + return f"{clean} {chat_suffix}".strip() if chat_suffix else clean + if not chat_suffix: + return f"{prefix}{clean}" + return f"{prefix}{clean} {chat_suffix}" + + +def build_dm_session_title(user_name: str, user_id: int, chat_id: int) -> str: + suffix = f"[chat:{chat_id}]" + return f"Telegram: DM {user_name} ({user_id}) {suffix}" + + +def build_group_session_title(chat_title: str, chat_id: int) -> str: + return f"Telegram: {chat_title} [chat:{chat_id}]" + + +def is_telegram_group_session_title(title: str) -> bool: + if not title.startswith("Telegram: "): + return False + rest = title[len("Telegram: ") :] + if rest.startswith("DM "): + return False + return "[chat:" in title + + +def telegram_middle_label(title: str) -> str: + """Label between 'Telegram: ' and ' [chat:N]'.""" + if not title.startswith("Telegram: "): + return title + body = title[len("Telegram: ") :] + suffix = extract_chat_id_suffix(title) + if suffix and body.endswith(suffix): + body = body[: -len(suffix)].rstrip() + return body + + +def should_refresh_label(stored: str, template: str) -> bool: + """Fixed label-drift policy (issue #121 + group rename stability).""" + if stored == template: + return False + if is_default_channel_title(stored): + return is_default_channel_title(template) and stored != template + if is_telegram_group_session_title(stored) and is_telegram_group_session_title( + template + ): + return telegram_middle_label(stored) != telegram_middle_label(template) + return False diff --git a/tools/telegram_session_sim/pyproject.toml b/tools/telegram_session_sim/pyproject.toml new file mode 100644 index 00000000..ac8d9894 --- /dev/null +++ b/tools/telegram_session_sim/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "telegram-session-sim" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] diff --git a/tools/telegram_session_sim/pytest.ini b/tools/telegram_session_sim/pytest.ini new file mode 100644 index 00000000..2efd58fd --- /dev/null +++ b/tools/telegram_session_sim/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + contract: correct-behavior tests parametrized over production vs fixed resolver diff --git a/tools/telegram_session_sim/router.py b/tools/telegram_session_sim/router.py new file mode 100644 index 00000000..cc5c0384 --- /dev/null +++ b/tools/telegram_session_sim/router.py @@ -0,0 +1,167 @@ +"""In-memory session router mirroring Telegram handler resolve logic.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Callable + +from .helpers import ( + build_dm_session_title, + compose_auto_title, + is_default_channel_title, + should_refresh_label, +) + +UtcNow = Callable[[], datetime] + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass +class SessionRow: + id: str + title: str + auto_title_attempted: bool = False + updated_at: datetime = field(default_factory=_utc_now) + archived: bool = False + + +class SessionStore: + def __init__(self, now: UtcNow | None = None) -> None: + self.rows: dict[str, SessionRow] = {} + self.chat_sessions: dict[int, str] = {} + self._now = now or _utc_now + self._tick = 0 + + def _bump(self, row: SessionRow) -> None: + self._tick += 1 + row.updated_at = self._now() + timedelta(seconds=self._tick) + + def create(self, title: str) -> SessionRow: + row = SessionRow(id=str(uuid.uuid4()), title=title) + self.rows[row.id] = row + self._bump(row) + return row + + def find_by_title_suffix(self, suffix: str) -> SessionRow | None: + matches = [ + r + for r in self.rows.values() + if not r.archived and r.title.endswith(suffix) + ] + if not matches: + return None + return max(matches, key=lambda r: r.updated_at) + + def touch(self, session_id: str) -> None: + row = self.rows[session_id] + self._bump(row) + + def set_title(self, session_id: str, title: str) -> None: + row = self.rows[session_id] + row.title = title + self._bump(row) + + def mark_auto_title_attempted(self, session_id: str) -> None: + self.rows[session_id].auto_title_attempted = True + + def reset_auto_title_attempted(self, session_id: str) -> None: + self.rows[session_id].auto_title_attempted = False + + +def _apply_label_drift( + store: SessionStore, + row: SessionRow, + template: str, + *, + safe: bool, +) -> None: + if safe: + if should_refresh_label(row.title, template): + store.set_title(row.id, template) + else: + # Production bug: any mismatch resets to template + if row.title != template: + store.set_title(row.id, template) + + +def resolve_suffix_only( + store: SessionStore, + chat_id: int, + user_name: str, + user_id: int, + *, + is_dm: bool = True, + chat_title: str = "", +) -> SessionRow: + template = ( + build_dm_session_title(user_name, user_id, chat_id) + if is_dm + else build_group_session_title(chat_title or "Group", chat_id) + ) + suffix = f"[chat:{chat_id}]" + existing = store.find_by_title_suffix(suffix) + if existing: + _apply_label_drift(store, existing, template, safe=False) + return store.rows[existing.id] + return store.create(template) + + +def resolve_with_chat_map( + store: SessionStore, + chat_id: int, + user_name: str, + user_id: int, + *, + is_dm: bool = True, + chat_title: str = "", +) -> SessionRow: + template = ( + build_dm_session_title(user_name, user_id, chat_id) + if is_dm + else build_group_session_title(chat_title or "Group", chat_id) + ) + suffix = f"[chat:{chat_id}]" + + bound = store.chat_sessions.get(chat_id) + if bound and bound in store.rows and not store.rows[bound].archived: + row = store.rows[bound] + _apply_label_drift(store, row, template, safe=True) + return row + + existing = store.find_by_title_suffix(suffix) + if existing: + _apply_label_drift(store, existing, template, safe=True) + store.chat_sessions[chat_id] = existing.id + return store.rows[existing.id] + + row = store.create(template) + store.chat_sessions[chat_id] = row.id + return row + + +def maybe_run_auto_title( + store: SessionStore, + session_id: str, + user_message: str, + llm_title: str, + *, + llm_failed: bool = False, +) -> None: + row = store.rows[session_id] + if llm_failed: + store.reset_auto_title_attempted(session_id) + return + if row.auto_title_attempted: + return + if user_message.strip() and ( + not row.title or is_default_channel_title(row.title) + ): + store.mark_auto_title_attempted(session_id) + new_title = compose_auto_title(row.title, llm_title) + if new_title: + store.set_title(session_id, new_title) diff --git a/tools/telegram_session_sim/sim.py b/tools/telegram_session_sim/sim.py new file mode 100644 index 00000000..ca2f4700 --- /dev/null +++ b/tools/telegram_session_sim/sim.py @@ -0,0 +1,57 @@ +"""Event API for Telegram session simulation.""" + +from __future__ import annotations + +from .helpers import build_dm_session_title +from .router import ( + SessionStore, + maybe_run_auto_title, + resolve_suffix_only, + resolve_with_chat_map, +) + + +class TelegramSessionSim: + def __init__(self, *, use_fixed_resolver: bool = False) -> None: + self.store = SessionStore() + self.use_fixed_resolver = use_fixed_resolver + self._resolve = ( + resolve_with_chat_map if use_fixed_resolver else resolve_suffix_only + ) + + def on_message( + self, + chat_id: int, + user_name: str, + user_id: int, + text: str = "hello", + *, + is_dm: bool = True, + chat_title: str = "", + ) -> str: + row = self._resolve( + self.store, chat_id, user_name, user_id, is_dm=is_dm, chat_title=chat_title + ) + llm_title = " ".join(text.split()[:5]) or "New topic" + maybe_run_auto_title(self.store, row.id, text, llm_title) + return row.id + + def on_new( + self, + chat_id: int, + user_name: str, + user_id: int, + *, + is_owner: bool = True, + ) -> str: + row = self.store.create(build_dm_session_title(user_name, user_id, chat_id)) + if self.use_fixed_resolver: + self.store.chat_sessions[chat_id] = row.id + return row.id + + def on_sessions_switch(self, chat_id: int, session_id: str) -> None: + self.store.touch(session_id) + self.store.chat_sessions[chat_id] = session_id + + def on_auto_title_complete(self, session_id: str, llm_title: str) -> None: + maybe_run_auto_title(self.store, session_id, "seed", llm_title) diff --git a/tools/telegram_session_sim/test_session_sim.py b/tools/telegram_session_sim/test_session_sim.py new file mode 100644 index 00000000..fc253e6d --- /dev/null +++ b/tools/telegram_session_sim/test_session_sim.py @@ -0,0 +1,133 @@ +"""TDD contract tests for Telegram session resolve (issue #121). + +Correct-behavior tests are parametrized over the resolver: + + production β€” mirrors current handler (suffix + naive label drift) + fixed β€” chat_sessions map + should_refresh_label + +Run: + pytest -q # expect FAILURES on [production] (red) + pytest -q -k fixed # green when developing the fix + pytest -q -k production # red until production matches fixed +""" + +from __future__ import annotations + +import pytest + +from .helpers import ( + build_dm_session_title, + compose_auto_title, + is_default_channel_title, + should_refresh_label, +) +from .router import SessionStore, maybe_run_auto_title +from .sim import TelegramSessionSim + +RESOLVERS = ( + pytest.param(False, id="production"), + pytest.param(True, id="fixed"), +) + + +# ── Contract tests (correct behavior) ───────────────────────────────────── + + +@pytest.mark.parametrize("use_fixed_resolver", RESOLVERS) +def test_auto_title_survives_second_message(use_fixed_resolver: bool): + """After auto-title, message 2 must not revert to the default DM template.""" + sim = TelegramSessionSim(use_fixed_resolver=use_fixed_resolver) + chat_id = 133526395 + sid = sim.on_message(chat_id, "Alexey", 133526395, "fix deploy pipeline") + title_after_first = sim.store.rows[sid].title + assert not is_default_channel_title(title_after_first), "auto-title should run on msg 1" + + sim.on_message(chat_id, "Alexey", 133526395, "second message") + title_after_second = sim.store.rows[sid].title + + assert not is_default_channel_title(title_after_second), ( + "label drift must not clobber auto-titled session (#121)" + ) + assert title_after_second == title_after_first + + +@pytest.mark.parametrize("use_fixed_resolver", RESOLVERS) +def test_switch_survives_updated_at_race(use_fixed_resolver: bool): + """After /sessions switch, a background touch on another row must not steal routing.""" + store = SessionStore() + chat_id = 42 + a = store.create(build_dm_session_title("A", 1, chat_id)) + b = store.create(build_dm_session_title("A", 1, chat_id)) + store.touch(a.id) + + sim = TelegramSessionSim(use_fixed_resolver=use_fixed_resolver) + sim.store = store + sim.on_sessions_switch(chat_id, a.id) + + # Simulate RSI / another session row getting a newer updated_at + store.touch(b.id) + + sid = sim.on_message(chat_id, "A", 1, "ping") + assert sid == a.id, "message must route to the session user switched to" + + +@pytest.mark.parametrize("use_fixed_resolver", RESOLVERS) +def test_new_then_switch_back_uses_older_session(use_fixed_resolver: bool): + """/new creates B; switch to A; next message must hit A.""" + sim = TelegramSessionSim(use_fixed_resolver=use_fixed_resolver) + chat_id = 200 + sid_a = sim.on_message(chat_id, "U", 1, "first topic") + sid_b = sim.on_new(chat_id, "U", 1, is_owner=True) + assert sid_a != sid_b + + sim.on_sessions_switch(chat_id, sid_a) + sid_msg = sim.on_message(chat_id, "U", 1, "back to first") + assert sid_msg == sid_a + + +# ── Unit tests (pure helpers, always green) ─────────────────────────────── + + +def test_auto_title_retries_after_llm_failure(): + store = SessionStore() + row = store.create(build_dm_session_title("U", 1, 99)) + maybe_run_auto_title(store, row.id, "hello", "", llm_failed=True) + assert not store.rows[row.id].auto_title_attempted + maybe_run_auto_title(store, row.id, "hello", "Deploy fix") + assert not is_default_channel_title(store.rows[row.id].title) + + +def test_suffix_lookup_picks_most_recent_without_switch(): + store = SessionStore() + chat_id = 7 + older = store.create(build_dm_session_title("U", 1, chat_id)) + newer = store.create(build_dm_session_title("U", 1, chat_id)) + store.touch(older.id) + store.touch(newer.id) + hit = store.find_by_title_suffix(f"[chat:{chat_id}]") + assert hit is not None + assert hit.id == newer.id + + +def test_should_refresh_label_group_rename(): + old = "Telegram: Old Group [chat:-1]" + new = "Telegram: New Group [chat:-1]" + assert should_refresh_label(old, new) is True + + +def test_should_refresh_label_skips_auto_titled_dm(): + auto = "Telegram: Fix deploy [chat:133526395]" + template = build_dm_session_title("Alexey", 133526395, 133526395) + assert should_refresh_label(auto, template) is False + + +def test_compose_auto_title_preserves_suffix(): + old = build_dm_session_title("A", 1, 42) + out = compose_auto_title(old, '"Deploy fix"') + assert out.endswith("[chat:42]") + assert "Deploy fix" in out + + +def test_default_dm_title_is_detected(): + t = build_dm_session_title("Alice", 1, 99) + assert is_default_channel_title(t) diff --git a/tools/telegram_session_sim/validate-hermes.sh b/tools/telegram_session_sim/validate-hermes.sh new file mode 100755 index 00000000..a39c675f --- /dev/null +++ b/tools/telegram_session_sim/validate-hermes.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Manual Hermes validation checklist for Telegram session fix (issue #121). +# Run from Mac after deploying opencrabs with session_resolve changes. +set -euo pipefail + +echo "=== Hermes Telegram session validation ===" +echo "Manual steps in @oc_l1979_bot or ops bot:" +echo " 1. /new β†’ send one message β†’ wait 10s for auto-title" +echo " 2. Send second message β†’ /sessions list must show non-default title" +echo " 3. /sessions β†’ switch older session β†’ send ping β†’ verify context" +echo "" + +if ! command -v ssh >/dev/null; then + echo "ssh not found; skipping DB snapshot" + exit 0 +fi + +if ssh -o BatchMode=yes -o ConnectTimeout=10 hermes true 2>/dev/null; then + echo "=== Recent Telegram sessions (ops profile) ===" + ssh hermes 'sqlite3 ~/.opencrabs/profiles/ops/opencrabs.db \ + "SELECT substr(title,1,70), auto_title_attempted, datetime(updated_at) \ + FROM sessions WHERE title LIKE \"%Telegram%\" AND archived_at IS NULL \ + ORDER BY updated_at DESC LIMIT 8;"' || true +else + echo "ssh hermes unavailable β€” run DB query manually (see README.md)" +fi From 21b09015f35c9849dd9887739dd80fdce43e7cdf Mon Sep 17 00:00:00 2001 From: Alexey Leshchenko Date: Tue, 26 May 2026 13:31:29 +0300 Subject: [PATCH 2/6] =?UTF-8?q?fix(telegram):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20idle=20on=20bound,=20/new=20title=20helper,=20drop?= =?UTF-8?q?=20Python=20sim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply session_idle_expired to chat-bound path (parity with suffix resolve) - Use session_resolve::build_session_title for /new - Add choose_resolve_source + TelegramState policy tests - Remove tools/telegram_session_sim from upstream (lives in vds/servers repo) --- src/channels/telegram/handler.rs | 84 ++++++--- src/channels/telegram/session_resolve.rs | 50 ++++++ src/tests/telegram_session_resolve_test.rs | 29 ++- tools/telegram_session_sim/.gitignore | 1 - tools/telegram_session_sim/README.md | 60 ------- tools/telegram_session_sim/__init__.py | 15 -- .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 475 bytes .../__pycache__/helpers.cpython-314.pyc | Bin 0 -> 6092 bytes .../__pycache__/router.cpython-314.pyc | Bin 0 -> 9971 bytes .../__pycache__/sim.cpython-314.pyc | Bin 0 -> 3910 bytes ...t_session_sim.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 18292 bytes tools/telegram_session_sim/helpers.py | 105 ----------- tools/telegram_session_sim/pyproject.toml | 8 - tools/telegram_session_sim/pytest.ini | 3 - tools/telegram_session_sim/router.py | 167 ------------------ tools/telegram_session_sim/sim.py | 57 ------ .../telegram_session_sim/test_session_sim.py | 133 -------------- tools/telegram_session_sim/validate-hermes.sh | 26 --- 18 files changed, 133 insertions(+), 605 deletions(-) delete mode 100644 tools/telegram_session_sim/.gitignore delete mode 100644 tools/telegram_session_sim/README.md delete mode 100644 tools/telegram_session_sim/__init__.py create mode 100644 tools/telegram_session_sim/__pycache__/__init__.cpython-314.pyc create mode 100644 tools/telegram_session_sim/__pycache__/helpers.cpython-314.pyc create mode 100644 tools/telegram_session_sim/__pycache__/router.cpython-314.pyc create mode 100644 tools/telegram_session_sim/__pycache__/sim.cpython-314.pyc create mode 100644 tools/telegram_session_sim/__pycache__/test_session_sim.cpython-314-pytest-8.4.2.pyc delete mode 100644 tools/telegram_session_sim/helpers.py delete mode 100644 tools/telegram_session_sim/pyproject.toml delete mode 100644 tools/telegram_session_sim/pytest.ini delete mode 100644 tools/telegram_session_sim/router.py delete mode 100644 tools/telegram_session_sim/sim.py delete mode 100644 tools/telegram_session_sim/test_session_sim.py delete mode 100755 tools/telegram_session_sim/validate-hermes.sh diff --git a/src/channels/telegram/handler.rs b/src/channels/telegram/handler.rs index 60d220d8..c1b1c113 100644 --- a/src/channels/telegram/handler.rs +++ b/src/channels/telegram/handler.rs @@ -847,7 +847,6 @@ pub(crate) async fn handle_message( } }); - // Resolve session: owner shares the TUI session, other users get their own. // Owner = first user in the config's allowed_users list (Vec order, not HashSet). let owner_id = tg_cfg .allowed_users @@ -899,26 +898,57 @@ pub(crate) async fn handle_message( && let Ok(Some(bound)) = session_svc.get_session(bound_id).await && !bound.is_archived() { - if session_resolve::should_refresh_label( - bound.title.as_deref().unwrap_or(""), - &session_title, - ) { - let mut renamed = bound.clone(); - renamed.title = Some(session_title.clone()); - if let Err(e) = session_svc.update_session(&renamed).await { - tracing::warn!( - "Telegram: failed to refresh session {} label: {}", - bound_id, + if session_resolve::session_idle_expired(bound.updated_at, idle_timeout_hours) { + if let Err(e) = session_svc.archive_session(bound.id).await { + tracing::error!( + "Telegram: failed to archive idle chat-bound session {}: {}", + bound.id, e ); } + match crate::channels::session_init::create_channel_session( + &session_svc, + Some(session_title.clone()), + ) + .await + { + Ok(new_session) => { + tracing::info!( + "Telegram: idle-timeout reset (chat-bound) β€” new session {} for \"{}\"", + new_session.id, + session_title, + ); + new_session.id + } + Err(e) => { + tracing::error!("Telegram: failed to create session: {}", e); + bot.send_message(msg.chat.id, "Internal error creating session.") + .await?; + return Ok(()); + } + } + } else { + if session_resolve::should_refresh_label( + bound.title.as_deref().unwrap_or(""), + &session_title, + ) { + let mut renamed = bound.clone(); + renamed.title = Some(session_title.clone()); + if let Err(e) = session_svc.update_session(&renamed).await { + tracing::warn!( + "Telegram: failed to refresh session {} label: {}", + bound_id, + e + ); + } + } + tracing::debug!( + "Telegram: using chat-bound session {} for chat_id={}", + bound_id, + chat_id + ); + bound_id } - tracing::debug!( - "Telegram: using chat-bound session {} for chat_id={}", - bound_id, - chat_id - ); - bound_id } else { // 1) Stable lookup: any session whose title ends with the chat_id // suffix is THIS chat regardless of how the label has changed. @@ -955,10 +985,7 @@ pub(crate) async fn handle_message( } if let Some(session) = existing { - if idle_timeout_hours.is_some_and(|h| { - let elapsed = (chrono::Utc::now() - session.updated_at).num_seconds(); - elapsed > (h * 3600.0) as i64 - }) { + if session_resolve::session_idle_expired(session.updated_at, idle_timeout_hours) { if let Err(e) = session_svc.archive_session(session.id).await { tracing::error!("Telegram: failed to archive session {}: {}", session.id, e); } @@ -1115,14 +1142,13 @@ pub(crate) async fn handle_message( // via `find_session_by_title_suffix` and resolution // reverts to the previously-bound session β€” i.e. /new // appears to do nothing (issue #89). - let session_title = if is_dm { - format!( - "Telegram: DM {} ({}) {}", - user.first_name, user_id, chat_id_suffix - ) - } else { - format!("Telegram: {} {}", chat_title, chat_id_suffix) - }; + let session_title = session_resolve::build_session_title( + is_dm, + &user.first_name, + user_id, + &chat_title, + chat_id, + ); // Archive the previous session on /new, except for the owner β€” // owner sessions stay non-archived so they remain visible in // /sessions for history review. Guest sessions get archived diff --git a/src/channels/telegram/session_resolve.rs b/src/channels/telegram/session_resolve.rs index 97eae1a5..d0278dae 100644 --- a/src/channels/telegram/session_resolve.rs +++ b/src/channels/telegram/session_resolve.rs @@ -32,6 +32,36 @@ pub fn chat_id_suffix(chat_id: i64) -> String { format!("[chat:{chat_id}]") } +/// True when a session exceeded the configured idle window (same rule as handler suffix path). +pub fn session_idle_expired(updated_at: chrono::DateTime, idle_hours: Option) -> bool { + idle_hours.is_some_and(|h| { + let elapsed = (chrono::Utc::now() - updated_at).num_seconds(); + elapsed > (h * 3600.0) as i64 + }) +} + +/// Handler resolve policy: explicit chat binding wins over suffix `updated_at` winner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolveSource { + ChatBound, + Suffix, + Create, +} + +pub fn choose_resolve_source( + chat_bound: Option, + bound_archived: bool, + suffix_match: Option, +) -> ResolveSource { + if chat_bound.is_some() && !bound_archived { + ResolveSource::ChatBound + } else if suffix_match.is_some() { + ResolveSource::Suffix + } else { + ResolveSource::Create + } +} + /// Whether to overwrite a stored session title with the freshly built template. /// /// - Default DM titles: refresh when the template default changed (display name). @@ -106,4 +136,24 @@ mod tests { let new = build_session_title(true, "Bob", 1, "", 99); assert!(should_refresh_label(&old, &new)); } + + #[test] + fn chat_bound_wins_over_suffix_candidate() { + let a = uuid::Uuid::new_v4(); + let b = uuid::Uuid::new_v4(); + assert_eq!( + choose_resolve_source(Some(a), false, Some(b)), + ResolveSource::ChatBound + ); + } + + #[test] + fn archived_bound_falls_through_to_suffix() { + let a = uuid::Uuid::new_v4(); + let b = uuid::Uuid::new_v4(); + assert_eq!( + choose_resolve_source(Some(a), true, Some(b)), + ResolveSource::Suffix + ); + } } diff --git a/src/tests/telegram_session_resolve_test.rs b/src/tests/telegram_session_resolve_test.rs index be7d6b1a..75e390aa 100644 --- a/src/tests/telegram_session_resolve_test.rs +++ b/src/tests/telegram_session_resolve_test.rs @@ -1,8 +1,11 @@ //! Integration tests for Telegram session title + label drift (issue #121). use crate::channels::telegram::session_resolve::{ - build_session_title, chat_id_suffix, should_refresh_label, + build_session_title, chat_id_suffix, choose_resolve_source, should_refresh_label, + ResolveSource, }; +use crate::channels::telegram::TelegramState; +use uuid::Uuid; use crate::db::Database; use crate::db::models::Session; use crate::db::repository::SessionRepository; @@ -17,6 +20,30 @@ async fn fresh_repo() -> (Database, SessionRepository) { (db, repo) } +#[test] +fn resolve_policy_prefers_chat_bound_over_suffix_winner() { + let bound = Uuid::new_v4(); + let suffix = Uuid::new_v4(); + assert_eq!( + choose_resolve_source(Some(bound), false, Some(suffix)), + ResolveSource::ChatBound + ); +} + +#[tokio::test] +async fn telegram_state_chat_map_survives_suffix_competition() { + let state = TelegramState::new(); + let chat_id = 4242_i64; + let bound = Uuid::new_v4(); + let suffix_winner = Uuid::new_v4(); + state.register_session_chat(bound, chat_id).await; + assert_eq!(state.chat_session(chat_id).await, Some(bound)); + assert_eq!( + choose_resolve_source(state.chat_session(chat_id).await, false, Some(suffix_winner)), + ResolveSource::ChatBound + ); +} + #[test] fn should_not_clobber_auto_titled_dm_title() { let auto = "Telegram: Fix deploy pipeline [chat:133526395]"; diff --git a/tools/telegram_session_sim/.gitignore b/tools/telegram_session_sim/.gitignore deleted file mode 100644 index c18dd8d8..00000000 --- a/tools/telegram_session_sim/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__/ diff --git a/tools/telegram_session_sim/README.md b/tools/telegram_session_sim/README.md deleted file mode 100644 index eb0f3d13..00000000 --- a/tools/telegram_session_sim/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Telegram session simulator - -Fast TDD harness for OpenCrabs Telegram session routing ([issue #121](https://github.com/adolfousier/opencrabs/issues/121)) without compiling Rust locally. - -## Run (proper TDD) - -Contract tests assert **correct** behavior and are parametrized as `[production]` vs `[fixed]`: - -```bash -cd tools/telegram_session_sim - -# Full suite: production params FAIL (red), fixed params PASS (green), units PASS -python3 -m pytest -q - -# While developing the fix β€” only the green path -python3 -m pytest -q -k fixed - -# Prove production still has the bug (all red on contract tests) -python3 -m pytest -q -k production -``` - -Expected on current code before upstream handler matches `fixed`: - -| Command | Result | -|---------|--------| -| `pytest -q` | 3 failed `[production]`, 3 passed `[fixed]`, 6 unit tests passed | -| `pytest -q -k fixed` | all green | -| `pytest -q -k production` | 3 failed (intentional red) | - -When the Rust fix is ported and the sim’s `production` resolver is updated to match, `pytest -q` goes fully green. - -## What it models - -- Suffix lookup (`[chat:ID]` β†’ newest `updated_at`) -- Label drift (buggy: full-title compare; fixed: `should_refresh_label`) -- Auto-title composition (preserve `[chat:ID]`) -- `/sessions` switch via `chat_sessions` map (fixed resolver only) - -## Resolvers - -| Param id | `use_fixed_resolver` | Role in TDD | -|----------|----------------------|-------------| -| `production` | `False` | Red until bug fixed (`resolve_suffix_only` + naive label drift) | -| `fixed` | `True` | Green target (`resolve_with_chat_map` + `should_refresh_label`) | - -Upstream Rust tests: `src/tests/telegram_session_resolve_test.rs` and `src/channels/telegram/session_resolve.rs`. - -## Hermes validation (manual) - -After deploying a build with the fix: - -1. `/new` β†’ send a message β†’ wait ~10s for auto-title. -2. Send a **second** message β€” title in `/sessions` must stay non-default. -3. `/sessions` β†’ switch an older session β†’ send `ping` β€” reply should use that session's context. - -```bash -ssh hermes 'sqlite3 ~/.opencrabs/profiles/ops/opencrabs.db \ - "SELECT substr(title,1,60), auto_title_attempted, updated_at FROM sessions \ - WHERE title LIKE \"%Telegram%\" ORDER BY updated_at DESC LIMIT 5;"' -``` diff --git a/tools/telegram_session_sim/__init__.py b/tools/telegram_session_sim/__init__.py deleted file mode 100644 index 8c98301c..00000000 --- a/tools/telegram_session_sim/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Telegram session routing simulator for OpenCrabs issue #121.""" - -from .helpers import ( - compose_auto_title, - is_default_channel_title, - should_refresh_label, -) -from .sim import TelegramSessionSim - -__all__ = [ - "TelegramSessionSim", - "compose_auto_title", - "is_default_channel_title", - "should_refresh_label", -] diff --git a/tools/telegram_session_sim/__pycache__/__init__.cpython-314.pyc b/tools/telegram_session_sim/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5de1d3ceb16185ee74fb7ed0216ccae39b78632f GIT binary patch literal 475 zcmZ8eu};G<5Vez(qE;|eATiNMWr>g)wR6P)OIZK~SUIK1mAE)@u$=)W#NY5qWaS5d z3bAs5cHtyHo$dF0_s)mgTOCGmbicc};EcUBn{_y=WI3kfiABt^8y@jfK6R`!b*($~ ztk-YOoYU5H!*2B1kVW1(x9!Lsu%Pugxk8DzI?jEA#t2pTT2)pQx4sd1C1b0!Kc{tG zVsWD5nel}&75anGcr*;$x|gWDR0bigtO6^n#ClH{NO2xl(n6BOMS*g4vtu$<$rLos zHJS`$JVP1qy0`A)@~??Yk)H*gZjme9c+_qK9zn2Kw*gX>0MzXaWr^DSqtq_7{;O@M zv6KKWY)M*HN8z<0K`5zsl3{VD!bGLy7D}xam{=1kGMY3-L#vb|T+Iho^8tE11Q3O= y0K@X3-kjtrtt1}lEh<4j#LPayyURI$Wyjy1$DI#G@YP}b;L~e$o#(dh66FVCiHMT` literal 0 HcmV?d00001 diff --git a/tools/telegram_session_sim/__pycache__/helpers.cpython-314.pyc b/tools/telegram_session_sim/__pycache__/helpers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c92a6e84d13a3faa76ae329a1ae4062a71d1b93 GIT binary patch literal 6092 zcmbVQU2qfE72eg~%97}CdanwgsZOw@*%>d}wp&2Ob|Joa&IF(a|V44roRkT+cFVTPCXyLVSBf$czhXZGH+ zd(YmVbH4L)??_v~$Dy40dDj<0E64qVzSN7uB=~241X1K6`_xec_)XrJ5;dJm)h zau9R}qg&-p&|QoU$a_Ovq-W{e*X=>w79P!$}gi45z9Q!$TCUK(hBZ%;A~Mk%fe& z(xC^8kE1`sLeywRQRkEMO7>0Q*s+(d_vZN+AGXGsV*-^S#Ori^R$?~ijnk*@ zLg+wh9~a|g=MlOmtAy}{ir(M{3_fgdVZ&LkH_rUb$vp8RC|4IZCjtsL)W+C+Pw8qcAclC z&hZ2D*gS7CkL&}^{Y&0y<(4!tpU|$p@LjcB!nid3!g=mP$>4_#KJudFTv|=$NNOA( zLNA0XnMCsDxHNUoVfa)nK{WMNTAL>jC3nw3de9gyqNv(SM$wa2<0)k}QOIcV{U&bwF2eU+{uy=zz(Bg?Lu z*zxV{JGaY)ia1ymCv|ahHMG`wZ^WeUhaPnPWkmN*Hb(MSg+m*{A#xDhzB0119^v?{4V$K^&@z$JWJTYoUsGQum&GF}xPJ>(*zEYv-nb zTv!ix+4h-sC%odT_%nU%#Xb&w1;p@Ke%Q-HH`#2h=ROLo4hE;Gl7!TVEZ8*#tzd-kR989C+jp zG{jd|BQ+5wK8lDRN~2^KQYk0ht{L!Ja+#FncIX&JOD3~my$g7Mt+L+%lX{R%`W(^j z2#s||sIt+lnazFZ9B<~Lt&|r(H06E|+5gw;n`tsFs?EB4H_a;sb81Rq8i;USrwIrv zvBpK3l$7{#^Hu=&q6NV5Hd+10!`8KLAFT>;@b?03?-_U;(m#v^}0pEGW$H8v=Vz zrwnI0s~G~5PCCVsv)6+2CzxL~7xA2P@TWIvwUT^Fo3#nnJOD=V_E%sOjky#p~=YwDHV?Rxs6nljyn$JCOK>7glEW;>uudNa*@08ZO&@chY5 z4%I4jf#$xGH4mKZ<94?}je^0elWiUHspyx=gkhhP2=Jqb&2ew?Al0nvi?y|4+^~$@e@2*DHj_Lk)bk{rEB}?_l z7Adj6c9$$YeGoeIYteiWOm>i{n+_BwutS89tpCfl~Z z3|qfh$3>nd-wkiuIC!#1yW6p1mq<&aEbN4%E|1v>RnpWnLB7NhW-|knI)v(_1gIa0 zvVAn)NkfD;-a1vx=((G>vtD zmlX^A?q`3;>#q9TB{ik7^Cf-Se5O8kjG#>sI0Y9)F9>#uld)*FQQ{doKlvCuOk)a&BKNmfL52g- za_e@O(cn8-2^=m?JrM$>Lw`E@hodW=YB0PJ4CAC$n!c-+Pk#4{Qbrd;Rq^n;c(@`S z!6D8s7B4oGUkBlvx@!l*s+(nbmb><-T8=0wGopGmWg(wQXo~5o_hCP;^*wPg+5XzT z-~d)ZufboVx$kPa=Wu|(UM$mbKda$Ok-NsfoVGWmecRkn3TNj&u8G42Y}362OoHA^ zVB9(CSen4LeLTcVj7Rm&%JwsyH6t}MlfxdP$vEs)m@~5DgJbDOQ|Yg8JIdg4G)2<0 z8ZJyT>Exm`oL1F>B883}KPHVx%!Nrrp*fC(>w=kdCao<-MyG(xAxuiBKVx{qrP>ur zGu%;;4q4%$P0U`_~c2;s+Kc-31G zk5$F4jXXkWtQ% zPti+lMy0<#b@Dv_i2Lb3xq+uHPp3oY+P8wZpWO-qv|IaeC9u_oBk5Kr&L~^^kpyn} Ukc4b`5mmQ>@WIdbf{#t}U)Wh%2><{9 literal 0 HcmV?d00001 diff --git a/tools/telegram_session_sim/__pycache__/router.cpython-314.pyc b/tools/telegram_session_sim/__pycache__/router.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef0161b11177320bef39ea94827c1003e7a9f891 GIT binary patch literal 9971 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 Kr9MH*5BOiBb`7oo literal 0 HcmV?d00001 diff --git a/tools/telegram_session_sim/__pycache__/test_session_sim.cpython-314-pytest-8.4.2.pyc b/tools/telegram_session_sim/__pycache__/test_session_sim.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ebf752196e740537312f831dc5d583ab6741155 GIT binary patch literal 18292 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 literal 0 HcmV?d00001 diff --git a/tools/telegram_session_sim/helpers.py b/tools/telegram_session_sim/helpers.py deleted file mode 100644 index c1e6cba4..00000000 --- a/tools/telegram_session_sim/helpers.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Pure helpers ported from opencrabs src/brain/agent/service/types.rs.""" - -from __future__ import annotations - -CHANNEL_PREFIXES = ( - "Telegram: ", - "Discord: ", - "Slack: ", - "WhatsApp: ", - "Trello: ", -) - - -def clean_auto_title(raw: str) -> str: - trimmed = raw.strip().strip('"').strip("'") - if not trimmed: - return "" - if len(trimmed) > 60: - return trimmed[:60] - return trimmed - - -def is_default_channel_title(title: str) -> bool: - if title == "New Chat": - return True - if title.startswith("Telegram: "): - rest = title[len("Telegram: ") :] - return rest.startswith("DM ") and "(" in rest and ")" in rest - if title.startswith("Discord: "): - return title[len("Discord: ") :].startswith("#") - if title.startswith("Slack: "): - return title[len("Slack: ") :].startswith("#") - return False - - -def extract_channel_prefix(title: str) -> str: - for prefix in CHANNEL_PREFIXES: - if title.startswith(prefix): - return prefix - return "" - - -def extract_chat_id_suffix(title: str) -> str: - pos = title.rfind("[chat:") - if pos == -1: - return "" - suffix = title[pos:] - if suffix.endswith("]"): - return suffix - return "" - - -def compose_auto_title(old_title: str, llm_title: str) -> str: - clean = clean_auto_title(llm_title) - if not clean: - return old_title - prefix = extract_channel_prefix(old_title) - chat_suffix = extract_chat_id_suffix(old_title) - if not prefix: - return f"{clean} {chat_suffix}".strip() if chat_suffix else clean - if not chat_suffix: - return f"{prefix}{clean}" - return f"{prefix}{clean} {chat_suffix}" - - -def build_dm_session_title(user_name: str, user_id: int, chat_id: int) -> str: - suffix = f"[chat:{chat_id}]" - return f"Telegram: DM {user_name} ({user_id}) {suffix}" - - -def build_group_session_title(chat_title: str, chat_id: int) -> str: - return f"Telegram: {chat_title} [chat:{chat_id}]" - - -def is_telegram_group_session_title(title: str) -> bool: - if not title.startswith("Telegram: "): - return False - rest = title[len("Telegram: ") :] - if rest.startswith("DM "): - return False - return "[chat:" in title - - -def telegram_middle_label(title: str) -> str: - """Label between 'Telegram: ' and ' [chat:N]'.""" - if not title.startswith("Telegram: "): - return title - body = title[len("Telegram: ") :] - suffix = extract_chat_id_suffix(title) - if suffix and body.endswith(suffix): - body = body[: -len(suffix)].rstrip() - return body - - -def should_refresh_label(stored: str, template: str) -> bool: - """Fixed label-drift policy (issue #121 + group rename stability).""" - if stored == template: - return False - if is_default_channel_title(stored): - return is_default_channel_title(template) and stored != template - if is_telegram_group_session_title(stored) and is_telegram_group_session_title( - template - ): - return telegram_middle_label(stored) != telegram_middle_label(template) - return False diff --git a/tools/telegram_session_sim/pyproject.toml b/tools/telegram_session_sim/pyproject.toml deleted file mode 100644 index ac8d9894..00000000 --- a/tools/telegram_session_sim/pyproject.toml +++ /dev/null @@ -1,8 +0,0 @@ -[project] -name = "telegram-session-sim" -version = "0.1.0" -requires-python = ">=3.10" -dependencies = [] - -[project.optional-dependencies] -dev = ["pytest>=8.0"] diff --git a/tools/telegram_session_sim/pytest.ini b/tools/telegram_session_sim/pytest.ini deleted file mode 100644 index 2efd58fd..00000000 --- a/tools/telegram_session_sim/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -markers = - contract: correct-behavior tests parametrized over production vs fixed resolver diff --git a/tools/telegram_session_sim/router.py b/tools/telegram_session_sim/router.py deleted file mode 100644 index cc5c0384..00000000 --- a/tools/telegram_session_sim/router.py +++ /dev/null @@ -1,167 +0,0 @@ -"""In-memory session router mirroring Telegram handler resolve logic.""" - -from __future__ import annotations - -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -from typing import Callable - -from .helpers import ( - build_dm_session_title, - compose_auto_title, - is_default_channel_title, - should_refresh_label, -) - -UtcNow = Callable[[], datetime] - - -def _utc_now() -> datetime: - return datetime.now(timezone.utc) - - -@dataclass -class SessionRow: - id: str - title: str - auto_title_attempted: bool = False - updated_at: datetime = field(default_factory=_utc_now) - archived: bool = False - - -class SessionStore: - def __init__(self, now: UtcNow | None = None) -> None: - self.rows: dict[str, SessionRow] = {} - self.chat_sessions: dict[int, str] = {} - self._now = now or _utc_now - self._tick = 0 - - def _bump(self, row: SessionRow) -> None: - self._tick += 1 - row.updated_at = self._now() + timedelta(seconds=self._tick) - - def create(self, title: str) -> SessionRow: - row = SessionRow(id=str(uuid.uuid4()), title=title) - self.rows[row.id] = row - self._bump(row) - return row - - def find_by_title_suffix(self, suffix: str) -> SessionRow | None: - matches = [ - r - for r in self.rows.values() - if not r.archived and r.title.endswith(suffix) - ] - if not matches: - return None - return max(matches, key=lambda r: r.updated_at) - - def touch(self, session_id: str) -> None: - row = self.rows[session_id] - self._bump(row) - - def set_title(self, session_id: str, title: str) -> None: - row = self.rows[session_id] - row.title = title - self._bump(row) - - def mark_auto_title_attempted(self, session_id: str) -> None: - self.rows[session_id].auto_title_attempted = True - - def reset_auto_title_attempted(self, session_id: str) -> None: - self.rows[session_id].auto_title_attempted = False - - -def _apply_label_drift( - store: SessionStore, - row: SessionRow, - template: str, - *, - safe: bool, -) -> None: - if safe: - if should_refresh_label(row.title, template): - store.set_title(row.id, template) - else: - # Production bug: any mismatch resets to template - if row.title != template: - store.set_title(row.id, template) - - -def resolve_suffix_only( - store: SessionStore, - chat_id: int, - user_name: str, - user_id: int, - *, - is_dm: bool = True, - chat_title: str = "", -) -> SessionRow: - template = ( - build_dm_session_title(user_name, user_id, chat_id) - if is_dm - else build_group_session_title(chat_title or "Group", chat_id) - ) - suffix = f"[chat:{chat_id}]" - existing = store.find_by_title_suffix(suffix) - if existing: - _apply_label_drift(store, existing, template, safe=False) - return store.rows[existing.id] - return store.create(template) - - -def resolve_with_chat_map( - store: SessionStore, - chat_id: int, - user_name: str, - user_id: int, - *, - is_dm: bool = True, - chat_title: str = "", -) -> SessionRow: - template = ( - build_dm_session_title(user_name, user_id, chat_id) - if is_dm - else build_group_session_title(chat_title or "Group", chat_id) - ) - suffix = f"[chat:{chat_id}]" - - bound = store.chat_sessions.get(chat_id) - if bound and bound in store.rows and not store.rows[bound].archived: - row = store.rows[bound] - _apply_label_drift(store, row, template, safe=True) - return row - - existing = store.find_by_title_suffix(suffix) - if existing: - _apply_label_drift(store, existing, template, safe=True) - store.chat_sessions[chat_id] = existing.id - return store.rows[existing.id] - - row = store.create(template) - store.chat_sessions[chat_id] = row.id - return row - - -def maybe_run_auto_title( - store: SessionStore, - session_id: str, - user_message: str, - llm_title: str, - *, - llm_failed: bool = False, -) -> None: - row = store.rows[session_id] - if llm_failed: - store.reset_auto_title_attempted(session_id) - return - if row.auto_title_attempted: - return - if user_message.strip() and ( - not row.title or is_default_channel_title(row.title) - ): - store.mark_auto_title_attempted(session_id) - new_title = compose_auto_title(row.title, llm_title) - if new_title: - store.set_title(session_id, new_title) diff --git a/tools/telegram_session_sim/sim.py b/tools/telegram_session_sim/sim.py deleted file mode 100644 index ca2f4700..00000000 --- a/tools/telegram_session_sim/sim.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Event API for Telegram session simulation.""" - -from __future__ import annotations - -from .helpers import build_dm_session_title -from .router import ( - SessionStore, - maybe_run_auto_title, - resolve_suffix_only, - resolve_with_chat_map, -) - - -class TelegramSessionSim: - def __init__(self, *, use_fixed_resolver: bool = False) -> None: - self.store = SessionStore() - self.use_fixed_resolver = use_fixed_resolver - self._resolve = ( - resolve_with_chat_map if use_fixed_resolver else resolve_suffix_only - ) - - def on_message( - self, - chat_id: int, - user_name: str, - user_id: int, - text: str = "hello", - *, - is_dm: bool = True, - chat_title: str = "", - ) -> str: - row = self._resolve( - self.store, chat_id, user_name, user_id, is_dm=is_dm, chat_title=chat_title - ) - llm_title = " ".join(text.split()[:5]) or "New topic" - maybe_run_auto_title(self.store, row.id, text, llm_title) - return row.id - - def on_new( - self, - chat_id: int, - user_name: str, - user_id: int, - *, - is_owner: bool = True, - ) -> str: - row = self.store.create(build_dm_session_title(user_name, user_id, chat_id)) - if self.use_fixed_resolver: - self.store.chat_sessions[chat_id] = row.id - return row.id - - def on_sessions_switch(self, chat_id: int, session_id: str) -> None: - self.store.touch(session_id) - self.store.chat_sessions[chat_id] = session_id - - def on_auto_title_complete(self, session_id: str, llm_title: str) -> None: - maybe_run_auto_title(self.store, session_id, "seed", llm_title) diff --git a/tools/telegram_session_sim/test_session_sim.py b/tools/telegram_session_sim/test_session_sim.py deleted file mode 100644 index fc253e6d..00000000 --- a/tools/telegram_session_sim/test_session_sim.py +++ /dev/null @@ -1,133 +0,0 @@ -"""TDD contract tests for Telegram session resolve (issue #121). - -Correct-behavior tests are parametrized over the resolver: - - production β€” mirrors current handler (suffix + naive label drift) - fixed β€” chat_sessions map + should_refresh_label - -Run: - pytest -q # expect FAILURES on [production] (red) - pytest -q -k fixed # green when developing the fix - pytest -q -k production # red until production matches fixed -""" - -from __future__ import annotations - -import pytest - -from .helpers import ( - build_dm_session_title, - compose_auto_title, - is_default_channel_title, - should_refresh_label, -) -from .router import SessionStore, maybe_run_auto_title -from .sim import TelegramSessionSim - -RESOLVERS = ( - pytest.param(False, id="production"), - pytest.param(True, id="fixed"), -) - - -# ── Contract tests (correct behavior) ───────────────────────────────────── - - -@pytest.mark.parametrize("use_fixed_resolver", RESOLVERS) -def test_auto_title_survives_second_message(use_fixed_resolver: bool): - """After auto-title, message 2 must not revert to the default DM template.""" - sim = TelegramSessionSim(use_fixed_resolver=use_fixed_resolver) - chat_id = 133526395 - sid = sim.on_message(chat_id, "Alexey", 133526395, "fix deploy pipeline") - title_after_first = sim.store.rows[sid].title - assert not is_default_channel_title(title_after_first), "auto-title should run on msg 1" - - sim.on_message(chat_id, "Alexey", 133526395, "second message") - title_after_second = sim.store.rows[sid].title - - assert not is_default_channel_title(title_after_second), ( - "label drift must not clobber auto-titled session (#121)" - ) - assert title_after_second == title_after_first - - -@pytest.mark.parametrize("use_fixed_resolver", RESOLVERS) -def test_switch_survives_updated_at_race(use_fixed_resolver: bool): - """After /sessions switch, a background touch on another row must not steal routing.""" - store = SessionStore() - chat_id = 42 - a = store.create(build_dm_session_title("A", 1, chat_id)) - b = store.create(build_dm_session_title("A", 1, chat_id)) - store.touch(a.id) - - sim = TelegramSessionSim(use_fixed_resolver=use_fixed_resolver) - sim.store = store - sim.on_sessions_switch(chat_id, a.id) - - # Simulate RSI / another session row getting a newer updated_at - store.touch(b.id) - - sid = sim.on_message(chat_id, "A", 1, "ping") - assert sid == a.id, "message must route to the session user switched to" - - -@pytest.mark.parametrize("use_fixed_resolver", RESOLVERS) -def test_new_then_switch_back_uses_older_session(use_fixed_resolver: bool): - """/new creates B; switch to A; next message must hit A.""" - sim = TelegramSessionSim(use_fixed_resolver=use_fixed_resolver) - chat_id = 200 - sid_a = sim.on_message(chat_id, "U", 1, "first topic") - sid_b = sim.on_new(chat_id, "U", 1, is_owner=True) - assert sid_a != sid_b - - sim.on_sessions_switch(chat_id, sid_a) - sid_msg = sim.on_message(chat_id, "U", 1, "back to first") - assert sid_msg == sid_a - - -# ── Unit tests (pure helpers, always green) ─────────────────────────────── - - -def test_auto_title_retries_after_llm_failure(): - store = SessionStore() - row = store.create(build_dm_session_title("U", 1, 99)) - maybe_run_auto_title(store, row.id, "hello", "", llm_failed=True) - assert not store.rows[row.id].auto_title_attempted - maybe_run_auto_title(store, row.id, "hello", "Deploy fix") - assert not is_default_channel_title(store.rows[row.id].title) - - -def test_suffix_lookup_picks_most_recent_without_switch(): - store = SessionStore() - chat_id = 7 - older = store.create(build_dm_session_title("U", 1, chat_id)) - newer = store.create(build_dm_session_title("U", 1, chat_id)) - store.touch(older.id) - store.touch(newer.id) - hit = store.find_by_title_suffix(f"[chat:{chat_id}]") - assert hit is not None - assert hit.id == newer.id - - -def test_should_refresh_label_group_rename(): - old = "Telegram: Old Group [chat:-1]" - new = "Telegram: New Group [chat:-1]" - assert should_refresh_label(old, new) is True - - -def test_should_refresh_label_skips_auto_titled_dm(): - auto = "Telegram: Fix deploy [chat:133526395]" - template = build_dm_session_title("Alexey", 133526395, 133526395) - assert should_refresh_label(auto, template) is False - - -def test_compose_auto_title_preserves_suffix(): - old = build_dm_session_title("A", 1, 42) - out = compose_auto_title(old, '"Deploy fix"') - assert out.endswith("[chat:42]") - assert "Deploy fix" in out - - -def test_default_dm_title_is_detected(): - t = build_dm_session_title("Alice", 1, 99) - assert is_default_channel_title(t) diff --git a/tools/telegram_session_sim/validate-hermes.sh b/tools/telegram_session_sim/validate-hermes.sh deleted file mode 100755 index a39c675f..00000000 --- a/tools/telegram_session_sim/validate-hermes.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Manual Hermes validation checklist for Telegram session fix (issue #121). -# Run from Mac after deploying opencrabs with session_resolve changes. -set -euo pipefail - -echo "=== Hermes Telegram session validation ===" -echo "Manual steps in @oc_l1979_bot or ops bot:" -echo " 1. /new β†’ send one message β†’ wait 10s for auto-title" -echo " 2. Send second message β†’ /sessions list must show non-default title" -echo " 3. /sessions β†’ switch older session β†’ send ping β†’ verify context" -echo "" - -if ! command -v ssh >/dev/null; then - echo "ssh not found; skipping DB snapshot" - exit 0 -fi - -if ssh -o BatchMode=yes -o ConnectTimeout=10 hermes true 2>/dev/null; then - echo "=== Recent Telegram sessions (ops profile) ===" - ssh hermes 'sqlite3 ~/.opencrabs/profiles/ops/opencrabs.db \ - "SELECT substr(title,1,70), auto_title_attempted, datetime(updated_at) \ - FROM sessions WHERE title LIKE \"%Telegram%\" AND archived_at IS NULL \ - ORDER BY updated_at DESC LIMIT 8;"' || true -else - echo "ssh hermes unavailable β€” run DB query manually (see README.md)" -fi From a92b272fdc641bc2df7aef707c12315a896f0d14 Mon Sep 17 00:00:00 2001 From: Alexey Leshchenko Date: Tue, 26 May 2026 13:31:37 +0300 Subject: [PATCH 3/6] chore: drop accidental __pycache__ from tools removal --- .../__pycache__/__init__.cpython-314.pyc | Bin 475 -> 0 bytes .../__pycache__/helpers.cpython-314.pyc | Bin 6092 -> 0 bytes .../__pycache__/router.cpython-314.pyc | Bin 9971 -> 0 bytes .../__pycache__/sim.cpython-314.pyc | Bin 3910 -> 0 bytes ...est_session_sim.cpython-314-pytest-8.4.2.pyc | Bin 18292 -> 0 bytes 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tools/telegram_session_sim/__pycache__/__init__.cpython-314.pyc delete mode 100644 tools/telegram_session_sim/__pycache__/helpers.cpython-314.pyc delete mode 100644 tools/telegram_session_sim/__pycache__/router.cpython-314.pyc delete mode 100644 tools/telegram_session_sim/__pycache__/sim.cpython-314.pyc delete mode 100644 tools/telegram_session_sim/__pycache__/test_session_sim.cpython-314-pytest-8.4.2.pyc diff --git a/tools/telegram_session_sim/__pycache__/__init__.cpython-314.pyc b/tools/telegram_session_sim/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 5de1d3ceb16185ee74fb7ed0216ccae39b78632f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 475 zcmZ8eu};G<5Vez(qE;|eATiNMWr>g)wR6P)OIZK~SUIK1mAE)@u$=)W#NY5qWaS5d z3bAs5cHtyHo$dF0_s)mgTOCGmbicc};EcUBn{_y=WI3kfiABt^8y@jfK6R`!b*($~ ztk-YOoYU5H!*2B1kVW1(x9!Lsu%Pugxk8DzI?jEA#t2pTT2)pQx4sd1C1b0!Kc{tG zVsWD5nel}&75anGcr*;$x|gWDR0bigtO6^n#ClH{NO2xl(n6BOMS*g4vtu$<$rLos zHJS`$JVP1qy0`A)@~??Yk)H*gZjme9c+_qK9zn2Kw*gX>0MzXaWr^DSqtq_7{;O@M zv6KKWY)M*HN8z<0K`5zsl3{VD!bGLy7D}xam{=1kGMY3-L#vb|T+Iho^8tE11Q3O= y0K@X3-kjtrtt1}lEh<4j#LPayyURI$Wyjy1$DI#G@YP}b;L~e$o#(dh66FVCiHMT` diff --git a/tools/telegram_session_sim/__pycache__/helpers.cpython-314.pyc b/tools/telegram_session_sim/__pycache__/helpers.cpython-314.pyc deleted file mode 100644 index 5c92a6e84d13a3faa76ae329a1ae4062a71d1b93..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6092 zcmbVQU2qfE72eg~%97}CdanwgsZOw@*%>d}wp&2Ob|Joa&IF(a|V44roRkT+cFVTPCXyLVSBf$czhXZGH+ zd(YmVbH4L)??_v~$Dy40dDj<0E64qVzSN7uB=~241X1K6`_xec_)XrJ5;dJm)h zau9R}qg&-p&|QoU$a_Ovq-W{e*X=>w79P!$}gi45z9Q!$TCUK(hBZ%;A~Mk%fe& z(xC^8kE1`sLeywRQRkEMO7>0Q*s+(d_vZN+AGXGsV*-^S#Ori^R$?~ijnk*@ zLg+wh9~a|g=MlOmtAy}{ir(M{3_fgdVZ&LkH_rUb$vp8RC|4IZCjtsL)W+C+Pw8qcAclC z&hZ2D*gS7CkL&}^{Y&0y<(4!tpU|$p@LjcB!nid3!g=mP$>4_#KJudFTv|=$NNOA( zLNA0XnMCsDxHNUoVfa)nK{WMNTAL>jC3nw3de9gyqNv(SM$wa2<0)k}QOIcV{U&bwF2eU+{uy=zz(Bg?Lu z*zxV{JGaY)ia1ymCv|ahHMG`wZ^WeUhaPnPWkmN*Hb(MSg+m*{A#xDhzB0119^v?{4V$K^&@z$JWJTYoUsGQum&GF}xPJ>(*zEYv-nb zTv!ix+4h-sC%odT_%nU%#Xb&w1;p@Ke%Q-HH`#2h=ROLo4hE;Gl7!TVEZ8*#tzd-kR989C+jp zG{jd|BQ+5wK8lDRN~2^KQYk0ht{L!Ja+#FncIX&JOD3~my$g7Mt+L+%lX{R%`W(^j z2#s||sIt+lnazFZ9B<~Lt&|r(H06E|+5gw;n`tsFs?EB4H_a;sb81Rq8i;USrwIrv zvBpK3l$7{#^Hu=&q6NV5Hd+10!`8KLAFT>;@b?03?-_U;(m#v^}0pEGW$H8v=Vz zrwnI0s~G~5PCCVsv)6+2CzxL~7xA2P@TWIvwUT^Fo3#nnJOD=V_E%sOjky#p~=YwDHV?Rxs6nljyn$JCOK>7glEW;>uudNa*@08ZO&@chY5 z4%I4jf#$xGH4mKZ<94?}je^0elWiUHspyx=gkhhP2=Jqb&2ew?Al0nvi?y|4+^~$@e@2*DHj_Lk)bk{rEB}?_l z7Adj6c9$$YeGoeIYteiWOm>i{n+_BwutS89tpCfl~Z z3|qfh$3>nd-wkiuIC!#1yW6p1mq<&aEbN4%E|1v>RnpWnLB7NhW-|knI)v(_1gIa0 zvVAn)NkfD;-a1vx=((G>vtD zmlX^A?q`3;>#q9TB{ik7^Cf-Se5O8kjG#>sI0Y9)F9>#uld)*FQQ{doKlvCuOk)a&BKNmfL52g- za_e@O(cn8-2^=m?JrM$>Lw`E@hodW=YB0PJ4CAC$n!c-+Pk#4{Qbrd;Rq^n;c(@`S z!6D8s7B4oGUkBlvx@!l*s+(nbmb><-T8=0wGopGmWg(wQXo~5o_hCP;^*wPg+5XzT z-~d)ZufboVx$kPa=Wu|(UM$mbKda$Ok-NsfoVGWmecRkn3TNj&u8G42Y}362OoHA^ zVB9(CSen4LeLTcVj7Rm&%JwsyH6t}MlfxdP$vEs)m@~5DgJbDOQ|Yg8JIdg4G)2<0 z8ZJyT>Exm`oL1F>B883}KPHVx%!Nrrp*fC(>w=kdCao<-MyG(xAxuiBKVx{qrP>ur zGu%;;4q4%$P0U`_~c2;s+Kc-31G zk5$F4jXXkWtQ% zPti+lMy0<#b@Dv_i2Lb3xq+uHPp3oY+P8wZpWO-qv|IaeC9u_oBk5Kr&L~^^kpyn} Ukc4b`5mmQ>@WIdbf{#t}U)Wh%2><{9 diff --git a/tools/telegram_session_sim/__pycache__/router.cpython-314.pyc b/tools/telegram_session_sim/__pycache__/router.cpython-314.pyc deleted file mode 100644 index ef0161b11177320bef39ea94827c1003e7a9f891..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9971 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 Kr9MH*5BOiBb`7oo diff --git a/tools/telegram_session_sim/__pycache__/test_session_sim.cpython-314-pytest-8.4.2.pyc b/tools/telegram_session_sim/__pycache__/test_session_sim.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 6ebf752196e740537312f831dc5d583ab6741155..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18292 zcmdU1Z)_V!cHbqJ61k*ENw#9ie^-{Hh>0yle=PZL&Wi0sb{w6uaj4v#Y=RP35@U*_ zW+^-7HHC6X5exlbr!5-gQ*@tB?w9I5^@;+;^;^FjK#8&>ELlX6DVGeed_)%<{Q#jV!?RPty%=J*pRkF-%xj6^n?E|5+4- zSwR)@!gWCvFNp)5yyppgQk2?*#IJ>!RwtBo74h!GTadG*FYT z83^aYa9>oVOOb)veC4uU@#IjAb)sj%6OJ2SGBY@K@wAnuIYLzn^B0SXVW(|r7flFGn&%U+0_|sm*w+WLbS2`u~BU@ zaWe%4SrrMQDY*oc*YYG)(2`2_rUv&WHLD(SN|qHRN3zM8u{_qV{QKEN<7&m}!BL zODZ#&d@Ai6wy8vZY*K?c!y%BPq7h7FGTD3r$5bDTO2$*8Gb!kIa>^Qzcs`X+YexN8 zb}E!nUGK=|w9FVujOrbEr~^Xt;ow86r=}oN=Zh{5 z8SS~-MkpRPr$dX!$!=)xY53~9Aea@t7Q$lp7a06nqCbxzTcAC~2wnmBLa%&}->k+G zBi0B&pH;EXOy!`rIZsR+0?NuO=25+>N0oXcb|>crIy3I19Wn*GGcV>ny4Ow*`R%wB z%Sj_aD;|@2On#={c*e|pR+t{Q;uHUivonHIL9d~(6|;Xqr2DK|)GDWpMnOMPYf%n? zTB^L71xqZSgj*BM6|LdeS6t1)2vu~LZMAA~o7`7X{;7(>kaR2ms;K%^P&hl{^*}{i zuDsgg%X!>WQO=b|yBzQ)t3k&MxYP3)=>F2UjkQV-34hR4>UXSCzs1q-wMxTQEq~IhH1Zv+Qa-*rT&27mtkTH;_bMHJ z|HObGOn+?kVdC?OCk+)(#Ral!S1iyis=J5Z@AgQzm<4wN9;% z$sJ-@?(Z#;asf32K)9jsa{qW9U?xJLV-y}8Ri*&gBqlVaOPQJhXb6}J02qK+0LfX3 z*-W6MTzC~Q?^G_G$ZPHQMT#{(|Ed1lYNM(@t-Yn z0;?38==xpQ8qZ?J}>{ueL8;x;}U~?os0ewrP z;~B6|j6hsZjB7;85hL7Bha8~uMYKbVT~_U20HhNc3aX(=s4xx4G)E9ZgQXJ` z)Mr#v<0F+cAeAI2D2yO-#2D;yCVE(c_24);SV7zAyxpxM(rRV40EPrXr11Dz z>oepgSV%fRQ=DUKG|4HTJ_28T01)x4@Ku!%JWzc8#Z~$I?D>uGp4pc-NbvZoR)g$!EqMAX zO#ip^x$wvbkqv+U*IxMj8gsv}Zhein{>MsDLg*5up#d*$2hN&*`$C9T}YWvvt= zr)}EbTDNGWH1()T2pwDwcCAb1rzCY1g9mt$N1T}}ThTy%vt1REIg-38h(If(0anzv zGtCIXe^LiQR3r%JnKedNH8qrI(1$k=rlPe}E50gzLBn5NWxsa&KteBWFntEbkkeqY zjDwtb{n@RIr4gT1jVfLjW1b$>91uaEoirrYDQSYd3x**HWfg2c>*NIAIkRn3ynHs@H-%xhnU5 zP2Y@FQ&@G!f>jsA2{0ePDCFkckgMsV*2Me(tir<+4qzpzz7cM~3^i6T1<4A7kQrHa zOYVkCl{NYnk4a`^+2vfJUyPRn`07Kyj_MsL>_ZoE|(fB{EI)KwK1 z0N{Ydp(>Xd@4;B00=mf~V4$);MQ{ZyPGMX5>F+s930Cw`(nXyU4R|$Y}lYnagSJ+@j&ufV^WX%9*m}ob|egg9jJ|pzd z5nL(+1qeBWnPP$<^Mx)8B2s{eWi<;Mnye_`$RY}32b_2LY@wFJIs`$z3>x(g66|?Y zEIeBQjQ~3_%m)z@^8s3FPbKAdqV)uYfKfF@G=QE2pKX9i*h_+L4GO48E9TY%sCQyo3T*EEajvmE9E_lvdHQXJU^AEpAk;_ikOdVY_^~3L#t(ow5 zFfZJFw!?Yhi7QBN=AD-Z+TA` zhL}v{mHu`H8pJmWLF#S+>Rf8*}MP~`65+~9oQ!uiFz#aBzAQ=sx8@ABN`d$$(63$HEh zzH_-0>V@23?c59NQWN`>q^3K^d5T9Ic`NP|MXBlDMNSVyjug*^$gindzZ~4RE}5T_wC_$IPw|K&Z^fOWDDAtaae5$fqJh{pA?O(wB8Qf;*|EV`*q+>3=hNU6_M z&meS#A+9v7vY@J6OFMTUrw_ZS*y(Odvk^j_)72jX8+68mQe_G6WV1KOV2}Uzu8QQ6L$1a#ng)0|_GRrcay2AJxH)f)ku|U8zN}r|bxt>z2lWbZrsdZTsKwlt%kKTr}|+pqn4m7HGGp= zI-NBHR6cS#Jry5Mq|#claK?1Z<8}txnl76Pu$j>Aa40A8xVr&nnEVcbO>u#MBSjee zoqU09Xu#e^;gyO?!K`IgD?}=GDz-3asdpc2T4;kB+X&XFdZa3moP#9v3}GPY#{i98 zf`W&cy0|)w-EK@X)z^-cwwlAkWOOT`!t4bTw-syhf@{EO%RWzzV+k}pnc|~*@FJ2r zT>HnJi9V;%dx9Fh=(IO4rY576zI%d)r9eNj9UE)bW{5_vL0dn8udYxX`7qQ-?Or)@ z_vqZw)jh`+$Cf&mMoW86mgG~j=O0Rq^9{eK`gzr2^-}$>r3cc*jmYl1g}K6f%O|ZL zw?2q87p30yNbho_7b2@c4A+9a>yRn+E=#?|2pF6gU&XkY&vIDkY+(PY^uk8lvA>t^ z%S+w=(EH2Y2W_w&(7)bxZn^CoM8F$fY&%zq^kY1T;aaeN9S9ts5+&m~D~{xv)DK>9 zc54;m=j?c-tP++FRegi{+Y3s_UHx6J&WYQqK=cZWn^d6ZTbA=eIb6dPSIpz4MlC@| zg}z&?u3F-y6&Jegj4uBfWQ68=Y4liOS(F8Ge zkEdXdeDpRuJZ1c(=5n;gc7_{1YSfY>R-M3rLIwm1P9#va8V(~{yiQ>~IO7cnYi$AruDDAzMyz>JvnAxW!?M45$nc@*g-il4JhJ`te zGXm9a1jBcG=K#kX_~^|K--MrgZ!Y5wxcOO?4%`74V`s29He}|l0EL-ab0LPjdv7?2 ztrXsPlizSgpv}!d1Sgf+;rpo_t_nFg$D0%FbG^f&<($lci+j2&=DqB&YC*i;pyD0z zFTLE(@`5T3$0QK%k>M7Hcn2HY;Yf8k&0FEDZmwv_#b$>Suofd*ouIWj<${kF5!}jy zm;`q{zy{mtyFITAHrQ>s@*3OBBir1y%enGsmjmADD<|IpZ=xlXE7Z$&yzz3t8-S9L z?dWZ9hrBHNt6Kl$@_eXl!OK%X zFl0345EtF7IlYRgp4Ba_8;IaeO-a=;rk!*(=|PsSUUsXOG2mjmAL9gyw%V$0&-wzONz zV(@Fa$YOp_%TY5w_~TrkuCh4HWpNl}ab4kQTNb}$A7*C4xU;+acvo-t$>Y}uz92%- zcl%PM_?6H($K)%t*fo3MVdUwB$)&)r;85(v*#VTI zQYqL`luj%iXF>49x@3M{Y+!=b-a*O2zTY}@av8O{Y~3t!=m zU7UYJ42ru)ATPOeb#sMO$V>K|a}?;dZv-v!=e6QFx68T9AYP8iN5^))JH7p!JGa-} zY56F?LA~wx;D;(ITm`-n`{}pqaDV!_i_6j(fW!jk)8C`Y)8jD__id(cSz|Q8oPl<| zuDe{I^{T1XOE@f$;I=)ooj9TI?~GSC1N~|Z*jxSzG#uE$9B`LU`5cf5HGtBWoP`M} zgbv#~E{6*NCVP9jt`#nuyDx}4UECmBI8U@p5}gB+@;mem5`ehY<_kdOnY``Jp?;h} z5(6IW{U0)l$uS6Ef60@|Sf>(T??_2yEGG`^r=U4P5K$R^t3}7A3&qCAkfa?04u8o=_>K#c<>)RUKf-{zSyC8#3j!UNrHO~(he6^tD&04~?hNnuYH&W2zAV8U zKkq_mlU-UPF4GS}9BMY>2E%2RBQ5Jk|m8CO!_q6gsy|)UtB`<5bhi!1@X2fc>Ywi=<rbb~w8Bw` z;BnbUc9_VPXknYKxQlFv%$Ga}W-^r#INnbn8KPeDK2q|>UPs}=AN6MXs9S$w0RMTw zUQnZdYmkRm`L!hZ3!r}wzB)$W8TTKYzqKeXc9s0?v;7Z)EerjnU~5roT?@9ZOJ$#u z)VdmM;h8JtNuCd77hq%3EDBUsYE5b_ZmJYA>BARoHmc+CWOghbH{^JHd?pY3zVM#` zB5-N=t=HM;2pLUr_5xfxn*exSt(|~RBbA4j*?5*wZRSyTwo%237}anvAP0YUU`u8; zDTuO-Dtd^WFn@V9g$Fyl^{2apyO{o=v!v=m^MAt zMYpY~^O?FEC~PJuW9cN&mw#yAnV9yUnSwt9Noy~VzXlTQxBe*vkG!HNe&Gp<(r@<) zV#|LD!u!H|HD60U(eqe<;Omee_WhgCf~T{CA6)s_m3s~IXI7=Q4N0b{-Sf|^N{1i$ zyfvQL0Q{eg$N#~;clWJ}jmu(VvFX_2?UH!%D-Zk$!(*@DIrRu*j}C7^g>N94WEg$a ux&<8^dN#=*_N7uKz9@cK9}o|I+2|F!z6^QAlV64;@z7UckN6B7f&T+zTH##) From e9a6dbfe5d90cc29913250569292faffc33ed6bb Mon Sep 17 00:00:00 2001 From: Alexey Leshchenko Date: Tue, 26 May 2026 13:44:59 +0300 Subject: [PATCH 4/6] test(telegram): idle-on-bound + session_idle_expired unit tests Document resolve policy in handler. Add chat_bound_idle integration test and session_idle_expired boundary tests per review. --- src/channels/telegram/handler.rs | 2 ++ src/channels/telegram/session_resolve.rs | 18 ++++++++++++ src/tests/telegram_session_resolve_test.rs | 33 ++++++++++++++++++++-- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/channels/telegram/handler.rs b/src/channels/telegram/handler.rs index c1b1c113..92cbd112 100644 --- a/src/channels/telegram/handler.rs +++ b/src/channels/telegram/handler.rs @@ -893,6 +893,8 @@ pub(crate) async fn handle_message( session_resolve::build_legacy_session_title(is_dm, &user.first_name, user_id, &chat_title); let session_id = { + // Resolve policy (chat map β†’ suffix β†’ create): see + // session_resolve::choose_resolve_source and telegram_session_resolve_test. // 0) Explicit chatβ†’session binding from /sessions switch or prior message. if let Some(bound_id) = telegram_state.chat_session(chat_id).await && let Ok(Some(bound)) = session_svc.get_session(bound_id).await diff --git a/src/channels/telegram/session_resolve.rs b/src/channels/telegram/session_resolve.rs index d0278dae..c8aac2c5 100644 --- a/src/channels/telegram/session_resolve.rs +++ b/src/channels/telegram/session_resolve.rs @@ -156,4 +156,22 @@ mod tests { ResolveSource::Suffix ); } + + #[test] + fn session_idle_expired_within_and_past_window() { + let recent = chrono::Utc::now() - chrono::Duration::minutes(30); + assert!(!session_idle_expired(recent, Some(1.0))); + + let stale = chrono::Utc::now() - chrono::Duration::hours(2); + assert!(session_idle_expired(stale, Some(1.0))); + assert!(!session_idle_expired(stale, None)); + } + + #[test] + fn session_idle_expired_boundary_not_yet_expired() { + let at_limit = chrono::Utc::now() - chrono::Duration::seconds(3600); + assert!(!session_idle_expired(at_limit, Some(1.0))); + let past_limit = chrono::Utc::now() - chrono::Duration::seconds(3601); + assert!(session_idle_expired(past_limit, Some(1.0))); + } } diff --git a/src/tests/telegram_session_resolve_test.rs b/src/tests/telegram_session_resolve_test.rs index 75e390aa..a108493a 100644 --- a/src/tests/telegram_session_resolve_test.rs +++ b/src/tests/telegram_session_resolve_test.rs @@ -1,8 +1,8 @@ //! Integration tests for Telegram session title + label drift (issue #121). use crate::channels::telegram::session_resolve::{ - build_session_title, chat_id_suffix, choose_resolve_source, should_refresh_label, - ResolveSource, + build_session_title, chat_id_suffix, choose_resolve_source, session_idle_expired, + should_refresh_label, ResolveSource, }; use crate::channels::telegram::TelegramState; use uuid::Uuid; @@ -98,6 +98,35 @@ async fn auto_titled_title_survives_should_refresh_check() { assert!(!should_refresh_label(&auto_titled, &template)); } +/// Mirrors handler chat-bound idle branch: archive stale bound row, create replacement. +#[tokio::test] +async fn chat_bound_idle_archives_and_creates_new_session() { + let (db, repo) = fresh_repo().await; + let ctx = ServiceContext::new(db.pool().clone()); + let svc = SessionService::new(ctx.clone()); + let chat_id = 77_i64; + let title = build_session_title(true, "U", 1, "", chat_id); + + let mut bound = Session::new(Some(title.clone()), None, None); + bound.updated_at = chrono::Utc::now() - chrono::Duration::hours(48); + repo.create(&bound).await.expect("create bound"); + assert!(session_idle_expired(bound.updated_at, Some(1.0))); + + repo.archive(bound.id).await.expect("archive idle bound"); + let new_session = svc + .create_session(Some(title)) + .await + .expect("create replacement"); + + assert_ne!(new_session.id, bound.id); + let archived = svc + .get_session(bound.id) + .await + .expect("get") + .expect("row"); + assert!(archived.is_archived()); +} + #[tokio::test] async fn service_update_session_title_preserves_suffix() { let db = Database::connect_in_memory() From 217c70164c3fd78c30b7cdc78c6ad1cb1d442635 Mon Sep 17 00:00:00 2001 From: Alexey Leshchenko Date: Tue, 26 May 2026 13:46:33 +0300 Subject: [PATCH 5/6] fix(telegram): remove dead extra_sessions; expand resolve policy tests Guest session switch used register_session_chat only; extra_sessions map was never read on ingest. Document choose_resolve_source on bound branch. --- src/channels/telegram/agent.rs | 23 ++++++-------- src/channels/telegram/handler.rs | 5 +++ src/tests/telegram_session_resolve_test.rs | 37 ++++++++++++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/channels/telegram/agent.rs b/src/channels/telegram/agent.rs index 10be8a43..c866dd53 100644 --- a/src/channels/telegram/agent.rs +++ b/src/channels/telegram/agent.rs @@ -8,7 +8,6 @@ use crate::brain::agent::AgentService; use crate::config::Config; use crate::db::ChannelMessageRepository; use crate::services::{ServiceContext, SessionService}; -use std::collections::HashMap; use std::sync::Arc; use teloxide::prelude::*; use tokio::sync::Mutex; @@ -107,9 +106,6 @@ impl TelegramAgent { } } - // Per-user session tracking for non-owner users (owner shares TUI session) - let extra_sessions: Arc>> = - Arc::new(Mutex::new(HashMap::new())); let agent = self.agent_service.clone(); let session_svc = self.session_service.clone(); let bot_token = Arc::new(token); @@ -179,14 +175,12 @@ impl TelegramAgent { let agent = agent.clone(); let session_svc = session_svc.clone(); let shared_session = shared_session.clone(); - let extra_sessions = extra_sessions.clone(); let config_rx = config_rx.clone(); move |bot: Bot, query: CallbackQuery| { let state = telegram_state.clone(); let agent = agent.clone(); let session_svc = session_svc.clone(); let shared_session = shared_session.clone(); - let extra_sessions = extra_sessions.clone(); let config_rx = config_rx.clone(); async move { if let Some(data) = query.data.as_deref() { @@ -382,15 +376,16 @@ impl TelegramAgent { if is_owner { *shared_session.lock().await = Some(new_id); - } else { - extra_sessions.lock().await.insert( - caller_id, - (new_id, std::time::Instant::now()), - ); } - state - .register_session_chat(new_id, query.message.as_ref().map(|m| m.chat().id.0).unwrap_or(caller_id)) - .await; + // Owner and guest: bind chat_id β†’ session_id for + // handle_message (issue #121). Guest extra_sessions + // map was removed β€” it was never read on ingest. + let switch_chat_id = query + .message + .as_ref() + .map(|m| m.chat().id.0) + .unwrap_or(caller_id); + state.register_session_chat(new_id, switch_chat_id).await; // Touch updated_at so find_session_by_title_suffix returns this session on next message if let Ok(Some(s)) = session_svc.get_session(new_id).await { diff --git a/src/channels/telegram/handler.rs b/src/channels/telegram/handler.rs index 92cbd112..b1264e8e 100644 --- a/src/channels/telegram/handler.rs +++ b/src/channels/telegram/handler.rs @@ -896,9 +896,14 @@ pub(crate) async fn handle_message( // Resolve policy (chat map β†’ suffix β†’ create): see // session_resolve::choose_resolve_source and telegram_session_resolve_test. // 0) Explicit chatβ†’session binding from /sessions switch or prior message. + // Policy: choose_resolve_source (tests) β€” ChatBound when map β†’ live row. if let Some(bound_id) = telegram_state.chat_session(chat_id).await && let Ok(Some(bound)) = session_svc.get_session(bound_id).await && !bound.is_archived() + && matches!( + session_resolve::choose_resolve_source(Some(bound_id), false, None), + session_resolve::ResolveSource::ChatBound + ) { if session_resolve::session_idle_expired(bound.updated_at, idle_timeout_hours) { if let Err(e) = session_svc.archive_session(bound.id).await { diff --git a/src/tests/telegram_session_resolve_test.rs b/src/tests/telegram_session_resolve_test.rs index a108493a..c0956ff6 100644 --- a/src/tests/telegram_session_resolve_test.rs +++ b/src/tests/telegram_session_resolve_test.rs @@ -99,6 +99,43 @@ async fn auto_titled_title_survives_should_refresh_check() { } /// Mirrors handler chat-bound idle branch: archive stale bound row, create replacement. +/// Guest /sessions switch only needs register_session_chat (extra_sessions map removed). +#[tokio::test] +async fn register_session_chat_binds_guest_dm() { + let state = TelegramState::new(); + let guest_chat_id = 9988_i64; + let session_id = Uuid::new_v4(); + state.register_session_chat(session_id, guest_chat_id).await; + assert_eq!(state.chat_session(guest_chat_id).await, Some(session_id)); + assert_eq!( + choose_resolve_source(state.chat_session(guest_chat_id).await, false, None), + ResolveSource::ChatBound + ); +} + +#[tokio::test] +async fn archived_chat_map_entry_uses_suffix_not_bound() { + let bound = Uuid::new_v4(); + let suffix = Uuid::new_v4(); + assert_eq!( + choose_resolve_source(Some(bound), true, Some(suffix)), + ResolveSource::Suffix + ); +} + +#[tokio::test] +async fn suffix_path_when_chat_map_empty() { + let suffix = Uuid::new_v4(); + assert_eq!( + choose_resolve_source(None, false, Some(suffix)), + ResolveSource::Suffix + ); + assert_eq!( + choose_resolve_source(None, false, None), + ResolveSource::Create + ); +} + #[tokio::test] async fn chat_bound_idle_archives_and_creates_new_session() { let (db, repo) = fresh_repo().await; From d105159b80879768fed9e146cdf0cc1ece9b80ec Mon Sep 17 00:00:00 2001 From: Alexey Leshchenko Date: Tue, 26 May 2026 14:00:44 +0300 Subject: [PATCH 6/6] fix(telegram): import session_resolve in handler Sibling module was declared in mod.rs but not in scope for handler.rs. --- src/channels/telegram/handler.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/channels/telegram/handler.rs b/src/channels/telegram/handler.rs index b1264e8e..310c2dcf 100644 --- a/src/channels/telegram/handler.rs +++ b/src/channels/telegram/handler.rs @@ -3,6 +3,7 @@ //! Processes incoming messages: text, voice (STT/TTS), photos, image documents, allowlist enforcement. //! Supports live streaming (edit-based) and Telegram-native approval inline keyboards. +use super::session_resolve; use super::TelegramState; use crate::brain::agent::{AgentService, ProgressCallback, ProgressEvent}; use crate::config::{Config, RespondTo};