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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/ccbot/bot/_session_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@

from __future__ import annotations

import asyncio
import logging

from telegram.ext import ContextTypes

from ..handlers.directory_browser import take_pending_text
from ..handlers.message_sender import safe_edit, safe_send
from ..handlers.notifications import (
detach_paused_cards_at_message,
paint_card_on_carrier,
)
from ..local_terminal import open_terminal_for_window
from ..naming import maybe_auto_name
from ..session import session_manager
from ..tmux_manager import tmux_manager

Expand Down Expand Up @@ -165,16 +168,17 @@ async def create_and_activate_session(
if ws.session_id and not sess.claude_session_id:
session_manager.set_session_claude_id(sess.id, ws.session_id)

# Forward any pending text held while the picker was up.
pending_text = context.user_data.get("_pending_text") if context.user_data else None
# Forward any pending text held while the picker was up. ``take_pending_text``
# drops a stale stash (older than PENDING_TEXT_TTL_S) so a message typed
# hours ago against a since-gone session can never be injected here — the
# 2026-06-28 "medical insurance" misroute.
pending_text = take_pending_text(context.user_data)
if pending_text:
logger.debug(
"Forwarding pending text to window %s (len=%d)",
created_wname,
len(pending_text),
)
if context.user_data is not None:
context.user_data.pop("_pending_text", None)
send_ok, send_msg = await session_manager.send_to_window(
created_wid, pending_text
)
Expand All @@ -185,3 +189,11 @@ async def create_and_activate_session(
user.id,
f"❌ Failed to send pending message: {send_msg}",
)
elif len(pending_text) >= 20:
# Name the session from its actual first human message (this
# pending text), not from whatever inbound happens to land
# first. ``maybe_auto_name`` is one-shot (re-entrancy guard),
# so a later message can't override it.
asyncio.create_task(
maybe_auto_name(sess.id, pending_text, getattr(user, "id", None))
)
7 changes: 2 additions & 5 deletions src/ccbot/bot/callbacks/window_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
UNBOUND_WINDOWS_KEY,
build_directory_browser,
clear_window_picker_state,
take_pending_text,
)
from ...handlers.message_sender import safe_edit, safe_send
from ...session import session_manager
Expand Down Expand Up @@ -77,11 +78,7 @@ async def handle(

await safe_edit(query, f"✅ Bound to window `{display}`")

pending_text = (
context.user_data.get("_pending_text") if context.user_data else None
)
if context.user_data is not None:
context.user_data.pop("_pending_text", None)
pending_text = take_pending_text(context.user_data)
if pending_text:
send_ok, send_msg = await session_manager.send_to_window(
selected_wid, pending_text
Expand Down
3 changes: 2 additions & 1 deletion src/ccbot/bot/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
STATE_SELECTING_SESSION,
STATE_SELECTING_WINDOW,
build_directory_browser,
stash_pending_text,
)
from ..handlers.interactive_ui import (
get_interactive_window,
Expand Down Expand Up @@ -776,7 +777,7 @@ async def _resolve_active_window(
context.user_data[BROWSE_PATH_KEY] = start_path
context.user_data[BROWSE_PAGE_KEY] = 0
context.user_data[BROWSE_DIRS_KEY] = subdirs
context.user_data["_pending_text"] = text
stash_pending_text(context.user_data, text)
await safe_reply(update.message, msg_text, reply_markup=keyboard)
return None

Expand Down
60 changes: 60 additions & 0 deletions src/ccbot/handlers/directory_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- clear_browse_state: Clear browsing state from user_data
"""

import logging
import os
import time
from collections.abc import Callable
Expand Down Expand Up @@ -55,6 +56,65 @@
SESSIONS_KEY = "cached_sessions" # Cache of ClaudeSession list[Any]
SESSIONS_PAGE_KEY = "sessions_page" # current page index in session picker

logger = logging.getLogger(__name__)

# Pending-text stash: a message the user typed while no active session
# existed is held in user_data until a session is created, then forwarded.
PENDING_TEXT_KEY = "_pending_text"

# Max age before a stashed pending message is treated as abandoned.
# context.user_data lives in memory and is NOT cleared between messages —
# only a bot restart wipes it. Without an expiry a stash can survive for
# hours and then be injected into an unrelated session created much later
# (the 2026-06-28 "medical insurance" misroute: a 01:05 message resurfaced
# in a 10:34 session). 10 min is generous for a browser/picker flow yet
# kills any multi-hour leak.
PENDING_TEXT_TTL_S = 600.0


def stash_pending_text(user_data: dict[str, Any] | None, text: str) -> None:
"""Hold the user's text while a directory/session picker is up.

Stamped with a timestamp so ``take_pending_text`` can reject a stale
stash. See ``PENDING_TEXT_TTL_S``.
"""
if user_data is not None:
user_data[PENDING_TEXT_KEY] = {"text": text, "ts": time.time()}


def take_pending_text(
user_data: dict[str, Any] | None, max_age_s: float | None = PENDING_TEXT_TTL_S
) -> str | None:
"""Pop the stashed pending text, returning it only if still fresh.

Always clears the slot (a leaked stash must never re-fire). Returns
None when absent, empty, or older than ``max_age_s``. Tolerates the
legacy bare-string format (treated as fresh) for state in flight
across a deploy.
"""
if not user_data:
return None
raw = user_data.pop(PENDING_TEXT_KEY, None)
if raw is None:
return None
if isinstance(raw, str): # legacy pre-TTL format
return raw or None
if not isinstance(raw, dict):
return None
text = raw.get("text")
if not text:
return None
age = time.time() - float(raw.get("ts", 0.0))
if max_age_s is not None and age > max_age_s:
logger.info(
"Dropping stale pending text (age=%.0fs > %.0fs, len=%d)",
age,
max_age_s,
len(text),
)
return None
return text


def clear_browse_state(user_data: dict[str, Any] | None) -> None:
"""Clear directory browsing state keys from user_data."""
Expand Down
7 changes: 5 additions & 2 deletions tests/e2e/test_inbound_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,11 @@ async def test_text_with_no_active_session_opens_dir_browser(fake_tmux, fake_bot
await text_handler(update, ctx)

assert fake_tmux.sent == []
# The pending text is held for forwarding after session creation.
assert ctx.user_data.get("_pending_text") == "hello there"
# The pending text is held (timestamped) for forwarding after session
# creation; ``take_pending_text`` reads it back with a freshness guard.
from ccbot.handlers.directory_browser import take_pending_text

assert take_pending_text(ctx.user_data) == "hello there"


@pytest.mark.asyncio
Expand Down
81 changes: 81 additions & 0 deletions tests/test_pending_text_ttl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Pending-text stash freshness guard (regression for the 2026-06-28 misroute).

A message typed while no active session existed is held in ``user_data``
and forwarded once a session is created. ``user_data`` lives in memory and
is only cleared by a bot restart, so without an expiry a stash could
survive for hours and then be injected into an unrelated session created
much later — exactly how a 01:05 "smartphone 100%" message resurfaced in a
10:34 session that then got auto-named "medical insurance".

These tests pin the TTL contract of ``stash_pending_text`` /
``take_pending_text``.
"""

import time

from ccbot.handlers.directory_browser import (
PENDING_TEXT_KEY,
PENDING_TEXT_TTL_S,
stash_pending_text,
take_pending_text,
)


def test_round_trip_fresh() -> None:
ud: dict = {}
stash_pending_text(ud, "hello world")
assert take_pending_text(ud) == "hello world"
# Slot is cleared after taking — a leaked stash must never re-fire.
assert PENDING_TEXT_KEY not in ud
assert take_pending_text(ud) is None


def test_stale_is_dropped() -> None:
ud: dict = {}
stash_pending_text(ud, "9.5h old night message")
# Backdate the stamp past the TTL.
ud[PENDING_TEXT_KEY]["ts"] = time.time() - (PENDING_TEXT_TTL_S + 60)
assert take_pending_text(ud) is None
# Even though dropped, the slot is still cleared.
assert PENDING_TEXT_KEY not in ud


def test_just_within_ttl_survives() -> None:
ud: dict = {}
stash_pending_text(ud, "recent")
ud[PENDING_TEXT_KEY]["ts"] = time.time() - (PENDING_TEXT_TTL_S - 30)
assert take_pending_text(ud) == "recent"


def test_custom_max_age() -> None:
ud: dict = {}
stash_pending_text(ud, "x")
ud[PENDING_TEXT_KEY]["ts"] = time.time() - 5
assert take_pending_text(ud, max_age_s=1) is None


def test_no_expiry_when_max_age_none() -> None:
ud: dict = {}
stash_pending_text(ud, "x")
ud[PENDING_TEXT_KEY]["ts"] = time.time() - 99999
assert take_pending_text(ud, max_age_s=None) == "x"


def test_legacy_bare_string_tolerated() -> None:
# State in flight across the deploy that introduces the dict format.
ud: dict = {PENDING_TEXT_KEY: "legacy text"}
assert take_pending_text(ud) == "legacy text"
assert PENDING_TEXT_KEY not in ud


def test_absent_and_empty() -> None:
assert take_pending_text(None) is None
assert take_pending_text({}) is None
assert (
take_pending_text({PENDING_TEXT_KEY: {"text": "", "ts": time.time()}}) is None
)


def test_stash_none_user_data_is_noop() -> None:
# Must not raise when user_data is unavailable.
stash_pending_text(None, "x")
Loading