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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/coding_agent_telegram/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ async def log_incoming_private_message(update, _context) -> None:
)
app.add_handler(CallbackQueryHandler(router.handle_queue_batch_callback, pattern=r"^queuebatch:(group|single|cancel)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_queue_continue_callback, pattern=r"^queuecontinue:(yes|no)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_agent_reply_option_callback, pattern=r"^agentopt:[0-9a-f]{12}:[0-9]$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:[0-9a-f]{12}$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_branch_discrepancy_callback, pattern=r"^branchdiscrepancy:(stored|current)$", block=False))
app.add_handler(CallbackQueryHandler(router.handle_commit_generate_callback, pattern=r"^commitgen:(confirm|cancel)$"))
Expand Down
3 changes: 3 additions & 0 deletions src/coding_agent_telegram/resources/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@
"runtime.provider_output_index": "{provider} output {index}/{total}",
"runtime.provider_output_single": "{provider} output",
"runtime.replacement_session_stall": "Replacement session creation appears stuck.\nThe local agent process is still running but has not produced output.\nOn macOS, this might be because a hidden permission dialog is waiting for confirmation on the machine running the bot.",
"runtime.reply_option_select_button": "✅ Select this option",
"runtime.reply_option_selected": "▶️ Continuing with: {choice}",
"runtime.reply_options_prompt": "⚡ Action needed — choose one to continue:",
"runtime.resume_created_new": "Resume failed, so a new session was created.\nNew session ID: {session_id}\nNew session name: {session_name}",
"runtime.resume_id_changed": "Resume succeeded, but the session ID changed.\nNew session ID: {session_id}\nNew session name: {session_name}",
"runtime.sensitive_diff_omitted": "{path}\nThis file contains sensitive content and was omitted.",
Expand Down
18 changes: 15 additions & 3 deletions src/coding_agent_telegram/router/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import os
import re
import secrets
import shlex
from collections import deque
from concurrent.futures import CancelledError, Future
Expand All @@ -27,7 +28,11 @@
from coding_agent_telegram.session_runtime import PhotoAttachmentStore, SessionRuntime
from coding_agent_telegram.session_store import SessionStore
from coding_agent_telegram.speech_to_text import WhisperSpeechToText
from coding_agent_telegram.telegram_sender import send_text
from coding_agent_telegram.telegram_sender import (
affirmative_inline_button_kwargs,
negative_inline_button_kwargs,
send_text,
)


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -118,6 +123,7 @@ def __init__(self, deps: RouterDeps) -> None:
bot_id=deps.bot_id,
git=self.git,
run_with_typing=self._run_with_typing,
register_reply_options=self._register_agent_reply_options,
)
# Per-workspace asyncio locks keyed by project_folder name.
# Prevents concurrent agent runs on the same workspace regardless of
Expand All @@ -132,6 +138,7 @@ def __init__(self, deps: RouterDeps) -> None:
self._chat_message_queue_draining: set[int] = set()
self._last_run_results: dict[int, object] = {}
self._branch_source_tokens: dict[str, tuple[str, str, str]] = {}
self._agent_reply_option_tokens: dict[str, tuple[int, tuple[str, ...]]] = {}

def _register_branch_source_token(self, source_kind: str, source_branch: str, new_branch: str) -> str:
key = f"{source_kind}:{source_branch}:{new_branch}"
Expand All @@ -142,6 +149,11 @@ def _register_branch_source_token(self, source_kind: str, source_branch: str, ne
def _lookup_branch_source_token(self, token: str) -> tuple[str, str, str] | None:
return self._branch_source_tokens.get(token)

def _register_agent_reply_options(self, chat_id: int, options: tuple[str, ...]) -> str:
token = secrets.token_hex(6)
self._agent_reply_option_tokens[token] = (chat_id, options)
return token

def _sorted_sessions(self, sessions: dict[str, dict[str, str]]) -> list[tuple[str, dict[str, str]]]:
indexed_sessions = list(enumerate(sessions.items()))
sorted_indexed_sessions = sorted(
Expand Down Expand Up @@ -227,10 +239,10 @@ def _t(self, update: Update | None, key: str, **kwargs) -> str:
return translate(self._locale(update), key, **kwargs)

def _affirmative_inline_button_kwargs(self) -> dict[str, dict[str, str]]:
return {"api_kwargs": {"style": "primary"}}
return affirmative_inline_button_kwargs()

def _negative_inline_button_kwargs(self) -> dict[str, dict[str, str]]:
return {"api_kwargs": {"style": "danger"}}
return negative_inline_button_kwargs()

async def _notify_if_current_project_busy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
chat = update.effective_chat
Expand Down
31 changes: 31 additions & 0 deletions src/coding_agent_telegram/router/message_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,37 @@ async def _process_user_message(
finally:
await self._drain_chat_message_queue(chat_id, context)

@require_allowed_chat(answer_callback=True)
async def handle_agent_reply_option_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if query is None or not query.data:
return

await query.answer()
parts = query.data.split(":", 2)
if len(parts) != 3:
return
_, token, index_text = parts
entry = self._agent_reply_option_tokens.pop(token, None)
if entry is None:
if hasattr(query, "edit_message_reply_markup"):
await query.edit_message_reply_markup(reply_markup=None)
return

chat_id, options = entry
if update.effective_chat is None or update.effective_chat.id != chat_id:
return
try:
option_text = options[int(index_text)]
except (ValueError, IndexError):
return

if hasattr(query, "edit_message_reply_markup"):
await query.edit_message_reply_markup(reply_markup=None)

await send_text(update, context, self._t(update, "runtime.reply_option_selected", choice=option_text))
await self._process_user_message(update, context, option_text)

@require_allowed_chat()
async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message is None or not update.message.text:
Expand Down
84 changes: 80 additions & 4 deletions src/coding_agent_telegram/session_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
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
Expand All @@ -32,6 +32,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,
Expand All @@ -58,6 +59,42 @@
# 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 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


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] = []
for line in tail_lines:
match = _OPTION_LINE_RE.match(line)
if match:
options.append(match.group(1).strip())

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)
Expand Down Expand Up @@ -163,6 +200,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:
Expand All @@ -175,13 +213,15 @@ def __init__(
bot_id: str,
git: GitWorkspaceManager,
run_with_typing: RunWithTyping,
register_reply_options: RegisterReplyOptions,
) -> None:
self.cfg = cfg
self.store = store
self.agent_runner = agent_runner
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
Expand Down Expand Up @@ -253,7 +293,7 @@ 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", "")
logger.info(
"Running message for chat %s on session '%s' (%s) in project '%s' with provider '%s'. "
Expand Down Expand Up @@ -400,7 +440,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(
Expand Down Expand Up @@ -688,6 +728,16 @@ async def _send_assistant_chunks(
return

total = len(segments)

# If the 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.
# Tapping one just sends the option text back as the next chat message — same
# as if the user had typed it — so this never needs to interrupt or hold open
# the CLI process.
reply_options: tuple[str, ...] = ()
if provider == "claude" and 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(
Expand All @@ -700,7 +750,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
Expand All @@ -720,6 +770,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"<b>{html.escape(self._t(update, 'runtime.reply_options_prompt'))}</b>",
)
# 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:
Expand Down
13 changes: 9 additions & 4 deletions src/coding_agent_telegram/session_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -149,10 +153,11 @@ def _write_session_record(
initialized_from: 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,
"branch_name": branch_name or "",
"origin": origin,
"origin_label": origin_label or ("Bot managed session" if origin == "bot" else origin),
Expand Down Expand Up @@ -193,7 +198,7 @@ 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)

Expand Down Expand Up @@ -257,7 +262,7 @@ def mutate(chat_data: dict[str, Any]) -> None:
)
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

Expand Down Expand Up @@ -376,7 +381,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:
Expand Down
Loading
Loading