free-claude-code/messaging/commands.py
Ali Khokhar 85b601884d
Some checks are pending
CI / Ban suppressions and legacy annotations (push) Waiting to run
CI / pytest (push) Waiting to run
CI / ruff-check (push) Waiting to run
CI / ruff-format (push) Waiting to run
CI / ty (push) Waiting to run
Remove legacy future annotation imports (#982)
## Problem

Python 3.14 provides native lazy annotations, but the codebase still
relied on legacy future annotation imports. Those imports also made
type-only import cycles easier to hide instead of fixing ownership
boundaries.

## Changes

| Before | After |
| --- | --- |
| Python files used `from __future__ import annotations`. | Python files
rely on Python 3.14 native lazy annotations. |
| Some runtime modules used `TYPE_CHECKING` or local imports for
required dependencies. | Runtime modules use top-level owner-module
imports with explicit boundaries. |
| Local and GitHub guardrails only rejected type ignore suppressions. |
Local and GitHub guardrails reject type ignore suppressions and legacy
future annotation imports. |
| Agent docs only documented the no-type-ignore rule. | Agent docs
document the Python 3.14 annotation and import-boundary rules. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR moves the codebase to Python 3.14 native lazy annotations. The
main changes are:

- Removed legacy `from __future__ import annotations` imports across
Python modules.
- Promoted selected runtime dependencies from `TYPE_CHECKING` or local
imports to explicit owner-module imports.
- Added local, GitHub, and contract-test guardrails to reject legacy
future annotation imports.
- Updated agent docs with the annotation and import-boundary rules.
- Bumped the package patch version for production-file changes.
</details>

<h3>Confidence Score: 5/5</h3>

Safe to merge with low risk.

The changes are mostly mechanical annotation cleanup with matching CI
and contract-test guardrails. Reviewed import-boundary updates did not
show a confirmed runtime cycle or dependency break.

No files require special attention.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Performed an end-to-end validation of the guardrail contract suite: an
environment check confirmed uv availability, a guardrail pytest run used
CPython 3.14.0 with 5 passing contract tests, 3 focused CI-script tests
passed, and the direct CI suppressions guardrail command (including the
legacy future-annotations grep) also passed.

<a
href="https://app.greptile.com/trex/runs/13303335/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| api/runtime.py | Moves messaging, CLI manager, session, limiter, and
tree dependencies from local/type-checking imports to explicit top-level
owner-module imports. |
| messaging/platforms/telegram.py | Removes future annotations and
promotes Telegram SDK type imports into the existing availability guard.
|
| messaging/platforms/telegram_inbound.py | Removes future annotations
and imports Telegram SDK types at module scope for inbound
normalization. |
| tests/contracts/test_import_boundaries.py | Adds an AST contract that
rejects legacy future annotation imports across Python files. |
| scripts/ci.sh | Extends the local suppression check to reject legacy
future annotation imports alongside type-ignore suppressions. |
| scripts/ci.ps1 | Mirrors the local PowerShell CI suppression check for
legacy future annotations. |
| .github/workflows/tests.yml | Renames and broadens the GitHub
guardrail job to reject both type suppressions and legacy future
annotations. |
| pyproject.toml | Bumps the patch version for production-file changes.
|

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Dev as Developer/CI
participant Guard as Suppression guard
participant AST as Import-boundary contract test
participant Py as Python modules

Dev->>Guard: Run local/GitHub suppression check
Guard->>Py: "Scan *.py for type ignores and future annotations"
Guard-->>Dev: Fail if legacy annotation import remains
Dev->>AST: Run pytest contract tests
AST->>Py: Parse imports with ast
AST-->>Dev: Assert no future annotations/import-boundary violations
Py-->>Dev: Use Python 3.14 native lazy annotations
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Dev as Developer/CI
participant Guard as Suppression guard
participant AST as Import-boundary contract test
participant Py as Python modules

Dev->>Guard: Run local/GitHub suppression check
Guard->>Py: "Scan *.py for type ignores and future annotations"
Guard-->>Dev: Fail if legacy annotation import remains
Dev->>AST: Run pytest contract tests
AST->>Py: Parse imports with ast
AST-->>Dev: Assert no future annotations/import-boundary violations
Py-->>Dev: Use Python 3.14 native lazy annotations
```

</a>
</details>

<sub>Reviews (2): Last reviewed commit: ["Remove legacy future
annotations
import"](6e6cda69da)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=41875785)</sub>

<!-- /greptile_comment -->
2026-07-04 21:41:51 -07:00

269 lines
9.4 KiB
Python

"""Command handlers for messaging platform commands (/stop, /stats, /clear).
Commands depend on MessagingCommandContext instead of the concrete workflow.
"""
from loguru import logger
from .command_context import MessagingCommandContext
from .models import IncomingMessage
async def handle_stop_command(
handler: MessagingCommandContext, incoming: IncomingMessage
) -> None:
"""Handle /stop command from messaging platform."""
# Reply-scoped stop: reply "/stop" to stop only that task.
if incoming.is_reply() and incoming.reply_to_message_id:
reply_id = incoming.reply_to_message_id
tree = handler.tree_queue.get_tree_for_node(reply_id)
node_id = handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
if not node_id:
msg_id = await handler.outbound.queue_send_message(
incoming.chat_id,
handler.format_status(
"", "Stopped.", "Nothing to stop for that message."
),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
count = await handler.stop_task(node_id)
noun = "request" if count == 1 else "requests"
msg_id = await handler.outbound.queue_send_message(
incoming.chat_id,
handler.format_status("", "Stopped.", f"Cancelled {count} {noun}."),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
# Global stop: legacy behavior (stop everything)
count = await handler.stop_all_tasks()
msg_id = await handler.outbound.queue_send_message(
incoming.chat_id,
handler.format_status(
"", "Stopped.", f"Cancelled {count} pending or active requests."
),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
async def handle_stats_command(
handler: MessagingCommandContext, incoming: IncomingMessage
) -> None:
"""Handle /stats command."""
stats = handler.cli_manager.get_stats()
tree_count = handler.tree_queue.get_tree_count()
ctx = handler.get_render_ctx()
msg_id = await handler.outbound.queue_send_message(
incoming.chat_id,
"📊 "
+ ctx.bold("Stats")
+ "\n"
+ ctx.escape_text(f"• Active CLI: {stats['active_sessions']}")
+ "\n"
+ ctx.escape_text(f"• Message Trees: {tree_count}"),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
async def _delete_message_ids(
handler: MessagingCommandContext, chat_id: str, msg_ids: set[str]
) -> None:
"""Best-effort delete messages by ID. Sorts numeric IDs descending."""
if not msg_ids:
return
def _as_int(s: str) -> int | None:
try:
return int(str(s))
except Exception:
return None
numeric: list[tuple[int, str]] = []
non_numeric: list[str] = []
for mid in msg_ids:
n = _as_int(mid)
if n is None:
non_numeric.append(mid)
else:
numeric.append((n, mid))
numeric.sort(reverse=True)
ordered = [mid for _, mid in numeric] + non_numeric
try:
CHUNK = 100
for i in range(0, len(ordered), CHUNK):
chunk = ordered[i : i + CHUNK]
await handler.outbound.queue_delete_messages(
chat_id, chunk, fire_and_forget=False
)
except Exception as e:
logger.debug(f"Batch delete failed: {type(e).__name__}: {e}")
async def _handle_clear_branch(
handler: MessagingCommandContext,
incoming: IncomingMessage,
branch_root_id: str,
) -> None:
"""
Clear a branch (replied-to node + all descendants).
Order: cancel tasks, delete messages, remove branch, update session store.
"""
tree = handler.tree_queue.get_tree_for_node(branch_root_id)
if not tree:
return
# 1) Cancel branch tasks (no stop_all)
cancelled = await handler.tree_queue.cancel_branch(branch_root_id)
handler.update_cancelled_nodes_ui(cancelled)
# 2) Collect message IDs from branch nodes only
msg_ids: set[str] = set()
branch_ids = tree.get_descendants(branch_root_id)
for nid in branch_ids:
node = tree.get_node(nid)
if node:
if node.incoming.message_id:
msg_ids.add(str(node.incoming.message_id))
if node.status_message_id:
msg_ids.add(str(node.status_message_id))
if incoming.message_id:
msg_ids.add(str(incoming.message_id))
# 3) Delete messages (best-effort)
await _delete_message_ids(handler, incoming.chat_id, msg_ids)
# 4) Remove branch from tree
_removed, root_id, removed_entire_tree = await handler.tree_queue.remove_branch(
branch_root_id
)
# 5) Update session store
try:
if removed_entire_tree:
handler.session_store.remove_tree_snapshot(root_id)
else:
updated_tree = handler.tree_queue.get_tree(root_id)
if updated_tree:
handler.session_store.save_tree_snapshot(updated_tree.snapshot())
except Exception as e:
logger.warning(f"Failed to update session store after branch clear: {e}")
async def handle_clear_command(
handler: MessagingCommandContext, incoming: IncomingMessage
) -> None:
"""
Handle /clear command.
Reply-scoped: reply to a message to clear that branch (node + descendants).
Standalone: global clear (stop all, delete all chat messages, reset store).
"""
from messaging.trees import TreeQueueManager
if incoming.is_reply() and incoming.reply_to_message_id:
reply_id = incoming.reply_to_message_id
tree = handler.tree_queue.get_tree_for_node(reply_id)
branch_root_id = (
handler.tree_queue.resolve_parent_node_id(reply_id) if tree else None
)
if not branch_root_id:
if handler.voice_cancellation is not None:
cancelled = await handler.voice_cancellation.cancel_pending_voice(
incoming.chat_id, reply_id
)
if cancelled is not None:
voice_msg_id, status_msg_id = cancelled
msg_ids_to_del: set[str] = {voice_msg_id, status_msg_id}
if incoming.message_id is not None:
msg_ids_to_del.add(str(incoming.message_id))
await _delete_message_ids(handler, incoming.chat_id, msg_ids_to_del)
msg_id = await handler.outbound.queue_send_message(
incoming.chat_id,
handler.format_status("🗑", "Cleared.", "Voice note cancelled."),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
msg_id = await handler.outbound.queue_send_message(
incoming.chat_id,
handler.format_status(
"🗑", "Cleared.", "Nothing to clear for that message."
),
fire_and_forget=False,
message_thread_id=incoming.message_thread_id,
)
handler.record_outgoing_message(
incoming.platform, incoming.chat_id, msg_id, "command"
)
return
await _handle_clear_branch(handler, incoming, branch_root_id)
return
# Global clear
# 1) Stop tasks first (ensures no more work is running).
await handler.stop_all_tasks()
# 2) Clear chat: best-effort delete messages we can identify.
msg_ids: set[str] = set()
# Add any recorded message IDs for this chat (commands, command replies, etc).
try:
for mid in handler.session_store.get_message_ids_for_chat(
incoming.platform, incoming.chat_id
):
if mid is not None:
msg_ids.add(str(mid))
except Exception as e:
logger.debug(f"Failed to read message log for /clear: {e}")
try:
msg_ids.update(
handler.tree_queue.get_message_ids_for_chat(
incoming.platform, incoming.chat_id
)
)
except Exception as e:
logger.warning(f"Failed to gather messages for /clear: {e}")
# Also delete the command message itself.
if incoming.message_id is not None:
msg_ids.add(str(incoming.message_id))
await _delete_message_ids(handler, incoming.chat_id, msg_ids)
# 3) Clear persistent state and reset in-memory queue/tree state.
try:
handler.session_store.clear_all()
except Exception as e:
logger.warning(f"Failed to clear session store: {e}")
handler.replace_tree_queue(
TreeQueueManager(
queue_update_callback=handler.update_queue_positions,
node_started_callback=handler.mark_node_processing,
)
)