" model marker). Every one of those reports zero usage, and a
+ # genuine API turn never does (even a minimal one bills a couple of baseline
+ # input tokens), so treat an all-zero usage block as a stub regardless of the
+ # model marker's exact spelling -- which keeps this working even if Claude
+ # Code renames that marker -- and keep scanning for the last turn that
+ # actually hit the API.
+ continue
+ return total
+ return None
+
+
+def _claude_activity(session_id: str) -> SessionActivity:
+ projects_root = claude_projects_root()
+ if not projects_root.exists():
+ return SessionActivity(None, None)
+ # Session IDs are UUIDs, unique across every project directory, so a glob by
+ # filename alone finds the right transcript without needing to reimplement
+ # Claude Code's project-path-to-folder-name encoding.
+ try:
+ matches = sorted(projects_root.glob(f"*/{session_id}.jsonl"))
+ except OSError:
+ return SessionActivity(None, None)
+ if not matches:
+ return SessionActivity(None, None)
+ path = matches[0]
+ return SessionActivity(_mtime_utc(path), _claude_last_assistant_usage_tokens(path))
+
+
+def _codex_activity(session_id: str) -> SessionActivity:
+ rows = query_codex_state_db("SELECT updated_at, tokens_used FROM threads WHERE id = ?", (session_id,))
+ if not rows or not rows[0][0]:
+ return SessionActivity(None, None)
+ updated_at, tokens_used = rows[0]
+ size_tokens = int(tokens_used) if isinstance(tokens_used, (int, float)) else None
+ return SessionActivity(datetime.fromtimestamp(updated_at, tz=timezone.utc), size_tokens)
+
+
+def _copilot_activity(session_id: str) -> SessionActivity:
+ latest: Optional[datetime] = None
+ for root in copilot_session_roots(Path.home()):
+ session_dir = root / "session-state" / session_id
+ for name in ("workspace.yaml", "events.jsonl"):
+ candidate = _mtime_utc(session_dir / name)
+ if candidate is not None and (latest is None or candidate > latest):
+ latest = candidate
+ # No local, cheaply-available token/context-size signal for Copilot -- see the
+ # module docstring for why that's fine (no idle-based cache-expiry concern there).
+ return SessionActivity(latest, None)
+
+
+# One entry per provider this module knows how to inspect. A provider missing here
+# (or not yet added) falls back to "unknown activity" below, rather than needing its
+# own if/elif branch kept in sync with this dict.
+_ACTIVITY_LOOKUP: dict[str, Callable[[str], SessionActivity]] = {
+ "claude": _claude_activity,
+ "codex": _codex_activity,
+ "copilot": _copilot_activity,
+}
+
+
+def native_session_activity(provider: str, session_id: str) -> SessionActivity:
+ """Return (last_activity, size_tokens) for *session_id*'s native transcript."""
+ if not session_id:
+ return SessionActivity(None, None)
+ normalized_provider = (provider or "").strip().lower()
+ lookup = _ACTIVITY_LOOKUP.get(normalized_provider)
+ if lookup is None:
+ return SessionActivity(None, None)
+ return lookup(session_id)
+
+
+def gap_seconds_since(last_activity: Optional[datetime]) -> Optional[float]:
+ """Seconds since *last_activity*, or None if it's unknown."""
+ if last_activity is None:
+ return None
+ now = datetime.now(timezone.utc)
+ return max(0.0, (now - last_activity).total_seconds())
+
+
+def humanize_gap_seconds(seconds: float) -> str:
+ """Render a gap as a short human string, e.g. "12h 30m" or "45m"."""
+ total_minutes = int(seconds // 60)
+ days, remainder_minutes = divmod(total_minutes, 24 * 60)
+ hours, minutes = divmod(remainder_minutes, 60)
+ parts: list[str] = []
+ if days:
+ parts.append(f"{days}d")
+ if hours:
+ parts.append(f"{hours}h")
+ if minutes or not parts:
+ parts.append(f"{minutes}m")
+ return " ".join(parts[:2])
+
+
+# Largest-to-smallest so the first divisor a count actually reaches wins.
+_TOKEN_COUNT_UNITS: tuple[tuple[int, str], ...] = (
+ (1_000_000_000, "B"),
+ (1_000_000, "M"),
+ (1_000, "k"),
+)
+
+
+def humanize_token_count(tokens: int) -> str:
+ """Render a token count as a short human string, e.g. "800", "1k", "200k", "1M",
+ "11M". Rounds down to one decimal place rather than to nearest, so a count just
+ under a unit's boundary (e.g. 999,999) reads as "999.9k" rather than rolling over
+ to a misleading "1000k"."""
+ if tokens < 1000:
+ return str(tokens)
+ for divisor, suffix in _TOKEN_COUNT_UNITS:
+ if tokens < divisor:
+ continue
+ value = math.floor((tokens / divisor) * 10) / 10
+ return f"{value:g}{suffix}"
+ return str(tokens)
diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py
index cd3fe7e..8cee78d 100644
--- a/src/coding_agent_telegram/session_runtime.py
+++ b/src/coding_agent_telegram/session_runtime.py
@@ -10,10 +10,11 @@
import os
from typing import Awaitable, Callable, Optional, Sequence
-from telegram import Update
+from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ContextTypes
from coding_agent_telegram.agent_runner import AgentRunResult, MultiAgentRunner
+from coding_agent_telegram.claude_health import claude_auth_failure_message, is_claude_auth_failure
from coding_agent_telegram.config import AppConfig, DEFAULT_MAX_PHOTO_ATTACHMENT_BYTES
from coding_agent_telegram.diff_utils import (
TEXTUAL_DIFF_UNAVAILABLE,
@@ -32,6 +33,7 @@
from coding_agent_telegram.providers import provider_label as provider_display_label
from coding_agent_telegram.session_store import SessionStore
from coding_agent_telegram.telegram_sender import (
+ affirmative_inline_button_kwargs,
markdownish_to_html,
send_code_block,
send_html_text,
@@ -54,10 +56,90 @@
"{summary}\n\n"
"Acknowledge that you have loaded the handoff summary and are ready to continue."
)
+# Mirrors session_lifecycle_commands.SESSION_PRIMING_PROMPT (duplicated rather than
+# imported to avoid a router -> session_runtime -> router import cycle): makes the CLI
+# hand back a session ID without acting on the throwaway prompt.
+NEW_SESSION_PRIMING_PROMPT = "Reply with exactly: ready. Do not make any changes, run any commands, or use any tools."
# Matches absolute filesystem paths (Unix and Windows styles) in error messages.
_ABSOLUTE_PATH_RE = re.compile(r"(?:^|(?<=\s)|(?<=[\"'(]))((?:/[^\s\"',;)]+)+|[A-Za-z]:\\[^\s\"',;)]+)")
+# Matches a trailing "-resumeN" suffix so re-compacting an already-compacted
+# session rotates the number instead of stacking suffixes.
+_RESUME_SUFFIX_RE = re.compile(r"-resume\d+$", re.IGNORECASE)
+
+# Matches a trailing "-newN" suffix so repeatedly switching to a fresh session rotates
+# the number instead of stacking suffixes.
+_NEW_SUFFIX_RE = re.compile(r"-new\d+$", re.IGNORECASE)
+
+# Fallback substring marking an agent-run failure as "this session ID can't be resumed"
+# for providers without a structured signal for it, so _replace_invalid_session_if_needed
+# knows to create a replacement session instead of just reporting the failure. Claude has
+# its own precise signal (AgentRunResult.error_code == "session_not_found", set from the
+# CLI's structured "errors" field -- see agent_runner._claude_events_report_session_not_found)
+# and is checked separately below; this generic "resume" substring is the only fallback
+# available for Codex/Copilot, none of which have a documented, stable error string, so it's
+# kept broad and is only ever matched against a failure's error_message, never used to
+# override a success.
+_UNRESUMABLE_SESSION_FALLBACK_PHRASE = "resume"
+
+# Matches a numbered/lettered list line, e.g. "1. Do X" or "a) Do Y".
+_OPTION_LINE_RE = re.compile(r"^\s*(?:[0-9]{1,2}[.)]|[A-Za-z][.)])\s+(.{2,140}?)\s*$")
+# Requires an explicit "which one do you want" style cue near the option list,
+# so an ordinary numbered list in a reply doesn't get mistaken for a menu.
+_OPTION_QUESTION_CUE_RE = re.compile(
+ r"\b(which (one|option|approach|way)|let me know which|should i|shall i|"
+ r"would you like me to|which would you|go with|pick one|choose one|which do you want)\b",
+ re.IGNORECASE,
+)
+_MAX_REPLY_OPTIONS = 6
+_REPLY_OPTION_TAIL_LINES = 12
+# Trailing characters that can sit after a label's question mark and hide it. Claude
+# routinely bolds numbered questions ("1. **Use Redis or in-memory?**"), which would
+# otherwise read as a plain choice rather than a question.
+_OPTION_LABEL_TRAILING_NOISE = "*_`)]. \t"
+# A list of independent questions has every line ending in a question mark; a menu for
+# one decision may still have a single "Something else?" style escape option, so one
+# question mark alone must not suppress the whole menu.
+_MIN_QUESTION_LABELS_FOR_MULTI_QUESTION = 2
+
+
+def _label_is_question(label: str) -> bool:
+ return label.rstrip(_OPTION_LABEL_TRAILING_NOISE).endswith("?")
+
+
+def _detect_reply_options(text: str) -> tuple[str, ...]:
+ """Return option labels if the assistant's reply is asking the user to pick one."""
+ stripped = text.strip()
+ if not stripped:
+ return ()
+ tail_lines = stripped.splitlines()[-_REPLY_OPTION_TAIL_LINES:]
+ if not _OPTION_QUESTION_CUE_RE.search("\n".join(tail_lines)):
+ return ()
+
+ options: list[str] = []
+ question_labels = 0
+ for line in tail_lines:
+ match = _OPTION_LINE_RE.match(line)
+ if match:
+ label = match.group(1).strip()
+ if _label_is_question(label):
+ question_labels += 1
+ options.append(label)
+
+ if question_labels >= _MIN_QUESTION_LABELS_FOR_MULTI_QUESTION:
+ # Each line is its own question (e.g. "1. Should I use A or B?"), not a choice
+ # for one decision — bail out rather than offering buttons that would resend a
+ # question as if it were an answer.
+ return ()
+ if len(options) < 2:
+ return ()
+ return tuple(options[:_MAX_REPLY_OPTIONS])
+
+
+def _session_provider(session: dict[str, str]) -> str:
+ return str(session.get("provider") or "codex").strip().lower() or "codex"
+
def _reply_to_message_id(update: Update) -> int | None:
message = getattr(update, "message", None)
@@ -143,17 +225,30 @@ async def store_photo(self, update: Update, project_folder: str) -> Path:
attachments_root = self.attachments_root(project_folder)
attachments_root.mkdir(parents=True, exist_ok=True)
- target = attachments_root / f"{digest}{suffix}"
- if not target.exists():
- target.write_bytes(content)
- return target
-
- def build_prompt(self, attachment_path: Path, project_path: Path, caption: str) -> str:
- rel_path = os.path.relpath(attachment_path, start=project_path).replace(os.sep, "/")
- lines = [
- f"An image is attached at {rel_path}.",
- IMAGE_INSPECTION_PROMPT,
- ]
+ # Eight hex characters keep paths readable while still providing roughly
+ # four billion possible names. Should a prefix collision ever occur, grow
+ # only that filename until it is unambiguous.
+ for length in range(8, len(digest) + 1, 8):
+ target = attachments_root / f"{digest[:length]}{suffix}"
+ if not target.exists():
+ target.write_bytes(content)
+ return target
+ if target.read_bytes() == content:
+ return target
+ # A full SHA-256 collision is not realistically possible, but keep the
+ # fallback deterministic rather than overwriting an existing attachment.
+ raise PhotoAttachmentError("photo_name_collision", "Could not store photo attachment safely.")
+
+ def build_prompt(self, attachment_paths: Sequence[Path], project_path: Path, caption: str) -> str:
+ rel_paths = [os.path.relpath(path, start=project_path).replace(os.sep, "/") for path in attachment_paths]
+ if len(rel_paths) == 1:
+ lines = [f"An image is attached at {rel_paths[0]}.", IMAGE_INSPECTION_PROMPT]
+ else:
+ lines = [
+ "Images are attached at:",
+ *(f"- {path}" for path in rel_paths),
+ "Open and inspect every image before answering.",
+ ]
caption = caption.strip()
if caption:
lines.extend(["", "User caption:", caption])
@@ -163,6 +258,7 @@ def build_prompt(self, attachment_path: Path, project_path: Path, caption: str)
RunWithTyping = Callable[..., Awaitable[object]]
+RegisterReplyOptions = Callable[[int, tuple[str, ...]], str]
class SessionRuntime:
@@ -175,6 +271,7 @@ def __init__(
bot_id: str,
git: GitWorkspaceManager,
run_with_typing: RunWithTyping,
+ register_reply_options: RegisterReplyOptions,
) -> None:
self.cfg = cfg
self.store = store
@@ -182,6 +279,7 @@ def __init__(
self.bot_id = bot_id
self.git = git
self.run_with_typing = run_with_typing
+ self.register_reply_options = register_reply_options
def _locale(self, update: Update | None) -> str:
return self.cfg.locale
@@ -189,12 +287,34 @@ def _locale(self, update: Update | None) -> str:
def _t(self, update: Update | None, key: str, **kwargs) -> str:
return translate(self._locale(update), key, **kwargs)
+ def _claude_auth_error_text(self, provider: str, error_message: Optional[str]) -> Optional[str]:
+ """Returns the same guidance text the background Claude auth health
+ check sends (see claude_health.py) when a run just failed for that
+ reason, so a user who hits it live (before the periodic check would
+ have caught it) gets the fix instructions immediately instead of the
+ raw CLI error."""
+ if provider != "claude" or not is_claude_auth_failure(error_message):
+ return None
+ return claude_auth_failure_message(self.cfg.locale, error_message)
+
+ def _agent_failure_text(self, update: Update | None, provider: str, result: AgentRunResult) -> str:
+ if getattr(result, "error_code", None) == "agent_aborted":
+ return self._t(update, "runtime.agent_run_aborted")
+ error_message = result.error_message
+ claude_auth_text = self._claude_auth_error_text(provider, error_message)
+ if claude_auth_text:
+ return claude_auth_text
+ if error_message:
+ return _sanitize_agent_error(error_message, error_code=getattr(result, "error_code", None))
+ return self._t(update, "runtime.agent_run_failed")
+
def _take_reply_to_message_id(self, reply_state: dict[str, int | None]) -> int | None:
reply_to_message_id = reply_state.get("reply_to_message_id")
reply_state["reply_to_message_id"] = None
return reply_to_message_id
- def _next_rotated_session_name(self, chat_id: int, base_name: str) -> str:
+ def _next_unique_session_name(self, chat_id: int, base_name: str, *, suffix_template: str) -> str:
+ """Find the first unused name of the form suffix_template.format(base=base_name, n=1), n=2, ...)."""
existing = {
data.get("name", "").strip().lower()
for data in self.store.list_sessions(self.bot_id, chat_id).values()
@@ -202,11 +322,28 @@ def _next_rotated_session_name(self, chat_id: int, base_name: str) -> str:
}
suffix = 1
while True:
- candidate = f"{base_name}-{suffix}"
+ candidate = suffix_template.format(base=base_name, n=suffix)
if candidate.lower() not in existing:
return candidate
suffix += 1
+ def _next_rotated_session_name(self, chat_id: int, base_name: str) -> str:
+ return self._next_unique_session_name(chat_id, base_name, suffix_template="{base}-{n}")
+
+ def _next_resume_session_name(self, chat_id: int, base_name: str) -> str:
+ """Like ``_next_rotated_session_name``, but for compaction: strips any existing
+ ``-resumeN`` suffix first so repeated compaction produces "name-resume1",
+ "name-resume2", ... instead of "name-resume1-resume1-resume1"."""
+ stripped_base_name = _RESUME_SUFFIX_RE.sub("", base_name)
+ return self._next_unique_session_name(chat_id, stripped_base_name, suffix_template="{base}-resume{n}")
+
+ def _next_switch_session_name(self, chat_id: int, base_name: str) -> str:
+ """Like ``_next_resume_session_name``, but for switching to a clean session with no
+ handoff summary: strips any existing ``-newN`` suffix first so repeated switching
+ produces "name-new1", "name-new2", ... instead of stacking suffixes."""
+ stripped_base_name = _NEW_SUFFIX_RE.sub("", base_name)
+ return self._next_unique_session_name(chat_id, stripped_base_name, suffix_template="{base}-new{n}")
+
def should_skip_git_repo_check(self, project_folder: str) -> bool:
return self.cfg.codex_skip_git_repo_check or self.store.is_project_trusted(project_folder)
@@ -253,8 +390,9 @@ async def run_active_session(
return None
project_folder = session["project_folder"]
- provider = session.get("provider", "codex")
+ provider = _session_provider(session)
branch_name = session.get("branch_name", "")
+ model = (session.get("model") or "").strip() or None
logger.info(
"Running message for chat %s on session '%s' (%s) in project '%s' with provider '%s'. "
"Prompt (first 200 chars): %.200r",
@@ -295,6 +433,7 @@ async def run_active_session(
workspace_lock_key=project_folder,
skip_git_repo_check=self.should_skip_git_repo_check(project_folder),
image_paths=image_paths,
+ model=model,
stall_message=self._t(update, "runtime.active_run_stall"),
progress_label=self._t(update, "runtime.live_agent_output"),
)
@@ -331,13 +470,7 @@ async def run_active_session(
active_id,
result.error_message or "unknown error",
)
- error_text = (
- _sanitize_agent_error(result.error_message, error_code=getattr(result, "error_code", None))
- if result.error_message
- else self._t(update, "runtime.agent_run_failed")
- )
- if getattr(result, "error_code", None) == "agent_aborted":
- error_text = self._t(update, "runtime.agent_run_aborted")
+ error_text = self._agent_failure_text(update, provider, result)
await send_text(update, context, error_text)
return result
@@ -351,6 +484,9 @@ async def run_active_session(
project_folder,
provider,
branch_name=branch_name,
+ # Not a user-initiated new session -- the CLI just rotated the id for
+ # the same conversation, so the model override carries over.
+ model=model,
)
logger.info(
"Resume returned a different session id for chat %s; switched from '%s' (%s) to '%s' (%s).",
@@ -400,7 +536,7 @@ async def compact_active_session(
return None
project_folder = session["project_folder"]
- provider = session.get("provider", "codex")
+ provider = _session_provider(session)
branch_name = session.get("branch_name", "")
session_name = session["name"]
logger.info(
@@ -428,6 +564,7 @@ async def compact_active_session(
COMPACT_SUMMARY_PROMPT,
workspace_lock_key=project_folder,
skip_git_repo_check=self.should_skip_git_repo_check(project_folder),
+ model=(session.get("model") or "").strip() or None,
stall_message=self._t(update, "runtime.active_run_stall"),
progress_label=self._t(update, "runtime.live_agent_output"),
)
@@ -439,13 +576,7 @@ async def compact_active_session(
)
return None
if not summary_result.success:
- error_text = (
- _sanitize_agent_error(summary_result.error_message, error_code=getattr(summary_result, "error_code", None))
- if summary_result.error_message
- else self._t(update, "runtime.agent_run_failed")
- )
- if getattr(summary_result, "error_code", None) == "agent_aborted":
- error_text = self._t(update, "runtime.agent_run_aborted")
+ error_text = self._agent_failure_text(update, provider, summary_result)
await send_text(update, context, error_text)
return summary_result
@@ -470,23 +601,20 @@ async def compact_active_session(
COMPACT_BOOTSTRAP_TEMPLATE.format(summary=compact_summary),
workspace_lock_key=project_folder,
skip_git_repo_check=self.should_skip_git_repo_check(project_folder),
+ # Seeds context and returns a session ID only. The summary lists "next
+ # steps", which an autopilot agent would otherwise start executing here.
+ priming_only=True,
stall_message=self._t(update, "runtime.replacement_session_stall"),
progress_label=self._t(update, "runtime.live_agent_output"),
)
if create_result is None:
return None
if not create_result.success or not create_result.session_id:
- error_text = (
- _sanitize_agent_error(create_result.error_message, error_code=getattr(create_result, "error_code", None))
- if create_result.error_message
- else self._t(update, "runtime.agent_run_failed")
- )
- if getattr(create_result, "error_code", None) == "agent_aborted":
- error_text = self._t(update, "runtime.agent_run_aborted")
+ error_text = self._agent_failure_text(update, provider, create_result)
await send_text(update, context, error_text)
return create_result
- switched_session_name = self._next_rotated_session_name(chat_id, session_name)
+ switched_session_name = self._next_resume_session_name(chat_id, session_name)
self.store.create_session(
self.bot_id,
chat_id,
@@ -508,6 +636,85 @@ async def compact_active_session(
)
return create_result
+ async def switch_to_new_session(
+ self,
+ update: Update,
+ context: ContextTypes.DEFAULT_TYPE,
+ ) -> AgentRunResult | None:
+ """Abandon the active session's context entirely and start a clean one.
+
+ Unlike ``compact_active_session``, this never resumes the old (possibly cold)
+ session, so it carries none of that unavoidable full-transcript reprocess cost --
+ at the price of the new session having no memory of the old one at all.
+ """
+ chat_id = update.effective_chat.id
+ active_id, session, project_path = await self._active_session_or_notify(update, context)
+ if active_id is None or session is None or project_path is None:
+ return None
+
+ project_folder = session["project_folder"]
+ provider = _session_provider(session)
+ branch_name = session.get("branch_name", "")
+ session_name = session["name"]
+ logger.info(
+ "Switching chat %s from session '%s' (%s) to a fresh session in project '%s' with provider '%s'.",
+ chat_id,
+ session_name,
+ active_id,
+ project_folder,
+ provider,
+ )
+
+ if branch_name and self.git.is_git_repo(project_path):
+ checkout = await self._checkout_branch(update, context, project_path, branch_name)
+ if not checkout:
+ return None
+
+ await send_text(update, context, self._t(update, "runtime.switching_session"))
+ create_result = await self.run_with_typing(
+ update,
+ context,
+ self.agent_runner.create_session,
+ provider,
+ project_path,
+ NEW_SESSION_PRIMING_PROMPT,
+ workspace_lock_key=project_folder,
+ skip_git_repo_check=self.should_skip_git_repo_check(project_folder),
+ # Priming-only, same reasoning as compact_active_session's replacement
+ # session: the throwaway prompt must not be actionable.
+ priming_only=True,
+ stall_message=self._t(update, "runtime.replacement_session_stall"),
+ progress_label=self._t(update, "runtime.live_agent_output"),
+ )
+ if create_result is None:
+ return None
+ if not create_result.success or not create_result.session_id:
+ error_text = self._agent_failure_text(update, provider, create_result)
+ await send_text(update, context, error_text)
+ return create_result
+
+ switched_session_name = self._next_switch_session_name(chat_id, session_name)
+ self.store.create_session(
+ self.bot_id,
+ chat_id,
+ create_result.session_id,
+ switched_session_name,
+ project_folder,
+ provider,
+ branch_name=branch_name,
+ )
+ await send_text(
+ update,
+ context,
+ self._t(
+ update,
+ "runtime.session_switched",
+ session_name=switched_session_name,
+ session_id=create_result.session_id,
+ ),
+ )
+ return create_result
+
async def _checkout_branch(
self,
update: Update,
@@ -538,7 +745,17 @@ async def _replace_invalid_session_if_needed(
user_message: str,
image_paths: Sequence[Path],
):
- if result.success or not result.error_message or "resume" not in result.error_message.lower():
+ if result.success or not result.error_message:
+ return result, active_id, session_name
+ # Claude has its own precise, structured signal (checked first); the substring
+ # fallback only kicks in for other providers, since for Claude it would also
+ # match a genuine (if failed) turn's model-generated result text that happens to
+ # mention "resume" for an unrelated reason -- exactly the false-positive this
+ # structured signal exists to avoid.
+ is_unresumable = getattr(result, "error_code", None) == "session_not_found" or (
+ provider != "claude" and _UNRESUMABLE_SESSION_FALLBACK_PHRASE in result.error_message.lower()
+ )
+ if not is_unresumable:
return result, active_id, session_name
logger.info(
@@ -547,6 +764,9 @@ async def _replace_invalid_session_if_needed(
active_id,
chat_id,
)
+ # Not a user-initiated new session -- the old one just expired underneath the
+ # same conversation, so the model override carries over rather than resetting.
+ model = (session.get("model") or "").strip() or None
create_result = await self.run_with_typing(
update,
context,
@@ -557,6 +777,7 @@ async def _replace_invalid_session_if_needed(
workspace_lock_key=project_folder,
skip_git_repo_check=self.should_skip_git_repo_check(project_folder),
image_paths=image_paths,
+ model=model,
stall_message=self._t(update, "runtime.replacement_session_stall"),
progress_label=self._t(update, "runtime.live_agent_output"),
)
@@ -574,6 +795,7 @@ async def _replace_invalid_session_if_needed(
project_folder,
provider,
branch_name=branch_name,
+ model=model,
)
logger.info(
"Created a replacement session for chat %s after resume failure: old='%s' (%s) new='%s' (%s).",
@@ -688,6 +910,18 @@ async def _send_assistant_chunks(
return
total = len(segments)
+
+ # If an agent's final reply reads like it's asking the user to pick between a
+ # few options, detect them now so we can offer buttons after the reply is sent.
+ # This is deliberately provider-neutral: Codex and Copilot run as one-shot
+ # subprocesses just like Claude, so a Telegram reply must become the next
+ # session turn rather than trying to hold an interactive CLI prompt open.
+ # Tapping one sends the option text back as the next chat message — the same
+ # as if the user had typed it.
+ reply_options: tuple[str, ...] = ()
+ if segments[-1].kind == "prose" and update.effective_chat is not None:
+ reply_options = _detect_reply_options(segments[-1].text)
+
for index, segment in enumerate(segments, start=1):
if segment.kind == "code":
await send_code_block(
@@ -700,7 +934,7 @@ async def _send_assistant_chunks(
)
continue
- provider_label = provider_display_label(provider) or "Codex"
+ provider_label = provider_display_label(provider) or "Agent"
title_prefix = (
self._t(update, "runtime.provider_output_single", provider=provider_label)
if total == 1
@@ -720,6 +954,32 @@ async def _send_assistant_chunks(
reply_to_message_id=self._take_reply_to_message_id(reply_state),
)
+ if reply_options and update.effective_chat is not None:
+ token = self.register_reply_options(update.effective_chat.id, reply_options)
+ await send_html_text(
+ update,
+ context,
+ f"{html.escape(self._t(update, 'runtime.reply_options_prompt'))}",
+ )
+ # Each option gets its own message with a single button right under it, so
+ # the full option text is always visible next to the button that picks it —
+ # no truncation, no guessing which button maps to which paragraph.
+ for index, option in enumerate(reply_options):
+ await send_html_text(
+ update,
+ context,
+ html.escape(option),
+ reply_markup=self._reply_option_keyboard(update, token, index),
+ )
+
+ def _reply_option_keyboard(self, update: Update, token: str, index: int) -> InlineKeyboardMarkup:
+ button = InlineKeyboardButton(
+ self._t(update, "runtime.reply_option_select_button"),
+ callback_data=f"agentopt:{token}:{index}",
+ **affirmative_inline_button_kwargs(),
+ )
+ return InlineKeyboardMarkup([[button]])
+
def _chunk_assistant_prose(self, title_prefix: str, text: str) -> list[str]:
normalized = text.strip()
if not normalized:
diff --git a/src/coding_agent_telegram/session_store.py b/src/coding_agent_telegram/session_store.py
index e32ba9b..2967ecc 100644
--- a/src/coding_agent_telegram/session_store.py
+++ b/src/coding_agent_telegram/session_store.py
@@ -11,6 +11,10 @@
T = TypeVar("T")
+def _normalize_provider(provider: str) -> str:
+ return str(provider or "codex").strip().lower() or "codex"
+
+
class SessionStoreError(Exception):
"""Raised when the session store cannot be accessed due to a file-lock conflict."""
@@ -147,12 +151,15 @@ def _write_session_record(
origin: str = "bot",
origin_label: Optional[str] = None,
initialized_from: Optional[str] = None,
+ model: Optional[str] = None,
) -> dict[str, str]:
now = self._now()
+ normalized_provider = _normalize_provider(provider)
sessions[session_id] = {
"name": session_name,
"project_folder": project_folder,
- "provider": provider,
+ "provider": normalized_provider,
+ "model": (model or "").strip(),
"branch_name": branch_name or "",
"origin": origin,
"origin_label": origin_label or ("Bot managed session" if origin == "bot" else origin),
@@ -193,10 +200,24 @@ def mutate(chat_data: dict[str, Any]) -> None:
def set_current_provider(self, bot_id: str, chat_id: int, provider: str) -> None:
def mutate(chat_data: dict[str, Any]) -> None:
- chat_data["current_provider"] = provider
+ chat_data["current_provider"] = _normalize_provider(provider)
self._mutate_chat_data(bot_id, chat_id, mutate, create=True)
+ def set_session_model(self, bot_id: str, chat_id: int, session_id: str, model: Optional[str]) -> bool:
+ """Store a model override on a session record; empty/None clears it back to the CLI/env default."""
+
+ def mutate(chat_data: dict[str, Any]) -> bool:
+ session = chat_data.get("sessions", {}).get(session_id)
+ if not session:
+ return False
+ session["model"] = (model or "").strip()
+ session["updated_at"] = self._now()
+ return True
+
+ result = self._mutate_chat_data(bot_id, chat_id, mutate)
+ return False if result is None else result
+
def set_pending_action(self, bot_id: str, chat_id: int, pending_action: Optional[dict[str, Any]]) -> None:
def mutate(chat_data: dict[str, Any]) -> None:
if pending_action:
@@ -241,6 +262,7 @@ def create_session(
origin: str = "bot",
origin_label: Optional[str] = None,
initialized_from: Optional[str] = None,
+ model: Optional[str] = None,
) -> None:
def mutate(chat_data: dict[str, Any]) -> None:
sessions = chat_data.setdefault("sessions", {})
@@ -254,10 +276,11 @@ def mutate(chat_data: dict[str, Any]) -> None:
origin=origin,
origin_label=origin_label,
initialized_from=initialized_from,
+ model=model,
)
chat_data["active_session_id"] = session_id
chat_data["current_project_folder"] = project_folder
- chat_data["current_provider"] = provider
+ chat_data["current_provider"] = _normalize_provider(provider)
if branch_name:
chat_data["current_branch"] = branch_name
@@ -376,7 +399,7 @@ def mutate(chat_data: dict[str, Any]) -> bool:
chat_data["active_session_id"] = session_id
chat_data["current_project_folder"] = session["project_folder"]
- chat_data["current_provider"] = session.get("provider", "codex")
+ chat_data["current_provider"] = _normalize_provider(session.get("provider", "codex"))
if session.get("branch_name"):
chat_data["current_branch"] = session["branch_name"]
else:
diff --git a/src/coding_agent_telegram/supervisor.py b/src/coding_agent_telegram/supervisor.py
new file mode 100644
index 0000000..8e52dc3
--- /dev/null
+++ b/src/coding_agent_telegram/supervisor.py
@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+"""Keep the installed console command alive across Telegram/network failures.
+
+The repository's ``startup.sh`` has an equivalent shell supervisor. Installed
+users do not have that script, so the public console entry point uses this
+portable implementation instead. The polling child remains in ``cli.py``;
+keeping it separate means a restart always creates a fresh asyncio loop and
+Telegram HTTP client.
+"""
+
+import os
+import signal
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+from typing import NoReturn, Sequence
+
+from coding_agent_telegram.config import default_app_internal_root
+
+
+MIN_BACKOFF_SECONDS = 5.0
+MAX_BACKOFF_SECONDS = 300.0
+HEALTHY_RUN_SECONDS = 60.0
+HEARTBEAT_MAX_AGE_SECONDS = 300.0
+WATCHDOG_CHECK_SECONDS = 30.0
+DNS_MAX_ATTEMPTS = 60
+DNS_RETRY_SECONDS = 5.0
+CHILD_PID_FILE_NAME = "coding-agent-telegram.child.pid"
+CHILD_COMMAND_MARKER = "coding_agent_telegram"
+CHILD_PID_FILE_ENV = "CODING_AGENT_TELEGRAM_CHILD_PID_FILE"
+
+
+def _log(message: str) -> None:
+ print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} SUPERVISOR: {message}", flush=True)
+
+
+def _heartbeat_path() -> Path:
+ # cli.py obtains the same location from the loaded AppConfig. No dotenv
+ # parsing is needed before the child has validated its configuration.
+ return default_app_internal_root() / "polling.heartbeat"
+
+
+def _child_pid_path() -> Path:
+ configured_path = os.getenv(CHILD_PID_FILE_ENV, "").strip()
+ if configured_path:
+ return Path(configured_path).expanduser()
+ return default_app_internal_root() / CHILD_PID_FILE_NAME
+
+
+def _write_child_pid(pid_path: Path, pid: int) -> None:
+ pid_path.parent.mkdir(parents=True, exist_ok=True)
+ temporary_path = pid_path.with_suffix(pid_path.suffix + ".tmp")
+ temporary_path.write_text(f"{pid}\n", encoding="utf-8")
+ temporary_path.replace(pid_path)
+
+
+def _remove_child_pid(pid_path: Path) -> None:
+ pid_path.unlink(missing_ok=True)
+
+
+def _process_command(pid: int) -> str | None:
+ """Return a process command line when the platform exposes one."""
+ try:
+ result = subprocess.run(
+ ["ps", "-p", str(pid), "-o", "command="],
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ )
+ except OSError:
+ return None
+ return result.stdout.strip() if result.returncode == 0 else None
+
+
+def _stop_orphaned_child(pid_path: Path) -> None:
+ """Stop a bot left behind by a force-killed supervisor.
+
+ The command check prevents a stale, reused PID from terminating an
+ unrelated process. It also recognizes the previous startup.sh child
+ command, so migration does not create a temporary 409 polling conflict.
+ """
+ try:
+ pid = int(pid_path.read_text(encoding="utf-8").strip())
+ except (OSError, ValueError):
+ _remove_child_pid(pid_path)
+ return
+
+ try:
+ os.kill(pid, 0)
+ except OSError:
+ _remove_child_pid(pid_path)
+ return
+
+ command = _process_command(pid)
+ if command is None or CHILD_COMMAND_MARKER not in command:
+ _log(f"stale child pid file refers to pid {pid}, but it is not a bot process; leaving it alone.")
+ _remove_child_pid(pid_path)
+ return
+
+ _log(f"found an orphaned bot process (pid {pid}) -- stopping it first.")
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except OSError:
+ pass
+ for _ in range(20):
+ try:
+ os.kill(pid, 0)
+ except OSError:
+ break
+ time.sleep(1)
+ else:
+ _log(f"orphaned bot (pid {pid}) did not exit after 20s, sending SIGKILL")
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except OSError:
+ pass
+ _remove_child_pid(pid_path)
+
+
+def _wait_for_dns() -> None:
+ for attempt in range(DNS_MAX_ATTEMPTS):
+ try:
+ socket.getaddrinfo("api.telegram.org", 443)
+ if attempt:
+ _log(f"DNS is back after {attempt * DNS_RETRY_SECONDS:.0f}s.")
+ return
+ except OSError:
+ if attempt == 0:
+ _log("waiting for DNS...")
+ time.sleep(DNS_RETRY_SECONDS)
+ _log(f"DNS still down after {DNS_MAX_ATTEMPTS * DNS_RETRY_SECONDS:.0f}s -- starting anyway.")
+
+
+def _stop_child(child: subprocess.Popen[object]) -> None:
+ if child.poll() is not None:
+ return
+ child.terminate()
+ try:
+ child.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ _log(f"bot (pid {child.pid}) did not exit after 20s, sending SIGKILL")
+ child.kill()
+ child.wait()
+
+
+def _run_supervisor(argv: Sequence[str]) -> int:
+ # The only public one-shot command must not start a long-lived supervisor.
+ if argv and argv[0] == "claude-auth":
+ from coding_agent_telegram.cli import main as cli_main
+
+ original_argv = sys.argv
+ try:
+ sys.argv = [original_argv[0], *argv]
+ cli_main()
+ finally:
+ sys.argv = original_argv
+ return 0
+
+ heartbeat_file = _heartbeat_path()
+ child_pid_file = _child_pid_path()
+ stopping = False
+ child: subprocess.Popen[object] | None = None
+
+ def stop_requested(_signum: int, _frame: object) -> None:
+ nonlocal stopping
+ stopping = True
+ if child is not None:
+ _log(f"stop requested, forwarding SIGTERM to bot (pid {child.pid})")
+ _stop_child(child)
+
+ previous_term = signal.signal(signal.SIGTERM, stop_requested)
+ previous_int = signal.signal(signal.SIGINT, stop_requested)
+ try:
+ _stop_orphaned_child(child_pid_file)
+ backoff = MIN_BACKOFF_SECONDS
+ while not stopping:
+ _wait_for_dns()
+ if stopping:
+ break
+
+ # A previous child's heartbeat is stale by definition.
+ heartbeat_file.unlink(missing_ok=True)
+ child = subprocess.Popen([sys.executable, "-m", "coding_agent_telegram.cli", *argv])
+ _write_child_pid(child_pid_file, child.pid)
+ _log(f"bot started (pid {child.pid}).")
+ started_at = time.monotonic()
+
+ while child.poll() is None and not stopping:
+ time.sleep(WATCHDOG_CHECK_SECONDS)
+ if heartbeat_file.exists():
+ age = time.time() - heartbeat_file.stat().st_mtime
+ if age > HEARTBEAT_MAX_AGE_SECONDS:
+ _log(f"no heartbeat for {age:.0f}s -- bot (pid {child.pid}) looks wedged, restarting it.")
+ _stop_child(child)
+ break
+
+ if stopping:
+ break
+ status = child.wait()
+ _remove_child_pid(child_pid_file)
+ ran_for = time.monotonic() - started_at
+ if ran_for >= HEALTHY_RUN_SECONDS:
+ backoff = MIN_BACKOFF_SECONDS
+ _log(f"bot exited (status {status}) after {ran_for:.0f}s -- restarting in {backoff:.0f}s.")
+ time.sleep(backoff)
+ backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
+ finally:
+ if child is not None:
+ _stop_child(child)
+ _remove_child_pid(child_pid_file)
+ signal.signal(signal.SIGTERM, previous_term)
+ signal.signal(signal.SIGINT, previous_int)
+ _log("supervisor exiting")
+ return 0
+
+
+def main() -> NoReturn:
+ raise SystemExit(_run_supervisor(sys.argv[1:]))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/coding_agent_telegram/telegram_sender.py b/src/coding_agent_telegram/telegram_sender.py
index 127835d..52c41e8 100644
--- a/src/coding_agent_telegram/telegram_sender.py
+++ b/src/coding_agent_telegram/telegram_sender.py
@@ -6,7 +6,7 @@
from dataclasses import dataclass
from typing import Optional
-from telegram import Update
+from telegram import InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.error import BadRequest
from telegram.ext import ContextTypes
@@ -68,6 +68,14 @@ class AssistantSegment:
language: Optional[str] = None
+def affirmative_inline_button_kwargs() -> dict[str, dict[str, str]]:
+ return {"api_kwargs": {"style": "primary"}}
+
+
+def negative_inline_button_kwargs() -> dict[str, dict[str, str]]:
+ return {"api_kwargs": {"style": "danger"}}
+
+
def _max_telegram_message_length(context: ContextTypes.DEFAULT_TYPE) -> int:
bot_data = getattr(context, "bot_data", None)
if isinstance(bot_data, dict):
@@ -87,6 +95,7 @@ async def send_text(
text: str,
*,
reply_to_message_id: Optional[int] = None,
+ reply_markup: Optional[InlineKeyboardMarkup] = None,
) -> None:
if update.effective_chat is None:
return
@@ -100,12 +109,14 @@ async def send_text(
resolved_reply_to_message_id,
text,
)
+ last_index = len(chunks) - 1
for index, chunk in enumerate(chunks):
await context.bot.send_message(
chat_id=update.effective_chat.id,
text=html.escape(chunk),
parse_mode=ParseMode.HTML,
reply_to_message_id=resolved_reply_to_message_id if index == 0 else None,
+ reply_markup=reply_markup if index == last_index else None,
)
@@ -138,6 +149,7 @@ async def send_html_text(
text: str,
*,
reply_to_message_id: Optional[int] = None,
+ reply_markup: Optional[InlineKeyboardMarkup] = None,
) -> None:
if update.effective_chat is None:
return
@@ -150,7 +162,13 @@ async def send_html_text(
text,
)
if len(text) > max_length:
- await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id)
+ await send_text(
+ update,
+ context,
+ _strip_html_tags(text),
+ reply_to_message_id=reply_to_message_id,
+ reply_markup=reply_markup,
+ )
return
try:
await context.bot.send_message(
@@ -158,11 +176,18 @@ async def send_html_text(
text=text,
parse_mode=ParseMode.HTML,
reply_to_message_id=_default_reply_to_message_id(update, reply_to_message_id),
+ reply_markup=reply_markup,
)
except BadRequest as exc:
if "Can't parse entities" not in str(exc):
raise
- await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id)
+ await send_text(
+ update,
+ context,
+ _strip_html_tags(text),
+ reply_to_message_id=reply_to_message_id,
+ reply_markup=reply_markup,
+ )
def markdownish_to_html(text: str) -> str:
diff --git a/src/coding_agent_telegram/usage_status.py b/src/coding_agent_telegram/usage_status.py
new file mode 100644
index 0000000..92345b5
--- /dev/null
+++ b/src/coding_agent_telegram/usage_status.py
@@ -0,0 +1,458 @@
+from __future__ import annotations
+
+"""Best-effort quota lookups for each coding-agent provider.
+
+None of Claude Code, Codex, or Copilot expose 5-hour/weekly rate-limit
+percentages through a documented non-interactive flag -- those numbers only
+render inside each CLI's own interactive TUI (Claude's ``/usage``, Codex's
+``/status``, Copilot's ``/usage``).
+
+- Claude: every real ``-p`` turn's ``stream-json`` output includes a
+ ``rate_limit_event`` with a ``unifiedWindows`` object carrying
+ ``five_hour``/``seven_day`` utilization -- present only for Pro/Max
+ subscribers authenticated via OAuth (API-key billing has no such windows).
+ Since it rides along on any Claude call the bot was already going to make,
+ ``observe_claude_rate_limit_event`` opportunistically caches it from every
+ such call (see ``agent_runner._run``), and ``get_claude_usage`` reads
+ *only* that cache -- there is no live fallback probe. A window that has
+ never been observed, or whose cached value has rolled past its own
+ ``resets_at``, reports N/A rather than paying for a dedicated API call just
+ to answer a status check; it starts reporting again the next time any real
+ Claude call happens to observe it. The two windows are tracked and expired
+ independently, since a bot idle for a few hours can easily have a stale
+ five-hour window sitting next to a still-fresh weekly one.
+- Codex: its ``app-server`` JSON-RPC daemon exposes ``account/rateLimits/read``,
+ returning ``primary`` (5h) / ``secondary`` (weekly) ``usedPercent`` from a
+ pure local query -- no model call, no cost, so it's always fetched live.
+
+Copilot has no equivalent, and not just because the API is missing: since
+GitHub retired premium requests for a monthly AI-credit balance (June 2026),
+Copilot no longer has a 5-hour/weekly rolling window at all -- credits burn
+against a monthly cycle, viewable only on GitHub's billing page, with no CLI
+or API exposing an individual account's remaining balance. ``fetch_copilot_usage``
+always reports unavailable rather than inventing a window that doesn't exist.
+"""
+
+import json
+import logging
+import subprocess
+import threading
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional
+
+import portalocker
+
+logger = logging.getLogger(__name__)
+
+CODEX_APP_SERVER_TIMEOUT_SECONDS = 15.0
+
+# Shown in place of a window's percentage when this cache has nothing usable
+# for it. Two distinct reasons, so the message matches reality: a window we
+# have simply never seen reads differently from one we saw and watched expire.
+CLAUDE_WINDOW_NEVER_OBSERVED_NOTE = "no data yet -- will show after your next Claude turn"
+CLAUDE_WINDOW_EXPIRED_NOTE = "past its reset time -- will update after your next Claude turn"
+
+
+@dataclass(frozen=True)
+class RateWindow:
+ used_percent: float
+ resets_at: Optional[int] = None # unix epoch seconds
+
+
+@dataclass(frozen=True)
+class ProviderUsage:
+ provider: str
+ available: bool
+ five_hour: Optional[RateWindow] = None
+ weekly: Optional[RateWindow] = None
+ plan: Optional[str] = None
+ error: Optional[str] = None
+ # Set only when a window came from the passive cache -- lets callers show
+ # "as of X ago". None (never cached) reads the same as a live value here.
+ observed_at: Optional[float] = None
+ # Populated per-window only for Claude, only when that window is None,
+ # explaining why (see CLAUDE_WINDOW_*_NOTE above) instead of a bare
+ # "unknown" -- Codex/Copilot don't need this, they're never partially
+ # available.
+ five_hour_note: Optional[str] = None
+ weekly_note: Optional[str] = None
+
+
+@dataclass(frozen=True)
+class _ClaudeRateLimitSnapshot:
+ five_hour: Optional[RateWindow]
+ weekly: Optional[RateWindow]
+ observed_at: float
+
+
+# Process-wide: the underlying `claude` CLI's OAuth login is per-machine, not
+# per Telegram bot/chat, so one cache shared across every bot instance on this
+# host matches the actual scope of what it's caching. Optionally backed by its
+# own small file on disk (see configure_persistence) so a bot restart doesn't
+# throw away a window that's still live -- without that, /status would
+# misreport "no data yet" for whatever's left of the window after every
+# restart, not just report genuinely fresh state.
+#
+# Deliberately its own file rather than a key in the main session state.json:
+# this snapshot refreshes on every real Claude turn (not just rare
+# session-lifecycle events like /new or /switch), and it's disposable --
+# worst case a lost write just means one more "no data yet" until the next
+# Claude turn observes it again. Writing it into state.json would mean
+# re-copying the entire session/backup blob on every single turn just to
+# protect a few bytes of best-effort telemetry, and would serialize these
+# frequent writes against unrelated, rarer session-lifecycle writes sharing
+# that file's lock.
+_claude_rate_limit_cache: Optional[_ClaudeRateLimitSnapshot] = None
+_claude_rate_limit_lock = threading.Lock()
+_claude_rate_limit_path: Optional[Path] = None
+_RATE_LIMIT_LOCK_TIMEOUT_SECONDS = 5
+
+
+def _rate_window_to_dict(window: Optional[RateWindow]) -> Optional[dict]:
+ if window is None:
+ return None
+ return {"used_percent": window.used_percent, "resets_at": window.resets_at}
+
+
+def _rate_window_from_dict(data: object) -> Optional[RateWindow]:
+ if not isinstance(data, dict):
+ return None
+ used_percent = data.get("used_percent")
+ if not isinstance(used_percent, (int, float)):
+ return None
+ resets_at = data.get("resets_at")
+ return RateWindow(
+ used_percent=float(used_percent),
+ resets_at=resets_at if isinstance(resets_at, int) else None,
+ )
+
+
+def _snapshot_to_dict(snapshot: _ClaudeRateLimitSnapshot) -> dict:
+ return {
+ "five_hour": _rate_window_to_dict(snapshot.five_hour),
+ "weekly": _rate_window_to_dict(snapshot.weekly),
+ "observed_at": snapshot.observed_at,
+ }
+
+
+def _snapshot_from_dict(data: dict) -> Optional[_ClaudeRateLimitSnapshot]:
+ observed_at = data.get("observed_at")
+ if not isinstance(observed_at, (int, float)):
+ return None
+ return _ClaudeRateLimitSnapshot(
+ five_hour=_rate_window_from_dict(data.get("five_hour")),
+ weekly=_rate_window_from_dict(data.get("weekly")),
+ observed_at=float(observed_at),
+ )
+
+
+def _read_locked_json_file(path: Path) -> Optional[dict]:
+ """Return the parsed JSON object at *path*, or None if it doesn't exist,
+ is empty, or isn't a JSON object. Locked against other processes writing
+ the same file (see the module-level comment on why this isn't state.json)."""
+ lock_file = path.with_suffix(path.suffix + ".lock")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with portalocker.Lock(str(lock_file), timeout=_RATE_LIMIT_LOCK_TIMEOUT_SECONDS):
+ if not path.exists():
+ return None
+ raw = path.read_text(encoding="utf-8").strip()
+ if not raw:
+ return None
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def _write_locked_json_file(path: Path, data: dict) -> None:
+ """Atomically (temp file + rename) and lock-safely overwrite *path* with
+ *data*. No backup copy -- unlike state.json, this file's contents are
+ disposable/self-healing, so there's nothing worth preserving a prior
+ version of."""
+ lock_file = path.with_suffix(path.suffix + ".lock")
+ temp_file = path.with_suffix(path.suffix + ".tmp")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ serialized = json.dumps(data, indent=2, ensure_ascii=False)
+ with portalocker.Lock(str(lock_file), timeout=_RATE_LIMIT_LOCK_TIMEOUT_SECONDS):
+ temp_file.write_text(serialized + "\n", encoding="utf-8")
+ temp_file.replace(path)
+
+
+def configure_persistence(path: Path) -> None:
+ """Back the passive rate-limit cache with a dedicated JSON file at *path*
+ and seed the cache from it if present.
+
+ Called once at startup. Loading here (rather than lazily on first read)
+ means the very first ``/status`` after a restart can already show the
+ last-observed window instead of "no data yet", as long as that window
+ hasn't rolled past its own reset time -- get_claude_usage's existing
+ expiry check handles that either way.
+ """
+ global _claude_rate_limit_path, _claude_rate_limit_cache
+ _claude_rate_limit_path = path
+ try:
+ data = _read_locked_json_file(path)
+ except (OSError, portalocker.LockException):
+ logger.warning("Could not load persisted Claude rate-limit snapshot; starting empty.", exc_info=True)
+ return
+ if data is None:
+ return
+ snapshot = _snapshot_from_dict(data)
+ if snapshot is None:
+ return
+ with _claude_rate_limit_lock:
+ _claude_rate_limit_cache = snapshot
+
+
+def _claude_rate_window(window: Optional[dict]) -> Optional[RateWindow]:
+ if not isinstance(window, dict):
+ return None
+ utilization = window.get("utilization")
+ if not isinstance(utilization, (int, float)):
+ return None
+ resets_at = window.get("resetsAt")
+ return RateWindow(
+ used_percent=round(float(utilization) * 100, 1),
+ resets_at=resets_at if isinstance(resets_at, int) else None,
+ )
+
+
+def parse_claude_rate_limit_event(raw_events: list) -> Optional[ProviderUsage]:
+ """Extract usage windows from a claude ``-p`` run's parsed jsonl events.
+
+ Uses the *last* ``rate_limit_event``, not the first: a single ``-p``
+ invocation can make more than one real API turn internally (e.g. a
+ tool-use loop), each capable of emitting its own event as utilization
+ climbs, and only the final one reflects the run's actual ending usage.
+ """
+ usage: Optional[ProviderUsage] = None
+ for event in raw_events:
+ if not isinstance(event, dict) or event.get("type") != "rate_limit_event":
+ continue
+ windows = (event.get("rate_limit_info") or {}).get("unifiedWindows") or {}
+ usage = ProviderUsage(
+ provider="claude",
+ available=True,
+ five_hour=_claude_rate_window(windows.get("five_hour")),
+ weekly=_claude_rate_window(windows.get("seven_day")),
+ )
+ return usage
+
+
+def _store_claude_snapshot(usage: ProviderUsage) -> None:
+ global _claude_rate_limit_cache
+ snapshot = _ClaudeRateLimitSnapshot(
+ five_hour=usage.five_hour,
+ weekly=usage.weekly,
+ observed_at=time.time(),
+ )
+ with _claude_rate_limit_lock:
+ _claude_rate_limit_cache = snapshot
+ path = _claude_rate_limit_path
+ if path is not None:
+ # Best-effort: a failed disk write must not lose the in-memory update
+ # above, which is what every real Claude call up to this point relied
+ # on already existing.
+ try:
+ _write_locked_json_file(path, _snapshot_to_dict(snapshot))
+ except (OSError, portalocker.LockException):
+ logger.warning("Could not persist Claude rate-limit snapshot to disk.", exc_info=True)
+
+
+def observe_claude_rate_limit_event(raw_events: list) -> None:
+ """Best-effort cache update from any real claude call's raw events.
+
+ Called after every claude subprocess run (see ``agent_runner._run``)
+ regardless of which command triggered it, so ``get_claude_usage`` can
+ usually answer ``/status`` from a real, recent observation instead of
+ paying for a dedicated probe on every check.
+ """
+ usage = parse_claude_rate_limit_event(raw_events)
+ if usage is not None:
+ _store_claude_snapshot(usage)
+
+
+# Backstop for a window whose resets_at came back missing (a malformed or
+# partial API response -- see _claude_rate_window/_rate_window_from_dict,
+# both of which fall back to None rather than guessing) and would otherwise
+# never expire on its own below. Set comfortably past the longest real window
+# (7 days) so it never second-guesses a legitimately fresh weekly window that
+# *does* have a resets_at -- this only kicks in when that field is absent.
+# Matters more now than it would have before configure_persistence existed:
+# a bad value used to be bounded by the process's own lifetime, and now
+# persists across restarts until a fresh event happens to overwrite it.
+_MAX_SNAPSHOT_AGE_SECONDS = 8 * 24 * 3600
+
+
+def _resolve_window(
+ window: Optional[RateWindow], now: float, observed_at: Optional[float]
+) -> tuple[Optional[RateWindow], Optional[str]]:
+ """Return the window if it's still trustworthy, else ``(None, reason)``.
+
+ A cached window is trustworthy only until its own reported reset time --
+ past that point the real window has already rolled over to a fresh count
+ this cache never observed, so continuing to show the old percentage would
+ be actively wrong, not just stale.
+ """
+ if window is None:
+ return None, CLAUDE_WINDOW_NEVER_OBSERVED_NOTE
+ if window.resets_at is not None:
+ if now >= window.resets_at:
+ return None, CLAUDE_WINDOW_EXPIRED_NOTE
+ elif observed_at is not None and now - observed_at >= _MAX_SNAPSHOT_AGE_SECONDS:
+ return None, CLAUDE_WINDOW_EXPIRED_NOTE
+ return window, None
+
+
+def get_claude_usage() -> ProviderUsage:
+ """Read Claude's usage from the passive cache only -- no live fallback.
+
+ The five-hour and weekly windows are resolved independently: an idle bot
+ can easily have one window sitting well past its reset while the other is
+ still fresh, and treating them as a pair would throw away the still-good
+ one just because its sibling expired.
+ """
+ now = time.time()
+ with _claude_rate_limit_lock:
+ snapshot = _claude_rate_limit_cache
+
+ observed_at = snapshot.observed_at if snapshot else None
+ five_hour, five_hour_note = _resolve_window(snapshot.five_hour if snapshot else None, now, observed_at)
+ weekly, weekly_note = _resolve_window(snapshot.weekly if snapshot else None, now, observed_at)
+
+ return ProviderUsage(
+ provider="claude",
+ available=True,
+ five_hour=five_hour,
+ five_hour_note=five_hour_note,
+ weekly=weekly,
+ weekly_note=weekly_note,
+ observed_at=snapshot.observed_at if snapshot is not None else None,
+ )
+
+
+def _codex_percent_window(window: Optional[dict]) -> Optional[RateWindow]:
+ if not isinstance(window, dict):
+ return None
+ used_percent = window.get("usedPercent")
+ if not isinstance(used_percent, (int, float)):
+ return None
+ resets_at = window.get("resetsAt")
+ return RateWindow(
+ used_percent=round(float(used_percent), 1),
+ resets_at=resets_at if isinstance(resets_at, int) else None,
+ )
+
+
+def parse_codex_rate_limits_result(result: dict) -> ProviderUsage:
+ rate_limits = result.get("rateLimits") or {}
+ return ProviderUsage(
+ provider="codex",
+ available=True,
+ five_hour=_codex_percent_window(rate_limits.get("primary")),
+ weekly=_codex_percent_window(rate_limits.get("secondary")),
+ plan=rate_limits.get("planType") if isinstance(rate_limits.get("planType"), str) else None,
+ )
+
+
+def _send_json_rpc(proc: subprocess.Popen, message: dict) -> None:
+ proc.stdin.write(json.dumps(message) + "\n")
+ proc.stdin.flush()
+
+
+def fetch_codex_usage(codex_bin: str) -> ProviderUsage:
+ """Query Codex's local app-server daemon over JSON-RPC for rate limits.
+
+ Spawns a fresh, short-lived ``codex app-server`` process rather than
+ reusing a persistent daemon -- this is an on-demand status check, not
+ something worth keeping a background process alive for.
+ """
+ try:
+ proc = subprocess.Popen(
+ [codex_bin, "app-server"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ bufsize=1,
+ )
+ except OSError as exc:
+ return ProviderUsage(provider="codex", available=False, error=str(exc))
+
+ outcome: dict = {}
+ done = threading.Event()
+
+ def read_stdout() -> None:
+ try:
+ for line in proc.stdout:
+ stripped = line.strip()
+ if not stripped:
+ continue
+ try:
+ message = json.loads(stripped)
+ except json.JSONDecodeError:
+ continue
+ if message.get("id") != 2:
+ continue
+ if "result" in message:
+ outcome["result"] = message["result"]
+ elif "error" in message:
+ outcome["error"] = message["error"]
+ done.set()
+ return
+ except (OSError, ValueError):
+ done.set()
+
+ reader = threading.Thread(target=read_stdout, daemon=True)
+ reader.start()
+
+ try:
+ _send_json_rpc(
+ proc,
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {"clientInfo": {"name": "coding-agent-telegram", "version": "1.0.0"}},
+ },
+ )
+ _send_json_rpc(proc, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
+ _send_json_rpc(proc, {"jsonrpc": "2.0", "id": 2, "method": "account/rateLimits/read", "params": {}})
+ done.wait(timeout=CODEX_APP_SERVER_TIMEOUT_SECONDS)
+ except (BrokenPipeError, OSError) as exc:
+ outcome.setdefault("error", str(exc))
+ finally:
+ try:
+ proc.terminate()
+ proc.wait(timeout=3)
+ except Exception:
+ proc.kill()
+
+ if "result" in outcome:
+ return parse_codex_rate_limits_result(outcome["result"])
+
+ error = outcome.get("error")
+ if isinstance(error, dict):
+ error_text = error.get("message") or str(error)
+ elif error:
+ error_text = str(error)
+ else:
+ try:
+ error_text = (proc.stderr.read() or "").strip()
+ except Exception:
+ error_text = ""
+ return ProviderUsage(provider="codex", available=False, error=error_text or "No response from codex app-server.")
+
+
+def fetch_copilot_usage() -> ProviderUsage:
+ return ProviderUsage(
+ provider="copilot",
+ available=False,
+ error=(
+ "Copilot bills against a monthly AI-credit balance, not a 5-hour/weekly window, and there's "
+ "no CLI or API to read an individual account's remaining credits (only the GitHub billing page)."
+ ),
+ )
diff --git a/startup.sh b/startup.sh
index 9775095..2aa9121 100755
--- a/startup.sh
+++ b/startup.sh
@@ -1,221 +1,28 @@
-#!/usr/bin/env bash
-
-set -euo pipefail
+#!/bin/bash
+# Repository entry point. It prepares the checkout once, then replaces this
+# shell with the same Python supervisor used by the installed console command.
+# That supervisor owns crash recovery, DNS backoff, heartbeat watchdogs, and
+# cleanup of an orphaned bot left by a forced kill.
+set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
-DEFAULT_ENV_FILE=".env_coding_agent_telegram"
-PYTHON_BIN="${PYTHON_BIN:-python3}"
-ENV_FILE="${ENV_FILE:-}"
-ENV_TEMPLATE_FILE="${ENV_TEMPLATE_FILE:-src/coding_agent_telegram/resources/.env.example}"
-VENV_DIR="${VENV_DIR:-.venv}"
-
-resolve_user_home() {
- "$PYTHON_BIN" - <<'PY'
-from pathlib import Path
-import os
-import pwd
-
-sudo_user = os.getenv("SUDO_USER", "").strip()
-if sudo_user and sudo_user != "root":
- try:
- print(pwd.getpwnam(sudo_user).pw_dir)
- except KeyError:
- print(Path.home())
-else:
- print(Path.home())
-PY
-}
-
-if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
- echo "Error: $PYTHON_BIN was not found in PATH." >&2
- exit 1
-fi
-
-APP_HOME_DIR="$(resolve_user_home)/.coding-agent-telegram"
-HOME_ENV_FILE="$APP_HOME_DIR/$DEFAULT_ENV_FILE"
-STATE_FILE_DEFAULT="$APP_HOME_DIR/state.json"
-STATE_BACKUP_FILE_DEFAULT="$APP_HOME_DIR/state.json.bak"
-LOG_DIR_DEFAULT="$APP_HOME_DIR/logs"
-LOCAL_PRETEND_VERSION="${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM:-0.0.dev0}"
-INSTALL_STATE_FILE_NAME=".coding-agent-telegram-install-state"
-FORCE_REINSTALL="${FORCE_REINSTALL:-0}"
-
-compute_install_fingerprint() {
- local files=()
- local file
- for file in pyproject.toml setup.py; do
- if [[ -f "$file" ]]; then
- files+=("$file")
- fi
- done
- if [[ "${#files[@]}" -eq 0 ]]; then
- printf 'no-packaging-files\n'
- return
- fi
- shasum -a 256 "${files[@]}" | shasum -a 256 | awk '{print $1}'
-}
-
-if [[ -z "$ENV_FILE" ]]; then
- if [[ -f "$HOME_ENV_FILE" ]]; then
- ENV_FILE="$HOME_ENV_FILE"
- elif [[ -f "$DEFAULT_ENV_FILE" ]]; then
- ENV_FILE="$DEFAULT_ENV_FILE"
- else
- ENV_FILE="$HOME_ENV_FILE"
- fi
-fi
-
-NEW_ENV_CREATED=0
-if [[ ! -f "$ENV_FILE" ]]; then
- if [[ -f "$ENV_TEMPLATE_FILE" ]]; then
- ENV_FILE_TARGET="$ENV_FILE" ENV_TEMPLATE_SOURCE="$ENV_TEMPLATE_FILE" PYTHONPATH="$SCRIPT_DIR/src${PYTHONPATH:+:$PYTHONPATH}" "$PYTHON_BIN" - <<'PY'
-from pathlib import Path
-import os
-from coding_agent_telegram.config import create_initial_env_file
-from coding_agent_telegram.i18n import translate
-
-env_path = Path(os.environ["ENV_FILE_TARGET"]).expanduser()
-template_path = Path(os.environ["ENV_TEMPLATE_SOURCE"]).expanduser()
-app_locale = create_initial_env_file(env_path, template_path)
-print(translate(app_locale, "bootstrap.env_created_locale_line", env_path=env_path, app_locale=app_locale))
-print(translate(app_locale, "bootstrap.env_created_change_line", env_path=env_path))
-PY
- NEW_ENV_CREATED=1
- else
- echo "Error: $ENV_FILE is missing and $ENV_TEMPLATE_FILE was not found." >&2
- exit 1
- fi
-fi
-
-STATE_FILE="$STATE_FILE_DEFAULT"
-STATE_BACKUP_FILE="$STATE_BACKUP_FILE_DEFAULT"
-if [[ -f "$APP_HOME_DIR/state.json" ]]; then
- STATE_FILE="$APP_HOME_DIR/state.json"
-elif [[ -f "./state.json" ]]; then
- STATE_FILE="./state.json"
-fi
-if [[ -f "$APP_HOME_DIR/state.json.bak" ]]; then
- STATE_BACKUP_FILE="$APP_HOME_DIR/state.json.bak"
-elif [[ -f "./state.json.bak" ]]; then
- STATE_BACKUP_FILE="./state.json.bak"
-fi
-LOG_DIR="$LOG_DIR_DEFAULT"
-
-mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$STATE_BACKUP_FILE")" "$LOG_DIR"
-touch "$STATE_FILE" "$STATE_BACKUP_FILE"
-
-if [[ ! -d "$VENV_DIR" ]]; then
- "$PYTHON_BIN" -m venv "$VENV_DIR"
+echo "$(date '+%Y-%m-%d %H:%M:%S') SUPERVISOR: running bootstrap.sh..."
+./bootstrap.sh
+bootstrap_status=$?
+if [ "$bootstrap_status" -ne 0 ]; then
+ echo "$(date '+%Y-%m-%d %H:%M:%S') SUPERVISOR: bootstrap.sh failed (status $bootstrap_status); not starting." >&2
+ exit "$bootstrap_status"
fi
-source "$VENV_DIR/bin/activate"
+PYTHON="$SCRIPT_DIR/.venv/bin/python3"
-python -m pip install --upgrade pip >/dev/null
-INSTALL_STATE_FILE="$VENV_DIR/$INSTALL_STATE_FILE_NAME"
-CURRENT_INSTALL_FINGERPRINT="$(compute_install_fingerprint)"
-STORED_INSTALL_FINGERPRINT=""
-if [[ -f "$INSTALL_STATE_FILE" ]]; then
- STORED_INSTALL_FINGERPRINT="$(<"$INSTALL_STATE_FILE")"
-fi
-
-NEEDS_REINSTALL=0
-if [[ "$FORCE_REINSTALL" == "1" ]]; then
- NEEDS_REINSTALL=1
-elif ! python -c "import coding_agent_telegram" >/dev/null 2>&1; then
- NEEDS_REINSTALL=1
-elif [[ "$CURRENT_INSTALL_FINGERPRINT" != "$STORED_INSTALL_FINGERPRINT" ]]; then
- NEEDS_REINSTALL=1
-fi
-
-if [[ "$NEEDS_REINSTALL" == "1" ]]; then
- echo "Installing local package into $VENV_DIR."
- SETUPTOOLS_SCM_PRETEND_VERSION_FOR_CODING_AGENT_TELEGRAM="$LOCAL_PRETEND_VERSION" \
- python -m pip install -e .
- printf '%s\n' "$CURRENT_INSTALL_FINGERPRINT" > "$INSTALL_STATE_FILE"
-else
- echo "Existing editable install detected; skipping reinstall."
-fi
-
-if [[ "$NEW_ENV_CREATED" == "1" ]]; then
- python -m coding_agent_telegram.stt_setup offer \
- --env-file "$ENV_FILE" \
- --python-bin "$VENV_DIR/bin/python" \
- --installer-label "./install-stt.sh"
-fi
-
-set -a
-source "$ENV_FILE"
-set +a
-
-required_vars=(
- WORKSPACE_ROOT
- TELEGRAM_BOT_TOKENS
-)
-
-for var_name in "${required_vars[@]}"; do
- if [[ -z "${!var_name:-}" ]]; then
- echo "Error: $var_name must be set in $ENV_FILE." >&2
- echo "Post-installation checklist:"
- echo "1. Edit $ENV_FILE"
- echo "2. Set WORKSPACE_ROOT to the parent folder containing your projects"
- echo "3. Set TELEGRAM_BOT_TOKENS to one or more bot tokens"
- echo "4. Set ALLOWED_CHAT_IDS to your Telegram chat id(s)"
- echo "5. Run: ./startup.sh"
- exit 1
- fi
-done
-
-if [[ -z "${ALLOWED_CHAT_IDS:-}" ]]; then
- echo "Error: set ALLOWED_CHAT_IDS in $ENV_FILE." >&2
- echo "Run: ./startup.sh after updating $ENV_FILE."
- exit 1
-fi
-
-DEFAULT_AGENT_PROVIDER="${DEFAULT_AGENT_PROVIDER:-codex}"
-CODEX_BIN="${CODEX_BIN:-codex}"
-COPILOT_BIN="${COPILOT_BIN:-copilot}"
-CLAUDE_BIN="${CLAUDE_BIN:-claude}"
-
-case "$DEFAULT_AGENT_PROVIDER" in
- codex)
- if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then
- echo "Error: Codex CLI not found: $CODEX_BIN" >&2
- echo "Check DEFAULT_AGENT_PROVIDER and CODEX_BIN in $ENV_FILE." >&2
- echo "If this machine only has Copilot or Claude Code, set DEFAULT_AGENT_PROVIDER=copilot or claude." >&2
- exit 1
- fi
- ;;
- copilot)
- if ! command -v "$COPILOT_BIN" >/dev/null 2>&1; then
- echo "Error: Copilot CLI not found: $COPILOT_BIN" >&2
- echo "Check DEFAULT_AGENT_PROVIDER and COPILOT_BIN in $ENV_FILE." >&2
- echo "If this machine only has Codex or Claude Code, set DEFAULT_AGENT_PROVIDER=codex or claude." >&2
- exit 1
- fi
- ;;
- claude)
- if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
- echo "Error: Claude Code CLI not found: $CLAUDE_BIN" >&2
- echo "Check DEFAULT_AGENT_PROVIDER and CLAUDE_BIN in $ENV_FILE." >&2
- echo "If this machine only has Codex or Copilot, set DEFAULT_AGENT_PROVIDER=codex or copilot." >&2
- exit 1
- fi
- ;;
- *)
- echo "Error: DEFAULT_AGENT_PROVIDER must be codex, copilot, or claude." >&2
- exit 1
- ;;
-esac
-
-echo "Post-installation guide:"
-echo "1. Confirm $ENV_FILE contains WORKSPACE_ROOT, TELEGRAM_BOT_TOKENS, and ALLOWED_CHAT_IDS."
-echo "2. State files are ready at $STATE_FILE and $STATE_BACKUP_FILE."
-echo "3. Application logs will be written under $LOG_DIR."
-echo "4. Optional voice-to-text: run ./install-stt.sh if you want local Whisper support."
-echo "5. Start the server with: ./startup.sh"
-echo "6. In Telegram, start conversations."
-echo "Starting coding-agent-telegram..."
+# Preserve the checkout-specific STT setup hint. The installed command keeps
+# its normal `coding-agent-telegram-stt-install` hint instead.
export CODING_AGENT_TELEGRAM_STT_INSTALL_HINT="./install-stt.sh"
-exec python -m coding_agent_telegram
+# Keep the existing checkout-local location so an orphan from the former shell
+# supervisor is recovered on the first run after upgrading.
+export CODING_AGENT_TELEGRAM_CHILD_PID_FILE="$SCRIPT_DIR/coding-agent-telegram.child.pid"
+
+exec "$PYTHON" -m coding_agent_telegram.supervisor "$@"
diff --git a/tests/conftest.py b/tests/conftest.py
index 99f1af1..6822df4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,12 @@
import sys
+import asyncio
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+
+
+def pytest_runtest_setup(item):
+ try:
+ asyncio.get_event_loop()
+ except RuntimeError:
+ asyncio.set_event_loop(asyncio.new_event_loop())
diff --git a/tests/test_agent_runner.py b/tests/test_agent_runner.py
index 7364d39..4baa57b 100644
--- a/tests/test_agent_runner.py
+++ b/tests/test_agent_runner.py
@@ -131,7 +131,9 @@ def test_copilot_runner_uses_prompt_mode_shape(monkeypatch):
sandbox_mode="workspace-write",
)
- result = runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False)
+ result = runner.create_session(
+ "copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False, priming_only=True
+ )
assert calls[0][0] == [
"copilot",
@@ -194,7 +196,9 @@ def test_codex_runner_attaches_images_for_create_and_resume(monkeypatch):
assert str(image_path) in calls[1][0]
-def test_copilot_runner_rejects_image_attachments():
+def test_copilot_runner_accepts_image_paths_in_prompt(monkeypatch):
+ calls: list = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
runner = MultiAgentRunner(
codex_bin="codex",
copilot_bin="copilot",
@@ -203,10 +207,10 @@ def test_copilot_runner_rejects_image_attachments():
)
image_path = Path("/tmp/project/.coding-agent-telegram/telegram_attachments/img.jpg")
- result = runner.create_session("copilot", Path("/tmp/project"), "hello", image_paths=(image_path,))
+ result = runner.create_session("copilot", Path("/tmp/project"), f"Read {image_path}", image_paths=(image_path,))
- assert result.success is False
- assert result.error_message == "Image attachments are not supported for Copilot sessions."
+ assert result.success is True
+ assert "Read /tmp/project/.coding-agent-telegram/telegram_attachments/img.jpg" in calls[0][0]
def test_copilot_runner_uses_native_home_when_copilot_home_is_unset(monkeypatch):
@@ -221,9 +225,12 @@ def test_copilot_runner_uses_native_home_when_copilot_home_is_unset(monkeypatch)
sandbox_mode="workspace-write",
)
- runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=True)
+ runner.create_session(
+ "copilot", Path("/tmp/project"), "hello", skip_git_repo_check=True, priming_only=True
+ )
assert "COPILOT_HOME" not in calls[0][2]
+ # skip_git_repo_check must not smuggle a permission grant into a priming run.
assert "--allow-all" not in calls[0][0]
assert "--allow-all-tools" not in calls[0][0]
@@ -466,6 +473,42 @@ def test_codex_runner_passes_model_when_configured(monkeypatch):
assert calls[0][0][:4] == ["codex", "exec", "-m", "gpt-5-codex"]
+def test_codex_runner_create_session_model_override_takes_precedence(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ codex_model="gpt-5-codex",
+ )
+
+ runner.create_session("codex", Path("/tmp/project"), "hello", skip_git_repo_check=False, model="o4-mini")
+
+ assert calls[0][0][:4] == ["codex", "exec", "-m", "o4-mini"]
+
+
+def test_codex_runner_resume_session_model_override_takes_precedence(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ codex_model="gpt-5-codex",
+ )
+
+ runner.resume_session(
+ "codex", "sess_1", Path("/tmp/project"), "hello again", skip_git_repo_check=False, model="o4-mini"
+ )
+
+ assert calls[0][0][:5] == ["codex", "exec", "resume", "-m", "o4-mini"]
+
+
def test_copilot_runner_passes_model_when_configured(monkeypatch):
calls = []
monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
@@ -478,7 +521,9 @@ def test_copilot_runner_passes_model_when_configured(monkeypatch):
copilot_model="gpt-5",
)
- runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False)
+ runner.create_session(
+ "copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False, priming_only=True
+ )
assert calls[0][0][:5] == [
"copilot",
@@ -489,11 +534,29 @@ def test_copilot_runner_passes_model_when_configured(monkeypatch):
]
-def test_copilot_runner_passes_tool_permission_flags(monkeypatch):
+def test_copilot_runner_resume_session_model_override_takes_precedence(monkeypatch):
calls = []
monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ copilot_model="gpt-5",
+ )
+
+ runner.resume_session(
+ "copilot", "sess_1", Path("/tmp/project"), "hello again", skip_git_repo_check=False, model="claude-sonnet-4.6"
+ )
+
+ assert calls[0][0][:2] == ["copilot", "--resume=sess_1"]
+ assert "--model" in calls[0][0]
+ assert calls[0][0][calls[0][0].index("--model") + 1] == "claude-sonnet-4.6"
+
+
+def _copilot_tool_permission_runner() -> MultiAgentRunner:
+ return MultiAgentRunner(
codex_bin="codex",
copilot_bin="copilot",
approval_policy="never",
@@ -507,7 +570,16 @@ def test_copilot_runner_passes_tool_permission_flags(monkeypatch):
copilot_available_tools=("shell", "apply_patch"),
)
- runner.create_session("copilot", Path("/tmp/project"), "hello", skip_git_repo_check=False)
+
+def test_copilot_priming_session_creation_withholds_tool_permission_flags(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = _copilot_tool_permission_runner()
+
+ runner.create_session(
+ "copilot", Path("/tmp/project"), "prime me", skip_git_repo_check=False, priming_only=True
+ )
assert "--allow-all-tools" not in calls[0][0]
assert "--allow-tool" not in calls[0][0]
@@ -515,6 +587,24 @@ def test_copilot_runner_passes_tool_permission_flags(monkeypatch):
assert "--available-tools" not in calls[0][0]
+def test_copilot_session_creation_with_real_prompt_passes_tool_permission_flags(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = _copilot_tool_permission_runner()
+
+ # The replacement-session path (a resume that failed) passes the real user request
+ # here, so it must run with the operator's configured permissions -- otherwise
+ # Copilot is left unable to act on a request Codex and Claude would have executed.
+ runner.create_session("copilot", Path("/tmp/project"), "fix the bug", skip_git_repo_check=False)
+
+ args = calls[0][0]
+ assert "--allow-all-tools" in args
+ assert args[args.index("--allow-tool") + 1] == "shell(git)"
+ assert args[args.index("--deny-tool") + 1] == "shell(rm)"
+ assert args[args.index("--available-tools") + 1] == "shell,apply_patch"
+
+
# ---------------------------------------------------------------------------
# Claude provider
# ---------------------------------------------------------------------------
@@ -614,6 +704,40 @@ def test_claude_runner_passes_model_and_tool_flags_when_configured(monkeypatch):
]
+def test_claude_runner_resume_session_model_override_takes_precedence(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ claude_model="sonnet",
+ )
+
+ runner.resume_session("claude", "sess_1", Path("/tmp/project"), "hello again", model="opus")
+
+ assert calls[0][0][:5] == ["claude", "--resume", "sess_1", "--model", "opus"]
+
+
+def test_claude_runner_create_session_without_override_uses_configured_default(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ claude_model="sonnet",
+ )
+
+ runner.create_session("claude", Path("/tmp/project"), "hello")
+
+ assert calls[0][0][:3] == ["claude", "--model", "sonnet"]
+
+
def test_claude_runner_reports_failure_from_result_event(monkeypatch):
calls = []
monkeypatch.setattr(
@@ -636,6 +760,69 @@ def test_claude_runner_reports_failure_from_result_event(monkeypatch):
assert result.success is False
assert result.error_message == "error_max_turns"
assert result.session_id == "sess_claude"
+ assert result.error_code is None
+
+
+def test_claude_runner_prefers_errors_array_over_generic_subtype(monkeypatch):
+ """A resume against a session ID Claude has no local transcript for fails with an
+ empty "result" and the generic subtype "error_during_execution" -- the actual reason
+ only shows up in the "errors" array. That's the message worth surfacing/matching
+ against for resume-failure recovery, not the opaque subtype."""
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout=(
+ '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"",'
+ '"session_id":"sess_claude","errors":["No conversation found with session ID: sess_claude"]}\n'
+ ),
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ result = runner.resume_session("claude", "sess_claude", Path("/tmp/project"), "hello")
+
+ assert result.success is False
+ assert result.error_message == "No conversation found with session ID: sess_claude"
+ assert result.error_code == "session_not_found"
+
+
+def test_claude_runner_does_not_set_session_not_found_code_for_other_errors(monkeypatch):
+ """error_code="session_not_found" is a precise signal, not a generic is_error flag --
+ a failure for some other reason (even one that also lacks "result" text) must not be
+ mistaken for an unresumable session, or _replace_invalid_session_if_needed would
+ discard a perfectly resumable session over an unrelated failure."""
+ calls = []
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ make_fake_popen(
+ calls,
+ process_stdout=(
+ '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"",'
+ '"session_id":"sess_claude","errors":["Network error while contacting the API"]}\n'
+ ),
+ ),
+ )
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ result = runner.resume_session("claude", "sess_claude", Path("/tmp/project"), "hello")
+
+ assert result.success is False
+ assert result.error_message == "Network error while contacting the API"
+ assert result.error_code is None
def test_claude_runner_extracts_assistant_message_text_as_progress(monkeypatch):
@@ -762,6 +949,102 @@ def test_claude_runner_ignores_image_paths_without_error(monkeypatch):
assert result.success is True
+def test_claude_priming_session_creation_runs_read_only(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ claude_permission_mode="bypassPermissions",
+ )
+
+ runner.create_session("claude", Path("/tmp/project"), "prime me", priming_only=True)
+
+ args = calls[0][0]
+ assert args[args.index("--permission-mode") + 1] == "plan"
+ assert "bypassPermissions" not in args
+
+
+def test_codex_priming_session_creation_runs_read_only(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ )
+
+ runner.create_session("codex", Path("/tmp/project"), "prime me", priming_only=True)
+
+ args = calls[0][0]
+ assert "approval_policy=never" in args
+ assert "sandbox_mode=read-only" in args
+ assert "sandbox_mode=workspace-write" not in args
+
+
+def test_codex_session_creation_with_real_prompt_keeps_configured_sandbox_mode(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="on-failure",
+ sandbox_mode="workspace-write",
+ )
+
+ # The replacement-session path passes the real user request here, so it must not
+ # be downgraded to read-only.
+ runner.create_session("codex", Path("/tmp/project"), "fix the bug")
+
+ args = calls[0][0]
+ assert "sandbox_mode=workspace-write" in args
+ assert "approval_policy=on-failure" in args
+
+
+def test_claude_session_creation_with_real_prompt_keeps_configured_permission_mode(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ claude_permission_mode="bypassPermissions",
+ )
+
+ # The replacement-session path passes the real user request here, so it must not
+ # be downgraded to read-only.
+ runner.create_session("claude", Path("/tmp/project"), "fix the bug")
+
+ args = calls[0][0]
+ assert args[args.index("--permission-mode") + 1] == "bypassPermissions"
+
+
+def test_claude_resume_keeps_configured_permission_mode(monkeypatch):
+ calls = []
+ monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
+
+ runner = MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="workspace-write",
+ claude_permission_mode="bypassPermissions",
+ )
+
+ runner.resume_session("claude", "sess_abc", Path("/tmp/project"), "keep working")
+
+ args = calls[0][0]
+ assert args[args.index("--permission-mode") + 1] == "bypassPermissions"
+
+
# ---------------------------------------------------------------------------
# _validate_session_id
# ---------------------------------------------------------------------------
@@ -870,7 +1153,7 @@ def test_resume_session_returns_failure_for_unsupported_provider(monkeypatch):
assert calls == []
-def test_copilot_resume_rejects_image_attachments(monkeypatch):
+def test_copilot_resume_accepts_image_paths_in_prompt(monkeypatch):
calls: list = []
monkeypatch.setattr("coding_agent_telegram.agent_runner.subprocess.Popen", make_fake_popen(calls))
@@ -884,13 +1167,12 @@ def test_copilot_resume_rejects_image_attachments(monkeypatch):
"copilot",
"sess_1",
Path("/tmp/project"),
- "hello",
+ "Read /tmp/image.png",
image_paths=[Path("/tmp/image.png")],
)
- assert result.success is False
- assert "not supported" in (result.error_message or "").lower()
- assert calls == [] # no subprocess launched
+ assert result.success is True
+ assert "Read /tmp/image.png" in calls[0][0]
def test_runner_uses_internal_code_for_generic_command_failure(monkeypatch):
diff --git a/tests/test_bot.py b/tests/test_bot.py
index 24e9439..cf1ad4b 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -5,15 +5,35 @@ def test_default_bot_commands_hide_commit_and_push_when_disabled():
commands = default_bot_commands(enable_commit_command=False)
names = [command.command for command in commands]
- assert names == ["provider", "project", "branch", "current", "new", "switch", "compact", "diff", "pull", "push", "abort"]
+ assert names == ["provider", "model", "project", "branch", "current", "status", "new", "switch", "compact", "diff", "pull", "push", "log", "reset", "abort"]
assert "commit" not in names
+ descriptions = {command.command: command.description for command in commands}
+ assert descriptions["pull"] == "Git pull the current session branch"
+ assert all("Git" in descriptions[name] for name in ("pull", "push", "log", "reset"))
def test_default_bot_commands_show_commit_and_push_when_enabled():
commands = default_bot_commands(enable_commit_command=True)
names = [command.command for command in commands]
- assert names == ["provider", "project", "branch", "current", "new", "switch", "compact", "diff", "commit", "pull", "push", "abort"]
+ assert names == [
+ "provider",
+ "model",
+ "project",
+ "branch",
+ "current",
+ "status",
+ "new",
+ "switch",
+ "compact",
+ "diff",
+ "commit",
+ "pull",
+ "push",
+ "log",
+ "reset",
+ "abort",
+ ]
# ---------------------------------------------------------------------------
diff --git a/tests/test_claude_auth_subcommand.py b/tests/test_claude_auth_subcommand.py
new file mode 100644
index 0000000..5e7c3d6
--- /dev/null
+++ b/tests/test_claude_auth_subcommand.py
@@ -0,0 +1,85 @@
+from types import SimpleNamespace
+
+import pytest
+
+import coding_agent_telegram.cli as cli
+from coding_agent_telegram.claude_health import ClaudeHealthResult
+
+
+def _patch_common(monkeypatch, tmp_path, *, existing_token=None):
+ env_path = tmp_path / ".env"
+ if existing_token is not None:
+ env_path.write_text(f"CLAUDE_CODE_OAUTH_TOKEN={existing_token}\n", encoding="utf-8")
+ monkeypatch.setattr(cli, "_ensure_env_file", lambda: (env_path, None))
+ monkeypatch.setattr(cli, "load_config", lambda path: SimpleNamespace(app_internal_root=tmp_path))
+ monkeypatch.setattr(cli, "_build_runner", lambda cfg, **kwargs: object())
+ monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
+ return env_path
+
+
+def test_declining_verification_just_saves_the_token(monkeypatch, tmp_path):
+ env_path = _patch_common(monkeypatch, tmp_path)
+ monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: False)
+ checked = {"called": False}
+ monkeypatch.setattr(cli, "check_claude_auth", lambda *a, **k: checked.__setitem__("called", True))
+
+ cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"])
+
+ assert checked["called"] is False
+ assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-newtoken" in env_path.read_text(encoding="utf-8")
+
+
+def test_accepting_verification_uses_the_cheap_model_and_keeps_token_on_success(monkeypatch, tmp_path):
+ env_path = _patch_common(monkeypatch, tmp_path)
+ monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: True)
+ seen_kwargs = {}
+
+ def fake_check(runner, scratch_dir):
+ return ClaudeHealthResult(healthy=True, is_auth_failure=False, detail="")
+
+ build_calls = []
+ monkeypatch.setattr(
+ cli, "_build_runner", lambda cfg, **kwargs: build_calls.append(kwargs) or object()
+ )
+ monkeypatch.setattr(cli, "check_claude_auth", fake_check)
+
+ cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"])
+
+ assert build_calls == [{"claude_model": cli.CLAUDE_AUTH_VERIFY_MODEL}]
+ assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-newtoken" in env_path.read_text(encoding="utf-8")
+ monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
+
+
+def test_failed_verification_rolls_back_to_previous_token(monkeypatch, tmp_path):
+ env_path = _patch_common(monkeypatch, tmp_path, existing_token="sk-ant-oat01-oldtoken")
+ monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: True)
+ monkeypatch.setattr(
+ cli,
+ "check_claude_auth",
+ lambda *a, **k: ClaudeHealthResult(healthy=False, is_auth_failure=True, detail="401 Invalid bearer token"),
+ )
+
+ with pytest.raises(SystemExit):
+ cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"])
+
+ text = env_path.read_text(encoding="utf-8")
+ assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-oldtoken" in text
+ assert "newtoken" not in text
+ monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
+
+
+def test_failed_verification_removes_token_when_none_existed_before(monkeypatch, tmp_path):
+ env_path = _patch_common(monkeypatch, tmp_path)
+ monkeypatch.setattr(cli, "_prompt_yes_no", lambda *a, **k: True)
+ monkeypatch.setattr(
+ cli,
+ "check_claude_auth",
+ lambda *a, **k: ClaudeHealthResult(healthy=False, is_auth_failure=True, detail="401 Invalid bearer token"),
+ )
+
+ with pytest.raises(SystemExit):
+ cli._run_claude_auth_subcommand(["sk-ant-oat01-newtoken"])
+
+ text = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
+ assert "CLAUDE_CODE_OAUTH_TOKEN" not in text
+ monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
diff --git a/tests/test_claude_health.py b/tests/test_claude_health.py
new file mode 100644
index 0000000..1c10a0b
--- /dev/null
+++ b/tests/test_claude_health.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import io
+from pathlib import Path
+
+from coding_agent_telegram.agent_runner import MultiAgentRunner
+from coding_agent_telegram.claude_health import check_claude_auth
+
+
+class FakePopen:
+ def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0):
+ self.stdout = io.StringIO(stdout)
+ self.stderr = io.StringIO(stderr)
+ self.returncode = returncode
+
+ def poll(self):
+ return self.returncode
+
+ def kill(self):
+ self.returncode = -9
+
+ def terminate(self):
+ self.returncode = -15
+
+
+def _make_runner() -> MultiAgentRunner:
+ return MultiAgentRunner(
+ codex_bin="codex",
+ copilot_bin="copilot",
+ approval_policy="never",
+ sandbox_mode="read-only",
+ claude_bin="claude",
+ )
+
+
+def test_check_claude_auth_healthy(monkeypatch, tmp_path):
+ stdout = (
+ '{"type":"system","subtype":"init","session_id":"abc-123"}\n'
+ '{"type":"result","is_error":false,"result":"ok"}\n'
+ )
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ lambda *a, **k: FakePopen(stdout=stdout, returncode=0),
+ )
+
+ result = check_claude_auth(_make_runner(), tmp_path / "scratch")
+
+ assert result.healthy is True
+ assert result.is_auth_failure is False
+ assert result.detail == ""
+
+
+def test_check_claude_auth_detects_auth_failure(monkeypatch, tmp_path):
+ # No "result" event at all -- the CLI can exit after an early auth failure
+ # without ever emitting one, so session_lifecycle_commands.py's own success
+ # check (`not result.success or not result.session_id`) is what actually
+ # catches this, not `parsed_success`. This mirrors that exact shape.
+ stdout = (
+ '{"type":"system","subtype":"init"}\n'
+ '{"type":"result","is_error":true,'
+ '"result":"Failed to authenticate: OAuth session expired and could not be refreshed"}\n'
+ )
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ lambda *a, **k: FakePopen(stdout=stdout, returncode=0),
+ )
+
+ result = check_claude_auth(_make_runner(), tmp_path / "scratch")
+
+ assert result.healthy is False
+ assert result.is_auth_failure is True
+ assert "authenticate" in result.detail.lower()
+
+
+def test_check_claude_auth_missing_session_id_is_unhealthy(monkeypatch, tmp_path):
+ # success stays True by default when no "result" event appears at all, but
+ # there's still no session_id -- must not be reported healthy.
+ stdout = '{"type":"system","subtype":"init"}\n'
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ lambda *a, **k: FakePopen(stdout=stdout, returncode=0),
+ )
+
+ result = check_claude_auth(_make_runner(), tmp_path / "scratch")
+
+ assert result.healthy is False
+ assert result.is_auth_failure is False
+
+
+def test_check_claude_auth_non_auth_failure(monkeypatch, tmp_path):
+ stdout = '{"type":"result","is_error":true,"result":"Rate limit exceeded."}\n'
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ lambda *a, **k: FakePopen(stdout=stdout, returncode=0),
+ )
+
+ result = check_claude_auth(_make_runner(), tmp_path / "scratch")
+
+ assert result.healthy is False
+ assert result.is_auth_failure is False
+ assert result.detail == "Rate limit exceeded."
+
+
+def test_check_claude_auth_creates_scratch_dir(monkeypatch, tmp_path):
+ stdout = '{"type":"result","is_error":false,"result":"ok"}\n'
+ monkeypatch.setattr(
+ "coding_agent_telegram.agent_runner.subprocess.Popen",
+ lambda *a, **k: FakePopen(stdout=stdout, returncode=0),
+ )
+ scratch_dir = tmp_path / "does" / "not" / "exist" / "yet"
+
+ check_claude_auth(_make_runner(), scratch_dir)
+
+ assert scratch_dir.is_dir()
diff --git a/tests/test_command_router.py b/tests/test_command_router.py
index e5576df..eb4657b 100644
--- a/tests/test_command_router.py
+++ b/tests/test_command_router.py
@@ -7,12 +7,15 @@
import shlex
import sys
import threading
+import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from coding_agent_telegram.agent_runner import AgentProgressInfo, AgentRunResult, AgentStallInfo
from coding_agent_telegram.command_router import CommandRouter, RouterDeps
+from coding_agent_telegram.router.queue_processing import QueuedQuestion
+from coding_agent_telegram.router.session_lifecycle_commands import SESSION_PRIMING_PROMPT
from coding_agent_telegram.config import AppConfig
from coding_agent_telegram.session_store import SessionStore
from coding_agent_telegram.speech_to_text import SpeechToTextError
@@ -32,6 +35,8 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ priming_only=False,
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -42,6 +47,8 @@ def create_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "priming_only": priming_only,
+ "model": model,
"on_stall": on_stall,
"on_progress": on_progress,
}
@@ -63,6 +70,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -74,6 +82,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
"on_progress": on_progress,
}
@@ -97,6 +106,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -108,6 +118,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
"on_progress": on_progress,
}
@@ -128,6 +139,8 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ priming_only=False,
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -138,6 +151,8 @@ def create_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "priming_only": priming_only,
+ "model": model,
"on_stall": on_stall,
"on_progress": on_progress,
}
@@ -161,6 +176,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -172,6 +188,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
}
)
@@ -184,6 +201,38 @@ def resume_session(
)
+class ReplyOptionsRunner(DummyRunner):
+ def resume_session(
+ self,
+ provider,
+ session_id,
+ project_path,
+ user_message,
+ *,
+ skip_git_repo_check=False,
+ image_paths=(),
+ model=None,
+ on_stall=None,
+ on_progress=None,
+ ):
+ self.resume_calls.append({"provider": provider, "user_message": user_message})
+ if len(self.resume_calls) == 1:
+ text = (
+ "I found two ways to fix this. Which approach would you like me to take?\n"
+ "1. Patch the validator directly\n"
+ "2. Rewrite the parser"
+ )
+ else:
+ text = "Done."
+ return AgentRunResult(
+ session_id=session_id,
+ success=True,
+ assistant_text=text,
+ error_message=None,
+ raw_events=[],
+ )
+
+
class CommandBlockRunner(DummyRunner):
def resume_session(
self,
@@ -194,6 +243,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -205,6 +255,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
}
)
@@ -227,6 +278,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -238,6 +290,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
}
)
@@ -260,6 +313,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -271,6 +325,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
}
)
@@ -293,6 +348,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -304,6 +360,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
}
)
@@ -522,6 +579,9 @@ def make_config(tmp_path: Path, *, locale: str = "en") -> AppConfig:
codex_model="",
copilot_model="",
claude_model="",
+ codex_model_choices=("gpt-5.4",),
+ copilot_model_choices=("gpt-5.4", "claude-sonnet-4.6"),
+ claude_model_choices=("sonnet", "opus", "haiku"),
copilot_autopilot=True,
copilot_no_ask_user=True,
copilot_allow_all=True,
@@ -546,6 +606,10 @@ def make_config(tmp_path: Path, *, locale: str = "en") -> AppConfig:
default_agent_provider="codex",
agent_hard_timeout_seconds=0,
app_internal_root=tmp_path / ".coding-agent-telegram",
+ long_gap_warning_enabled=False,
+ claude_long_gap_seconds=3600,
+ codex_long_gap_seconds=600,
+ copilot_long_gap_seconds=600,
locale=locale,
)
@@ -559,6 +623,7 @@ def seed_codex_native_session(
branch: str,
created_at: int,
updated_at: int,
+ tokens_used: int = 0,
) -> None:
codex_dir = home / ".codex"
codex_dir.mkdir(parents=True, exist_ok=True)
@@ -575,16 +640,17 @@ def seed_codex_native_session(
git_branch TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
+ tokens_used INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
- INSERT INTO threads (id, cwd, title, first_user_message, git_branch, created_at, updated_at, archived)
- VALUES (?, ?, ?, ?, ?, ?, ?, 0)
+ INSERT INTO threads (id, cwd, title, first_user_message, git_branch, created_at, updated_at, tokens_used, archived)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
""",
- (session_id, str(cwd), title, title, branch, created_at, updated_at),
+ (session_id, str(cwd), title, title, branch, created_at, updated_at, tokens_used),
)
conn.commit()
finally:
@@ -1156,6 +1222,8 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ priming_only=False,
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -1188,6 +1256,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -1223,6 +1292,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -1254,6 +1324,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -1312,6 +1383,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -1323,6 +1395,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
"on_progress": on_progress,
}
@@ -1364,6 +1437,7 @@ def resume_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -1375,6 +1449,7 @@ def resume_session(
"user_message": user_message,
"skip_git_repo_check": skip_git_repo_check,
"image_paths": image_paths,
+ "model": model,
"on_stall": on_stall,
"on_progress": on_progress,
}
@@ -1534,7 +1609,8 @@ def test_new_without_name_uses_new_session_as_default_name(tmp_path: Path):
state = store.get_chat_state("bot-a", 123)
assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123"
assert "Session created successfully: sess_abc123" in bot.messages[-1][1]
- assert runner.create_calls[-1]["user_message"] == "Create session: new session"
+ assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT
+ assert runner.create_calls[-1]["priming_only"] is True
def test_new_without_name_ignores_existing_new_session_labels(tmp_path: Path):
@@ -1579,7 +1655,8 @@ def test_plain_text_create_session_new_session_uses_unnamed_flow(tmp_path: Path)
state = store.get_chat_state("bot-a", 123)
assert state["sessions"]["sess_abc123"]["name"] == "sess_abc123"
- assert runner.create_calls[-1]["user_message"] == "Create session: new session"
+ assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT
+ assert runner.create_calls[-1]["priming_only"] is True
def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path):
@@ -1601,7 +1678,8 @@ def test_plain_text_create_session_with_name_matches_new_command(tmp_path: Path)
state = store.get_chat_state("bot-a", 123)
assert state["sessions"]["sess_abc123"]["name"] == "release prep"
- assert runner.create_calls[-1]["user_message"] == "Create session: release prep"
+ assert runner.create_calls[-1]["user_message"] == SESSION_PRIMING_PROMPT
+ assert runner.create_calls[-1]["priming_only"] is True
def test_provider_command_sends_inline_buttons(tmp_path: Path):
@@ -1623,7 +1701,8 @@ def test_provider_command_sends_inline_buttons(tmp_path: Path):
assert "Current provider: copilot" in message[1]
keyboard = message[3]
assert keyboard is not None
- buttons = keyboard.inline_keyboard[0]
+ buttons = [button for row in keyboard.inline_keyboard for button in row]
+ assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1]
assert buttons[0].callback_data == "provider:set:codex"
assert buttons[1].callback_data == "provider:set:copilot"
assert buttons[2].callback_data == "provider:set:claude"
@@ -1777,6 +1856,321 @@ async def fake_edit(text):
assert state["sessions"][state["active_session_id"]]["provider"] == "copilot"
+# ---------------------------------------------------------------------------
+# /model
+# ---------------------------------------------------------------------------
+
+
+def test_model_command_reports_no_active_session(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/model")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_model(update, context))
+
+ assert "No active session" in bot.messages[-1][1]
+
+
+def test_model_command_sends_inline_buttons_for_active_session_provider(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/model")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_model(update, context))
+
+ assert len(bot.messages) == 1
+ message = bot.messages[0]
+ keyboard = message[3]
+ assert keyboard is not None
+ # One button per row: the default option plus one per configured Claude model.
+ assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1, 1]
+ callback_data = [button.callback_data for row in keyboard.inline_keyboard for button in row]
+ assert callback_data == ["model:default", "model:set:0", "model:set:1", "model:set:2"]
+
+
+def test_model_command_prompt_flags_a_custom_model_as_not_in_the_list(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="claude-opus-5-preview")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/model")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_model(update, context))
+
+ assert "custom, not in the list below" in bot.messages[-1][1]
+ assert "claude-opus-5-preview" in bot.messages[-1][1]
+
+
+def test_model_callback_sets_session_model_override(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ answers = []
+ edited = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(
+ data="model:set:1", # index 1 -> "opus" in ("sonnet", "opus", "haiku")
+ answer=None,
+ edit_message_text=None,
+ ),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ answers.append("answered")
+
+ async def fake_edit(text):
+ edited.append(text)
+
+ update.callback_query.answer = fake_answer
+ update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_model_callback(update, context))
+
+ assert answers == ["answered"]
+ assert edited == ["Model set to: opus"]
+ assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "opus"
+
+
+def test_model_callback_reports_stale_selection_for_out_of_range_index(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ edited = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(
+ data="model:set:99",
+ answer=None,
+ edit_message_text=None,
+ ),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text):
+ edited.append(text)
+
+ update.callback_query.answer = fake_answer
+ update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_model_callback(update, context))
+
+ assert edited == ["⚠️ This button is no longer valid (the model list or provider may have changed). Run /model again."]
+ assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == ""
+
+
+def test_model_callback_default_option_clears_override(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ edited = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(
+ data="model:default",
+ answer=None,
+ edit_message_text=None,
+ ),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text):
+ edited.append(text)
+
+ update.callback_query.answer = fake_answer
+ update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_model_callback(update, context))
+
+ assert edited == ["Model set to: CLI default"]
+ assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == ""
+
+
+def test_new_session_resets_model_to_default_even_after_override(tmp_path: Path):
+ """A model override applies to the active session's resume calls, but an explicit
+ /new session must always start on the provider's configured default model."""
+ project = tmp_path / "backend"
+ project.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.set_current_project_folder("bot-a", 123, "backend")
+ store.set_current_provider("bot-a", 123, "claude")
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/new")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_new(update, context))
+
+ assert runner.create_calls[-1]["model"] is None
+ state = store.get_chat_state("bot-a", 123)
+ new_session = state["sessions"][state["active_session_id"]]
+ assert new_session["model"] == ""
+
+
+def test_active_session_resume_uses_stored_model_override(tmp_path: Path):
+ project = tmp_path / "backend"
+ project.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.set_current_project_folder("bot-a", 123, "backend")
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="do the thing")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert runner.resume_calls[-1]["model"] == "opus"
+
+
+class RejectingModelRunner(DummyRunner):
+ def create_session(
+ self,
+ provider,
+ project_path,
+ user_message,
+ *,
+ skip_git_repo_check=False,
+ image_paths=(),
+ priming_only=False,
+ model=None,
+ on_stall=None,
+ on_progress=None,
+ ):
+ self.create_calls.append({"provider": provider, "model": model})
+ return AgentRunResult(
+ session_id=None,
+ success=False,
+ assistant_text="",
+ error_message="Error: unknown model 'not-a-real-model'",
+ raw_events=[],
+ )
+
+
+def test_model_command_with_too_many_args_shows_usage(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/model foo bar")
+ bot = FakeBot()
+ context = SimpleNamespace(args=["foo", "bar"], bot=bot)
+
+ asyncio.run(router.handle_model(update, context))
+
+ assert "Usage: /model" in bot.messages[-1][1]
+ assert runner.create_calls == []
+
+
+def test_model_command_with_curated_model_id_skips_cli_probe(tmp_path: Path):
+ """Typing a model id that's already on the curated list should save immediately
+ without spending a CLI round trip to re-validate something already known-good."""
+ project = tmp_path / "backend"
+ project.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/model opus")
+ bot = FakeBot()
+ context = SimpleNamespace(args=["opus"], bot=bot)
+
+ asyncio.run(router.handle_model(update, context))
+
+ assert runner.create_calls == []
+ assert "Model set to: opus" in bot.messages[-1][1]
+ assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "opus"
+
+
+def test_model_command_with_valid_custom_model_id_probes_then_saves(tmp_path: Path):
+ project = tmp_path / "backend"
+ project.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.set_current_project_folder("bot-a", 123, "backend")
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/model claude-opus-5-preview")
+ bot = FakeBot()
+ context = SimpleNamespace(args=["claude-opus-5-preview"], bot=bot)
+
+ asyncio.run(router.handle_model(update, context))
+
+ assert runner.create_calls[-1]["model"] == "claude-opus-5-preview"
+ assert runner.create_calls[-1]["priming_only"] is True
+ assert "Model set to: claude-opus-5-preview" in bot.messages[-1][1]
+ assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == "claude-opus-5-preview"
+
+
+def test_model_command_with_invalid_custom_model_id_is_not_saved(tmp_path: Path):
+ project = tmp_path / "backend"
+ project.mkdir()
+ runner = RejectingModelRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.set_current_project_folder("bot-a", 123, "backend")
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/model not-a-real-model")
+ bot = FakeBot()
+ context = SimpleNamespace(args=["not-a-real-model"], bot=bot)
+
+ asyncio.run(router.handle_model(update, context))
+
+ assert "not-a-real-model" in bot.messages[-1][1]
+ assert "unknown model" in bot.messages[-1][1]
+ # The rejected model must not be persisted -- the session keeps its prior (empty) model.
+ assert store.list_sessions("bot-a", 123)["sess_1"]["model"] == ""
+
+
def test_text_message_is_queued_while_new_session_prerequisites_are_pending(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
@@ -2112,7 +2506,10 @@ def test_switch_lists_mixed_bot_and_native_project_sessions_with_legend(tmp_path
assert "initialized: Native codex review" in message
-def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monkeypatch):
+def test_switch_listing_shows_last_active_and_tokens_for_native_session(tmp_path: Path, monkeypatch):
+ """/switch should surface each session's real native activity (session_gap.py),
+ not just the bot's own state.json bookkeeping -- that's the only way to see how
+ stale/expensive-to-resume a session actually is before picking one."""
home = tmp_path / "home"
monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
@@ -2121,7 +2518,7 @@ def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monk
cfg = make_config(tmp_path)
store = SessionStore(cfg.state_file, cfg.state_backup_file)
store.set_current_project_folder("bot-a", 123, "backend")
- store.set_current_provider("bot-a", 123, "copilot")
+ store.set_current_provider("bot-a", 123, "codex")
seed_codex_native_session(
home,
session_id="sess_native_codex",
@@ -2129,16 +2526,76 @@ def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monk
title="Native codex review",
branch="enhancement",
created_at=1_700_000_000,
- updated_at=1_700_000_010,
- )
- seed_copilot_native_session(
- home,
- session_id="sess_native_copilot",
- branch="enhancement",
- created_at="2026-03-27T01:00:00Z",
- updated_at="2026-03-27T02:00:00Z",
- summary="Native copilot review",
- cwd=backend,
+ updated_at=int(time.time()) - 3600,
+ tokens_used=12_345,
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/switch")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_switch(update, context))
+
+ message = bot.messages[-1][1]
+ assert "Last active:" in message and "ago" in message
+ assert "~12.3k tokens used" in message
+
+
+def test_switch_listing_omits_activity_line_when_no_native_data_exists(tmp_path: Path, monkeypatch):
+ """A bot-managed session with no matching native transcript/db row (e.g. one seeded
+ straight into state.json, or one whose transcript already got cleaned up) has no
+ real activity to report -- the line should be omitted rather than showing a bogus
+ zero/unknown value."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_no_native_data", "orphan-session", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update(text="/switch")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_switch(update, context))
+
+ message = bot.messages[-1][1]
+ assert "orphan-session" in message
+ assert "Last active:" not in message
+ assert "tokens used" not in message
+
+
+def test_switch_lists_only_current_provider_native_sessions(tmp_path: Path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.set_current_project_folder("bot-a", 123, "backend")
+ store.set_current_provider("bot-a", 123, "copilot")
+ seed_codex_native_session(
+ home,
+ session_id="sess_native_codex",
+ cwd=backend,
+ title="Native codex review",
+ branch="enhancement",
+ created_at=1_700_000_000,
+ updated_at=1_700_000_010,
+ )
+ seed_copilot_native_session(
+ home,
+ session_id="sess_native_copilot",
+ branch="enhancement",
+ created_at="2026-03-27T01:00:00Z",
+ updated_at="2026-03-27T02:00:00Z",
+ summary="Native copilot review",
+ cwd=backend,
)
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
@@ -2309,6 +2766,63 @@ def test_current_reports_active_session_details(tmp_path: Path):
assert "Branch: feature-1" in message
+def test_current_shows_last_active_and_tokens_for_native_session(tmp_path: Path, monkeypatch):
+ """/current should surface the session's real native activity (session_gap.py), the
+ same signal /switch shows, so a chat doesn't need to run /switch just to see how
+ stale/expensive-to-resume the active session actually is."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_native_codex", "session-a", "backend", "codex", branch_name="feature-1")
+ seed_codex_native_session(
+ home,
+ session_id="sess_native_codex",
+ cwd=backend,
+ title="session-a",
+ branch="feature-1",
+ created_at=1_700_000_000,
+ updated_at=int(time.time()) - 3600,
+ tokens_used=12_345,
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update()
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_current(update, context))
+
+ message = bot.messages[-1][1]
+ assert "Current session: session-a" in message
+ assert "Last active:" in message and "ago" in message
+ assert "~12.3k tokens used" in message
+
+
+def test_current_omits_activity_line_when_no_native_data_exists(tmp_path: Path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_no_native_data", "orphan-session", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update()
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_current(update, context))
+
+ message = bot.messages[-1][1]
+ assert "Current session: orphan-session" in message
+ assert "Last active:" not in message
+ assert "tokens used" not in message
+
+
def test_switch_does_not_checkout_branch_immediately(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
@@ -2392,6 +2906,7 @@ def test_photo_message_is_saved_and_forwarded_to_codex(tmp_path: Path):
image_paths = runner.resume_calls[-1]["image_paths"]
assert len(image_paths) == 1
assert image_paths[0].is_file()
+ assert len(image_paths[0].stem) == 8
assert "/.coding-agent-telegram/telegram_attachments/backend/" in image_paths[0].as_posix()
assert runner.resume_calls[-1]["user_message"].startswith("An image is attached at ../.coding-agent-telegram/telegram_attachments/backend/")
assert "Open and inspect that image before answering." in runner.resume_calls[-1]["user_message"]
@@ -2424,7 +2939,7 @@ def test_photo_message_is_saved_and_forwarded_to_claude(tmp_path: Path):
assert image_paths[0].is_file()
-def test_photo_message_rejected_for_copilot_session(tmp_path: Path):
+def test_photo_message_is_saved_and_forwarded_to_copilot(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
runner = DummyRunner()
@@ -2443,8 +2958,71 @@ def test_photo_message_rejected_for_copilot_session(tmp_path: Path):
asyncio.run(router.handle_photo(update, context))
+ assert len(runner.resume_calls) == 1
+ assert len(runner.resume_calls[-1]["image_paths"]) == 1
+ assert "Open and inspect that image before answering." in runner.resume_calls[-1]["user_message"]
+
+
+def test_photo_album_is_forwarded_as_one_request(tmp_path: Path, monkeypatch):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_photo", "photo-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+ monkeypatch.setattr("coding_agent_telegram.router.message_commands.PHOTO_ALBUM_DEBOUNCE_SECONDS", 0.01)
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def send_album():
+ for message_id, content in ((102, b"second"), (101, b"first")):
+ photo = FakePhotoSize(FakeTelegramFile(content, f"photos/{message_id}.png"))
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ message=SimpleNamespace(
+ text=None, photo=[photo], caption="compare these", message_id=message_id, media_group_id="album-1"
+ ),
+ )
+ await router.handle_photo(update, context)
+ await asyncio.sleep(0.03)
+
+ asyncio.run(send_album())
+
+ assert len(runner.resume_calls) == 1
+ call = runner.resume_calls[-1]
+ assert len(call["image_paths"]) == 2
+ assert "Images are attached at:" in call["user_message"]
+ assert "Open and inspect every image before answering." in call["user_message"]
+
+
+def test_photo_album_over_limit_is_rejected_before_running_agent(tmp_path: Path, monkeypatch):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_photo", "photo-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ monkeypatch.setattr("coding_agent_telegram.router.message_commands.PHOTO_ALBUM_DEBOUNCE_SECONDS", 0.01)
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def send_too_many():
+ for message_id in range(1, 7):
+ photo = FakePhotoSize(FakeTelegramFile(b"image", f"photos/{message_id}.png"))
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ message=SimpleNamespace(text=None, photo=[photo], caption=None, message_id=message_id, media_group_id="album-2"),
+ )
+ await router.handle_photo(update, context)
+ await asyncio.sleep(0.03)
+
+ asyncio.run(send_too_many())
+
assert runner.resume_calls == []
- assert "Photo attachments are currently supported only for Codex and Claude sessions." in bot.messages[-1][1]
+ assert bot.messages[-1][1] == "Too many photos. A single album can contain at most 5 images."
def test_voice_message_sends_transcript_preview_before_running_agent(tmp_path: Path):
@@ -2742,6 +3320,47 @@ async def exercise():
asyncio.run(exercise())
+def test_text_after_photo_album_is_queued_behind_the_album(tmp_path: Path, monkeypatch):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = BlockingRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_photo", "photo-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+ monkeypatch.setattr("coding_agent_telegram.router.message_commands.PHOTO_ALBUM_DEBOUNCE_SECONDS", 0.01)
+
+ async def exercise():
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+ photo = FakePhotoSize(FakeTelegramFile(b"fake-image-bytes", "photos/pic.png"))
+ photo_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ message=SimpleNamespace(
+ text=None, photo=[photo], caption="inspect this", message_id=101, media_group_id="album-before-text"
+ ),
+ )
+
+ await router.handle_photo(photo_update, context)
+ await router.handle_message(make_update(text="follow-up text question", message_id=202), context)
+ assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages)
+
+ started = await asyncio.to_thread(runner.wait_started, 1, 1.0)
+ assert started is True
+ runner.release_next()
+ started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0)
+ assert started_second is True
+ runner.release_next()
+ await asyncio.sleep(0)
+
+ assert len(runner.resume_calls) == 2
+ assert "Open and inspect that image before answering." in runner.resume_calls[0]["user_message"]
+ assert runner.resume_calls[1]["user_message"] == "follow-up text question"
+
+ asyncio.run(exercise())
+
+
def test_busy_queue_and_final_output_reply_to_original_message(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
@@ -2932,6 +3551,148 @@ def test_copilot_output_uses_copilot_label(tmp_path: Path):
assert any("Copilot output" in message[1] for message in bot.messages)
+def test_claude_output_uses_claude_label(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = MarkdownRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_md", "markdown-session", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="check formatting")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert any("Claude output" in message[1] for message in bot.messages)
+ assert not any("Codex output" in message[1] for message in bot.messages)
+
+
+@pytest.mark.parametrize("provider", ["claude", "codex", "copilot"])
+def test_provider_reply_with_options_offers_buttons_and_resends_choice(tmp_path: Path, provider: str):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ReplyOptionsRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_opt", "opt-session", "backend", provider)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="how should I fix this bug?")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert len(runner.resume_calls) == 1
+ assert any("Which approach would you like" in message[1] for message in bot.messages)
+ assert any("Action needed" in message[1] for message in bot.messages)
+
+ option_messages = [message for message in bot.messages if message[3] is not None]
+ assert [message[1] for message in option_messages] == [
+ "Patch the validator directly",
+ "Rewrite the parser",
+ ]
+ for message in option_messages:
+ buttons = [button for row in message[3].inline_keyboard for button in row]
+ assert len(buttons) == 1
+ assert buttons[0].api_kwargs == {"style": "primary"}
+ assert buttons[0].callback_data.startswith("agentopt:")
+
+ token_callback_data = option_messages[0][3].inline_keyboard[0][0].callback_data
+
+ query = SimpleNamespace(data=token_callback_data, answer=None, edit_message_reply_markup=None)
+ edited_markup = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit_markup(reply_markup=None):
+ edited_markup.append(reply_markup)
+
+ query.answer = fake_answer
+ query.edit_message_reply_markup = fake_edit_markup
+ callback_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=query,
+ )
+
+ asyncio.run(router.handle_agent_reply_option_callback(callback_update, context))
+
+ assert edited_markup == [None]
+ assert len(runner.resume_calls) == 2
+ assert runner.resume_calls[1]["user_message"] == "Patch the validator directly"
+ assert any(
+ "Continuing with: Patch the validator directly" in message[1] for message in bot.messages
+ )
+
+
+def test_agent_reply_option_callback_ignores_unknown_token(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ReplyOptionsRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_opt", "opt-session", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ query = SimpleNamespace(data="agentopt:deadbeef0000:0", answer=None, edit_message_reply_markup=None)
+ edited_markup = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit_markup(reply_markup=None):
+ edited_markup.append(reply_markup)
+
+ query.answer = fake_answer
+ query.edit_message_reply_markup = fake_edit_markup
+ callback_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=query,
+ )
+ context = SimpleNamespace(args=[], bot=FakeBot())
+
+ asyncio.run(router.handle_agent_reply_option_callback(callback_update, context))
+
+ assert edited_markup == [None]
+ assert runner.resume_calls == []
+
+
+def test_agent_reply_option_tokens_are_capped_with_fifo_eviction(tmp_path: Path):
+ """Regression: a button the user never taps used to leave its token in the dict
+ forever, letting it grow without bound over a long-lived bot's uptime. Registering
+ past the cap must evict the oldest entries instead."""
+ from coding_agent_telegram.router.base import MAX_AGENT_REPLY_OPTION_TOKENS
+
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ first_token = router._register_agent_reply_options(123, ("a",))
+ tokens = [first_token]
+ for _ in range(MAX_AGENT_REPLY_OPTION_TOKENS - 1):
+ tokens.append(router._register_agent_reply_options(123, ("a",)))
+
+ assert len(router._agent_reply_option_tokens) == MAX_AGENT_REPLY_OPTION_TOKENS
+ assert first_token in router._agent_reply_option_tokens
+
+ overflow_token = router._register_agent_reply_options(123, ("a",))
+
+ assert len(router._agent_reply_option_tokens) == MAX_AGENT_REPLY_OPTION_TOKENS
+ assert first_token not in router._agent_reply_option_tokens, "oldest entry should be evicted first"
+ assert overflow_token in router._agent_reply_option_tokens
+ assert tokens[-1] in router._agent_reply_option_tokens
+
+
def test_message_reports_missing_project_folder_before_running_agent(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
@@ -3621,7 +4382,7 @@ def test_compact_reports_usage_when_args_are_passed(tmp_path: Path):
assert bot.messages[-1][1] == "Usage: /compact"
-@pytest.mark.parametrize("provider", ["codex", "copilot"])
+@pytest.mark.parametrize("provider", ["codex", "copilot", "claude"])
def test_compact_creates_fresh_session_from_summary(tmp_path: Path, provider: str):
backend = tmp_path / "backend"
backend.mkdir()
@@ -3643,39 +4404,61 @@ def test_compact_creates_fresh_session_from_summary(tmp_path: Path, provider: st
assert "compact handoff summary" in runner.resume_calls[-1]["user_message"].lower()
assert runner.create_calls[-1]["provider"] == provider
assert "Use this compact handoff summary" in runner.create_calls[-1]["user_message"]
+ # The bootstrap prompt is a handoff summary that lists "next steps"; it must be
+ # marked priming-only so no provider starts executing them while merely seeding
+ # the replacement session.
+ assert runner.create_calls[-1]["priming_only"] is True
state = store.get_chat_state("bot-a", 123)
assert state["active_session_id"] == "sess_compacted"
- assert state["sessions"]["sess_compacted"]["name"] == "current-session-1"
+ assert state["sessions"]["sess_compacted"]["name"] == "current-session-resume1"
assert "Session compacted successfully." in bot.messages[-1][1]
-def test_assistant_command_block_is_sent_separately(tmp_path: Path):
+def test_compact_run_twice_increments_resume_suffix(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
- runner = CommandBlockRunner()
+ runner = CompactingRunner()
cfg = make_config(tmp_path)
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_cmd", "command-session", "backend", "codex")
+ store.create_session("bot-a", 123, "sess_current", "current-session", "backend", "codex")
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
- update = make_update(text="give me the command")
bot = FakeBot()
context = SimpleNamespace(args=[], bot=bot)
+ update = make_update(text="/compact")
- asyncio.run(router.handle_message(update, context))
+ asyncio.run(router.handle_compact(update, context))
+ state = store.get_chat_state("bot-a", 123)
+ assert state["sessions"]["sess_compacted"]["name"] == "current-session-resume1"
- assert any(message[1] == "Command (2/2)" for message in bot.messages)
- assert any("git commit -m "test"" in message[1] for message in bot.messages)
+ asyncio.run(router.handle_compact(update, context))
+ state = store.get_chat_state("bot-a", 123)
+ assert state["sessions"]["sess_compacted"]["name"] == "current-session-resume2"
-def test_successful_resume_creates_new_session_and_switches_active_session(tmp_path: Path):
+def test_long_gap_warning_sent_and_holds_message_when_native_session_idle_past_threshold(
+ tmp_path: Path, monkeypatch
+):
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
backend.mkdir()
- runner = SessionIdRotatingRunner()
+ runner = DummyRunner()
cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_original", "rotating-session", "backend", "codex")
+ store.create_session("bot-a", 123, "sess_idle", "idle-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_idle",
+ cwd=backend,
+ title="idle-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200, # 2h ago, well past the 10-minute threshold
+ tokens_used=100_000, # above the size gate, so the warning isn't skipped as "too small to matter"
+ )
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
@@ -3685,261 +4468,1193 @@ def test_successful_resume_creates_new_session_and_switches_active_session(tmp_p
asyncio.run(router.handle_message(update, context))
- state = store.get_chat_state("bot-a", 123)
- assert state["active_session_id"] == "sess_rotated"
- assert "sess_rotated" in state["sessions"]
- assert "sess_original" in state["sessions"]
- assert state["sessions"]["sess_rotated"]["name"] == "rotating-session-1"
- assert "Resume succeeded, but the session ID changed." in bot.messages[1][1]
- assert "New session ID: sess_rotated" in bot.messages[1][1]
- assert "New session name: rotating-session-1" in bot.messages[1][1]
+ assert runner.resume_calls == []
+ chat_id, text, _parse_mode, reply_markup = bot.messages[-1]
+ assert chat_id == 123
+ assert "idle" in text.lower()
+ buttons = [button for row in reply_markup.inline_keyboard for button in row]
+ assert [button.callback_data for button in buttons] == ["longgap:switch", "longgap:compact", "longgap:proceed"]
+ pending = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending == {
+ "kind": "long_gap_confirm",
+ "user_message": "keep going",
+ "suppress_working_notice": False,
+ "image_paths": [],
+ }
-def test_invalid_resume_recovery_creates_new_session_and_switches_active_session(tmp_path: Path):
+def test_grouped_queued_questions_warn_before_resuming_long_idle_session(tmp_path: Path, monkeypatch):
+ """Grouping a queue batch must not bypass the same long-gap guard as a new message."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
backend.mkdir()
- runner = ResumeReplacementRunner()
+ runner = DummyRunner()
cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex")
+ store.create_session("bot-a", 123, "sess_idle", "idle-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_idle",
+ cwd=backend,
+ title="idle-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200,
+ tokens_used=100_000,
+ )
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
-
- update = make_update(text="keep going")
+ queue_file = router._next_queue_file_path(123)
+ queued_questions = [QueuedQuestion("first queued question"), QueuedQuestion("second queued question")]
+ router._write_queue_questions(queue_file, queued_questions)
bot = FakeBot()
context = SimpleNamespace(args=[], bot=bot)
- asyncio.run(router.handle_message(update, context))
+ continued = asyncio.run(
+ router._dispatch_queued_questions(
+ 123,
+ context,
+ queue_file=queue_file,
+ queued_messages=queued_questions,
+ grouped=True,
+ )
+ )
- state = store.get_chat_state("bot-a", 123)
- assert state["active_session_id"] == "sess_abc123"
- assert "sess_original" in state["sessions"]
- assert state["sessions"]["sess_abc123"]["name"] == "recover-session-1"
- assert "Resume failed, so a new session was created." in bot.messages[1][1]
- assert "New session ID: sess_abc123" in bot.messages[1][1]
- assert "New session name: recover-session-1" in bot.messages[1][1]
+ assert continued is True
+ assert runner.resume_calls == []
+ assert not queue_file.exists()
+ assert 123 not in router._chat_processing_queue_files
+ pending = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending["kind"] == "long_gap_confirm"
+ assert "Answer the following queued user questions in order." in pending["user_message"]
+ assert "first queued question" in pending["user_message"]
+ assert "second queued question" in pending["user_message"]
+ buttons = [button.callback_data for row in bot.messages[-1][3].inline_keyboard for button in row]
+ assert buttons == ["longgap:switch", "longgap:compact", "longgap:proceed"]
-def test_invalid_resume_recovery_uses_next_available_suffix_for_new_session_name(tmp_path: Path):
- backend = tmp_path / "backend"
- backend.mkdir()
- runner = ResumeReplacementRunner()
+def test_queued_message_waiting_for_replaced_project_runs_once_and_replies_to_original_question(tmp_path: Path):
+ """Resolving a missing project must not duplicate a held queue entry or quote /project."""
+ runner = DummyRunner()
cfg = make_config(tmp_path)
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex")
- store.create_session("bot-a", 123, "sess_existing", "recover-session-1", "backend", "codex")
- store.switch_session("bot-a", 123, "sess_original")
+ store.set_current_project_folder("bot-a", 123, "renamed-project")
+ store.create_session("bot-a", 123, "sess_missing", "old-session", "renamed-project", "codex")
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
-
- update = make_update(text="keep going")
+ queue_file = router._next_queue_file_path(123)
+ queued_question = QueuedQuestion("restore the chat history", reply_to_message_id=321)
+ router._write_queue_questions(queue_file, [queued_question])
bot = FakeBot()
context = SimpleNamespace(args=[], bot=bot)
- asyncio.run(router.handle_message(update, context))
+ continued = asyncio.run(
+ router._dispatch_queued_questions(
+ 123,
+ context,
+ queue_file=queue_file,
+ queued_messages=[queued_question],
+ grouped=False,
+ )
+ )
- state = store.get_chat_state("bot-a", 123)
- assert state["active_session_id"] == "sess_abc123"
- assert state["sessions"]["sess_abc123"]["name"] == "recover-session-2"
+ assert continued is True
+ assert runner.resume_calls == []
+ assert not queue_file.exists()
+ pending = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending["user_message"] == "restore the chat history"
+ assert pending["reply_to_message_id"] == 321
+ project_update = make_update(text="/project replacement-project", message_id=999)
+ asyncio.run(router.handle_project(project_update, SimpleNamespace(args=["replacement-project"], bot=bot)))
-def test_active_session_reports_stalled_agent_process(tmp_path: Path):
+ assert [call["user_message"] for call in runner.resume_calls] == ["restore the chat history"]
+ working_entries = [entry for entry in bot.sent_messages if entry["text"] == "Working on it..."]
+ assert working_entries[-1]["reply_to_message_id"] == 321
+
+
+def test_long_gap_warning_skipped_for_small_session_despite_long_idle(tmp_path: Path, monkeypatch):
+ """A session with little accumulated context shouldn't nag just because it sat idle
+ -- reprocessing it from scratch is cheap regardless, so the size gate should skip
+ the warning even though the idle threshold alone would have fired it."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
backend.mkdir()
- runner = StallingRunner()
+ runner = DummyRunner()
cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_stall", "stall-session", "backend", "codex")
+ store.create_session("bot-a", 123, "sess_idle_small", "small-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_idle_small",
+ cwd=backend,
+ title="small-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200, # well past the idle threshold
+ tokens_used=500, # well below the size gate
+ )
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
- update = make_update(text="continue")
+ update = make_update(text="keep going")
bot = FakeBot()
context = SimpleNamespace(args=[], bot=bot)
asyncio.run(router.handle_message(update, context))
- assert any("The current agent run appears stuck." in message[1] for message in bot.messages)
- assert any("hidden permission dialog" in message[1] for message in bot.messages)
+ assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "keep going"
+ assert store.get_chat_state("bot-a", 123).get("pending_action") is None
-def test_active_session_deletes_live_progress_message_when_final_output_is_sent(tmp_path: Path):
+def test_long_gap_size_gate_skip_is_cached_to_avoid_repeated_lookups(tmp_path: Path, monkeypatch):
+ """Regression: a session sitting under the size gate but past the idle threshold
+ used to repeat the blocking native_session_activity lookup on every single message,
+ since the gap-crossing cache above only helps while the gap hasn't crossed the
+ threshold yet. The size-gate skip must cache too."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
backend.mkdir()
- runner = ProgressRunner()
+ runner = DummyRunner()
cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex")
+ store.create_session("bot-a", 123, "sess_idle_small", "small-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_idle_small",
+ cwd=backend,
+ title="small-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200, # well past the idle threshold
+ tokens_used=500, # well below the size gate
+ )
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
- update = make_update(text="continue")
+ from coding_agent_telegram.router import message_commands
+ from coding_agent_telegram.session_gap import native_session_activity as real_native_session_activity
+
+ call_count = 0
+
+ def counting_native_session_activity(*args, **kwargs):
+ nonlocal call_count
+ call_count += 1
+ return real_native_session_activity(*args, **kwargs)
+
+ monkeypatch.setattr(message_commands, "native_session_activity", counting_native_session_activity)
+
bot = FakeBot()
context = SimpleNamespace(args=[], bot=bot)
- asyncio.run(router.handle_message(update, context))
+ asyncio.run(router.handle_message(make_update(text="first"), context))
+ asyncio.run(router.handle_message(make_update(text="second"), context))
- assert len(bot.deleted_messages) == 1
- assert bot.deleted_messages[0][0] == 123
- assert any("Codex output" in message[1] for message in bot.messages)
+ assert call_count == 1, "size-gate skip should be cached, not re-checked on every message"
+ assert runner.resume_calls[-1]["user_message"] == "second"
+ assert store.get_chat_state("bot-a", 123).get("pending_action") is None
-def test_active_session_reuses_single_live_progress_message(tmp_path: Path):
+def test_long_gap_warning_skipped_when_native_session_recently_active(tmp_path: Path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
backend.mkdir()
- runner = RapidProgressRunner()
+ runner = DummyRunner()
cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "copilot")
+ store.create_session("bot-a", 123, "sess_fresh", "fresh-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_fresh",
+ cwd=backend,
+ title="fresh-session",
+ branch="",
+ created_at=int(time.time()) - 30,
+ updated_at=int(time.time()) - 30, # 30s ago, well under the 10-minute threshold
+ )
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
- update = make_update(text="continue")
+ update = make_update(text="keep going")
bot = FakeBot()
context = SimpleNamespace(args=[], bot=bot)
asyncio.run(router.handle_message(update, context))
- progress_messages = [message for message in bot.messages if "Live agent output" in message[1]]
- assert len(progress_messages) == 2
- assert bot.edit_count == 1
+ assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "keep going"
+ assert store.get_chat_state("bot-a", 123).get("pending_action") is None
-def test_active_session_deletes_live_progress_message_even_if_progress_send_is_slow(tmp_path: Path):
- backend = tmp_path / "backend"
- backend.mkdir()
- runner = ProgressRunner()
- cfg = make_config(tmp_path)
- store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex")
- router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
- router.git = FakeGitManager(is_git_repo=False)
+def test_long_gap_provider_tables_stay_in_sync_with_providers_and_config():
+ """The long-gap check is table-driven, and both tables fail *silently* when a
+ provider is missing: an absent config entry disables the warning, and an absent
+ activity entry reports "unknown". A typo'd AppConfig field name would likewise turn
+ the feature off via getattr's default rather than raising. Pin all three."""
+ import dataclasses
- update = make_update(text="continue")
- bot = SlowProgressBot()
- context = SimpleNamespace(args=[], bot=bot)
+ from coding_agent_telegram.providers import SUPPORTED_PROVIDERS
+ from coding_agent_telegram.router.message_commands import _LONG_GAP_PROVIDER_CONFIG
+ from coding_agent_telegram.session_gap import _ACTIVITY_LOOKUP
- asyncio.run(router.handle_message(update, context))
+ assert set(_LONG_GAP_PROVIDER_CONFIG) == set(SUPPORTED_PROVIDERS)
+ assert set(_ACTIVITY_LOOKUP) == set(SUPPORTED_PROVIDERS)
- assert len(bot.deleted_messages) == 1
+ config_fields = {field.name for field in dataclasses.fields(AppConfig)}
+ for provider, provider_config in _LONG_GAP_PROVIDER_CONFIG.items():
+ assert provider_config.threshold_field in config_fields, provider
-def test_active_session_deletes_previous_live_progress_message_when_edit_falls_back_to_send(tmp_path: Path):
- backend = tmp_path / "backend"
- backend.mkdir()
- runner = RapidProgressRunner()
- cfg = make_config(tmp_path)
- store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex")
- router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
- router.git = FakeGitManager(is_git_repo=False)
+def _set_codex_thread_updated_at(home: Path, session_id: str, updated_at: int) -> None:
+ conn = sqlite3.connect(home / ".codex" / "state_5.sqlite")
+ try:
+ conn.execute("UPDATE threads SET updated_at = ? WHERE id = ?", (updated_at, session_id))
+ conn.commit()
+ finally:
+ conn.close()
- update = make_update(text="continue")
- bot = EditFailingProgressBot()
- context = SimpleNamespace(args=[], bot=bot)
- asyncio.run(router.handle_message(update, context))
+def _install_fake_monotonic(monkeypatch, clock: list[float]) -> None:
+ """Swap only message_commands' view of ``time`` so the gap cache can be aged
+ deterministically without touching the real clock everything else reads."""
+ from coding_agent_telegram.router import message_commands
- assert len(bot.deleted_messages) == 2
- deleted_ids = [message_id for chat_id, message_id in bot.deleted_messages if chat_id == 123]
- assert len(set(deleted_ids)) == 2
+ monkeypatch.setattr(message_commands, "time", SimpleNamespace(monotonic=lambda: clock[0]))
-def test_second_message_is_queued_while_first_run_is_still_running(tmp_path: Path):
- backend = tmp_path / "backend"
- backend.mkdir()
- runner = BlockingRunner()
+def _long_gap_router(tmp_path: Path, home: Path, backend: Path, *, session_id: str, updated_at: int):
+ runner = DummyRunner()
cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex")
+ store.create_session("bot-a", 123, session_id, "gap-cache-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id=session_id,
+ cwd=backend,
+ title="gap-cache-session",
+ branch="",
+ created_at=updated_at,
+ updated_at=updated_at,
+ tokens_used=100_000, # above the size gate
+ )
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)
+ return router, store, runner
- async def exercise():
- bot = FakeBot()
- first_update = make_update(text="first question")
- second_update = make_update(text="second question")
- first_context = SimpleNamespace(args=[], bot=bot)
- second_context = SimpleNamespace(args=[], bot=bot)
- first_task = asyncio.create_task(router.handle_message(first_update, first_context))
- started = await asyncio.to_thread(runner.wait_started, 1, 1.0)
- assert started is True
+def test_long_gap_cache_expires_when_gap_would_cross_threshold(tmp_path: Path, monkeypatch):
+ """A session checked just *under* the threshold must not stay cached past it.
- await router.handle_message(second_update, second_context)
+ Caching the check time and trusting it for a full threshold window let a session
+ checked at 590s idle (threshold 600s) skip the real check until 1190s idle -- nearly
+ double the threshold with no warning. The cache stores the crossing time instead."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ clock = [1000.0]
+ _install_fake_monotonic(monkeypatch, clock)
+ router, store, runner = _long_gap_router(
+ tmp_path,
+ home,
+ backend,
+ session_id="sess_edge",
+ updated_at=int(time.time()) - 590, # 10s short of the 600s threshold
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
- assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages)
- assert not any("Working on queued questions:" in message for _, message, _, _ in bot.messages)
- assert not any("already running on project" in message for _, message, _, _ in bot.messages)
- assert not any("Command failed" in message for _, message, _, _ in bot.messages)
+ asyncio.run(router.handle_message(make_update(text="first"), context))
+ assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "first"
- runner.release_next()
- started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0)
- assert started_second is True
- runner.release_next()
- await first_task
+ # The session goes quiet past the threshold, and only 60s of bot uptime elapses --
+ # far less than the 600s a check-time cache would have held for.
+ _set_codex_thread_updated_at(home, "sess_edge", int(time.time()) - 1200)
+ clock[0] += 60
- assert len(runner.resume_calls) == 2
- assert runner.resume_calls[0]["user_message"] == "first question"
- assert runner.resume_calls[1]["user_message"] == "second question"
- assert any("Working on queued questions:" in message for _, message, _, _ in bot.messages)
- assert any("1. second question" in message for _, message, _, _ in bot.messages)
+ asyncio.run(router.handle_message(make_update(text="second"), context))
- asyncio.run(exercise())
+ assert runner.resume_calls[-1]["user_message"] == "first", "second message should be held, not dispatched"
+ pending = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending["kind"] == "long_gap_confirm"
+ assert pending["user_message"] == "second"
-def test_second_message_is_queued_even_before_runner_reports_busy(tmp_path: Path):
+def test_long_gap_cache_skips_repeat_lookup_within_safe_window(tmp_path: Path, monkeypatch):
+ """The flip side: while the gap provably can't have crossed the threshold, the
+ check short-circuits instead of re-reading the provider's db on every message."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
backend.mkdir()
- runner = BlockingRunner()
- cfg = make_config(tmp_path)
- store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex")
- router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
- router.git = FakeGitManager(is_git_repo=False)
+ clock = [1000.0]
+ _install_fake_monotonic(monkeypatch, clock)
+ router, store, runner = _long_gap_router(
+ tmp_path,
+ home,
+ backend,
+ session_id="sess_active",
+ updated_at=int(time.time()) - 30, # freshly active: cached for ~570s
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
- async def exercise():
- bot = FakeBot()
- first_update = make_update(text="first question", message_id=101)
- second_update = make_update(text="second question", message_id=202)
+ asyncio.run(router.handle_message(make_update(text="first"), context))
- first_task = asyncio.create_task(router.handle_message(first_update, SimpleNamespace(args=[], bot=bot)))
- await asyncio.sleep(0)
- await router.handle_message(second_update, SimpleNamespace(args=[], bot=bot))
+ # Backdate the row well past the threshold. Within the safe window the cache must
+ # win, so this rewrite is invisible until the window lapses.
+ _set_codex_thread_updated_at(home, "sess_active", int(time.time()) - 99_999)
+ clock[0] += 5
- assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages)
+ asyncio.run(router.handle_message(make_update(text="second"), context))
- started = await asyncio.to_thread(runner.wait_started, 1, 1.0)
- assert started is True
- runner.release_next()
- started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0)
- assert started_second is True
- runner.release_next()
- await first_task
+ assert runner.resume_calls[-1]["user_message"] == "second"
+ assert store.get_chat_state("bot-a", 123).get("pending_action") is None
- assert len(runner.resume_calls) == 2
- assert runner.resume_calls[0]["user_message"] == "first question"
- assert runner.resume_calls[1]["user_message"] == "second question"
- asyncio.run(exercise())
+def _run_two_messages_concurrently(router, context) -> None:
+ """Telegram handlers are registered with block=False, so two messages arriving
+ together in one chat run as concurrent tasks."""
+
+ async def both() -> None:
+ await asyncio.gather(
+ router.handle_message(make_update(text="first", message_id=1), context),
+ router.handle_message(make_update(text="second", message_id=2), context),
+ )
+ asyncio.run(both())
-def test_grouped_queue_batch_requires_user_decision_then_processes_remaining_queue(tmp_path: Path):
+
+def test_concurrent_messages_on_idle_session_warn_once_and_queue_the_loser(tmp_path: Path, monkeypatch):
+ """The long-gap check awaits provider I/O between "nothing else is handling this
+ chat" and this message claiming it. Without a re-check afterwards both messages
+ warn, and the second's pending action overwrites the first's -- two sets of buttons
+ in the chat and the first message silently dropped."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
backend = tmp_path / "backend"
backend.mkdir()
- runner = BlockingRunner()
- cfg = make_config(tmp_path)
- store = SessionStore(cfg.state_file, cfg.state_backup_file)
- store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex")
- router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
- router.git = FakeGitManager(is_git_repo=False)
+ router, store, runner = _long_gap_router(
+ tmp_path, home, backend, session_id="sess_idle_race", updated_at=int(time.time()) - 7200
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
- async def exercise():
- bot = FakeBot()
- first_update = make_update(text="first question", message_id=101)
- second_update = make_update(text="two", message_id=202)
- third_update = make_update(text="three", message_id=303)
- fourth_update = make_update(text="four four four four four four four", message_id=404)
+ _run_two_messages_concurrently(router, context)
+
+ warnings = [text for _chat, text, _parse, _markup in bot.messages if "idle for" in str(text)]
+ assert len(warnings) == 1, "the second message overwrote the first message's held confirmation"
+ assert runner.resume_calls == []
+ pending = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending["kind"] == "long_gap_confirm"
+ assert pending["user_message"] == "first"
+ # The loser must be queued, not dropped, so it still runs after the button is tapped.
+ assert any("queued" in str(text).lower() for _chat, text, _parse, _markup in bot.messages)
+
+
+def test_concurrent_messages_on_active_session_dispatch_once(tmp_path: Path, monkeypatch):
+ """The same await window exists on the no-warning path, where the idle check passes
+ and the message goes straight to dispatch. There _is_project_busy is the primary
+ serializer, so this is an invariant check rather than a regression test for a
+ reproduced failure: neither message may run twice, whichever order they interleave
+ in."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ router, store, runner = _long_gap_router(
+ tmp_path, home, backend, session_id="sess_active_race", updated_at=int(time.time()) - 30
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ _run_two_messages_concurrently(router, context)
+
+ dispatched = [call["user_message"] for call in runner.resume_calls]
+ assert len(dispatched) == len(set(dispatched)), f"a message ran twice: {dispatched}"
+ assert "first" in dispatched, dispatched
+ assert store.get_chat_state("bot-a", 123).get("pending_action") is None
+
+
+def test_long_gap_proceed_anyway_dispatches_held_message(tmp_path: Path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_idle", "idle-session", "backend", "codex")
+ # Deliberately still idle past the threshold: this reproduces the "Proceed anyway"
+ # regression where replaying the message re-triggered _maybe_warn_long_gap because
+ # the native transcript's mtime doesn't move until the agent actually runs a turn.
+ seed_codex_native_session(
+ home,
+ session_id="sess_idle",
+ cwd=backend,
+ title="idle-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200,
+ tokens_used=100_000,
+ )
+ store.set_pending_action(
+ "bot-a",
+ 123,
+ {"kind": "long_gap_confirm", "user_message": "keep going", "suppress_working_notice": False},
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ edited = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append(text)
+
+ update.callback_query.answer = fake_answer
+ update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_long_gap_callback(update, context))
+
+ assert edited == ["Proceeding on the existing session..."]
+ assert runner.resume_calls and runner.resume_calls[-1]["user_message"] == "keep going"
+ assert store.get_chat_state("bot-a", 123).get("pending_action") is None
+
+
+def test_long_gap_compact_failure_still_dispatches_held_message(tmp_path: Path):
+ """If compaction fails (or the workspace is busy), the held message must still be
+ delivered instead of silently dropped -- it should fall back to running on the
+ original session rather than vanishing with no trace."""
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ # Plain DummyRunner's resume_session returns an empty assistant_text, so the
+ # compact summary step fails with "no usable handoff summary" -- exercising the
+ # compaction-failed path without a dedicated failing runner.
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_current", "current-session", "backend", "codex")
+ store.set_pending_action(
+ "bot-a",
+ 123,
+ {"kind": "long_gap_confirm", "user_message": "keep going", "suppress_working_notice": False},
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ edited = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data="longgap:compact", answer=None, edit_message_text=None),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append(text)
+
+ update.callback_query.answer = fake_answer
+ update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_long_gap_callback(update, context))
+
+ # No new session was created since compaction failed before that step.
+ assert runner.create_calls == []
+ # The compact summary attempt happened, then -- instead of being dropped -- the
+ # original held message was dispatched on the still-current session.
+ assert [call["user_message"] for call in runner.resume_calls][-1] == "keep going"
+ assert runner.resume_calls[-1]["session_id"] == "sess_current"
+ state = store.get_chat_state("bot-a", 123)
+ assert state["active_session_id"] == "sess_current"
+ assert state.get("pending_action") is None
+
+
+def test_long_gap_switch_starts_fresh_session_without_resuming_old_one(tmp_path: Path):
+ """Unlike compact, switching must never resume the old session -- that resume is
+ exactly the full-transcript reprocess cost this button exists to avoid."""
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_current", "current-session", "backend", "codex")
+ store.set_pending_action(
+ "bot-a",
+ 123,
+ {"kind": "long_gap_confirm", "user_message": "keep going", "suppress_working_notice": False},
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ edited = []
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data="longgap:switch", answer=None, edit_message_text=None),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append(text)
+
+ update.callback_query.answer = fake_answer
+ update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_long_gap_callback(update, context))
+
+ assert edited == ["Switching to a new session..."]
+ # The old session is never resumed to generate a summary -- that resume is exactly
+ # the full-transcript reprocess this path exists to avoid. The only resume_session
+ # call is the replayed message running against the brand-new session afterward.
+ assert all(call["session_id"] != "sess_current" for call in runner.resume_calls)
+ assert runner.resume_calls[-1]["session_id"] == "sess_abc123"
+ assert runner.resume_calls[-1]["user_message"] == "keep going"
+ assert runner.create_calls[0]["priming_only"] is True
+ state = store.get_chat_state("bot-a", 123)
+ assert state["active_session_id"] == "sess_abc123"
+ assert state["sessions"]["sess_abc123"]["name"] == "current-session-new1"
+ assert state.get("pending_action") is None
+
+
+def test_long_gap_replay_runs_before_messages_queued_during_the_wait(tmp_path: Path, monkeypatch):
+ """A message that arrives while the long-gap confirmation is pending gets queued
+ (as normal). Once the user resolves the prompt, the held (older) message must still
+ run first, then the queue drains -- not the other way around."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_idle_reorder", "reorder-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_idle_reorder",
+ cwd=backend,
+ title="reorder-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200,
+ tokens_used=100_000,
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ # First message triggers the warning and gets held.
+ asyncio.run(router.handle_message(make_update(text="first message"), context))
+ assert runner.resume_calls == []
+ assert store.get_chat_state("bot-a", 123)["pending_action"]["kind"] == "long_gap_confirm"
+
+ # A second message arrives before the user answers the prompt -- it must queue,
+ # not be dropped or jump ahead.
+ asyncio.run(router.handle_message(make_update(text="second message"), context))
+ assert runner.resume_calls == []
+
+ callback_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None),
+ )
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ return None
+
+ callback_update.callback_query.answer = fake_answer
+ callback_update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_long_gap_callback(callback_update, context))
+
+ dispatched_messages = [call["user_message"] for call in runner.resume_calls]
+ assert dispatched_messages == ["first message", "second message"]
+
+
+def test_long_gap_warning_also_applies_to_photo_messages(tmp_path: Path, monkeypatch):
+ """handle_photo must not bypass the long-gap check -- an image sent to a session
+ idle past its threshold should be held for confirmation just like a text message,
+ and the button reply should still deliver the image once resolved."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_photo_idle", "photo-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_photo_idle",
+ cwd=backend,
+ title="photo-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200,
+ tokens_used=100_000,
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ photo = FakePhotoSize(FakeTelegramFile(b"fake-image-bytes", "photos/pic.png"))
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ message=SimpleNamespace(text=None, photo=[photo], caption="what is shown here?"),
+ )
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_photo(update, context))
+
+ # Held, not dispatched.
+ assert runner.resume_calls == []
+ pending = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending["kind"] == "long_gap_confirm"
+ assert len(pending["image_paths"]) == 1
+ stored_image_path = Path(pending["image_paths"][0])
+ assert stored_image_path.is_file()
+
+ # Resolving with "proceed anyway" must still deliver the image.
+ callback_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None),
+ )
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ return None
+
+ callback_update.callback_query.answer = fake_answer
+ callback_update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_long_gap_callback(callback_update, context))
+
+ assert runner.resume_calls
+ dispatched_image_paths = runner.resume_calls[-1]["image_paths"]
+ assert dispatched_image_paths == (stored_image_path,)
+ assert "what is shown here?" in runner.resume_calls[-1]["user_message"]
+ assert store.get_chat_state("bot-a", 123).get("pending_action") is None
+
+
+def test_photo_does_not_clobber_pending_long_gap_confirmation(tmp_path: Path, monkeypatch):
+ """A photo sent while a text message's long-gap confirmation is still pending must
+ not silently overwrite it -- that would orphan the original warning's buttons and
+ lose the held text message when they're pressed."""
+ home = tmp_path / "home"
+ monkeypatch.setenv("HOME", str(home))
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ cfg = AppConfig(**{**cfg.__dict__, "long_gap_warning_enabled": True, "codex_long_gap_seconds": 600})
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_idle_clobber", "clobber-session", "backend", "codex")
+ seed_codex_native_session(
+ home,
+ session_id="sess_idle_clobber",
+ cwd=backend,
+ title="clobber-session",
+ branch="",
+ created_at=int(time.time()) - 7200,
+ updated_at=int(time.time()) - 7200,
+ tokens_used=100_000,
+ )
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ # First, a text message triggers and holds the long-gap warning.
+ asyncio.run(router.handle_message(make_update(text="original text message"), context))
+ pending_after_text = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending_after_text["kind"] == "long_gap_confirm"
+ assert pending_after_text["user_message"] == "original text message"
+
+ # A photo arrives before the user answers -- it must be rejected, not silently
+ # replace the pending confirmation.
+ photo = FakePhotoSize(FakeTelegramFile(b"fake-image-bytes", "photos/pic.png"))
+ photo_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ message=SimpleNamespace(text=None, photo=[photo], caption="ignore me"),
+ )
+ asyncio.run(router.handle_photo(photo_update, context))
+
+ pending_after_photo = store.get_chat_state("bot-a", 123)["pending_action"]
+ assert pending_after_photo == pending_after_text # untouched
+ assert runner.resume_calls == []
+
+ # Resolving the (still-original) prompt must run the original text, not the photo.
+ callback_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data="longgap:proceed", answer=None, edit_message_text=None),
+ )
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ return None
+
+ callback_update.callback_query.answer = fake_answer
+ callback_update.callback_query.edit_message_text = fake_edit
+
+ asyncio.run(router.handle_long_gap_callback(callback_update, context))
+
+ assert runner.resume_calls
+ assert runner.resume_calls[-1]["user_message"] == "original text message"
+ assert runner.resume_calls[-1]["image_paths"] == ()
+
+
+def test_assistant_command_block_is_sent_separately(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = CommandBlockRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_cmd", "command-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="give me the command")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert any(message[1] == "Command (2/2)" for message in bot.messages)
+ assert any("git commit -m "test"" in message[1] for message in bot.messages)
+
+
+def test_successful_resume_creates_new_session_and_switches_active_session(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = SessionIdRotatingRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_original", "rotating-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="keep going")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ state = store.get_chat_state("bot-a", 123)
+ assert state["active_session_id"] == "sess_rotated"
+ assert "sess_rotated" in state["sessions"]
+ assert "sess_original" in state["sessions"]
+ assert state["sessions"]["sess_rotated"]["name"] == "rotating-session-1"
+ assert "Resume succeeded, but the session ID changed." in bot.messages[1][1]
+ assert "New session ID: sess_rotated" in bot.messages[1][1]
+ assert "New session name: rotating-session-1" in bot.messages[1][1]
+
+
+def test_invalid_resume_recovery_creates_new_session_and_switches_active_session(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ResumeReplacementRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="keep going")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ state = store.get_chat_state("bot-a", 123)
+ assert state["active_session_id"] == "sess_abc123"
+ assert "sess_original" in state["sessions"]
+ assert state["sessions"]["sess_abc123"]["name"] == "recover-session-1"
+ assert "Resume failed, so a new session was created." in bot.messages[1][1]
+ assert "New session ID: sess_abc123" in bot.messages[1][1]
+ assert "New session name: recover-session-1" in bot.messages[1][1]
+
+
+def test_invalid_resume_recovery_uses_next_available_suffix_for_new_session_name(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ResumeReplacementRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "codex")
+ store.create_session("bot-a", 123, "sess_existing", "recover-session-1", "backend", "codex")
+ store.switch_session("bot-a", 123, "sess_original")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="keep going")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ state = store.get_chat_state("bot-a", 123)
+ assert state["active_session_id"] == "sess_abc123"
+ assert state["sessions"]["sess_abc123"]["name"] == "recover-session-2"
+
+
+def test_invalid_resume_recovery_recognizes_claude_session_not_found_error_code(tmp_path: Path):
+ """Claude's CLI reports an unresumable session ID as "No conversation found with
+ session ID: ..." (see agent_runner._claude_events_report_session_not_found), which
+ MultiAgentRunner surfaces as error_code="session_not_found" -- a structured signal
+ rather than a substring match on error_message, since that text can also be a
+ genuine (if failed) turn's model-generated output. The recovery check has to honor
+ that error_code, or a session with no local transcript fails identically forever
+ instead of ever recovering."""
+
+ class ClaudeNoConversationRunner(DummyRunner):
+ def resume_session(
+ self,
+ provider,
+ session_id,
+ project_path,
+ user_message,
+ *,
+ skip_git_repo_check=False,
+ image_paths=(),
+ model=None,
+ on_stall=None,
+ on_progress=None,
+ ):
+ self.resume_calls.append(
+ {
+ "provider": provider,
+ "session_id": session_id,
+ "project_path": project_path,
+ "user_message": user_message,
+ "skip_git_repo_check": skip_git_repo_check,
+ "image_paths": image_paths,
+ "model": model,
+ "on_stall": on_stall,
+ }
+ )
+ return AgentRunResult(
+ session_id=None,
+ success=False,
+ assistant_text="",
+ error_message=f"No conversation found with session ID: {session_id}",
+ raw_events=[],
+ error_code="session_not_found",
+ )
+
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ClaudeNoConversationRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="keep going")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ state = store.get_chat_state("bot-a", 123)
+ assert state["active_session_id"] == "sess_abc123"
+ assert "sess_original" in state["sessions"]
+ assert "Resume failed, so a new session was created." in bot.messages[1][1]
+
+
+def test_invalid_resume_recovery_ignores_resume_substring_in_claude_error_without_error_code(
+ tmp_path: Path,
+):
+ """A Claude failure whose error_message happens to contain "resume" for an unrelated
+ reason (e.g. a genuine model turn discussing a file called resume.pdf) must NOT be
+ treated as an unresumable session -- only error_code == "session_not_found" (a
+ structured signal, not a substring guess) should trigger replacing it. Doing
+ otherwise would discard a perfectly good session over unrelated content."""
+
+ class ClaudeUnrelatedFailureRunner(DummyRunner):
+ def resume_session(
+ self,
+ provider,
+ session_id,
+ project_path,
+ user_message,
+ *,
+ skip_git_repo_check=False,
+ image_paths=(),
+ model=None,
+ on_stall=None,
+ on_progress=None,
+ ):
+ self.resume_calls.append(
+ {
+ "provider": provider,
+ "session_id": session_id,
+ "project_path": project_path,
+ "user_message": user_message,
+ "skip_git_repo_check": skip_git_repo_check,
+ "image_paths": image_paths,
+ "model": model,
+ "on_stall": on_stall,
+ }
+ )
+ return AgentRunResult(
+ session_id=session_id,
+ success=False,
+ assistant_text="",
+ error_message="I couldn't finish reviewing resume.pdf before running out of turns.",
+ raw_events=[],
+ error_code=None,
+ )
+
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ClaudeUnrelatedFailureRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_original", "recover-session", "backend", "claude")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="keep going")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ state = store.get_chat_state("bot-a", 123)
+ assert state["active_session_id"] == "sess_original"
+ assert "resume.pdf" in bot.messages[-1][1]
+
+
+def test_active_session_reports_stalled_agent_process(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = StallingRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_stall", "stall-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="continue")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert any("The current agent run appears stuck." in message[1] for message in bot.messages)
+ assert any("hidden permission dialog" in message[1] for message in bot.messages)
+
+
+def test_active_session_deletes_live_progress_message_when_final_output_is_sent(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ProgressRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="continue")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert len(bot.deleted_messages) == 1
+ assert bot.deleted_messages[0][0] == 123
+ assert any("Codex output" in message[1] for message in bot.messages)
+
+
+def test_active_session_reuses_single_live_progress_message(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = RapidProgressRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "copilot")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="continue")
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ progress_messages = [message for message in bot.messages if "Live agent output" in message[1]]
+ assert len(progress_messages) == 2
+ assert bot.edit_count == 1
+
+
+def test_active_session_deletes_live_progress_message_even_if_progress_send_is_slow(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = ProgressRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="continue")
+ bot = SlowProgressBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert len(bot.deleted_messages) == 1
+
+
+def test_active_session_deletes_previous_live_progress_message_when_edit_falls_back_to_send(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = RapidProgressRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ update = make_update(text="continue")
+ bot = EditFailingProgressBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_message(update, context))
+
+ assert len(bot.deleted_messages) == 2
+ deleted_ids = [message_id for chat_id, message_id in bot.deleted_messages if chat_id == 123]
+ assert len(set(deleted_ids)) == 2
+
+
+def test_second_message_is_queued_while_first_run_is_still_running(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = BlockingRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ async def exercise():
+ bot = FakeBot()
+ first_update = make_update(text="first question")
+ second_update = make_update(text="second question")
+ first_context = SimpleNamespace(args=[], bot=bot)
+ second_context = SimpleNamespace(args=[], bot=bot)
+
+ first_task = asyncio.create_task(router.handle_message(first_update, first_context))
+ started = await asyncio.to_thread(runner.wait_started, 1, 1.0)
+ assert started is True
+
+ await router.handle_message(second_update, second_context)
+
+ assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages)
+ assert not any("Working on queued questions:" in message for _, message, _, _ in bot.messages)
+ assert not any("already running on project" in message for _, message, _, _ in bot.messages)
+ assert not any("Command failed" in message for _, message, _, _ in bot.messages)
+
+ runner.release_next()
+ started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0)
+ assert started_second is True
+ runner.release_next()
+ await first_task
+
+ assert len(runner.resume_calls) == 2
+ assert runner.resume_calls[0]["user_message"] == "first question"
+ assert runner.resume_calls[1]["user_message"] == "second question"
+ assert any("Working on queued questions:" in message for _, message, _, _ in bot.messages)
+ assert any("1. second question" in message for _, message, _, _ in bot.messages)
+
+ asyncio.run(exercise())
+
+
+def test_second_message_is_queued_even_before_runner_reports_busy(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = BlockingRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ async def exercise():
+ bot = FakeBot()
+ first_update = make_update(text="first question", message_id=101)
+ second_update = make_update(text="second question", message_id=202)
+
+ first_task = asyncio.create_task(router.handle_message(first_update, SimpleNamespace(args=[], bot=bot)))
+ await asyncio.sleep(0)
+ await router.handle_message(second_update, SimpleNamespace(args=[], bot=bot))
+
+ assert any("Question queued as Q1." in message for _, message, _, _ in bot.messages)
+
+ started = await asyncio.to_thread(runner.wait_started, 1, 1.0)
+ assert started is True
+ runner.release_next()
+ started_second = await asyncio.to_thread(runner.wait_started, 2, 1.0)
+ assert started_second is True
+ runner.release_next()
+ await first_task
+
+ assert len(runner.resume_calls) == 2
+ assert runner.resume_calls[0]["user_message"] == "first question"
+ assert runner.resume_calls[1]["user_message"] == "second question"
+
+ asyncio.run(exercise())
+
+
+def test_grouped_queue_batch_requires_user_decision_then_processes_remaining_queue(tmp_path: Path):
+ backend = tmp_path / "backend"
+ backend.mkdir()
+ runner = BlockingRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_queue", "queue-session", "backend", "codex")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=False)
+
+ async def exercise():
+ bot = FakeBot()
+ first_update = make_update(text="first question", message_id=101)
+ second_update = make_update(text="two", message_id=202)
+ third_update = make_update(text="three", message_id=303)
+ fourth_update = make_update(text="four four four four four four four", message_id=404)
first_context = SimpleNamespace(args=[], bot=bot)
first_task = asyncio.create_task(router.handle_message(first_update, first_context))
@@ -3955,7 +5670,8 @@ async def exercise():
assert len(prompt_messages) == 1
keyboard = prompt_messages[0][3]
assert keyboard is not None
- buttons = keyboard.inline_keyboard[0]
+ buttons = [button for row in keyboard.inline_keyboard for button in row]
+ assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1]
assert buttons[0].callback_data == "queuebatch:group"
assert buttons[1].callback_data == "queuebatch:single"
assert buttons[2].callback_data == "queuebatch:cancel"
@@ -4420,6 +6136,22 @@ def _run_pull_command(router: CommandRouter, *, args: list[str] | None = None) -
return bot
+def _run_log_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot:
+ update = make_update(text="/log" if not args else "/log " + " ".join(args))
+ bot = FakeBot()
+ context = SimpleNamespace(args=args or [], bot=bot)
+ asyncio.run(router.handle_log(update, context))
+ return bot
+
+
+def _run_reset_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot:
+ update = make_update(text="/reset" if not args else "/reset " + " ".join(args))
+ bot = FakeBot()
+ context = SimpleNamespace(args=args or [], bot=bot)
+ asyncio.run(router.handle_reset(update, context))
+ return bot
+
+
def _run_diff_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot:
update = make_update(text="/diff" if not args else "/diff " + " ".join(args))
bot = FakeBot()
@@ -4439,6 +6171,14 @@ def test_commit_executes_only_valid_git_commands_and_ignores_non_git_segments(tm
],
),
)
+ lock_states = []
+ original_run_safe_commit_command = router.git.run_safe_commit_command
+
+ def run_safe_commit_command(project_path, args):
+ lock_states.append(router._workspace_locks["backend"].locked())
+ return original_run_safe_commit_command(project_path, args)
+
+ router.git.run_safe_commit_command = run_safe_commit_command
bot = _run_commit_command(router, '/commit git add -u && rm -rf / && git commit -m "safe"')
@@ -4457,6 +6197,7 @@ def test_commit_executes_only_valid_git_commands_and_ignores_non_git_segments(tm
assert "[telegram-enhance 5b9a263] safe" in bot.messages[-1][1]
assert "Ignored non-git commands:" in bot.messages[-1][1]
assert "- rm -rf /" in bot.messages[-1][1]
+ assert lock_states == [True, True]
def test_commit_is_rejected_when_disabled(tmp_path: Path):
@@ -4781,6 +6522,23 @@ def test_push_uses_current_session_branch(tmp_path: Path):
assert buttons[1].api_kwargs == {"style": "danger"}
+def test_push_escapes_backticks_in_branch_name_for_markdown(tmp_path: Path):
+ backend = (tmp_path / "backend").resolve()
+ backend.mkdir()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ branch_name = "feature/foo`bar"
+ store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name=branch_name)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=True, current_branch=branch_name)
+ router.runtime.git = router.git
+
+ bot = _run_push_command(router)
+
+ assert bot.messages[-1][1] == "Push branch `feature/foo\\`bar` to `origin`?"
+ assert bot.messages[-1][2] == "Markdown"
+
+
def test_push_confirmation_executes_push(tmp_path: Path):
backend = (tmp_path / "backend").resolve()
backend.mkdir()
@@ -4796,11 +6554,21 @@ def test_push_confirmation_executes_push(tmp_path: Path):
push_result=SimpleNamespace(success=True, message="Pushed branch 'feature-1' to origin.", current_branch="feature-1"),
)
router.runtime.git = router.git
+ lock_states = []
+ original_push_branch = router.git.push_branch
+
+ def push_branch(project_path, branch_name):
+ lock_states.append(router._workspace_locks["backend"].locked())
+ return original_push_branch(project_path, branch_name)
+
+ router.git.push_branch = push_branch
+ prompt_bot = _run_push_command(router)
+ confirm_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="push:confirm",
+ data=confirm_callback_data,
answer=None,
edit_message_text=None,
),
@@ -4824,6 +6592,7 @@ async def fake_edit(text, parse_mode=None):
assert bot.messages[-1][1].startswith('')
assert f"${shlex.join(['git', 'push', 'origin', 'feature-1'])}" in bot.messages[-1][1]
assert "[Completed]" in bot.messages[-1][1]
+ assert lock_states == [True]
def test_push_confirmation_cancel_does_not_push(tmp_path: Path):
@@ -4836,11 +6605,13 @@ def test_push_confirmation_cancel_does_not_push(tmp_path: Path):
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
router.runtime.git = router.git
+ prompt_bot = _run_push_command(router)
+ cancel_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][1].callback_data
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="push:cancel",
+ data=cancel_callback_data,
answer=None,
edit_message_text=None,
),
@@ -4862,6 +6633,38 @@ async def fake_edit(text):
assert edited == ["Push cancelled."]
+def test_push_confirmation_expires_when_active_session_branch_changes(tmp_path: Path):
+ backend = (tmp_path / "backend").resolve()
+ backend.mkdir()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name="feature-1")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+ router.runtime.git = router.git
+ prompt_bot = _run_push_command(router)
+ callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+ store.set_active_session_branch("bot-a", 123, "feature-2")
+ router.git._current_branch = "feature-2"
+
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None):
+ edited.append(text)
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_push_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert "expired" in edited[-1].lower()
+ assert router.git.push_calls == []
+
+
def test_pull_refreshes_active_session_branch(tmp_path: Path):
backend = (tmp_path / "backend").resolve()
backend.mkdir()
@@ -4886,8 +6689,8 @@ def test_pull_refreshes_active_session_branch(tmp_path: Path):
assert router.git.refresh_calls == []
assert bot.messages[-1][1] == "Pull branch `feature-1` from `origin`?"
buttons = bot.messages[-1][3].inline_keyboard[0]
- assert buttons[0].callback_data == "pull:confirm"
- assert buttons[1].callback_data == "pull:cancel"
+ assert buttons[0].callback_data.startswith("pull:confirm:")
+ assert buttons[1].callback_data.startswith("pull:cancel:")
assert buttons[0].text == "Confirm pull"
assert buttons[1].text == "Cancel"
@@ -4919,7 +6722,7 @@ def test_pull_confirmation_refreshes_default_and_session_branch(tmp_path: Path):
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(
is_git_repo=True,
- current_branch="main",
+ current_branch="feature-1",
default_branch="develop",
checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
)
@@ -4928,12 +6731,22 @@ def test_pull_confirmation_refreshes_default_and_session_branch(tmp_path: Path):
warnings=("git fetch origin failed.",),
)
router.runtime.git = router.git
+ lock_states = []
+ original_refresh_current_branch = router.git.refresh_current_branch
+
+ def refresh_current_branch(project_path):
+ lock_states.append(router._workspace_locks["backend"].locked())
+ return original_refresh_current_branch(project_path)
+
+ router.git.refresh_current_branch = refresh_current_branch
+ prompt_bot = _run_pull_command(router)
+ confirm_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="pull:confirm",
+ data=confirm_callback_data,
answer=None,
edit_message_text=None,
),
@@ -4957,11 +6770,11 @@ async def fake_edit(text, parse_mode=None):
(backend, "develop"),
(backend, "feature-1"),
]
- assert "Updated branch 'develop' from origin." in bot.messages[-1][1]
- assert "Updated branch 'feature-1' from origin." in bot.messages[-1][1]
+ assert "Updated branch" not in bot.messages[-1][1]
assert "Refresh warnings:" in bot.messages[-1][1]
assert "- git fetch origin failed." in bot.messages[-1][1]
assert router.git.push_calls == []
+ assert lock_states == [True, True]
def test_pull_confirmation_cancel_does_not_refresh(tmp_path: Path):
@@ -4974,11 +6787,13 @@ def test_pull_confirmation_cancel_does_not_refresh(tmp_path: Path):
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
router.runtime.git = router.git
+ prompt_bot = _run_pull_command(router)
+ cancel_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][1].callback_data
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="pull:cancel",
+ data=cancel_callback_data,
answer=None,
edit_message_text=None,
),
@@ -4995,10 +6810,430 @@ async def fake_edit(text):
update.callback_query.answer = fake_answer
update.callback_query.edit_message_text = fake_edit
- asyncio.run(router.handle_pull_callback(update, context))
+ asyncio.run(router.handle_pull_callback(update, context))
+
+ assert edited == ["Pull cancelled."]
+ assert router.git.refresh_calls == []
+
+
+def test_pull_confirmation_expires_when_active_session_branch_changes(tmp_path: Path):
+ backend = (tmp_path / "backend").resolve()
+ backend.mkdir()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_pull", "pull-session", "backend", "codex", branch_name="feature-1")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+ router.runtime.git = router.git
+ prompt_bot = _run_pull_command(router)
+ callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+ store.set_active_session_branch("bot-a", 123, "feature-2")
+ router.git._current_branch = "feature-2"
+
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None):
+ edited.append(text)
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_pull_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert "expired" in edited[-1].lower()
+ assert router.git.refresh_calls == []
+
+
+def test_log_shows_top_five_commits(tmp_path: Path):
+ router, backend = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
+
+ bot = _run_log_command(router)
+
+ assert router.git.git_commands == [(backend, ["log", "-5", "--oneline"])]
+ assert f"${shlex.join(['git', 'log', '-5', '--oneline'])}" in bot.messages[-1][1]
+
+
+def test_reset_selects_four_targets_and_confirms_before_reset(tmp_path: Path):
+ router, backend = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(
+ is_git_repo=True,
+ current_branch="feature-1",
+ default_branch="main",
+ checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+ ),
+ )
+ router.runtime.git = router.git
+
+ bot = _run_reset_command(router)
+
+ keyboard = bot.messages[-1][3].inline_keyboard
+ assert [[button.text for button in row] for row in keyboard] == [
+ ["local/main"],
+ ["origin/main"],
+ ["local/feature-1"],
+ ["origin/feature-1"],
+ ]
+
+ edited = []
+ select_callback_data = keyboard[1][0].callback_data
+ select_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=select_callback_data, answer=None, edit_message_text=None),
+ )
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append((text, parse_mode, reply_markup))
+
+ select_update.callback_query.answer = fake_answer
+ select_update.callback_query.edit_message_text = fake_edit
+ asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+
+ assert edited[-1][0] == "Reset the current branch with `git reset --hard origin/main`?"
+ confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+ assert confirm_callback_data.startswith("reset:confirm:")
+ assert router.git.git_commands == []
+
+ confirm_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+ assert router.git.refresh_calls == [(backend, "main")]
+ assert router.git.git_commands == [(backend, ["reset", "--hard", "origin/main"])]
+ assert router.git.current_branch(backend) == "feature-1"
+
+
+def test_reset_confirmation_is_bound_to_its_selected_target(tmp_path: Path):
+ router, backend = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(
+ is_git_repo=True,
+ current_branch="feature-1",
+ default_branch="main",
+ checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+ ),
+ )
+ router.runtime.git = router.git
+ bot = FakeBot()
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append((text, parse_mode, reply_markup))
+
+ async def select(callback_data: str) -> str:
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ await router.handle_reset_callback(update, SimpleNamespace(args=[], bot=bot))
+ return edited[-1][2].inline_keyboard[0][0].callback_data
+
+ first_keyboard = _run_reset_command(router).messages[-1][3].inline_keyboard
+ first_confirmation = asyncio.run(select(first_keyboard[1][0].callback_data))
+ second_keyboard = _run_reset_command(router).messages[-1][3].inline_keyboard
+ second_confirmation = asyncio.run(select(second_keyboard[2][0].callback_data))
+
+ assert first_confirmation != second_confirmation
+
+ confirm_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=first_confirmation, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+ assert router.git.git_commands == [(backend, ["reset", "--hard", "origin/main"])]
+
+
+def test_reset_selection_expires_when_active_session_branch_changes(tmp_path: Path):
+ router, _ = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+ )
+ router.runtime.git = router.git
+ callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[0][0].callback_data
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+ router.git._current_branch = "feature-2"
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append(text)
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert "expired" in edited[-1].lower()
+ assert router._reset_selections() == {}
+
+
+def test_reset_confirmation_survives_retryable_branch_discrepancy(tmp_path: Path):
+ router, backend = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+ )
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+ router.runtime.git = router.git
+ bot = FakeBot()
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append((text, parse_mode, reply_markup))
+
+ select_callback = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+ select_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=select_callback, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+ confirm_callback = edited[-1][2].inline_keyboard[0][0].callback_data
+ confirm_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=confirm_callback, answer=fake_answer, edit_message_text=fake_edit),
+ )
+
+ router.git._current_branch = "main"
+ asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+ assert "Branch discrepancy detected" in bot.messages[-1][1]
+ assert router.git.git_commands == []
+
+ router.git._current_branch = "feature-1"
+ asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+ assert router.git.git_commands == [(backend, ["reset", "--hard", "feature-1"])]
+
+
+def test_reset_confirmation_stops_when_project_becomes_busy(tmp_path: Path):
+ router, backend = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+ )
+ router.runtime.git = router.git
+ bot = FakeBot()
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append((text, parse_mode, reply_markup))
+
+ select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+ select_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+ confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+ router._is_project_busy = lambda _chat_id: True
+
+ confirm_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ message=None,
+ callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+ assert router.git.git_commands == []
+ assert f"An agent is currently running on project '{backend.name}'." in bot.messages[-1][1]
+
+
+def test_reset_restores_session_branch_when_origin_pull_fails(tmp_path: Path):
+ router, backend = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(
+ is_git_repo=True,
+ current_branch="feature-1",
+ default_branch="main",
+ checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+ ),
+ )
+ router.git.refresh_result = SimpleNamespace(success=True, warnings=("git pull failed for branch: main",))
+ router.runtime.git = router.git
+ bot = FakeBot()
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append((text, parse_mode, reply_markup))
+
+ select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[1][0].callback_data
+ select_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+ confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+ confirm_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+
+ asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+ assert router.git.current_branch(backend) == "feature-1"
+ assert router.git.git_commands == []
+ assert "git pull failed for branch: main" in bot.messages[-1][1]
+
+
+@pytest.mark.parametrize("command_name", ["commit", "diff", "log", "pull", "push", "reset"])
+def test_git_commands_warn_and_stop_on_session_branch_discrepancy(tmp_path: Path, command_name: str):
+ router, _ = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch="main", default_branch="main"),
+ )
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+ router.runtime.git = router.git
+
+ if command_name == "commit":
+ bot = _run_commit_command(router, "/commit git status")
+ elif command_name == "diff":
+ bot = _run_diff_command(router)
+ elif command_name == "log":
+ bot = _run_log_command(router)
+ elif command_name == "pull":
+ bot = _run_pull_command(router)
+ elif command_name == "push":
+ bot = _run_push_command(router)
+ else:
+ bot = _run_reset_command(router)
+
+ assert "Branch discrepancy detected" in bot.messages[-1][1]
+ assert "main" in bot.messages[-1][1]
+ assert "git status" not in bot.messages[-1][1]
+ assert router.git.git_commands == []
+ assert router.git.safe_git_commands == []
+ assert router.git.push_calls == []
+ assert router.git.refresh_calls == []
+ keyboard = bot.messages[-1][3]
+ assert [button.callback_data for row in keyboard.inline_keyboard for button in row] == [
+ "gitbranchdiscrepancy:stored",
+ "gitbranchdiscrepancy:current",
+ ]
+
+
+def test_git_branch_discrepancy_stored_choice_switches_branch_like_branch_command(tmp_path: Path):
+ router, _ = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(
+ is_git_repo=True,
+ current_branch="main",
+ default_branch="main",
+ local_branches=["main", "feature-1"],
+ prepare_from_source_result=SimpleNamespace(
+ success=True,
+ message="Switched to existing local branch 'feature-1'.",
+ current_branch="feature-1",
+ ),
+ ),
+ )
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, reply_markup=None):
+ edited.append((text, reply_markup))
+
+ query = SimpleNamespace(
+ data="gitbranchdiscrepancy:stored",
+ answer=fake_answer,
+ edit_message_text=fake_edit,
+ )
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=query,
+ message=None,
+ )
+
+ asyncio.run(router.handle_git_branch_discrepancy_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert router.git.prepare_from_source_calls[-1][1:] == ("local", "feature-1", "feature-1")
+ state = router.deps.store.get_chat_state("bot-a", 123)
+ assert state["current_branch"] == "feature-1"
+ assert state["sessions"]["sess_commit"]["branch_name"] == "feature-1"
+ assert "Current branch: feature-1" in edited[-1][0]
+
+
+def test_git_command_warns_when_repository_has_detached_head(tmp_path: Path):
+ router, _ = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch=None, default_branch="main"),
+ )
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+ router.runtime.git = router.git
+
+ bot = _run_log_command(router)
+
+ assert "Branch discrepancy detected" in bot.messages[-1][1]
+ assert "detached HEAD" in bot.messages[-1][1]
+ assert router.git.git_commands == []
+
+
+def test_reset_acknowledges_callback_and_holds_workspace_lock_during_reset(tmp_path: Path):
+ router, backend = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+ )
+ router.runtime.git = router.git
+ bot = FakeBot()
+ edited = []
+ answers = []
+ lock_states = []
+
+ async def fake_answer():
+ answers.append(True)
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append((text, parse_mode, reply_markup))
+
+ def run_git_command(project_path, args):
+ lock_states.append(router._workspace_locks["backend"].locked())
+ router.git.git_commands.append((project_path, args))
+ return SimpleNamespace(success=True, message="reset complete")
+
+ router.git.run_git_command = run_git_command
+ select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+ select_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+ confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+ confirm_update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+
+ asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
- assert edited == ["Pull cancelled."]
- assert router.git.refresh_calls == []
+ assert len(answers) == 2
+ assert lock_states == [True]
+ assert router.git.git_commands == [(backend, ["reset", "--hard", "feature-1"])]
+ assert not router._workspace_locks["backend"].locked()
def test_diff_lists_tracked_and_untracked_filenames(monkeypatch, tmp_path: Path):
@@ -5030,7 +7265,9 @@ def test_diff_lists_tracked_and_untracked_filenames(monkeypatch, tmp_path: Path)
labels = [button.text for row in reply_markup.inline_keyboard for button in row]
callback_data = [button.callback_data for row in reply_markup.inline_keyboard for button in row]
assert labels == ["1. app.py"]
- assert callback_data == ["diffshow:0"]
+ assert len(callback_data) == 1
+ assert callback_data[0].startswith("diffshow:")
+ assert callback_data[0].endswith(":0")
def test_diff_callback_sends_selected_file_diff(monkeypatch, tmp_path: Path):
@@ -5055,11 +7292,13 @@ def test_diff_callback_sends_selected_file_diff(monkeypatch, tmp_path: Path):
if include_cached
else [],
)
+ prompt_bot = _run_diff_command(router)
+ show_callback_data = prompt_bot.messages[-1][3].inline_keyboard[1][0].callback_data
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="diffshow:1",
+ data=show_callback_data,
answer=None,
),
)
@@ -5078,6 +7317,102 @@ async def fake_answer():
assert "new" in bot.messages[-1][1]
+def test_diff_callback_uses_the_file_snapshot_shown_to_the_user(monkeypatch, tmp_path: Path):
+ backend = (tmp_path / "backend").resolve()
+ backend.mkdir()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+ router.runtime.git = router.git
+ changed_files = ["src/first.py", "src/selected.py"]
+ monkeypatch.setattr(
+ "coding_agent_telegram.router.git_commands.split_changed_files",
+ lambda _project_path: (list(changed_files), []),
+ )
+ collected_files = []
+
+ def fake_collect(_project_path, files, *, against_ref=None, include_cached=False):
+ collected_files.extend(files)
+ return [SimpleNamespace(path=files[0], diff="--- a/file\n+++ b/file\n@@\n-old\n+new")]
+
+ monkeypatch.setattr("coding_agent_telegram.router.git_commands.collect_diffs", fake_collect)
+ prompt_bot = _run_diff_command(router)
+ callback_data = prompt_bot.messages[-1][3].inline_keyboard[1][0].callback_data
+ changed_files[:] = ["src/replacement.py"]
+
+ async def fake_answer():
+ return None
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=callback_data, answer=fake_answer),
+ )
+ asyncio.run(router.handle_diff_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert collected_files == ["src/selected.py"]
+
+
+def test_diff_snapshot_expires_when_same_session_switches_branch(monkeypatch, tmp_path: Path):
+ backend = (tmp_path / "backend").resolve()
+ backend.mkdir()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+ router.runtime.git = router.git
+ monkeypatch.setattr(
+ "coding_agent_telegram.router.git_commands.split_changed_files",
+ lambda _project_path: (["src/app.py"], []),
+ )
+ callback_data = _run_diff_command(router).messages[-1][3].inline_keyboard[0][0].callback_data
+ store.set_active_session_branch("bot-a", 123, "feature-2")
+ router.git._current_branch = "feature-2"
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text, parse_mode=None, reply_markup=None):
+ edited.append(text)
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_diff_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert "expired" in edited[-1].lower()
+
+
+def test_diff_paginates_untracked_files_and_bounds_message_size(monkeypatch, tmp_path: Path):
+ backend = (tmp_path / "backend").resolve()
+ backend.mkdir()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+ router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+ router.runtime.git = router.git
+ untracked_files = [f"notes/{index}-{'x' * 240}.txt" for index in range(25)]
+ monkeypatch.setattr(
+ "coding_agent_telegram.router.git_commands.split_changed_files",
+ lambda _project_path: ([], untracked_files),
+ )
+
+ bot = _run_diff_command(router)
+
+ assert "Showing 1-10 of 25." in bot.messages[-1][1]
+ assert "notes/0-" in bot.messages[-1][1]
+ assert "notes/10-" not in bot.messages[-1][1]
+ assert len(bot.messages[-1][1]) < 4096
+ reply_markup = bot.messages[-1][3]
+ assert reply_markup is not None
+ assert reply_markup.inline_keyboard[-1][0].text == "Next"
+
+
def test_diff_limits_buttons_to_ten_per_page(monkeypatch, tmp_path: Path):
backend = (tmp_path / "backend").resolve()
backend.mkdir()
@@ -5101,11 +7436,12 @@ def test_diff_limits_buttons_to_ten_per_page(monkeypatch, tmp_path: Path):
rows = reply_markup.inline_keyboard
file_buttons = [button for row in rows[:-1] for button in row]
nav_buttons = rows[-1]
+ assert [len(row) for row in rows[:-1]] == [1] * 10
assert len(file_buttons) == 10
assert [button.text for button in file_buttons[:3]] == ["1. file_1.py", "2. file_2.py", "3. file_3.py"]
- assert [button.callback_data for button in file_buttons[-2:]] == ["diffshow:8", "diffshow:9"]
+ assert [button.callback_data.rsplit(":", 1)[1] for button in file_buttons[-2:]] == ["8", "9"]
assert [button.text for button in nav_buttons] == ["Next"]
- assert [button.callback_data for button in nav_buttons] == ["diffpage:1"]
+ assert [button.callback_data.rsplit(":", 1)[1] for button in nav_buttons] == ["1"]
assert "Showing 1-10 of 12." in bot.messages[-1][1]
assert "10. src/file_10.py" in bot.messages[-1][1]
assert "11. src/file_11.py" not in bot.messages[-1][1]
@@ -5126,12 +7462,14 @@ def test_diff_pagination_edits_message_for_next_page(monkeypatch, tmp_path: Path
"coding_agent_telegram.router.git_commands.split_changed_files",
lambda _project_path: (tracked_files, []),
)
+ prompt_bot = _run_diff_command(router)
+ next_callback_data = prompt_bot.messages[-1][3].inline_keyboard[-1][0].callback_data
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="diffpage:1",
+ data=next_callback_data,
answer=None,
edit_message_text=None,
),
@@ -5158,7 +7496,8 @@ async def fake_edit(text, parse_mode=None, reply_markup=None):
labels = [button.text for row in reply_markup.inline_keyboard for button in row]
callback_data = [button.callback_data for row in reply_markup.inline_keyboard for button in row]
assert "Prev" in labels
- assert callback_data[-1] == "diffpage:0"
+ assert callback_data[-1].startswith("diffpage:")
+ assert callback_data[-1].endswith(":0")
def test_diff_sends_usage_when_extra_args_provided(tmp_path: Path):
@@ -5591,7 +7930,16 @@ def test_handle_provider_sends_keyboard_when_no_args(tmp_path: Path):
# Should have sent a message with a reply_markup keyboard
assert len(bot.messages) >= 1
- assert bot.messages[-1][3] is not None # reply_markup present
+ reply_markup = bot.messages[-1][3]
+ assert reply_markup is not None
+ # Provider labels include availability/current-state text, so keep every
+ # provider on its own row to avoid Telegram truncating the labels.
+ assert [len(row) for row in reply_markup.inline_keyboard] == [1, 1, 1]
+ assert [button.callback_data for row in reply_markup.inline_keyboard for button in row] == [
+ "provider:set:codex",
+ "provider:set:copilot",
+ "provider:set:claude",
+ ]
def test_handle_provider_localizes_prompt_text(tmp_path: Path):
@@ -5800,10 +8148,11 @@ def test_commit_no_args_shows_generate_prompt(monkeypatch, tmp_path: Path):
assert reply_markup is not None
buttons = reply_markup.inline_keyboard[0]
assert buttons[0].text == "Generate command"
- assert buttons[0].callback_data == "commitgen:confirm"
+ assert buttons[0].callback_data.startswith("commitgen:confirm:")
assert buttons[0].api_kwargs == {"style": "primary"}
assert buttons[1].text == "Cancel"
- assert buttons[1].callback_data == "commitgen:cancel"
+ assert buttons[1].callback_data.startswith("commitgen:cancel:")
+ assert buttons[0].callback_data.rsplit(":", 1)[1] == buttons[1].callback_data.rsplit(":", 1)[1]
assert buttons[1].api_kwargs == {"style": "danger"}
@@ -5819,12 +8168,14 @@ async def fake_run_active_session(_update, _context, *, user_message, image_path
)
router.runtime.run_active_session = fake_run_active_session
+ prompt_bot = _run_commit_command(router, "/commit")
+ generate_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="commitgen:confirm",
+ data=generate_callback_data,
answer=None,
edit_message_text=None,
),
@@ -5844,20 +8195,23 @@ async def fake_edit(text):
asyncio.run(router.handle_commit_generate_callback(update, context))
assert edited == ["Generated commit command below."]
- assert router._generated_commit_commands()[123] == {
+ command_token = bot.messages[-1][3].inline_keyboard[0][0].callback_data.rsplit(":", 1)[1]
+ assert router._generated_commit_commands()[command_token] == {
+ "chat_id": "123",
"command": 'git add src/app.py && git commit -m "Update app"',
"session_id": "sess_commit",
"project_folder": "backend",
+ "branch_name": "",
}
assert bot.messages[-1][1] == "Do you want to execute the commit?"
reply_markup = bot.messages[-1][3]
assert reply_markup is not None
buttons = reply_markup.inline_keyboard[0]
assert buttons[0].text == "Execute commit"
- assert buttons[0].callback_data == "commitexec:confirm"
+ assert buttons[0].callback_data == f"commitexec:confirm:{command_token}"
assert buttons[0].api_kwargs == {"style": "primary"}
assert buttons[1].text == "Cancel"
- assert buttons[1].callback_data == "commitexec:cancel"
+ assert buttons[1].callback_data == f"commitexec:cancel:{command_token}"
assert buttons[1].api_kwargs == {"style": "danger"}
@@ -5885,17 +8239,20 @@ def test_commit_execute_callback_runs_generated_commit_command(monkeypatch, tmp_
],
),
)
- router._generated_commit_commands()[123] = {
+ token = "0123456789ab"
+ router._generated_commit_commands()[token] = {
+ "chat_id": "123",
"command": 'git add src/app.py && git commit -m "Update app"',
"session_id": "sess_commit",
"project_folder": "backend",
+ "branch_name": "",
}
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="commitexec:confirm",
+ data=f"commitexec:confirm:{token}",
answer=None,
edit_message_text=None,
),
@@ -5927,17 +8284,20 @@ def test_commit_execute_callback_rejects_when_active_session_changes(tmp_path: P
router, _ = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
(tmp_path / "frontend").mkdir()
router.deps.store.create_session("bot-a", 123, "sess_other", "other-session", "frontend", "codex")
- router._generated_commit_commands()[123] = {
+ token = "0123456789ab"
+ router._generated_commit_commands()[token] = {
+ "chat_id": "123",
"command": 'git add src/app.py && git commit -m "Update app"',
"session_id": "sess_commit",
"project_folder": "backend",
+ "branch_name": "",
}
edited = []
update = SimpleNamespace(
effective_chat=SimpleNamespace(id=123, type="private"),
callback_query=SimpleNamespace(
- data="commitexec:confirm",
+ data=f"commitexec:confirm:{token}",
answer=None,
edit_message_text=None,
),
@@ -5958,7 +8318,114 @@ async def fake_edit(text):
assert edited == ["The active session or project changed. Please generate the commit command again."]
assert router.git.safe_git_commands == []
- assert 123 not in router._generated_commit_commands()
+ assert token not in router._generated_commit_commands()
+
+
+def test_commit_generation_prompt_expires_when_branch_changes(tmp_path: Path):
+ router, _ = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1"),
+ )
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+ prompt_bot = _run_commit_command(router, "/commit")
+ callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+ router.git._current_branch = "feature-2"
+ run_calls = []
+
+ async def fake_run_active_session(*args, **kwargs):
+ run_calls.append((args, kwargs))
+ return None
+
+ router.runtime.run_active_session = fake_run_active_session
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text):
+ edited.append(text)
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+ )
+ asyncio.run(router.handle_commit_generate_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert edited == ["The active session or project changed. Please generate the commit command again."]
+ assert run_calls == []
+
+
+def test_commit_execute_cancel_consumes_only_its_token(tmp_path: Path):
+ router, _ = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
+ cancelled_token = "0123456789ab"
+ other_token = "abcdef012345"
+ payload = {
+ "chat_id": "123",
+ "command": 'git add src/app.py && git commit -m "Update app"',
+ "session_id": "sess_commit",
+ "project_folder": "backend",
+ "branch_name": "",
+ }
+ router._generated_commit_commands()[cancelled_token] = dict(payload)
+ router._generated_commit_commands()[other_token] = dict(payload)
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text):
+ edited.append(text)
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(
+ data=f"commitexec:cancel:{cancelled_token}",
+ answer=fake_answer,
+ edit_message_text=fake_edit,
+ ),
+ )
+ asyncio.run(router.handle_commit_execute_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert cancelled_token not in router._generated_commit_commands()
+ assert other_token in router._generated_commit_commands()
+ assert edited == ["Commit command generation cancelled."]
+
+
+def test_commit_execute_rejects_when_branch_changes(tmp_path: Path):
+ router, _ = _make_commit_router(
+ tmp_path,
+ git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-2"),
+ )
+ router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+ token = "0123456789ab"
+ router._generated_commit_commands()[token] = {
+ "chat_id": "123",
+ "command": 'git add src/app.py && git commit -m "Update app"',
+ "session_id": "sess_commit",
+ "project_folder": "backend",
+ "branch_name": "feature-1",
+ }
+ edited = []
+
+ async def fake_answer():
+ return None
+
+ async def fake_edit(text):
+ edited.append(text)
+
+ update = SimpleNamespace(
+ effective_chat=SimpleNamespace(id=123, type="private"),
+ callback_query=SimpleNamespace(
+ data=f"commitexec:confirm:{token}",
+ answer=fake_answer,
+ edit_message_text=fake_edit,
+ ),
+ )
+ asyncio.run(router.handle_commit_execute_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+ assert edited == ["The active session or project changed. Please generate the commit command again."]
+ assert router.git.safe_git_commands == []
def test_commit_no_valid_git_commands_found(tmp_path: Path):
@@ -6059,7 +8526,7 @@ async def fake_edit(text, parse_mode=None):
assert bot.messages == []
-def test_push_callback_empty_branch_warns(tmp_path: Path):
+def test_push_empty_branch_warns(tmp_path: Path):
backend = (tmp_path / "backend").resolve()
backend.mkdir()
runner = DummyRunner()
@@ -6071,33 +8538,12 @@ def test_push_callback_empty_branch_warns(tmp_path: Path):
router.git = FakeGitManager(is_git_repo=True, current_branch=None)
router.runtime.git = router.git
- edited = []
- update = SimpleNamespace(
- effective_chat=SimpleNamespace(id=123, type="private"),
- callback_query=SimpleNamespace(
- data="push:confirm",
- answer=None,
- edit_message_text=None,
- ),
- )
- bot = FakeBot()
- context = SimpleNamespace(args=[], bot=bot)
-
- async def fake_answer():
- return None
-
- async def fake_edit(text, parse_mode=None):
- edited.append(text)
-
- update.callback_query.answer = fake_answer
- update.callback_query.edit_message_text = fake_edit
-
- asyncio.run(router.handle_push_callback(update, context))
+ bot = _run_push_command(router)
- assert any("Could not determine the branch" in e for e in edited)
+ assert "Could not determine the branch" in bot.messages[-1][1]
-def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
+def test_push_warns_instead_of_checking_out_session_branch(tmp_path: Path):
backend = (tmp_path / "backend").resolve()
backend.mkdir()
runner = DummyRunner()
@@ -6105,7 +8551,7 @@ def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
store = SessionStore(cfg.state_file, cfg.state_backup_file)
store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name="feature-x")
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
- # current_branch differs from session branch so checkout is attempted
+ # A discrepancy is reported instead of silently checking out another branch.
router.git = FakeGitManager(
is_git_repo=True,
current_branch="main",
@@ -6113,30 +8559,11 @@ def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
)
router.runtime.git = router.git
- edited = []
- update = SimpleNamespace(
- effective_chat=SimpleNamespace(id=123, type="private"),
- callback_query=SimpleNamespace(
- data="push:confirm",
- answer=None,
- edit_message_text=None,
- ),
- )
- bot = FakeBot()
- context = SimpleNamespace(args=[], bot=bot)
-
- async def fake_answer():
- return None
-
- async def fake_edit(text, parse_mode=None):
- edited.append(text)
-
- update.callback_query.answer = fake_answer
- update.callback_query.edit_message_text = fake_edit
-
- asyncio.run(router.handle_push_callback(update, context))
+ bot = _run_push_command(router)
- assert any("Push cancelled" in e for e in edited)
+ assert "Branch discrepancy detected" in bot.messages[-1][1]
+ assert "feature-x" in bot.messages[-1][1]
+ assert "main" in bot.messages[-1][1]
assert router.git.push_calls == []
@@ -8004,6 +10431,8 @@ def create_session(
*,
skip_git_repo_check=False,
image_paths=(),
+ priming_only=False,
+ model=None,
on_stall=None,
on_progress=None,
):
@@ -8919,6 +11348,32 @@ def test_prompt_queue_batch_decision_early_exit_no_send_message(tmp_path: Path):
asyncio.run(router._prompt_queue_batch_decision(123, context, msgs)) # should not raise
+def test_prompt_queue_batch_decision_uses_one_button_per_row(tmp_path: Path):
+ from coding_agent_telegram.router.queue_processing import QueuedQuestion
+
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+ bot = FakeBot()
+
+ asyncio.run(
+ router._prompt_queue_batch_decision(
+ 123,
+ SimpleNamespace(bot=bot),
+ [QueuedQuestion(text="q1"), QueuedQuestion(text="q2")],
+ )
+ )
+
+ keyboard = bot.messages[-1][3]
+ assert [len(row) for row in keyboard.inline_keyboard] == [1, 1, 1]
+ assert [button.callback_data for row in keyboard.inline_keyboard for button in row] == [
+ "queuebatch:group",
+ "queuebatch:single",
+ "queuebatch:cancel",
+ ]
+
+
def test_clear_chat_message_queue_removes_processing_and_pending(tmp_path: Path):
runner = DummyRunner()
cfg = make_config(tmp_path)
@@ -10007,3 +12462,108 @@ async def always_false(*a, **kw):
bot = FakeBot()
context = SimpleNamespace(args=[], bot=bot)
asyncio.run(router._drain_chat_message_queue(123, context)) # should return without error
+
+
+def test_status_command_reports_each_provider_usage(tmp_path: Path, monkeypatch):
+ from coding_agent_telegram.usage_status import ProviderUsage, RateWindow
+
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ claude_calls = []
+
+ def fake_get_claude_usage():
+ claude_calls.append(True)
+ return ProviderUsage(
+ provider="claude",
+ available=True,
+ five_hour=RateWindow(used_percent=53.0, resets_at=int(time.time()) + 3600),
+ weekly=RateWindow(used_percent=5.0, resets_at=int(time.time()) + 86400),
+ )
+
+ monkeypatch.setattr(
+ "coding_agent_telegram.router.session_status_commands.get_claude_usage",
+ fake_get_claude_usage,
+ )
+ monkeypatch.setattr(
+ "coding_agent_telegram.router.session_status_commands.fetch_codex_usage",
+ lambda codex_bin: ProviderUsage(
+ provider="codex",
+ available=True,
+ five_hour=RateWindow(used_percent=0.0, resets_at=int(time.time()) + 3600),
+ weekly=RateWindow(used_percent=23.0, resets_at=int(time.time()) + 86400),
+ plan="plus",
+ ),
+ )
+
+ update = make_update()
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_status(update, context))
+
+ assert claude_calls == [True]
+
+ text = bot.messages[-1][1]
+ assert "Claude" in text
+ assert "53%" in text
+ assert "5%" in text
+ assert "Codex (plus)" in text
+ assert "23%" in text
+ assert "Copilot" in text
+ assert "Not available" in text
+
+
+def test_status_command_shows_na_note_for_expired_claude_window(tmp_path: Path, monkeypatch):
+ from coding_agent_telegram.usage_status import CLAUDE_WINDOW_EXPIRED_NOTE, ProviderUsage, RateWindow
+
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ monkeypatch.setattr(
+ "coding_agent_telegram.router.session_status_commands.get_claude_usage",
+ lambda: ProviderUsage(
+ provider="claude",
+ available=True,
+ five_hour=None,
+ five_hour_note=CLAUDE_WINDOW_EXPIRED_NOTE,
+ weekly=RateWindow(used_percent=10.0, resets_at=int(time.time()) + 86400),
+ observed_at=time.time() - 3600,
+ ),
+ )
+ monkeypatch.setattr(
+ "coding_agent_telegram.router.session_status_commands.fetch_codex_usage",
+ lambda codex_bin: ProviderUsage(provider="codex", available=True),
+ )
+
+ update = make_update()
+ bot = FakeBot()
+ context = SimpleNamespace(args=[], bot=bot)
+
+ asyncio.run(router.handle_status(update, context))
+
+ text = bot.messages[-1][1]
+ assert "N/A" in text
+ assert CLAUDE_WINDOW_EXPIRED_NOTE in text
+ assert "10%" in text
+ assert "last observed" in text
+
+
+def test_status_command_rejects_extra_args(tmp_path: Path):
+ runner = DummyRunner()
+ cfg = make_config(tmp_path)
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
+
+ update = make_update()
+ bot = FakeBot()
+ context = SimpleNamespace(args=["extra"], bot=bot)
+
+ asyncio.run(router.handle_status(update, context))
+
+ assert bot.messages[-1][1] == "Usage: /status"
+ assert not runner.create_calls
diff --git a/tests/test_config.py b/tests/test_config.py
index ce2e822..03ea5d2 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -4,6 +4,7 @@
import pytest
import coding_agent_telegram.config as config_module
+import coding_agent_telegram.models as models_module
from coding_agent_telegram.config import (
DEFAULT_MAX_TELEGRAM_MESSAGE_LENGTH,
DEFAULT_OPENAI_WHISPER_MODEL,
@@ -35,6 +36,9 @@ def _isolate_env(monkeypatch, tmp_path):
"CODEX_MODEL",
"COPILOT_MODEL",
"CLAUDE_MODEL",
+ "CODEX_MODEL_CHOICES",
+ "COPILOT_MODEL_CHOICES",
+ "CLAUDE_MODEL_CHOICES",
"COPILOT_AUTOPILOT",
"COPILOT_NO_ASK_USER",
"COPILOT_ALLOW_ALL",
@@ -108,6 +112,9 @@ def test_load_config_required(monkeypatch, tmp_path):
assert cfg.codex_model == ""
assert cfg.copilot_model == ""
assert cfg.claude_model == ""
+ assert cfg.codex_model_choices == models_module.DEFAULT_MODEL_CHOICES["codex"]
+ assert cfg.copilot_model_choices == models_module.DEFAULT_MODEL_CHOICES["copilot"]
+ assert cfg.claude_model_choices == models_module.DEFAULT_MODEL_CHOICES["claude"]
assert cfg.copilot_autopilot is True
assert cfg.copilot_no_ask_user is True
assert cfg.copilot_allow_all is True
@@ -142,6 +149,47 @@ def test_load_config_accepts_claude_as_default_provider(monkeypatch, tmp_path):
assert cfg.default_agent_provider == "claude"
+def test_load_config_model_choices_override(monkeypatch, tmp_path):
+ _isolate_env(monkeypatch, tmp_path)
+ monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
+ monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a")
+ monkeypatch.setenv("ALLOWED_CHAT_IDS", "123")
+ monkeypatch.setenv("CODEX_MODEL_CHOICES", "o4-mini, gpt-5.4")
+
+ cfg = load_config()
+
+ assert cfg.codex_model_choices == ("o4-mini", "gpt-5.4")
+ # Unset entirely -> falls back to the bundled template defaults.
+ assert cfg.copilot_model_choices == models_module.DEFAULT_MODEL_CHOICES["copilot"]
+
+
+def test_load_config_model_choices_can_be_explicitly_emptied(monkeypatch, tmp_path):
+ _isolate_env(monkeypatch, tmp_path)
+ monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
+ monkeypatch.setenv("TELEGRAM_BOT_TOKENS", "token-a")
+ monkeypatch.setenv("ALLOWED_CHAT_IDS", "123")
+ monkeypatch.setenv("CODEX_MODEL_CHOICES", "")
+
+ cfg = load_config()
+
+ # Explicitly set to empty (as opposed to unset) must be honored as "no curated
+ # choices", not silently fall back to the hardcoded default.
+ assert cfg.codex_model_choices == ()
+
+
+def test_model_choices_fall_back_when_template_is_missing(monkeypatch):
+ def missing_resources(_package):
+ raise FileNotFoundError
+
+ monkeypatch.setattr(models_module.resources, "files", missing_resources)
+
+ assert models_module._load_default_model_choices() == {
+ "codex": (),
+ "copilot": (),
+ "claude": ("sonnet", "opus", "fable", "haiku"),
+ }
+
+
def test_load_config_rejects_unknown_default_provider(monkeypatch, tmp_path):
_isolate_env(monkeypatch, tmp_path)
monkeypatch.setenv("WORKSPACE_ROOT", "~/git")
diff --git a/tests/test_diff_chunking.py b/tests/test_diff_chunking.py
index 36099ea..063de33 100644
--- a/tests/test_diff_chunking.py
+++ b/tests/test_diff_chunking.py
@@ -56,15 +56,21 @@ def test_build_summary_includes_branch_next_to_project():
def test_parse_status_paths_includes_renames_and_untracked():
- output = " M src/app.py\n?? src/new.py\nR old.py -> new.py\n"
+ output = " M src/app.py\0?? src/new.py\0R new.py\0old.py\0"
assert _parse_status_paths(output) == ["src/app.py", "src/new.py", "new.py"]
+def test_parse_status_paths_preserves_unquoted_special_filenames_from_z_mode():
+ output = " M café file.py\0?? trailing-space \0"
+
+ assert _parse_status_paths(output) == ["café file.py", "trailing-space "]
+
+
def test_split_changed_files_separates_tracked_and_untracked(monkeypatch, tmp_path: Path):
monkeypatch.setattr(
diff_utils_module,
"_git",
- lambda _project_path, _args: " M src/app.py\n?? src/new.py\nR old.py -> new.py\n",
+ lambda _project_path, _args: " M src/app.py\0?? src/new.py\0R new.py\0old.py\0",
)
tracked, untracked = split_changed_files(tmp_path)
diff --git a/tests/test_env_file_helpers.py b/tests/test_env_file_helpers.py
new file mode 100644
index 0000000..8bbb6ed
--- /dev/null
+++ b/tests/test_env_file_helpers.py
@@ -0,0 +1,67 @@
+from coding_agent_telegram.config import read_env_value, remove_env_value, upsert_env_value
+
+
+def test_upsert_env_value_appends_when_missing(tmp_path):
+ env_path = tmp_path / ".env"
+ env_path.write_text("WORKSPACE_ROOT=~/git\n", encoding="utf-8")
+
+ upsert_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-abc", comments=["# a comment"])
+
+ text = env_path.read_text(encoding="utf-8")
+ assert "WORKSPACE_ROOT=~/git" in text
+ assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-abc" in text
+ assert "# a comment" in text
+
+
+def test_upsert_env_value_overwrites_existing(tmp_path):
+ env_path = tmp_path / ".env"
+ env_path.write_text("CLAUDE_CODE_OAUTH_TOKEN=old-value\nOTHER=1\n", encoding="utf-8")
+
+ upsert_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN", "new-value")
+
+ text = env_path.read_text(encoding="utf-8")
+ assert "CLAUDE_CODE_OAUTH_TOKEN=new-value" in text
+ assert "old-value" not in text
+ assert "OTHER=1" in text
+
+
+def test_upsert_env_value_creates_missing_file(tmp_path):
+ env_path = tmp_path / "nested" / ".env"
+
+ upsert_env_value(env_path, "KEY", "value")
+
+ assert env_path.read_text(encoding="utf-8") == "KEY=value\n"
+
+
+def test_read_env_value_returns_none_when_missing(tmp_path):
+ env_path = tmp_path / ".env"
+ env_path.write_text("OTHER=1\n", encoding="utf-8")
+
+ assert read_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN") is None
+
+
+def test_read_env_value_returns_none_when_file_missing(tmp_path):
+ assert read_env_value(tmp_path / "does-not-exist.env", "KEY") is None
+
+
+def test_read_env_value_returns_current_value(tmp_path):
+ env_path = tmp_path / ".env"
+ env_path.write_text("CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xyz\n", encoding="utf-8")
+
+ assert read_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN") == "sk-ant-oat01-xyz"
+
+
+def test_remove_env_value_drops_only_matching_key(tmp_path):
+ env_path = tmp_path / ".env"
+ env_path.write_text("CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xyz\nOTHER=1\n", encoding="utf-8")
+
+ remove_env_value(env_path, "CLAUDE_CODE_OAUTH_TOKEN")
+
+ text = env_path.read_text(encoding="utf-8")
+ assert "CLAUDE_CODE_OAUTH_TOKEN" not in text
+ assert "OTHER=1" in text
+
+
+def test_remove_env_value_noop_when_file_missing(tmp_path):
+ # Should not raise.
+ remove_env_value(tmp_path / "does-not-exist.env", "KEY")
diff --git a/tests/test_session_gap.py b/tests/test_session_gap.py
new file mode 100644
index 0000000..d512eb3
--- /dev/null
+++ b/tests/test_session_gap.py
@@ -0,0 +1,115 @@
+"""Tests for session_gap.py."""
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+from coding_agent_telegram.session_gap import humanize_token_count, native_session_activity
+
+
+def _write_jsonl(path: Path, entries: list[dict]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("\n".join(json.dumps(entry) for entry in entries) + "\n", encoding="utf-8")
+
+
+def _real_usage_entry(total_tokens: int) -> dict:
+ return {
+ "type": "assistant",
+ "message": {
+ "model": "claude-sonnet-5",
+ "role": "assistant",
+ "usage": {
+ "input_tokens": total_tokens,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ },
+ },
+ }
+
+
+def _synthetic_stub_entry(text: str, error: str | None = None) -> dict:
+ entry = {
+ "type": "assistant",
+ "message": {
+ "model": "",
+ "role": "assistant",
+ "usage": {
+ "input_tokens": 0,
+ "cache_creation_input_tokens": 0,
+ "cache_read_input_tokens": 0,
+ },
+ "content": [{"type": "text", "text": text}],
+ },
+ }
+ if error is not None:
+ entry["error"] = error
+ return entry
+
+
+def test_claude_size_skips_trailing_synthetic_rate_limit_stub(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ session_file = claude_home / "projects" / "-tmp-proj" / "sess-1.jsonl"
+ _write_jsonl(
+ session_file,
+ [
+ _real_usage_entry(50_000),
+ _synthetic_stub_entry("You've hit your session limit · resets 12:40am (Asia/Shanghai)", error="rate_limit"),
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ _last_activity, size_tokens = native_session_activity("claude", "sess-1")
+
+ assert size_tokens == 50_000
+
+
+def test_claude_size_skips_trailing_synthetic_auth_and_no_response_stubs(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ session_file = claude_home / "projects" / "-tmp-proj" / "sess-2.jsonl"
+ _write_jsonl(
+ session_file,
+ [
+ _real_usage_entry(30_000),
+ _synthetic_stub_entry("Not logged in · Please run /login", error="authentication_failed"),
+ _synthetic_stub_entry("No response requested."),
+ ],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ _last_activity, size_tokens = native_session_activity("claude", "sess-2")
+
+ assert size_tokens == 30_000
+
+
+def test_claude_size_is_none_when_only_synthetic_entries_exist(tmp_path: Path):
+ claude_home = tmp_path / "claude-home"
+ session_file = claude_home / "projects" / "-tmp-proj" / "sess-3.jsonl"
+ _write_jsonl(
+ session_file,
+ [_synthetic_stub_entry("No response requested.")],
+ )
+
+ with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(claude_home)}):
+ _last_activity, size_tokens = native_session_activity("claude", "sess-3")
+
+ assert size_tokens is None
+
+
+def test_humanize_token_count_examples():
+ assert humanize_token_count(0) == "0"
+ assert humanize_token_count(800) == "800"
+ assert humanize_token_count(999) == "999"
+ assert humanize_token_count(1_000) == "1k"
+ assert humanize_token_count(200_000) == "200k"
+ assert humanize_token_count(1_000_000) == "1M"
+ assert humanize_token_count(11_000_000) == "11M"
+ assert humanize_token_count(1_000_000_000) == "1B"
+
+
+def test_humanize_token_count_rounds_down_instead_of_rolling_over_the_unit():
+ """A count just under a unit boundary should read as e.g. "999.9k", not round up
+ to a misleading "1000k" that looks like a typo for 1M."""
+ assert humanize_token_count(999_999) == "999.9k"
+ assert humanize_token_count(12_345) == "12.3k"
diff --git a/tests/test_session_runtime_claude_auth.py b/tests/test_session_runtime_claude_auth.py
new file mode 100644
index 0000000..4b4cc03
--- /dev/null
+++ b/tests/test_session_runtime_claude_auth.py
@@ -0,0 +1,67 @@
+from types import SimpleNamespace
+
+from coding_agent_telegram.agent_runner import AgentRunResult
+from coding_agent_telegram.session_runtime import SessionRuntime
+
+
+def _runtime(locale: str = "en") -> SessionRuntime:
+ return SessionRuntime(
+ cfg=SimpleNamespace(locale=locale),
+ store=None,
+ agent_runner=None,
+ bot_id="bot-a",
+ git=None,
+ run_with_typing=None,
+ register_reply_options=None,
+ )
+
+
+def _result(error_message, *, error_code=None) -> AgentRunResult:
+ return AgentRunResult(
+ session_id=None,
+ success=False,
+ assistant_text="",
+ error_message=error_message,
+ raw_events=[],
+ error_code=error_code,
+ )
+
+
+def test_agent_failure_text_uses_claude_auth_guidance_for_claude_auth_error():
+ runtime = _runtime()
+ result = _result("Failed to authenticate: OAuth session expired and could not be refreshed")
+
+ text = runtime._agent_failure_text(None, "claude", result)
+
+ assert "claude setup-token" in text
+ assert "claude-auth" in text
+
+
+def test_agent_failure_text_ignores_auth_wording_for_other_providers():
+ runtime = _runtime()
+ result = _result("Failed to authenticate: OAuth session expired and could not be refreshed")
+
+ text = runtime._agent_failure_text(None, "codex", result)
+
+ assert text == "Failed to authenticate: OAuth session expired and could not be refreshed"
+
+
+def test_agent_failure_text_falls_back_to_sanitized_error_for_non_auth_failures():
+ runtime = _runtime()
+ result = _result("Rate limit exceeded at /Users/daocha/git/some-project/file.py")
+
+ text = runtime._agent_failure_text(None, "claude", result)
+
+ assert text == "Rate limit exceeded at "
+
+
+def test_agent_failure_text_aborted_takes_priority_over_auth_wording():
+ runtime = _runtime()
+ result = _result(
+ "Failed to authenticate: OAuth session expired and could not be refreshed",
+ error_code="agent_aborted",
+ )
+
+ text = runtime._agent_failure_text(None, "claude", result)
+
+ assert text == "Agent run aborted by /abort."
diff --git a/tests/test_session_runtime_diff_merge.py b/tests/test_session_runtime_diff_merge.py
index d9916a4..63e2a53 100644
--- a/tests/test_session_runtime_diff_merge.py
+++ b/tests/test_session_runtime_diff_merge.py
@@ -1,5 +1,5 @@
from coding_agent_telegram.diff_utils import FileDiff, TEXTUAL_DIFF_UNAVAILABLE
-from coding_agent_telegram.session_runtime import SessionRuntime
+from coding_agent_telegram.session_runtime import SessionRuntime, _detect_reply_options
def _runtime() -> SessionRuntime:
@@ -10,6 +10,7 @@ def _runtime() -> SessionRuntime:
bot_id="bot-a",
git=None,
run_with_typing=None,
+ register_reply_options=None,
)
@@ -113,3 +114,90 @@ def test_merge_snapshot_diffs_handles_empty_inputs():
runtime = _runtime()
merged = runtime._merge_snapshot_diffs([], {})
assert merged == []
+
+
+# ---------------------------------------------------------------------------
+# _detect_reply_options
+# ---------------------------------------------------------------------------
+
+
+def test_detect_reply_options_finds_numbered_choices_with_question_cue():
+ text = (
+ "I found two ways to fix this. Which approach would you like me to take?\n"
+ "1. Patch the validator directly\n"
+ "2. Rewrite the parser"
+ )
+ assert _detect_reply_options(text) == ("Patch the validator directly", "Rewrite the parser")
+
+
+def test_detect_reply_options_ignores_plain_numbered_list_without_question_cue():
+ text = "Here is what I changed:\n1. Updated the validator\n2. Added a regression test"
+ assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_question_without_option_list():
+ text = "Should I proceed with these changes? Let me know and I'll continue."
+ assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_single_option_line():
+ text = "Which approach would you like me to take?\n1. Patch the validator directly"
+ assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_caps_at_max_options():
+ lines = [f"{i}. Option {i}" for i in range(1, 10)]
+ text = "Which one do you want?\n" + "\n".join(lines)
+ options = _detect_reply_options(text)
+ assert len(options) == 6
+ assert options[0] == "Option 1"
+
+
+def test_detect_reply_options_returns_empty_for_blank_text():
+ assert _detect_reply_options(" ") == ()
+
+
+def test_detect_reply_options_ignores_multiple_independent_questions():
+ text = (
+ "I have a couple of questions before proceeding:\n"
+ "1. Should I use approach A or B for the caching layer?\n"
+ "2. Do you want unit tests included in this PR?"
+ )
+ assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_multiple_questions_wrapped_in_markdown():
+ """Claude routinely bolds numbered questions, which puts the question mark inside
+ the emphasis markers. Matching on a bare trailing "?" missed exactly the formatting
+ the provider uses most."""
+ text = (
+ "I need a couple of decisions before I proceed - should I go ahead?\n"
+ "1. **Use Redis or in-memory for the cache?**\n"
+ "2. **Include unit tests in this PR?**"
+ )
+ assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_ignores_multiple_questions_in_italics_or_code():
+ text = (
+ "Which do you want me to settle first?\n"
+ "1. *Should the cache be write-through?*\n"
+ "2. `Do we keep the legacy endpoint?`"
+ )
+ assert _detect_reply_options(text) == ()
+
+
+def test_detect_reply_options_keeps_menu_with_a_single_trailing_escape_question():
+ """One question mark is an "or something else?" escape hatch on a real menu, not a
+ second independent question, so the menu must survive."""
+ text = (
+ "Which approach do you want?\n"
+ "1. Refactor the module first\n"
+ "2. Patch it in place\n"
+ "3. Something else?"
+ )
+ assert _detect_reply_options(text) == (
+ "Refactor the module first",
+ "Patch it in place",
+ "Something else?",
+ )
diff --git a/tests/test_session_store.py b/tests/test_session_store.py
index 9f26d9b..f969a20 100644
--- a/tests/test_session_store.py
+++ b/tests/test_session_store.py
@@ -28,6 +28,60 @@ def test_create_and_switch_session(tmp_path: Path):
assert chat["current_branch"] == "feature-1"
+def test_create_session_defaults_to_no_model_override(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+
+ sessions = store.list_sessions("bot-a", 123)
+ assert sessions["sess_1"]["model"] == ""
+
+
+def test_create_session_can_carry_over_a_model(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus")
+
+ sessions = store.list_sessions("bot-a", 123)
+ assert sessions["sess_1"]["model"] == "opus"
+
+
+def test_set_session_model_updates_existing_session(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude")
+
+ assert store.set_session_model("bot-a", 123, "sess_1", "opus") is True
+
+ sessions = store.list_sessions("bot-a", 123)
+ assert sessions["sess_1"]["model"] == "opus"
+
+
+def test_set_session_model_clears_override_with_empty_string(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "claude", model="opus")
+
+ assert store.set_session_model("bot-a", 123, "sess_1", "") is True
+
+ sessions = store.list_sessions("bot-a", 123)
+ assert sessions["sess_1"]["model"] == ""
+
+
+def test_set_session_model_returns_false_for_unknown_session(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+
+ assert store.set_session_model("bot-a", 123, "does-not-exist", "opus") is False
+
+
def test_set_current_provider_persists_in_chat_state(tmp_path: Path):
state = tmp_path / "state.json"
backup = tmp_path / "state.json.bak"
@@ -39,6 +93,55 @@ def test_set_current_provider_persists_in_chat_state(tmp_path: Path):
assert chat["current_provider"] == "copilot"
+def test_empty_provider_normalizes_to_codex_when_creating_session(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+
+ store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "")
+
+ chat = store.get_chat_state("bot-a", 123)
+ assert chat["current_provider"] == "codex"
+ assert chat["sessions"]["sess_1"]["provider"] == "codex"
+
+
+def test_empty_current_provider_normalizes_to_codex(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ store = SessionStore(state, backup)
+
+ store.set_current_provider("bot-a", 123, "")
+
+ assert store.get_chat_state("bot-a", 123)["current_provider"] == "codex"
+
+
+def test_switch_session_normalizes_empty_legacy_provider_to_codex(tmp_path: Path):
+ state = tmp_path / "state.json"
+ backup = tmp_path / "state.json.bak"
+ state.write_text(
+ json.dumps(
+ {
+ "chats": {
+ "bot-a:123": {
+ "sessions": {
+ "sess_legacy": {
+ "name": "legacy",
+ "project_folder": "backend",
+ "provider": "",
+ }
+ }
+ }
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ store = SessionStore(state, backup)
+
+ assert store.switch_session("bot-a", 123, "sess_legacy")
+ assert store.get_chat_state("bot-a", 123)["current_provider"] == "codex"
+
+
def test_set_pending_action_persists_and_clears(tmp_path: Path):
state = tmp_path / "state.json"
backup = tmp_path / "state.json.bak"
diff --git a/tests/test_speech_to_text.py b/tests/test_speech_to_text.py
index 8490146..2ecb645 100644
--- a/tests/test_speech_to_text.py
+++ b/tests/test_speech_to_text.py
@@ -23,6 +23,9 @@ def _cfg(tmp_path: Path, *, model: str = "base", timeout: int = 120) -> AppConfi
codex_model="",
copilot_model="",
claude_model="",
+ codex_model_choices=("gpt-5.4",),
+ copilot_model_choices=("gpt-5.4", "claude-sonnet-4.6"),
+ claude_model_choices=("sonnet", "opus", "haiku"),
copilot_autopilot=True,
copilot_no_ask_user=True,
copilot_allow_all=True,
@@ -47,6 +50,10 @@ def _cfg(tmp_path: Path, *, model: str = "base", timeout: int = 120) -> AppConfi
default_agent_provider="codex",
agent_hard_timeout_seconds=0,
app_internal_root=tmp_path / ".coding-agent-telegram",
+ long_gap_warning_enabled=False,
+ claude_long_gap_seconds=3600,
+ codex_long_gap_seconds=600,
+ copilot_long_gap_seconds=600,
locale="en",
)
diff --git a/tests/test_telegram_sender.py b/tests/test_telegram_sender.py
index fd768bf..6a2f908 100644
--- a/tests/test_telegram_sender.py
+++ b/tests/test_telegram_sender.py
@@ -36,7 +36,7 @@ def test_send_html_text_falls_back_to_plain_text_on_parse_error():
calls = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
calls.append((chat_id, text, parse_mode))
if len(calls) == 1:
raise BadRequest("Can't parse entities: can't find end tag corresponding to start tag \"code\"")
@@ -54,7 +54,7 @@ def test_send_text_chunks_long_messages():
calls = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
calls.append((chat_id, text, parse_mode))
update = SimpleNamespace(effective_chat=SimpleNamespace(id=123))
@@ -70,7 +70,7 @@ def test_send_html_text_chunks_long_messages_as_plain_text():
calls = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
calls.append((chat_id, text, parse_mode))
update = SimpleNamespace(effective_chat=SimpleNamespace(id=123))
@@ -86,7 +86,7 @@ def test_send_code_block_chunks_long_code_blocks():
calls = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
calls.append((chat_id, text, parse_mode))
update = SimpleNamespace(effective_chat=SimpleNamespace(id=123))
@@ -108,7 +108,7 @@ def test_send_text_does_nothing_when_effective_chat_is_none():
called = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
called.append(text)
update = SimpleNamespace(effective_chat=None)
@@ -122,7 +122,7 @@ def test_send_html_text_does_nothing_when_effective_chat_is_none():
called = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
called.append(text)
update = SimpleNamespace(effective_chat=None)
@@ -136,7 +136,7 @@ def test_send_code_block_does_nothing_when_effective_chat_is_none():
called = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
called.append(text)
update = SimpleNamespace(effective_chat=None)
@@ -156,7 +156,7 @@ def test_send_text_uses_default_length_when_no_bot_data():
calls = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
calls.append(text)
update = SimpleNamespace(effective_chat=SimpleNamespace(id=1))
@@ -230,7 +230,7 @@ def test_send_markdown_text_sends_message():
calls = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
calls.append((chat_id, text, parse_mode))
from telegram.constants import ParseMode
@@ -270,7 +270,7 @@ def test_send_html_text_reraises_non_parse_bad_request():
from telegram.error import BadRequest
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
raise BadRequest("Message is too long")
update = SimpleNamespace(effective_chat=SimpleNamespace(id=1))
@@ -387,7 +387,7 @@ def test_send_code_block_without_language():
calls = []
class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None):
+ async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None):
calls.append(text)
update = SimpleNamespace(effective_chat=SimpleNamespace(id=7))
diff --git a/tests/test_usage_status.py b/tests/test_usage_status.py
new file mode 100644
index 0000000..e1695b7
--- /dev/null
+++ b/tests/test_usage_status.py
@@ -0,0 +1,338 @@
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+
+import pytest
+
+from coding_agent_telegram.usage_status import (
+ CLAUDE_WINDOW_EXPIRED_NOTE,
+ CLAUDE_WINDOW_NEVER_OBSERVED_NOTE,
+ ProviderUsage,
+ configure_persistence,
+ fetch_copilot_usage,
+ get_claude_usage,
+ observe_claude_rate_limit_event,
+ parse_claude_rate_limit_event,
+ parse_codex_rate_limits_result,
+)
+
+
+@pytest.fixture(autouse=True)
+def _reset_claude_rate_limit_cache(monkeypatch):
+ """The passive cache and its optional persistence backing are process-wide
+ module state; isolate each test from whatever an earlier test left behind."""
+ monkeypatch.setattr("coding_agent_telegram.usage_status._claude_rate_limit_cache", None)
+ monkeypatch.setattr("coding_agent_telegram.usage_status._claude_rate_limit_path", None)
+ yield
+
+
+def test_parse_claude_rate_limit_event_extracts_both_windows():
+ events = [
+ {"type": "system", "subtype": "init"},
+ {
+ "type": "rate_limit_event",
+ "rate_limit_info": {
+ "unifiedWindows": {
+ "five_hour": {"utilization": 0.53, "resetsAt": 1788990600},
+ "seven_day": {"utilization": 0.05, "resetsAt": 1789570800},
+ }
+ },
+ },
+ ]
+
+ usage = parse_claude_rate_limit_event(events)
+
+ assert usage is not None
+ assert usage.provider == "claude"
+ assert usage.available is True
+ assert usage.five_hour.used_percent == 53.0
+ assert usage.five_hour.resets_at == 1788990600
+ assert usage.weekly.used_percent == 5.0
+ assert usage.weekly.resets_at == 1789570800
+
+
+def test_parse_claude_rate_limit_event_uses_last_not_first_when_multiple_present():
+ """A single -p invocation can make more than one real API turn internally
+ (e.g. a tool-use loop), each capable of emitting its own rate_limit_event
+ as utilization climbs -- only the last one reflects the run's ending usage."""
+ events = [
+ {
+ "type": "rate_limit_event",
+ "rate_limit_info": {
+ "unifiedWindows": {
+ "five_hour": {"utilization": 0.10, "resetsAt": 1},
+ "seven_day": {"utilization": 0.01, "resetsAt": 2},
+ }
+ },
+ },
+ {"type": "assistant", "message": {}},
+ {
+ "type": "rate_limit_event",
+ "rate_limit_info": {
+ "unifiedWindows": {
+ "five_hour": {"utilization": 0.15, "resetsAt": 1},
+ "seven_day": {"utilization": 0.02, "resetsAt": 2},
+ }
+ },
+ },
+ ]
+
+ usage = parse_claude_rate_limit_event(events)
+
+ assert usage.five_hour.used_percent == 15.0
+ assert usage.weekly.used_percent == 2.0
+
+
+def test_parse_claude_rate_limit_event_missing_returns_none():
+ events = [{"type": "system", "subtype": "init"}, {"type": "result", "result": "hi"}]
+
+ assert parse_claude_rate_limit_event(events) is None
+
+
+def test_parse_codex_rate_limits_result_extracts_primary_and_secondary():
+ result = {
+ "rateLimits": {
+ "primary": {"usedPercent": 0, "windowDurationMins": 300, "resetsAt": 1789005642},
+ "secondary": {"usedPercent": 23, "windowDurationMins": 10080, "resetsAt": 1789446557},
+ "planType": "plus",
+ }
+ }
+
+ usage = parse_codex_rate_limits_result(result)
+
+ assert usage.provider == "codex"
+ assert usage.available is True
+ assert usage.five_hour.used_percent == 0.0
+ assert usage.five_hour.resets_at == 1789005642
+ assert usage.weekly.used_percent == 23.0
+ assert usage.plan == "plus"
+
+
+def test_parse_codex_rate_limits_result_handles_missing_windows():
+ usage = parse_codex_rate_limits_result({"rateLimits": {}})
+
+ assert usage.available is True
+ assert usage.five_hour is None
+ assert usage.weekly is None
+ assert usage.plan is None
+
+
+def test_fetch_copilot_usage_always_unavailable():
+ usage = fetch_copilot_usage()
+
+ assert isinstance(usage, ProviderUsage)
+ assert usage.provider == "copilot"
+ assert usage.available is False
+ assert usage.error
+
+
+def _rate_limit_events(*, five_hour_resets_at: float, weekly_resets_at: float) -> list:
+ return [
+ {
+ "type": "rate_limit_event",
+ "rate_limit_info": {
+ "unifiedWindows": {
+ "five_hour": {"utilization": 0.4, "resetsAt": int(five_hour_resets_at)},
+ "seven_day": {"utilization": 0.1, "resetsAt": int(weekly_resets_at)},
+ }
+ },
+ }
+ ]
+
+
+def test_get_claude_usage_reports_na_for_both_windows_when_never_observed():
+ usage = get_claude_usage()
+
+ assert usage.provider == "claude"
+ assert usage.available is True
+ assert usage.five_hour is None
+ assert usage.five_hour_note == CLAUDE_WINDOW_NEVER_OBSERVED_NOTE
+ assert usage.weekly is None
+ assert usage.weekly_note == CLAUDE_WINDOW_NEVER_OBSERVED_NOTE
+ assert usage.observed_at is None
+
+
+def test_get_claude_usage_serves_both_windows_from_cache_when_fresh():
+ now = time.time()
+ observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now + 86400))
+
+ usage = get_claude_usage()
+
+ assert usage.five_hour.used_percent == 40.0
+ assert usage.five_hour_note is None
+ assert usage.weekly.used_percent == 10.0
+ assert usage.weekly_note is None
+ assert usage.observed_at is not None
+
+
+def test_get_claude_usage_reports_na_for_five_hour_window_that_has_reset_but_keeps_fresh_weekly():
+ now = time.time()
+ # five_hour already rolled past its reset; weekly is still within its window.
+ observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now - 10, weekly_resets_at=now + 86400))
+
+ usage = get_claude_usage()
+
+ assert usage.five_hour is None
+ assert usage.five_hour_note == CLAUDE_WINDOW_EXPIRED_NOTE
+ # The still-fresh weekly window is not thrown away just because its sibling expired.
+ assert usage.weekly.used_percent == 10.0
+ assert usage.weekly_note is None
+ # A snapshot still exists (just partially expired), so "last observed" stays meaningful.
+ assert usage.observed_at is not None
+
+
+def test_get_claude_usage_reports_na_for_weekly_window_that_has_reset_but_keeps_fresh_five_hour():
+ now = time.time()
+ observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now - 10))
+
+ usage = get_claude_usage()
+
+ assert usage.five_hour.used_percent == 40.0
+ assert usage.five_hour_note is None
+ assert usage.weekly is None
+ assert usage.weekly_note == CLAUDE_WINDOW_EXPIRED_NOTE
+
+
+def test_resolve_window_treats_missing_resets_at_as_expired_past_max_age():
+ """A window with no resets_at (a malformed/partial rate_limit_event) must
+ still eventually expire -- otherwise a bad value could get stuck reading
+ as "fresh" forever, which matters more now that it survives a restart."""
+ from coding_agent_telegram.usage_status import _MAX_SNAPSHOT_AGE_SECONDS, RateWindow, _resolve_window
+
+ now = time.time()
+ window = RateWindow(used_percent=50.0, resets_at=None)
+
+ resolved, note = _resolve_window(window, now, now - _MAX_SNAPSHOT_AGE_SECONDS - 1)
+
+ assert resolved is None
+ assert note == CLAUDE_WINDOW_EXPIRED_NOTE
+
+
+def test_resolve_window_keeps_missing_resets_at_fresh_within_max_age():
+ from coding_agent_telegram.usage_status import RateWindow, _resolve_window
+
+ now = time.time()
+ window = RateWindow(used_percent=50.0, resets_at=None)
+
+ resolved, note = _resolve_window(window, now, now - 60)
+
+ assert resolved == window
+ assert note is None
+
+
+def test_get_claude_usage_expires_window_with_missing_resets_at_after_max_age(monkeypatch):
+ """Integration-level check: a stale, resets_at-less window doesn't get
+ reported as live, while its sibling (with its own valid resets_at) is
+ unaffected."""
+ from coding_agent_telegram import usage_status as usage_status_module
+ from coding_agent_telegram.usage_status import _MAX_SNAPSHOT_AGE_SECONDS, RateWindow, _ClaudeRateLimitSnapshot
+
+ now = time.time()
+ stale_snapshot = _ClaudeRateLimitSnapshot(
+ five_hour=RateWindow(used_percent=50.0, resets_at=None),
+ weekly=RateWindow(used_percent=20.0, resets_at=now + 86400),
+ observed_at=now - _MAX_SNAPSHOT_AGE_SECONDS - 1,
+ )
+ monkeypatch.setattr(usage_status_module, "_claude_rate_limit_cache", stale_snapshot)
+
+ usage = get_claude_usage()
+
+ assert usage.five_hour is None
+ assert usage.five_hour_note == CLAUDE_WINDOW_EXPIRED_NOTE
+ assert usage.weekly.used_percent == 20.0
+ assert usage.weekly_note is None
+
+
+def test_get_claude_usage_never_makes_a_subprocess_call(monkeypatch):
+ """There is no live-probe fallback anymore -- get_claude_usage must be a
+ pure, free cache read regardless of cache state."""
+
+ def fail_if_called(*args, **kwargs):
+ raise AssertionError("get_claude_usage must not spawn a subprocess")
+
+ monkeypatch.setattr("coding_agent_telegram.usage_status.subprocess.run", fail_if_called)
+ monkeypatch.setattr("coding_agent_telegram.usage_status.subprocess.Popen", fail_if_called)
+
+ # No cache at all.
+ assert get_claude_usage().five_hour is None
+
+ # Cache present but expired.
+ now = time.time()
+ observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now - 10, weekly_resets_at=now - 10))
+ usage = get_claude_usage()
+ assert usage.five_hour is None
+ assert usage.weekly is None
+
+
+def _rate_limit_path(tmp_path: Path) -> Path:
+ return tmp_path / "claude_rate_limit.json"
+
+
+def test_observed_rate_limit_survives_a_restart_via_persistence(tmp_path: Path):
+ """Reproduces the bug report: a real Claude turn observes usage, the bot
+ process restarts (wiping the in-memory-only cache), and /status should
+ still show the last-observed window instead of "no data yet"."""
+ path = _rate_limit_path(tmp_path)
+ configure_persistence(path)
+ now = time.time()
+ observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now + 86400))
+
+ # Simulate a process restart: fresh cache, re-wired the same way cli.py
+ # does, pointed at the same file.
+ from coding_agent_telegram import usage_status as usage_status_module
+
+ usage_status_module._claude_rate_limit_cache = None
+ configure_persistence(path)
+
+ usage = get_claude_usage()
+ assert usage.five_hour.used_percent == 40.0
+ assert usage.five_hour_note is None
+ assert usage.weekly.used_percent == 10.0
+ assert usage.observed_at is not None
+
+
+def test_configure_persistence_with_no_prior_snapshot_leaves_cache_empty(tmp_path: Path):
+ configure_persistence(_rate_limit_path(tmp_path))
+
+ usage = get_claude_usage()
+ assert usage.five_hour is None
+ assert usage.five_hour_note == CLAUDE_WINDOW_NEVER_OBSERVED_NOTE
+
+
+def test_persisted_rate_limit_snapshot_lives_in_its_own_file(tmp_path: Path):
+ path = _rate_limit_path(tmp_path)
+ configure_persistence(path)
+ now = time.time()
+
+ observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now + 3600, weekly_resets_at=now + 86400))
+
+ assert path.exists()
+ data = json.loads(path.read_text(encoding="utf-8"))
+ assert data["five_hour"]["used_percent"] == 40.0
+ assert data["weekly"]["used_percent"] == 10.0
+ # No sibling state.json/backup churn from this write -- it's a standalone file.
+ assert sorted(p.name for p in tmp_path.iterdir()) == [
+ "claude_rate_limit.json",
+ "claude_rate_limit.json.lock",
+ ]
+
+
+def test_restart_after_window_rolled_over_reports_expired_not_stale_data(tmp_path: Path):
+ """A snapshot persisted just before its reset time should be reported as
+ expired after a restart, not served as if it were still live."""
+ path = _rate_limit_path(tmp_path)
+ configure_persistence(path)
+ now = time.time()
+ observe_claude_rate_limit_event(_rate_limit_events(five_hour_resets_at=now - 1, weekly_resets_at=now + 86400))
+
+ from coding_agent_telegram import usage_status as usage_status_module
+
+ usage_status_module._claude_rate_limit_cache = None
+ configure_persistence(path)
+
+ usage = get_claude_usage()
+ assert usage.five_hour is None
+ assert usage.five_hour_note == CLAUDE_WINDOW_EXPIRED_NOTE
+ assert usage.weekly.used_percent == 10.0