"""Multi-agent bot implementation where each agent has its own Matrix user account."""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from functools import cached_property
from typing import TYPE_CHECKING, Any, Literal

import nio
from tenacity import retry, retry_if_not_exception_type, stop_after_attempt, wait_exponential

from mindroom.matrix import image_handler
from mindroom.matrix.event_info import EventInfo
from mindroom.matrix.identity import (
    MatrixID,
    extract_agent_name,
    is_agent_id,
)
from mindroom.matrix.media import extract_media_caption
from mindroom.matrix.mentions import format_message_with_mentions
from mindroom.matrix.presence import (
    build_agent_status_message,
    is_user_online,
    set_presence_status,
    should_use_streaming,
)
from mindroom.matrix.reply_chain import ReplyChainCaches, derive_conversation_context
from mindroom.matrix.room_cleanup import cleanup_all_orphaned_bots
from mindroom.matrix.rooms import (
    is_dm_room,
    leave_non_dm_rooms,
    resolve_room_aliases,
)
from mindroom.matrix.state import MatrixState
from mindroom.matrix.typing import typing_indicator
from mindroom.matrix.users import (
    AgentMatrixUser,
    create_agent_user,
    login_agent_user,
)
from mindroom.memory import store_conversation_memory
from mindroom.memory.auto_flush import (
    mark_auto_flush_dirty_session,
    reprioritize_auto_flush_sessions,
)
from mindroom.stop import StopManager
from mindroom.streaming import (
    IN_PROGRESS_MARKER,
    ReplacementStreamingResponse,
    StreamingResponse,
    is_in_progress_message,
    send_streaming_response,
)
from mindroom.teams import (
    TeamFormationDecision,
    TeamMode,
    decide_team_formation,
    select_model_for_team,
    team_response,
    team_response_stream,
)
from mindroom.thread_utils import (
    check_agent_mentioned,
    create_session_id,
    get_agents_in_thread,
    get_all_mentioned_agents_in_thread,
    get_configured_agents_for_room,
    has_multiple_non_agent_users_in_thread,
    has_user_responded_after_message,
    should_agent_respond,
)
from mindroom.tool_system.runtime_context import ToolRuntimeContext, tool_runtime_context
from mindroom.tool_system.worker_routing import ToolExecutionIdentity, tool_execution_identity

from . import constants, interactive, voice_handler
from .agents import create_agent, create_session_storage, remove_run_by_event_id
from .ai import ai_response, stream_agent_response
from .attachment_media import resolve_attachment_media
from .attachments import (
    append_attachment_ids_prompt,
    merge_attachment_ids,
    parse_attachment_ids_from_event_source,
    parse_attachment_ids_from_thread_history,
    register_file_or_video_attachment,
    register_image_attachment,
    resolve_thread_attachment_ids,
)
from .authorization import (
    filter_agents_by_sender_permissions,
    get_available_agents_for_sender,
    get_effective_sender_id_for_reply_permissions,
    is_authorized_sender,
    is_sender_allowed_for_agent_reply,
)
from .background_tasks import create_background_task, wait_for_background_tasks
from .commands import config_confirmation
from .commands.handler import CommandEvent, CommandHandlerContext, _generate_welcome_message, handle_command
from .commands.parsing import Command, command_parser
from .constants import (
    ATTACHMENT_IDS_KEY,
    ORIGINAL_SENDER_KEY,
    ROUTER_AGENT_NAME,
    VOICE_RAW_AUDIO_FALLBACK_KEY,
    RuntimePaths,
    resolve_avatar_path,
)
from .knowledge.utils import MultiKnowledgeVectorDb, resolve_agent_knowledge
from .logging_config import emoji, get_logger
from .matrix.avatar import check_and_set_avatar
from .matrix.client import (
    PermanentMatrixStartupError,
    _latest_thread_event_id,
    edit_message,
    fetch_thread_history,
    get_joined_rooms,
    get_latest_thread_event_id_if_needed,
    join_room,
    send_message,
)
from .media_inputs import MediaInputs
from .response_tracker import ResponseTracker
from .routing import suggest_agent_for_message
from .scheduling import (
    cancel_all_running_scheduled_tasks,
    restore_scheduled_tasks,
)

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable
    from pathlib import Path

    import structlog
    from agno.agent import Agent
    from agno.knowledge.knowledge import Knowledge
    from agno.media import Image

    from mindroom.config.main import Config
    from mindroom.orchestrator import MultiAgentOrchestrator
    from mindroom.tool_system.events import ToolTraceEntry

logger = get_logger(__name__)

__all__ = ["AgentBot", "MultiKnowledgeVectorDb"]


# Constants
_SYNC_TIMEOUT_MS = 30000


def _create_task_wrapper(
    callback: Callable[..., Awaitable[None]],
) -> Callable[..., Awaitable[None]]:
    """Create a wrapper that runs the callback as a background task.

    This ensures the sync loop is never blocked by event processing,
    allowing the bot to handle new events (like stop reactions) while
    processing messages.
    """

    async def wrapper(*args: object, **kwargs: object) -> None:
        # Create the task but don't await it - let it run in background
        async def error_handler() -> None:
            try:
                await callback(*args, **kwargs)
            except asyncio.CancelledError:
                # Task was cancelled, this is expected during shutdown
                pass
            except Exception:
                # Log the exception with full traceback
                logger.exception("Error in event callback")

        # Keep a strong reference via background task registry.
        create_background_task(error_handler())

    return wrapper


@dataclass(frozen=True)
class _ResponseAction:
    """Result of the shared team-formation / should-respond decision."""

    kind: Literal["skip", "team", "individual"]
    form_team: TeamFormationDecision | None = None


@dataclass(frozen=True)
class _RouterDispatchResult:
    """Whether router dispatch consumed the event and if display-only echoes count as handled."""

    handled: bool
    mark_visible_echo_responded: bool = False


def _should_skip_mentions(event_source: dict) -> bool:
    """Check if mentions in this message should be ignored for agent responses.

    This is used for messages like scheduling confirmations that contain mentions
    but should not trigger agent responses.

    Args:
        event_source: The Matrix event source dict

    Returns:
        True if mentions should be ignored, False otherwise

    """
    content = event_source.get("content", {})
    return bool(content.get("com.mindroom.skip_mentions", False))


def create_bot_for_entity(
    entity_name: str,
    agent_user: AgentMatrixUser,
    config: Config,
    runtime_paths: RuntimePaths,
    storage_path: Path,
    config_path: Path | None = None,
) -> AgentBot | TeamBot | None:
    """Create appropriate bot instance for an entity (agent, team, or router).

    Args:
        entity_name: Name of the entity to create a bot for
        agent_user: Matrix user for the bot
        config: Configuration object
        runtime_paths: Explicit runtime context for paths, env, and Matrix identity resolution
        storage_path: Path for storing agent data
        config_path: Path to the YAML config file used by config-aware tools

    Returns:
        Bot instance or None if entity not found in config

    """
    enable_streaming = config.defaults.enable_streaming
    if entity_name == ROUTER_AGENT_NAME:
        all_room_aliases = config.get_all_configured_rooms()
        rooms = resolve_room_aliases(list(all_room_aliases), runtime_paths)
        return AgentBot(
            agent_user,
            storage_path,
            config,
            runtime_paths,
            rooms,
            config_path=config_path,
            enable_streaming=enable_streaming,
        )

    if entity_name in config.teams:
        team_config = config.teams[entity_name]
        rooms = resolve_room_aliases(team_config.rooms, runtime_paths)
        # Convert team member agent names into canonical agent Matrix IDs.
        # Team streaming resolves config agents from these IDs, so they must keep
        # the `mindroom_` prefix used by MatrixID.from_agent().
        team_matrix_ids = [
            MatrixID.from_agent(agent_name, config.get_domain(runtime_paths), runtime_paths)
            for agent_name in team_config.agents
        ]
        return TeamBot(
            agent_user=agent_user,
            storage_path=storage_path,
            config=config,
            runtime_paths=runtime_paths,
            rooms=rooms,
            config_path=config_path,
            team_agents=team_matrix_ids,
            team_mode=team_config.mode,
            team_model=team_config.model,
            enable_streaming=enable_streaming,
        )

    if entity_name in config.agents:
        agent_config = config.agents[entity_name]
        rooms = resolve_room_aliases(agent_config.rooms, runtime_paths)
        return AgentBot(
            agent_user,
            storage_path,
            config,
            runtime_paths,
            rooms,
            config_path=config_path,
            enable_streaming=enable_streaming,
        )

    msg = f"Entity '{entity_name}' not found in configuration."
    raise ValueError(msg)


@dataclass
class _MessageContext:
    """Context extracted from a Matrix message event."""

    am_i_mentioned: bool
    is_thread: bool
    thread_id: str | None
    thread_history: list[dict]
    mentioned_agents: list[MatrixID]
    has_non_agent_mentions: bool


type _MediaDispatchEvent = (
    nio.RoomMessageImage
    | nio.RoomEncryptedImage
    | nio.RoomMessageFile
    | nio.RoomEncryptedFile
    | nio.RoomMessageVideo
    | nio.RoomEncryptedVideo
    | nio.RoomMessageAudio
    | nio.RoomEncryptedAudio
)


@dataclass(frozen=True)
class _PreparedDispatch:
    """Common dispatch context reused across media handlers."""

    requester_user_id: str
    context: _MessageContext


@dataclass(frozen=True)
class _DispatchPayload:
    """Dispatch prompt + optional media + attachment metadata."""

    prompt: str
    media: MediaInputs = field(default_factory=MediaInputs)
    attachment_ids: list[str] | None = None


@dataclass(frozen=True)
class _SyntheticTextEvent:
    """Minimal text-event shape for internal normalized-media dispatch.

    This intentionally satisfies the ``CommandEvent`` protocol used by command handling.
    """

    sender: str
    event_id: str
    body: str
    source: dict[str, Any]


type _TextDispatchEvent = nio.RoomMessageText | _SyntheticTextEvent

type _DispatchEvent = _TextDispatchEvent | _MediaDispatchEvent


def _merge_response_extra_content(
    extra_content: dict[str, Any] | None,
    attachment_ids: list[str] | None,
) -> dict[str, Any] | None:
    """Merge optional attachment IDs into response metadata."""
    merged_extra_content = extra_content if extra_content is not None else {}
    if attachment_ids:
        merged_extra_content[ATTACHMENT_IDS_KEY] = attachment_ids
    return merged_extra_content or None


@dataclass
class AgentBot:
    """Represents a single agent bot with its own Matrix account."""

    _MATRIX_PROMPT_CONTEXT_MARKER = "[Matrix metadata for tool calls]"

    agent_user: AgentMatrixUser
    storage_path: Path
    config: Config
    runtime_paths: RuntimePaths
    rooms: list[str] = field(default_factory=list)
    config_path: Path | None = None

    client: nio.AsyncClient | None = field(default=None, init=False)
    running: bool = field(default=False, init=False)
    enable_streaming: bool = field(default=True)  # Enable/disable streaming responses
    orchestrator: MultiAgentOrchestrator | None = field(default=None, init=False)  # Reference to orchestrator
    _reply_chain: ReplyChainCaches = field(default_factory=ReplyChainCaches, init=False)

    @property
    def agent_name(self) -> str:
        """Get the agent name from username."""
        return self.agent_user.agent_name

    @cached_property
    def logger(self) -> structlog.stdlib.BoundLogger:
        """Get a logger with agent context bound."""
        return logger.bind(agent=emoji(self.agent_name))

    @cached_property
    def matrix_id(self) -> MatrixID:
        """Get the Matrix ID for this agent bot."""
        return self.agent_user.matrix_id

    def _resolve_reply_thread_id(
        self,
        thread_id: str | None,
        reply_to_event_id: str | None = None,
        *,
        room_id: str | None = None,
        event_source: dict[str, Any] | None = None,
        thread_mode_override: Literal["thread", "room"] | None = None,
    ) -> str | None:
        """Resolve the effective thread root for outgoing replies.

        In room mode this always returns ``None`` so callers send plain room
        messages and store room-level state. In thread mode, this prefers an
        existing thread ID and falls back to a safe root/reply target.
        """
        effective_thread_mode = thread_mode_override or self.config.get_entity_thread_mode(
            self.agent_name,
            self.runtime_paths,
            room_id=room_id,
        )
        if effective_thread_mode == "room":
            return None
        event_info = EventInfo.from_event(event_source)
        return thread_id or event_info.safe_thread_root or reply_to_event_id

    @property
    def show_tool_calls(self) -> bool:
        """Whether to show tool call details inline in responses."""
        return self._show_tool_calls_for_agent(self.agent_name)

    def _show_tool_calls_for_agent(self, agent_name: str) -> bool:
        """Resolve tool-call visibility for a specific agent."""
        agent_config = self.config.agents.get(agent_name)
        if agent_config and agent_config.show_tool_calls is not None:
            return agent_config.show_tool_calls
        return self.config.defaults.show_tool_calls

    def _get_shared_knowledge(self, base_id: str) -> Knowledge | None:
        """Get shared knowledge instance for a configured knowledge base."""
        orchestrator = self.orchestrator
        if orchestrator is None:
            return None
        manager = orchestrator.knowledge_managers.get(base_id)
        if manager is None:
            return None
        return manager.get_knowledge()

    def _knowledge_for_agent(self, agent_name: str) -> Knowledge | None:
        """Return shared knowledge for agents assigned to one or more knowledge bases."""
        return resolve_agent_knowledge(
            agent_name,
            self.config,
            self._get_shared_knowledge,
            on_missing_bases=lambda missing_base_ids: self.logger.warning(
                "Knowledge bases not available for agent",
                agent_name=agent_name,
                knowledge_bases=missing_base_ids,
            ),
        )

    @property  # Not cached_property because Team mutates it!
    def agent(self) -> Agent:
        """Get the Agno Agent instance for this bot."""
        knowledge = self._knowledge_for_agent(self.agent_name)
        return create_agent(
            agent_name=self.agent_name,
            config=self.config,
            runtime_paths=self.runtime_paths,
            knowledge=knowledge,
        )

    @cached_property
    def response_tracker(self) -> ResponseTracker:
        """Get or create the response tracker for this agent."""
        # Use the tracking subdirectory, not the root storage path
        tracking_dir = self.storage_path / "tracking"
        return ResponseTracker(self.agent_name, base_path=tracking_dir)

    @cached_property
    def stop_manager(self) -> StopManager:
        """Get or create the StopManager for this agent."""
        return StopManager()

    async def join_configured_rooms(self) -> None:
        """Join all rooms this agent is configured for."""
        assert self.client is not None
        joined_rooms = await get_joined_rooms(self.client)
        current_rooms = set(joined_rooms or [])
        current_rooms.update(self.client.rooms)

        for room_id in self.rooms:
            if room_id in current_rooms:
                self.logger.debug("Already joined room", room_id=room_id)
                await self._post_join_room_setup(room_id)
                continue

            if await join_room(self.client, room_id):
                current_rooms.add(room_id)
                self.logger.info("Joined room", room_id=room_id)
                await self._post_join_room_setup(room_id)
            else:
                self.logger.warning("Failed to join room", room_id=room_id)

    async def _post_join_room_setup(self, room_id: str) -> None:
        """Run room setup that should happen after joins and across restarts."""
        if self.agent_name != ROUTER_AGENT_NAME:
            return

        assert self.client is not None

        restored_tasks = await restore_scheduled_tasks(self.client, room_id, self.config, self.runtime_paths)
        if restored_tasks > 0:
            self.logger.info(f"Restored {restored_tasks} scheduled tasks in room {room_id}")

        restored_configs = await config_confirmation.restore_pending_changes(self.client, room_id)
        if restored_configs > 0:
            self.logger.info(f"Restored {restored_configs} pending config changes in room {room_id}")

        await self._send_welcome_message_if_empty(room_id)

    async def leave_unconfigured_rooms(self) -> None:
        """Leave any rooms this agent is no longer configured for."""
        assert self.client is not None

        # Get all rooms we're currently in
        joined_rooms = await get_joined_rooms(self.client)
        if joined_rooms is None:
            return

        current_rooms = set(joined_rooms)
        configured_rooms = set(self.rooms)
        if self.agent_name == ROUTER_AGENT_NAME:
            # The router is the long-lived manager of the root Space even though it is
            # not part of the normal configured room list for conversational routing.
            root_space_id = MatrixState.load(runtime_paths=self.runtime_paths).space_room_id
            if root_space_id is not None:
                configured_rooms.add(root_space_id)

        # Leave rooms we're no longer configured for (preserving DM rooms)
        await leave_non_dm_rooms(self.client, list(current_rooms - configured_rooms))

    async def ensure_user_account(self) -> None:
        """Ensure this agent has a Matrix user account.

        This method makes the agent responsible for its own user account creation,
        moving this responsibility from the orchestrator to the agent itself.
        """
        # If we already have a user_id (e.g., provided by tests or config), assume account exists
        if self.agent_user.user_id:
            return
        # Create or retrieve the Matrix user account
        self.agent_user = await create_agent_user(
            constants.runtime_matrix_homeserver(runtime_paths=self.runtime_paths),
            self.agent_name,
            self.agent_user.display_name,  # Use existing display name if available
            runtime_paths=self.runtime_paths,
        )
        self.logger.info(f"Ensured Matrix user account: {self.agent_user.user_id}")

    async def _set_avatar_if_available(self) -> None:
        """Set avatar for the agent if an avatar file exists."""
        if not self.client:
            return

        entity_type = "teams" if self.agent_name in self.config.teams else "agents"
        avatar_path = resolve_avatar_path(entity_type, self.agent_name, runtime_paths=self.runtime_paths)

        if avatar_path.exists():
            try:
                success = await check_and_set_avatar(self.client, avatar_path)
                if success:
                    self.logger.info(f"Successfully set avatar for {self.agent_name}")
                else:
                    self.logger.warning(f"Failed to set avatar for {self.agent_name}")
            except Exception as e:
                self.logger.warning(f"Failed to set avatar: {e}")

    async def _set_presence_with_model_info(self) -> None:
        """Set presence status with model information."""
        if self.client is None:
            return

        status_msg = build_agent_status_message(self.agent_name, self.config)
        await set_presence_status(self.client, status_msg)

    async def ensure_rooms(self) -> None:
        """Ensure agent is in the correct rooms based on configuration.

        This consolidates room management into a single method that:
        1. Joins configured rooms
        2. Leaves unconfigured rooms
        """
        await self.join_configured_rooms()
        await self.leave_unconfigured_rooms()

    async def start(self) -> None:
        """Start the agent bot with user account setup (but don't join rooms yet)."""
        await self.ensure_user_account()
        self.client = await login_agent_user(
            constants.runtime_matrix_homeserver(runtime_paths=self.runtime_paths),
            self.agent_user,
            runtime_paths=self.runtime_paths,
        )
        await self._set_avatar_if_available()
        await self._set_presence_with_model_info()

        # Register event callbacks - wrap them to run as background tasks
        # This ensures the sync loop is never blocked, allowing stop reactions to work
        self.client.add_event_callback(_create_task_wrapper(self._on_invite), nio.InviteEvent)  # ty: ignore[invalid-argument-type]  # InviteEvent doesn't inherit Event
        self.client.add_event_callback(_create_task_wrapper(self._on_message), nio.RoomMessageText)
        self.client.add_event_callback(_create_task_wrapper(self._on_reaction), nio.ReactionEvent)

        # Register media callbacks on all agents (each agent handles its own routing)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomMessageImage)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomEncryptedImage)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomMessageFile)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomEncryptedFile)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomMessageVideo)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomEncryptedVideo)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomMessageAudio)
        self.client.add_event_callback(_create_task_wrapper(self._on_media_message), nio.RoomEncryptedAudio)

        self.running = True

        # Router bot has additional responsibilities
        if self.agent_name == ROUTER_AGENT_NAME:
            try:
                await cleanup_all_orphaned_bots(self.client, self.config, self.runtime_paths)
            except Exception as e:
                self.logger.warning(f"Could not cleanup orphaned bots (non-critical): {e}")

        # Note: Room joining is deferred until after invitations are handled
        self.logger.info(f"Agent setup complete: {self.agent_user.user_id}")

    async def try_start(self) -> bool:
        """Try to start the agent bot with smart retry logic.

        Retries transient failures but stops immediately on permanent startup errors.

        Returns:
            True if the bot started successfully, False otherwise.

        """

        @retry(
            stop=stop_after_attempt(3),
            wait=wait_exponential(multiplier=1, min=2, max=10),
            retry=retry_if_not_exception_type(PermanentMatrixStartupError),
            reraise=True,
        )
        async def _start_with_retry() -> None:
            await self.start()

        try:
            await _start_with_retry()
            return True  # noqa: TRY300
        except Exception as exc:
            if isinstance(exc, PermanentMatrixStartupError):
                logger.error(f"Failed to start agent {self.agent_name}: {exc}")  # noqa: TRY400
                raise
            logger.exception(f"Failed to start agent {self.agent_name}")
            return False

    async def cleanup(self) -> None:
        """Clean up the agent by leaving all rooms and stopping.

        This method ensures clean shutdown when an agent is removed from config.
        """
        assert self.client is not None
        # Leave all rooms (preserving DM rooms)
        try:
            joined_rooms = await get_joined_rooms(self.client)
            if joined_rooms:
                await leave_non_dm_rooms(self.client, joined_rooms)
        except Exception:
            self.logger.exception("Error leaving rooms during cleanup")

        # Stop the bot
        await self.stop()

    async def stop(self) -> None:
        """Stop the agent bot."""
        self.running = False

        # Wait for any pending background tasks (like memory saves) to complete
        try:
            await wait_for_background_tasks(timeout=5.0)  # 5 second timeout
            self.logger.info("Background tasks completed")
        except Exception as e:
            self.logger.warning(f"Some background tasks did not complete: {e}")

        if self.agent_name == ROUTER_AGENT_NAME:
            cancelled_tasks = await cancel_all_running_scheduled_tasks()
            if cancelled_tasks > 0:
                self.logger.info("Cancelled running scheduled tasks", count=cancelled_tasks)

        if self.client is not None:
            self.logger.warning("Client is not None in stop()")
            await self.client.close()
        self.logger.info("Stopped agent bot")

    async def _send_welcome_message_if_empty(self, room_id: str) -> None:
        """Send a welcome message if the room has no messages yet.

        Only called by the router agent when joining a room.
        """
        assert self.client is not None

        # Check if room has any messages
        response = await self.client.room_messages(
            room_id,
            limit=2,  # Get 2 messages to check if we already sent welcome
            message_filter={"types": ["m.room.message"]},
        )

        # nio returns error types on failure - this is necessary
        if not isinstance(response, nio.RoomMessagesResponse):
            self.logger.error("Failed to check room messages", room_id=room_id, error=str(response))
            return

        # Only send welcome message if room is empty or only has our own welcome message
        if not response.chunk:
            # Room is completely empty
            self.logger.info("Room is empty, sending welcome message", room_id=room_id)

            # Generate and send the welcome message
            welcome_msg = _generate_welcome_message(room_id, self.config, self.runtime_paths)
            await self._send_response(
                room_id=room_id,
                reply_to_event_id=None,
                response_text=welcome_msg,
                thread_id=None,
                skip_mentions=True,
            )
            self.logger.info("Welcome message sent", room_id=room_id)
        elif len(response.chunk) == 1:
            # Check if the only message is our welcome message
            msg = response.chunk[0]
            if (
                isinstance(msg, nio.RoomMessageText)
                and msg.sender == self.agent_user.user_id
                and "Welcome to MindRoom" in msg.body
            ):
                self.logger.debug("Welcome message already sent", room_id=room_id)
                return
            # Otherwise, room has a different message, don't send welcome
        # Room has other messages, don't send welcome

    async def sync_forever(self) -> None:
        """Run the sync loop for this agent."""
        assert self.client is not None
        await self.client.sync_forever(timeout=_SYNC_TIMEOUT_MS, full_state=True)

    async def _on_invite(self, room: nio.MatrixRoom, event: nio.InviteEvent) -> None:
        assert self.client is not None
        self.logger.info("Received invite", room_id=room.room_id, sender=event.sender)
        if await join_room(self.client, room.room_id):
            self.logger.info("Joined room", room_id=room.room_id)
            # If this is the router agent and the room is empty, send a welcome message
            if self.agent_name == ROUTER_AGENT_NAME:
                await self._send_welcome_message_if_empty(room.room_id)
        else:
            self.logger.error("Failed to join room", room_id=room.room_id)

    async def _on_message(self, room: nio.MatrixRoom, event: nio.RoomMessageText) -> None:
        self.logger.info("Received message", event_id=event.event_id, room_id=room.room_id, sender=event.sender)
        assert self.client is not None
        if not isinstance(event.body, str) or is_in_progress_message(event.body):
            return

        event_info = EventInfo.from_event(event.source)
        requester_user_id = self._precheck_event(room, event, is_edit=event_info.is_edit)
        if requester_user_id is None:
            return

        # Handle edit events
        if event_info.is_edit:
            await self._handle_message_edit(room, event, event_info)
            return

        await interactive.handle_text_response(self.client, room, event, self.agent_name)
        await self._dispatch_text_message(room, event, requester_user_id)

    async def _dispatch_text_message(
        self,
        room: nio.MatrixRoom,
        event: _TextDispatchEvent,
        requester_user_id: str,
    ) -> None:
        """Run the normal text/command dispatch pipeline for a prepared text event."""
        assert isinstance(event.body, str)

        # Router handles commands exclusively
        command = command_parser.parse(event.body)
        if command:
            if self.agent_name == ROUTER_AGENT_NAME:
                # Router always handles commands, even in single-agent rooms
                # Commands like !schedule, !help, etc. need to work regardless
                await self._handle_command(room, event, command)
            return

        dispatch = await self._prepare_dispatch(
            room,
            event,
            requester_user_id=requester_user_id,
            event_label="message",
        )
        if dispatch is None:
            return

        content = event.source.get("content") if isinstance(event.source, dict) else None
        message_attachment_ids = parse_attachment_ids_from_event_source(event.source)
        message_extra_content: dict[str, Any] = {}
        if message_attachment_ids:
            message_extra_content[ATTACHMENT_IDS_KEY] = message_attachment_ids
        if isinstance(content, dict):
            original_sender = content.get(ORIGINAL_SENDER_KEY)
            if isinstance(original_sender, str):
                message_extra_content[ORIGINAL_SENDER_KEY] = original_sender
            raw_audio_fallback = content.get(VOICE_RAW_AUDIO_FALLBACK_KEY)
            if isinstance(raw_audio_fallback, bool) and raw_audio_fallback:
                message_extra_content[VOICE_RAW_AUDIO_FALLBACK_KEY] = True

        action = await self._resolve_dispatch_action(
            room,
            event,
            dispatch,
            message_for_decision=event.body,
            extra_content=message_extra_content or None,
        )
        if action is None:
            return

        context = dispatch.context
        payload = await self._build_dispatch_payload_with_attachments(
            room_id=room.room_id,
            context=context,
            prompt=event.body,
            current_attachment_ids=message_attachment_ids,
            media_thread_id=context.thread_id,
        )
        await self._execute_dispatch_action(
            room,
            event,
            dispatch,
            action,
            payload,
            processing_log="Processing",
        )

    async def _on_reaction(self, room: nio.MatrixRoom, event: nio.ReactionEvent) -> None:
        """Handle reaction events for interactive questions, stop functionality, and config confirmations."""
        assert self.client is not None

        # Check if sender is authorized to interact with agents
        if not is_authorized_sender(
            event.sender,
            self.config,
            room.room_id,
            self.runtime_paths,
            room_alias=room.canonical_alias,
        ):
            self.logger.debug(f"Ignoring reaction from unauthorized sender: {event.sender}")
            return

        # Check per-agent reply permissions before handling any reaction type
        # so disallowed senders cannot trigger stop confirmations, config
        # confirmations, or consume interactive questions.
        if not self._can_reply_to_sender(event.sender):
            self.logger.debug("Ignoring reaction due to reply permissions", sender=event.sender)
            return

        # Check if this is a stop button reaction for a message currently being generated
        # Only process stop functionality if:
        # 1. The reaction is 🛑
        # 2. The sender is not an agent (users only)
        # 3. The message is currently being generated by this agent
        if event.key == "🛑":
            # Check if this is from a bot/agent
            sender_agent_name = extract_agent_name(event.sender, self.config, self.runtime_paths)
            # Only handle stop from users, not agents, and only if tracking this message
            if not sender_agent_name and await self.stop_manager.handle_stop_reaction(event.reacts_to):
                self.logger.info(
                    "Stopped generation for message",
                    message_id=event.reacts_to,
                    stopped_by=event.sender,
                )
                # Remove the stop button immediately for user feedback
                await self.stop_manager.remove_stop_button(self.client, event.reacts_to)
                # Send a confirmation message
                await self._send_response(room.room_id, event.reacts_to, "✅ Generation stopped", None)
                return
            # Message is not being generated - let the reaction be handled for other purposes
            # (e.g., interactive questions). Don't return here so it can fall through!
            # Agent reactions with 🛑 also fall through to other handlers

        # Then check if this is a config confirmation reaction
        pending_change = config_confirmation.get_pending_change(event.reacts_to)

        if pending_change and self.agent_name == ROUTER_AGENT_NAME:
            # Only router handles config confirmations
            await config_confirmation.handle_confirmation_reaction(self, room, event, pending_change)
            return

        result = await interactive.handle_reaction(
            self.client,
            event,
            self.agent_name,
            self.config,
            self.runtime_paths,
        )

        if result:
            selected_value, thread_id = result
            # User selected an option from an interactive question

            # Check if we should process this reaction
            thread_history = []
            if thread_id:
                thread_history = await fetch_thread_history(self.client, room.room_id, thread_id)
                if has_user_responded_after_message(thread_history, event.reacts_to, self.matrix_id):
                    self.logger.info(
                        "Ignoring reaction - agent already responded after this question",
                        reacted_to=event.reacts_to,
                    )
                    return

            # Send immediate acknowledgment
            ack_text = f"You selected: {event.key} {selected_value}\n\nProcessing your response..."
            # Matrix doesn't allow reply relations to events that already have relations (reactions)
            # In threads, omit reply_to_event_id; the thread_id ensures correct placement
            ack_event_id = await self._send_response(
                room.room_id,
                None if thread_id else event.reacts_to,
                ack_text,
                thread_id,
            )

            if not ack_event_id:
                self.logger.error("Failed to send acknowledgment for reaction")
                return

            # Generate the response, editing the acknowledgment message
            # Note: existing_event_id is only used for interactive questions to edit the acknowledgment
            prompt = f"The user selected: {selected_value}"
            response_event_id = await self._generate_response(
                room_id=room.room_id,
                prompt=prompt,
                reply_to_event_id=event.reacts_to,
                thread_id=thread_id,
                thread_history=thread_history,
                existing_event_id=ack_event_id,  # Edit the acknowledgment instead of creating new message
                user_id=event.sender,
            )
            # Mark the original interactive question as responded
            self.response_tracker.mark_responded(event.reacts_to, response_event_id)

    async def _build_dispatch_payload_with_attachments(
        self,
        *,
        room_id: str,
        context: _MessageContext,
        prompt: str,
        current_attachment_ids: list[str],
        media_thread_id: str | None,
        fallback_images: list[Image] | None = None,
    ) -> _DispatchPayload:
        """Build dispatch payload by merging thread/history attachment media."""
        assert self.client is not None
        thread_attachment_ids = (
            await resolve_thread_attachment_ids(
                self.client,
                self.storage_path,
                room_id=room_id,
                thread_id=context.thread_id,
            )
            if context.thread_id
            else []
        )
        history_attachment_ids = parse_attachment_ids_from_thread_history(context.thread_history)
        attachment_ids = merge_attachment_ids(
            current_attachment_ids,
            thread_attachment_ids,
            history_attachment_ids,
        )
        resolved_attachment_ids, attachment_audio, attachment_images, attachment_files, attachment_videos = (
            resolve_attachment_media(
                self.storage_path,
                attachment_ids,
                room_id=room_id,
                thread_id=media_thread_id,
            )
        )
        if fallback_images is not None and not attachment_images:
            attachment_images = fallback_images
        return _DispatchPayload(
            prompt=append_attachment_ids_prompt(prompt, resolved_attachment_ids),
            media=MediaInputs.from_optional(
                audio=attachment_audio,
                images=attachment_images,
                files=attachment_files,
                videos=attachment_videos,
            ),
            attachment_ids=resolved_attachment_ids or None,
        )

    async def _on_audio_media_message(
        self,
        room: nio.MatrixRoom,
        event: nio.RoomMessageAudio | nio.RoomEncryptedAudio,
    ) -> None:
        """Normalize audio into a synthetic text event and reuse text dispatch."""
        assert self.client is not None

        requester_user_id = self._precheck_event(room, event)
        if requester_user_id is None:
            return

        if is_agent_id(event.sender, self.config, self.runtime_paths):
            self.logger.debug(
                "Ignoring agent audio event for voice transcription",
                event_id=event.event_id,
                sender=event.sender,
            )
            self.response_tracker.mark_responded(event.event_id)
            return

        event_info = EventInfo.from_event(event.source)
        _, thread_id, _ = await self._derive_conversation_context(room.room_id, event_info)
        effective_thread_id = self._resolve_reply_thread_id(
            thread_id,
            event.event_id,
            room_id=room.room_id,
            event_source=event.source,
        )
        prepared_voice = await voice_handler.prepare_voice_message(
            self.client,
            self.storage_path,
            room,
            event,
            self.config,
            runtime_paths=self.runtime_paths,
            sender_domain=self.matrix_id.domain,
            thread_id=effective_thread_id,
        )
        if prepared_voice is None:
            self.response_tracker.mark_responded(event.event_id)
            return

        await self._maybe_send_visible_voice_echo(
            room,
            event,
            text=prepared_voice.text,
            thread_id=effective_thread_id,
        )

        await self._dispatch_text_message(
            room,
            _SyntheticTextEvent(
                sender=event.sender,
                event_id=event.event_id,
                body=prepared_voice.text,
                source=prepared_voice.source,
            ),
            requester_user_id,
        )

    async def _maybe_send_visible_voice_echo(
        self,
        room: nio.MatrixRoom,
        event: nio.RoomMessageAudio | nio.RoomEncryptedAudio,
        *,
        text: str,
        thread_id: str | None,
    ) -> str | None:
        """Optionally post a display-only router echo for normalized audio."""
        if self.agent_name != ROUTER_AGENT_NAME or not self.config.voice.visible_router_echo:
            return None

        existing_visible_echo_event_id = self.response_tracker.get_visible_echo_event_id(event.event_id)
        if existing_visible_echo_event_id is not None:
            return existing_visible_echo_event_id

        visible_echo_event_id = await self._send_response(
            room_id=room.room_id,
            reply_to_event_id=event.event_id,
            response_text=text,
            thread_id=thread_id,
            skip_mentions=True,
        )
        if visible_echo_event_id is not None:
            self.response_tracker.mark_visible_echo_sent(event.event_id, visible_echo_event_id)
        return visible_echo_event_id

    async def _on_media_message(
        self,
        room: nio.MatrixRoom,
        event: _MediaDispatchEvent,
    ) -> None:
        """Handle image/file/video/audio events and dispatch media-aware responses."""
        assert self.client is not None

        if isinstance(event, nio.RoomMessageAudio | nio.RoomEncryptedAudio):
            await self._on_audio_media_message(room, event)
            return

        is_image_event = isinstance(event, nio.RoomMessageImage | nio.RoomEncryptedImage)
        default_caption = (
            "[Attached image]"
            if is_image_event
            else (
                "[Attached video]"
                if isinstance(event, nio.RoomMessageVideo | nio.RoomEncryptedVideo)
                else "[Attached file]"
            )
        )
        caption = extract_media_caption(event, default=default_caption)

        dispatch = await self._prepare_dispatch(
            room,
            event,
            event_label="image" if is_image_event else "media",
        )
        if dispatch is None:
            return

        context = dispatch.context
        action = await self._resolve_dispatch_action(
            room,
            event,
            dispatch,
            message_for_decision=event.body,
            router_message=caption,
            extra_content={ORIGINAL_SENDER_KEY: event.sender},
        )
        if action is None:
            return

        effective_thread_id = self._resolve_reply_thread_id(
            context.thread_id,
            event.event_id,
            room_id=room.room_id,
            event_source=event.source,
        )
        current_attachment_ids: list[str]
        fallback_images: list[Image] | None = None
        if is_image_event:
            assert isinstance(event, nio.RoomMessageImage | nio.RoomEncryptedImage)
            # Download image only after confirming we should respond.
            image = await image_handler.download_image(self.client, event)
            if image is None:
                self.logger.error("Failed to download image", event_id=event.event_id)
                self.response_tracker.mark_responded(event.event_id)
                return
            attachment_record = await register_image_attachment(
                self.client,
                self.storage_path,
                room_id=room.room_id,
                thread_id=effective_thread_id,
                event=event,
                image_bytes=image.content,
            )
            # Keep processing even without attachment registration; image bytes are already available to the model.
            current_attachment_ids = [attachment_record.attachment_id] if attachment_record is not None else []
            fallback_images = [image]
        else:
            assert isinstance(
                event,
                nio.RoomMessageFile | nio.RoomEncryptedFile | nio.RoomMessageVideo | nio.RoomEncryptedVideo,
            )
            attachment_record = await register_file_or_video_attachment(
                self.client,
                self.storage_path,
                room_id=room.room_id,
                thread_id=effective_thread_id,
                event=event,
            )
            if attachment_record is None:
                # File/video media is resolved from persisted attachments, so registration is required.
                self.logger.error("Failed to register media attachment", event_id=event.event_id)
                self.response_tracker.mark_responded(event.event_id)
                return
            current_attachment_ids = [attachment_record.attachment_id]
        payload = await self._build_dispatch_payload_with_attachments(
            room_id=room.room_id,
            context=context,
            prompt=caption,
            current_attachment_ids=current_attachment_ids,
            media_thread_id=effective_thread_id,
            fallback_images=fallback_images,
        )
        await self._execute_dispatch_action(
            room,
            event,
            dispatch,
            action,
            payload,
            processing_log="Processing image" if is_image_event else "Processing media message",
        )

    async def _register_routed_attachment(
        self,
        *,
        room_id: str,
        thread_id: str | None,
        event: _DispatchEvent,
    ) -> str | None:
        """Register a routed media event and return its attachment ID when available."""
        if isinstance(
            event,
            nio.RoomMessageFile | nio.RoomEncryptedFile | nio.RoomMessageVideo | nio.RoomEncryptedVideo,
        ):
            assert self.client is not None
            attachment_record = await register_file_or_video_attachment(
                self.client,
                self.storage_path,
                room_id=room_id,
                thread_id=thread_id,
                event=event,
            )
            if attachment_record is None:
                self.logger.error("Failed to register routed media attachment", event_id=event.event_id)
                return None
            return attachment_record.attachment_id

        if isinstance(event, nio.RoomMessageImage | nio.RoomEncryptedImage):
            assert self.client is not None
            attachment_record = await register_image_attachment(
                self.client,
                self.storage_path,
                room_id=room_id,
                thread_id=thread_id,
                event=event,
            )
            if attachment_record is None:
                self.logger.error("Failed to register routed image attachment", event_id=event.event_id)
                return None
            return attachment_record.attachment_id

        return None

    async def _derive_conversation_context(
        self,
        room_id: str,
        event_info: EventInfo,
    ) -> tuple[bool, str | None, list[dict[str, Any]]]:
        """Derive conversation context from threads or reply chains."""
        assert self.client is not None
        return await derive_conversation_context(
            self.client,
            room_id,
            event_info,
            self._reply_chain,
            self.logger,
            fetch_thread_history,
        )

    def _requester_user_id_for_event(
        self,
        event: CommandEvent,
    ) -> str:
        """Return the effective requester for per-user reply checks."""
        content = event.source.get("content") if isinstance(event.source, dict) else None
        if (
            event.sender == self.matrix_id.full_id
            and isinstance(content, dict)
            and isinstance(content.get(ORIGINAL_SENDER_KEY), str)
        ):
            return content[ORIGINAL_SENDER_KEY]
        return get_effective_sender_id_for_reply_permissions(
            event.sender,
            event.source,
            self.config,
            self.runtime_paths,
        )

    def _precheck_event(
        self,
        room: nio.MatrixRoom,
        event: _DispatchEvent,
        *,
        is_edit: bool = False,
    ) -> str | None:
        """Common early-exit checks shared by text/media/voice handlers.

        Returns the effective requester user ID when the event should be
        processed, or ``None`` when the event should be skipped.

        Checks (in order): self-authored, already processed (skipped for
        edits so restart recovery works), sender authorization, and
        per-agent reply permissions.
        """
        requester_user_id = self._requester_user_id_for_event(event)

        if requester_user_id == self.matrix_id.full_id:
            return None

        # Edits bypass the dedup check: if an edit is redelivered after a
        # restart the bot should still regenerate the response.
        if not is_edit and self.response_tracker.has_responded(event.event_id):
            return None

        if not is_authorized_sender(
            event.sender,
            self.config,
            room.room_id,
            self.runtime_paths,
            room_alias=room.canonical_alias,
        ):
            self.response_tracker.mark_responded(event.event_id)
            return None

        if not self._can_reply_to_sender(requester_user_id):
            self.response_tracker.mark_responded(event.event_id)
            return None

        return requester_user_id

    async def _prepare_dispatch(
        self,
        room: nio.MatrixRoom,
        event: _DispatchEvent,
        *,
        requester_user_id: str | None = None,
        event_label: str,
    ) -> _PreparedDispatch | None:
        """Run common precheck/context/sender-gating for dispatch handlers."""
        effective_requester_user_id = requester_user_id or self._precheck_event(room, event)
        if effective_requester_user_id is None:
            return None

        context = await self._extract_message_context(room, event)
        sender_agent_name = extract_agent_name(effective_requester_user_id, self.config, self.runtime_paths)
        if sender_agent_name and not context.am_i_mentioned:
            self.logger.debug(f"Ignoring {event_label} from other agent (not mentioned)")
            return None

        return _PreparedDispatch(
            requester_user_id=effective_requester_user_id,
            context=context,
        )

    async def _resolve_dispatch_action(
        self,
        room: nio.MatrixRoom,
        event: _DispatchEvent,
        dispatch: _PreparedDispatch,
        *,
        message_for_decision: str,
        router_message: str | None = None,
        extra_content: dict[str, Any] | None = None,
    ) -> _ResponseAction | None:
        """Resolve routing + team/individual/skip action for a prepared dispatch."""
        router_result = await self._handle_router_dispatch(
            room,
            event,
            dispatch.context,
            dispatch.requester_user_id,
            message=router_message,
            extra_content=extra_content,
        )
        if router_result.handled:
            visible_router_echo_event_id = self.response_tracker.get_visible_echo_event_id(event.event_id)
            if (
                router_result.mark_visible_echo_responded
                and visible_router_echo_event_id is not None
                and not self.response_tracker.has_responded(event.event_id)
            ):
                self.response_tracker.mark_responded(event.event_id, visible_router_echo_event_id)
            return None

        assert self.client is not None
        dm_room = await is_dm_room(self.client, room.room_id)
        action = await self._resolve_response_action(
            dispatch.context,
            room,
            dispatch.requester_user_id,
            message_for_decision,
            dm_room,
        )
        if action.kind == "skip":
            return None
        return action

    async def _execute_dispatch_action(
        self,
        room: nio.MatrixRoom,
        event: _DispatchEvent,
        dispatch: _PreparedDispatch,
        action: _ResponseAction,
        payload: _DispatchPayload,
        *,
        processing_log: str,
    ) -> None:
        """Execute resolved dispatch action and mark the source event responded."""
        if action.kind == "team":
            assert action.form_team is not None
            response_event_id = await self._generate_team_response_helper(
                room_id=room.room_id,
                reply_to_event_id=event.event_id,
                thread_id=dispatch.context.thread_id,
                payload=payload,
                team_agents=action.form_team.agents,
                team_mode=action.form_team.mode,
                thread_history=dispatch.context.thread_history,
                requester_user_id=dispatch.requester_user_id,
                existing_event_id=None,
            )
            self.response_tracker.mark_responded(event.event_id, response_event_id)
            return

        if not dispatch.context.am_i_mentioned:
            self.logger.info("Will respond: only agent in thread")

        self.logger.info(processing_log, event_id=event.event_id)
        response_event_id = await self._generate_response(
            room_id=room.room_id,
            prompt=payload.prompt,
            reply_to_event_id=event.event_id,
            thread_id=dispatch.context.thread_id,
            thread_history=dispatch.context.thread_history,
            user_id=dispatch.requester_user_id,
            media=payload.media,
            attachment_ids=payload.attachment_ids,
        )
        self.response_tracker.mark_responded(event.event_id, response_event_id)

    def _can_reply_to_sender(self, sender_id: str) -> bool:
        """Return whether this entity may reply to *sender_id*."""
        return is_sender_allowed_for_agent_reply(sender_id, self.agent_name, self.config, self.runtime_paths)

    async def _handle_router_dispatch(
        self,
        room: nio.MatrixRoom,
        event: _DispatchEvent,
        context: _MessageContext,
        requester_user_id: str,
        *,
        message: str | None = None,
        extra_content: dict[str, Any] | None = None,
    ) -> _RouterDispatchResult:
        """Run the router dispatch logic shared by text and media handlers.

        Returns whether router handling should short-circuit normal dispatch, and
        whether a display-only router voice echo should count as handled.
        """
        if self.agent_name != ROUTER_AGENT_NAME:
            return _RouterDispatchResult(handled=False)

        agents_in_thread = get_agents_in_thread(context.thread_history, self.config, self.runtime_paths)
        sender_visible = filter_agents_by_sender_permissions(
            agents_in_thread,
            requester_user_id,
            self.config,
            self.runtime_paths,
        )

        if not context.mentioned_agents and not context.has_non_agent_mentions and not sender_visible:
            if context.is_thread and has_multiple_non_agent_users_in_thread(
                context.thread_history,
                self.config,
                self.runtime_paths,
            ):
                self.logger.info("Skipping routing: multiple non-agent users in thread (mention required)")
                return _RouterDispatchResult(handled=True, mark_visible_echo_responded=True)
            available_agents = get_available_agents_for_sender(
                room,
                requester_user_id,
                self.config,
                self.runtime_paths,
            )
            if len(available_agents) == 1:
                self.logger.info("Skipping routing: only one agent present")
                return _RouterDispatchResult(handled=True, mark_visible_echo_responded=True)
            await self._handle_ai_routing(
                room,
                event,
                context.thread_history,
                context.thread_id,
                message=message,
                requester_user_id=requester_user_id,
                extra_content=extra_content,
            )
            return _RouterDispatchResult(handled=True)
        return _RouterDispatchResult(handled=True, mark_visible_echo_responded=True)

    async def _resolve_response_action(
        self,
        context: _MessageContext,
        room: nio.MatrixRoom,
        requester_user_id: str,
        message: str,
        is_dm: bool,
    ) -> _ResponseAction:
        """Decide whether to respond as a team, individually, or skip.

        Shared by text and image handlers to avoid duplicating the team
        formation + should-respond decision.
        """
        agents_in_thread = get_agents_in_thread(context.thread_history, self.config, self.runtime_paths)
        form_team = await self._decide_team_for_sender(
            agents_in_thread,
            context,
            room,
            requester_user_id,
            message,
            is_dm,
        )

        if form_team.should_form_team and self.matrix_id in form_team.agents:
            first_agent = min(form_team.agents, key=lambda x: x.full_id)
            if self.matrix_id != first_agent:
                return _ResponseAction(kind="skip")
            return _ResponseAction(kind="team", form_team=form_team)

        if not should_agent_respond(
            agent_name=self.agent_name,
            am_i_mentioned=context.am_i_mentioned,
            is_thread=context.is_thread,
            room=room,
            thread_history=context.thread_history,
            config=self.config,
            runtime_paths=self.runtime_paths,
            mentioned_agents=context.mentioned_agents,
            has_non_agent_mentions=context.has_non_agent_mentions,
            sender_id=requester_user_id,
        ):
            return _ResponseAction(kind="skip")

        return _ResponseAction(kind="individual")

    async def _decide_team_for_sender(
        self,
        agents_in_thread: list[MatrixID],
        context: _MessageContext,
        room: nio.MatrixRoom,
        requester_user_id: str,
        message: str,
        is_dm: bool,
    ) -> TeamFormationDecision:
        """Decide team formation using only agents the sender is allowed to interact with."""
        all_mentioned_in_thread = get_all_mentioned_agents_in_thread(
            context.thread_history,
            self.config,
            self.runtime_paths,
        )
        available_agents_in_room: list[MatrixID] | None = None
        if is_dm:
            available_agents_in_room = get_available_agents_for_sender(
                room,
                requester_user_id,
                self.config,
                self.runtime_paths,
            )
        return await decide_team_formation(
            self.matrix_id,
            filter_agents_by_sender_permissions(
                context.mentioned_agents,
                requester_user_id,
                self.config,
                self.runtime_paths,
            ),
            filter_agents_by_sender_permissions(
                agents_in_thread,
                requester_user_id,
                self.config,
                self.runtime_paths,
            ),
            filter_agents_by_sender_permissions(
                all_mentioned_in_thread,
                requester_user_id,
                self.config,
                self.runtime_paths,
            ),
            room=room,
            message=message,
            config=self.config,
            runtime_paths=self.runtime_paths,
            is_dm_room=is_dm,
            is_thread=context.is_thread,
            available_agents_in_room=available_agents_in_room,
        )

    async def _extract_message_context(self, room: nio.MatrixRoom, event: _DispatchEvent) -> _MessageContext:
        assert self.client is not None

        # Check if mentions should be ignored for this message
        skip_mentions = _should_skip_mentions(event.source)

        if skip_mentions:
            # Don't detect mentions if the message has skip_mentions metadata
            mentioned_agents: list[MatrixID] = []
            am_i_mentioned = False
            has_non_agent_mentions = False
        else:
            mentioned_agents, am_i_mentioned, has_non_agent_mentions = check_agent_mentioned(
                event.source,
                self.matrix_id,
                self.config,
                self.runtime_paths,
            )

        if am_i_mentioned:
            self.logger.info("Mentioned", event_id=event.event_id, room_id=room.room_id)

        event_info = EventInfo.from_event(event.source)
        if self.config.get_entity_thread_mode(self.agent_name, self.runtime_paths, room_id=room.room_id) == "room":
            is_thread = False
            thread_id = None
            thread_history: list[dict[str, Any]] = []
        else:
            is_thread, thread_id, thread_history = await self._derive_conversation_context(
                room.room_id,
                event_info,
            )

        return _MessageContext(
            am_i_mentioned=am_i_mentioned,
            is_thread=is_thread,
            thread_id=thread_id,
            thread_history=thread_history,
            mentioned_agents=mentioned_agents,
            has_non_agent_mentions=has_non_agent_mentions,
        )

    def _cached_room(self, room_id: str) -> nio.MatrixRoom | None:
        """Return room from client cache when available."""
        client = self.client
        if client is None:
            return None
        return client.rooms.get(room_id)

    def _build_tool_runtime_context(
        self,
        room_id: str,
        thread_id: str | None,
        reply_to_event_id: str | None,
        user_id: str | None,
        *,
        agent_name: str | None = None,
        attachment_ids: list[str] | None = None,
    ) -> ToolRuntimeContext | None:
        """Build shared runtime context for all tool calls."""
        if self.client is None:
            return None
        return ToolRuntimeContext(
            agent_name=agent_name or self.agent_name,
            room_id=room_id,
            thread_id=thread_id,
            resolved_thread_id=self._resolve_reply_thread_id(thread_id, reply_to_event_id, room_id=room_id),
            requester_id=user_id or self.matrix_id.full_id,
            client=self.client,
            config=self.config,
            runtime_paths=self.runtime_paths,
            room=self._cached_room(room_id),
            reply_to_event_id=reply_to_event_id,
            storage_path=self.storage_path,
            attachment_ids=tuple(attachment_ids or []),
        )

    def _build_tool_execution_identity(
        self,
        *,
        room_id: str,
        thread_id: str | None,
        reply_to_event_id: str | None,
        user_id: str | None,
        session_id: str,
        agent_name: str | None = None,
    ) -> ToolExecutionIdentity:
        """Build the serializable execution identity used for worker routing."""
        return ToolExecutionIdentity(
            channel="matrix",
            agent_name=agent_name or self.agent_name,
            requester_id=user_id or self.matrix_id.full_id,
            room_id=room_id,
            thread_id=thread_id,
            resolved_thread_id=self._resolve_reply_thread_id(thread_id, reply_to_event_id, room_id=room_id),
            session_id=session_id,
            tenant_id=self.runtime_paths.env_value("CUSTOMER_ID"),
            account_id=self.runtime_paths.env_value("ACCOUNT_ID"),
        )

    def _agent_has_matrix_messaging_tool(self, agent_name: str) -> bool:
        """Return whether an agent can issue Matrix message actions."""
        try:
            tool_names = self.config.get_agent_tools(agent_name)
        except ValueError:
            return False
        if not isinstance(tool_names, list | tuple | set):
            return False
        return "matrix_message" in tool_names

    def _append_matrix_prompt_context(
        self,
        prompt: str,
        *,
        room_id: str,
        thread_id: str | None,
        reply_to_event_id: str | None,
        include_context: bool,
    ) -> str:
        """Append room/thread/event ids to the LLM prompt when messaging tools are available."""
        if not include_context:
            return prompt
        if self._MATRIX_PROMPT_CONTEXT_MARKER in prompt:
            return prompt

        effective_thread_id = self._resolve_reply_thread_id(thread_id, reply_to_event_id, room_id=room_id)
        metadata_block = "\n".join(
            (
                self._MATRIX_PROMPT_CONTEXT_MARKER,
                f"room_id: {room_id}",
                f"thread_id: {effective_thread_id or 'none'}",
                f"reply_to_event_id: {reply_to_event_id or 'none'}",
                "Use these IDs when calling matrix_message.",
            ),
        )
        return f"{prompt.rstrip()}\n\n{metadata_block}"

    async def _generate_team_response_helper(
        self,
        room_id: str,
        reply_to_event_id: str,
        thread_id: str | None,
        team_agents: list[MatrixID],
        team_mode: str,
        thread_history: list[dict],
        requester_user_id: str,
        existing_event_id: str | None = None,
        *,
        payload: _DispatchPayload,
    ) -> str | None:
        """Generate a team response (shared between preformed teams and TeamBot).

        Returns the initial message ID if created, None otherwise.
        """
        assert self.client is not None

        # Get the appropriate model for this team and room
        model_name = select_model_for_team(self.agent_name, room_id, self.config, self.runtime_paths)
        room_mode = self.config.get_entity_thread_mode(self.agent_name, self.runtime_paths, room_id=room_id) == "room"

        # Decide streaming based on presence
        use_streaming = await should_use_streaming(
            self.client,
            room_id,
            requester_user_id=requester_user_id,
            enable_streaming=self.enable_streaming,
        )

        # Convert mode string to TeamMode enum
        mode = TeamMode.COORDINATE if team_mode == "coordinate" else TeamMode.COLLABORATE

        # Convert MatrixID list to agent names for non-streaming APIs
        agent_names = [mid.agent_name(self.config, self.runtime_paths) or mid.username for mid in team_agents]
        include_matrix_prompt_context = any(self._agent_has_matrix_messaging_tool(name) for name in agent_names)
        model_message = self._append_matrix_prompt_context(
            payload.prompt,
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            include_context=include_matrix_prompt_context,
        )
        session_id = create_session_id(room_id, thread_id)
        tool_context = self._build_tool_runtime_context(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=requester_user_id,
            attachment_ids=payload.attachment_ids,
        )
        execution_identity = self._build_tool_execution_identity(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=requester_user_id,
            session_id=session_id,
        )
        orchestrator = self.orchestrator
        if orchestrator is None:
            msg = "Orchestrator is not set"
            raise RuntimeError(msg)

        # Create async function for team response generation that takes message_id as parameter
        client = self.client

        async def generate_team_response(message_id: str | None) -> None:
            if use_streaming and not existing_event_id:
                # Show typing indicator while team generates streaming response
                async with typing_indicator(client, room_id):
                    with tool_execution_identity(execution_identity), tool_runtime_context(tool_context):
                        response_stream = team_response_stream(
                            agent_ids=team_agents,
                            message=model_message,
                            orchestrator=orchestrator,
                            mode=mode,
                            thread_history=thread_history,
                            model_name=model_name,
                            media=payload.media,
                            show_tool_calls=self.show_tool_calls,
                        )

                        event_id, accumulated = await send_streaming_response(
                            client,
                            room_id,
                            reply_to_event_id,
                            thread_id,
                            self.matrix_id.domain,
                            self.config,
                            self.runtime_paths,
                            response_stream,
                            streaming_cls=ReplacementStreamingResponse,
                            header=None,
                            show_tool_calls=self.show_tool_calls,
                            existing_event_id=message_id,
                            room_mode=room_mode,
                        )

                # Handle interactive questions in team responses
                await self._handle_interactive_question(
                    event_id,
                    accumulated,
                    room_id,
                    thread_id,
                    reply_to_event_id,
                    agent_name="team",
                )
            else:
                # Show typing indicator while team generates non-streaming response
                async with typing_indicator(client, room_id):
                    with tool_execution_identity(execution_identity), tool_runtime_context(tool_context):
                        response_text = await team_response(
                            agent_names=agent_names,
                            mode=mode,
                            message=model_message,
                            orchestrator=orchestrator,
                            thread_history=thread_history,
                            model_name=model_name,
                            media=payload.media,
                        )

                # Either edit the thinking message or send new
                if message_id:
                    await self._edit_message(room_id, message_id, response_text, thread_id)
                else:
                    assert self.client is not None
                    event_id = await self._send_response(
                        room_id,
                        reply_to_event_id,
                        response_text,
                        thread_id,
                    )
                    # Handle interactive questions in non-streaming team responses
                    if event_id:
                        await self._handle_interactive_question(
                            event_id,
                            response_text,
                            room_id,
                            thread_id,
                            reply_to_event_id,
                            agent_name="team",
                        )

        # Use unified handler for cancellation support
        # Always send thinking message unless we're editing an existing message
        thinking_msg = None
        if not existing_event_id:
            thinking_msg = "🤝 Team Response: Thinking..."

        return await self._run_cancellable_response(
            room_id=room_id,
            reply_to_event_id=reply_to_event_id,
            thread_id=thread_id,
            response_function=generate_team_response,
            thinking_message=thinking_msg,
            existing_event_id=existing_event_id,
            user_id=requester_user_id,
        )

    async def _run_cancellable_response(
        self,
        room_id: str,
        reply_to_event_id: str,
        thread_id: str | None,
        response_function: object,  # Function that generates the response (takes message_id)
        thinking_message: str | None = None,  # None means don't send thinking message
        existing_event_id: str | None = None,
        user_id: str | None = None,  # User ID for presence check
    ) -> str | None:
        """Run a response generation function with cancellation support.

        This unified handler provides:
        - Optional "Thinking..." message
        - Task cancellation via stop button (when user is online)
        - Proper cleanup on completion or cancellation

        Args:
            room_id: The room to send to
            reply_to_event_id: Event to reply to
            thread_id: Thread ID if in thread
            response_function: Async function that generates the response (takes message_id parameter)
            thinking_message: Thinking message to show (only used when existing_event_id is None)
            existing_event_id: ID of existing message to edit (for interactive questions)
            user_id: User ID for checking if they're online (for stop button decision)

        Returns:
            The initial message ID if created, None otherwise

        Note: In practice, either thinking_message or existing_event_id is provided, never both.

        """
        assert self.client is not None

        # Validate the mutual exclusivity constraint
        assert not (thinking_message and existing_event_id), (
            "thinking_message and existing_event_id are mutually exclusive"
        )

        # Send initial thinking message if not editing an existing message
        initial_message_id = None
        if thinking_message:
            assert not existing_event_id  # Redundant but makes the logic clear
            initial_message_id = await self._send_response(
                room_id,
                reply_to_event_id,
                f"{thinking_message} {IN_PROGRESS_MARKER}",
                thread_id,
            )

        # Determine which message ID to use
        message_id = existing_event_id or initial_message_id

        # Create cancellable task by calling the function with the message ID
        task: asyncio.Task[None] = asyncio.create_task(response_function(message_id))  # type: ignore[operator]

        # Track for stop button (only if we have a message to track)
        message_to_track = existing_event_id or initial_message_id
        show_stop_button = False  # Default to not showing

        if message_to_track:
            self.stop_manager.set_current(message_to_track, room_id, task, None)

            # Add stop button if configured AND user is online
            # This uses the same logic as streaming to determine if user is online
            show_stop_button = self.config.defaults.show_stop_button
            if show_stop_button and user_id:
                # Check if user is online - same logic as streaming decision
                user_is_online = await is_user_online(self.client, user_id)
                show_stop_button = user_is_online
                self.logger.info(
                    "Stop button decision",
                    message_id=message_to_track,
                    user_online=user_is_online,
                    show_button=show_stop_button,
                )

            if show_stop_button:
                self.logger.info("Adding stop button", message_id=message_to_track)
                await self.stop_manager.add_stop_button(self.client, room_id, message_to_track)

        try:
            await task
        except asyncio.CancelledError:
            self.logger.info("Response cancelled by user", message_id=message_to_track)
        except Exception as e:
            self.logger.exception("Error during response generation", error=str(e))
            raise
        finally:
            if message_to_track:
                tracked = self.stop_manager.tracked_messages.get(message_to_track)
                button_already_removed = tracked is None or tracked.reaction_event_id is None

                self.stop_manager.clear_message(
                    message_to_track,
                    client=self.client,
                    remove_button=show_stop_button and not button_already_removed,
                )

        return initial_message_id

    async def _process_and_respond(
        self,
        room_id: str,
        prompt: str,
        reply_to_event_id: str,
        thread_id: str | None,
        thread_history: list[dict],
        existing_event_id: str | None = None,
        user_id: str | None = None,
        media: MediaInputs | None = None,
        attachment_ids: list[str] | None = None,
    ) -> str | None:
        """Process a message and send a response (non-streaming)."""
        assert self.client is not None
        if not prompt.strip():
            return None

        media_inputs = media or MediaInputs()
        session_id = create_session_id(room_id, thread_id)
        knowledge = self._knowledge_for_agent(self.agent_name)
        model_prompt = self._append_matrix_prompt_context(
            prompt,
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            include_context=self._agent_has_matrix_messaging_tool(self.agent_name),
        )
        tool_context = self._build_tool_runtime_context(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            attachment_ids=attachment_ids,
        )
        execution_identity = self._build_tool_execution_identity(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            session_id=session_id,
        )
        tool_trace: list[ToolTraceEntry] = []
        run_metadata_content: dict[str, Any] = {}
        try:
            # Show typing indicator while generating response
            async with typing_indicator(self.client, room_id):
                with tool_execution_identity(execution_identity), tool_runtime_context(tool_context):
                    response_text = await ai_response(
                        agent_name=self.agent_name,
                        prompt=model_prompt,
                        session_id=session_id,
                        runtime_paths=self.runtime_paths,
                        config=self.config,
                        thread_history=thread_history,
                        room_id=room_id,
                        knowledge=knowledge,
                        user_id=user_id,
                        media=media_inputs,
                        reply_to_event_id=reply_to_event_id,
                        show_tool_calls=self.show_tool_calls,
                        tool_trace_collector=tool_trace,
                        run_metadata_collector=run_metadata_content,
                    )
        except asyncio.CancelledError:
            # Handle cancellation - send a message showing it was stopped
            self.logger.info("Non-streaming response cancelled by user", message_id=existing_event_id)
            if existing_event_id:
                cancelled_text = "**[Response cancelled by user]**"
                await self._edit_message(room_id, existing_event_id, cancelled_text, thread_id)
            raise
        except Exception as e:
            self.logger.exception("Error in non-streaming response", error=str(e))
            raise

        response_extra_content = _merge_response_extra_content(run_metadata_content, attachment_ids)
        if existing_event_id:
            # Edit the existing message
            await self._edit_message(
                room_id,
                existing_event_id,
                response_text,
                thread_id,
                tool_trace=tool_trace if self.show_tool_calls else None,
                extra_content=response_extra_content,
            )
            return existing_event_id

        response = interactive.parse_and_format_interactive(response_text, extract_mapping=True)
        event_id = await self._send_response(
            room_id,
            reply_to_event_id,
            response.formatted_text,
            thread_id,
            tool_trace=tool_trace if self.show_tool_calls else None,
            extra_content=response_extra_content,
        )
        if event_id and response.option_map and response.options_list:
            # For interactive questions, use the same thread root that _send_response uses:
            # - If already in a thread, use that thread_id
            # - If not in a thread, use reply_to_event_id (the user's message) as thread root
            # This ensures consistency with how the bot creates threads
            thread_root_for_registration = self._resolve_reply_thread_id(
                thread_id,
                reply_to_event_id,
                room_id=room_id,
            )
            interactive.register_interactive_question(
                event_id,
                room_id,
                thread_root_for_registration,
                response.option_map,
                self.agent_name,
            )
            await interactive.add_reaction_buttons(self.client, room_id, event_id, response.options_list)

        return event_id

    async def _send_skill_command_response(
        self,
        *,
        room_id: str,
        reply_to_event_id: str,
        thread_id: str | None,
        thread_history: list[dict],
        prompt: str,
        agent_name: str,
        user_id: str | None,
        reply_to_event: nio.RoomMessageText | None = None,
    ) -> str | None:
        """Send a skill command response using a specific agent."""
        assert self.client is not None
        if not prompt.strip():
            return None

        session_id = create_session_id(room_id, thread_id)
        knowledge = self._knowledge_for_agent(agent_name)
        model_prompt = self._append_matrix_prompt_context(
            prompt,
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            include_context=self._agent_has_matrix_messaging_tool(agent_name),
        )
        tool_context = self._build_tool_runtime_context(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            agent_name=agent_name,
        )
        execution_identity = self._build_tool_execution_identity(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            session_id=session_id,
            agent_name=agent_name,
        )
        reprioritize_auto_flush_sessions(
            self.storage_path,
            self.config,
            agent_name=agent_name,
            active_session_id=session_id,
        )
        show_tool_calls = self._show_tool_calls_for_agent(agent_name)
        tool_trace: list[ToolTraceEntry] = []
        run_metadata_content: dict[str, Any] = {}
        async with typing_indicator(self.client, room_id):
            with tool_execution_identity(execution_identity), tool_runtime_context(tool_context):
                response_text = await ai_response(
                    agent_name=agent_name,
                    prompt=model_prompt,
                    session_id=session_id,
                    runtime_paths=self.runtime_paths,
                    config=self.config,
                    thread_history=thread_history,
                    room_id=room_id,
                    knowledge=knowledge,
                    reply_to_event_id=reply_to_event_id,
                    show_tool_calls=show_tool_calls,
                    tool_trace_collector=tool_trace,
                    run_metadata_collector=run_metadata_content,
                )

        response = interactive.parse_and_format_interactive(response_text, extract_mapping=True)
        event_id = await self._send_response(
            room_id,
            reply_to_event_id,
            response.formatted_text,
            thread_id,
            reply_to_event=reply_to_event,
            skip_mentions=True,
            tool_trace=tool_trace if show_tool_calls else None,
            extra_content=run_metadata_content or None,
        )

        if event_id and response.option_map and response.options_list:
            thread_root_for_registration = self._resolve_reply_thread_id(
                thread_id,
                reply_to_event_id,
                room_id=room_id,
            )
            interactive.register_interactive_question(
                event_id,
                room_id,
                thread_root_for_registration,
                response.option_map,
                agent_name,
            )
            await interactive.add_reaction_buttons(
                self.client,
                room_id,
                event_id,
                response.options_list,
            )

        try:
            mark_auto_flush_dirty_session(
                self.storage_path,
                self.config,
                agent_name=agent_name,
                session_id=session_id,
            )
            if self.config.get_agent_memory_backend(agent_name) == "mem0":
                create_background_task(
                    store_conversation_memory(
                        prompt,
                        agent_name,
                        self.storage_path,
                        session_id,
                        self.config,
                        self.runtime_paths,
                        thread_history,
                        user_id,
                        execution_identity=execution_identity,
                    ),
                    name=f"memory_save_{agent_name}_{session_id}",
                )
        except Exception:  # pragma: no cover
            self.logger.debug("Skipping memory storage due to configuration error")

        return event_id

    async def _handle_interactive_question(
        self,
        event_id: str | None,
        content: str,
        room_id: str,
        thread_id: str | None,
        reply_to_event_id: str,
        agent_name: str | None = None,
    ) -> None:
        """Handle interactive question registration and reactions if present.

        Args:
            event_id: The message event ID
            content: The message content to check for interactive questions
            room_id: The Matrix room ID
            thread_id: Thread ID if in a thread
            reply_to_event_id: Event being replied to
            agent_name: Name of agent (for registration)

        """
        if not event_id or not self.client:
            return

        if interactive.should_create_interactive_question(content):
            response = interactive.parse_and_format_interactive(content, extract_mapping=True)
            if response.option_map and response.options_list:
                thread_root_for_registration = self._resolve_reply_thread_id(
                    thread_id,
                    reply_to_event_id,
                    room_id=room_id,
                )
                interactive.register_interactive_question(
                    event_id,
                    room_id,
                    thread_root_for_registration,
                    response.option_map,
                    agent_name or self.agent_name,
                )
                await interactive.add_reaction_buttons(
                    self.client,
                    room_id,
                    event_id,
                    response.options_list,
                )

    async def _process_and_respond_streaming(
        self,
        room_id: str,
        prompt: str,
        reply_to_event_id: str,
        thread_id: str | None,
        thread_history: list[dict],
        existing_event_id: str | None = None,
        user_id: str | None = None,
        media: MediaInputs | None = None,
        attachment_ids: list[str] | None = None,
    ) -> str | None:
        """Process a message and send a response (streaming)."""
        assert self.client is not None
        if not prompt.strip():
            return None

        media_inputs = media or MediaInputs()
        session_id = create_session_id(room_id, thread_id)
        knowledge = self._knowledge_for_agent(self.agent_name)
        room_mode = self.config.get_entity_thread_mode(self.agent_name, self.runtime_paths, room_id=room_id) == "room"
        model_prompt = self._append_matrix_prompt_context(
            prompt,
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            include_context=self._agent_has_matrix_messaging_tool(self.agent_name),
        )
        tool_context = self._build_tool_runtime_context(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            attachment_ids=attachment_ids,
        )
        execution_identity = self._build_tool_execution_identity(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            session_id=session_id,
        )
        run_metadata_content: dict[str, Any] = {}
        try:
            # Show typing indicator while generating response
            async with typing_indicator(self.client, room_id):
                with tool_execution_identity(execution_identity), tool_runtime_context(tool_context):
                    response_stream = stream_agent_response(
                        agent_name=self.agent_name,
                        prompt=model_prompt,
                        session_id=session_id,
                        runtime_paths=self.runtime_paths,
                        config=self.config,
                        thread_history=thread_history,
                        room_id=room_id,
                        knowledge=knowledge,
                        user_id=user_id,
                        media=media_inputs,
                        reply_to_event_id=reply_to_event_id,
                        show_tool_calls=self.show_tool_calls,
                        run_metadata_collector=run_metadata_content,
                    )
                    response_extra_content = _merge_response_extra_content(run_metadata_content, attachment_ids)

                    event_id, accumulated = await send_streaming_response(
                        self.client,
                        room_id,
                        reply_to_event_id,
                        thread_id,
                        self.matrix_id.domain,
                        self.config,
                        self.runtime_paths,
                        response_stream,
                        streaming_cls=StreamingResponse,
                        existing_event_id=existing_event_id,
                        room_mode=room_mode,
                        show_tool_calls=self.show_tool_calls,
                        extra_content=response_extra_content,
                    )

            # Handle interactive questions if present
            await self._handle_interactive_question(
                event_id,
                accumulated,
                room_id,
                thread_id,
                reply_to_event_id,
            )

        except asyncio.CancelledError:
            # send_streaming_response already preserves partial text and appends
            # a cancellation marker for the final edit.
            self.logger.info("Streaming cancelled by user", message_id=existing_event_id)
            raise
        except Exception as e:
            self.logger.exception("Error in streaming response", error=str(e))
            # Don't mark as responded if streaming failed
            return None
        else:
            return event_id

    async def _generate_response(
        self,
        room_id: str,
        prompt: str,
        reply_to_event_id: str,
        thread_id: str | None,
        thread_history: list[dict],
        existing_event_id: str | None = None,
        user_id: str | None = None,
        media: MediaInputs | None = None,
        attachment_ids: list[str] | None = None,
    ) -> str | None:
        """Generate and send/edit a response using AI.

        Args:
            room_id: The room to send the response to
            prompt: The prompt to send to the AI
            reply_to_event_id: The event to reply to
            thread_id: Thread ID if in a thread
            thread_history: Thread history for context
            existing_event_id: If provided, edit this message instead of sending a new one
                             (only used for interactive question responses)
            user_id: User ID of the sender for identifying user messages in history
            media: Optional multimodal inputs (audio/images/files/videos)
            attachment_ids: Attachment IDs available for tool-side file processing

        Returns:
            Event ID of the response message, or None if failed

        """
        assert self.client is not None
        media_inputs = media or MediaInputs()

        # Prepare session id for memory storage (store after sending response)
        session_id = create_session_id(room_id, thread_id)
        execution_identity = self._build_tool_execution_identity(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            session_id=session_id,
        )
        reprioritize_auto_flush_sessions(
            self.storage_path,
            self.config,
            agent_name=self.agent_name,
            active_session_id=session_id,
        )

        # Dynamically determine whether to use streaming based on user presence
        use_streaming = await should_use_streaming(
            self.client,
            room_id,
            requester_user_id=user_id,
            enable_streaming=self.enable_streaming,
        )

        # Create async function for generation that takes message_id as parameter
        async def generate(message_id: str | None) -> None:
            if use_streaming:
                await self._process_and_respond_streaming(
                    room_id,
                    prompt,
                    reply_to_event_id,
                    thread_id,
                    thread_history,
                    message_id,  # Edit the thinking message or existing
                    user_id=user_id,
                    media=media_inputs,
                    attachment_ids=attachment_ids,
                )
            else:
                await self._process_and_respond(
                    room_id,
                    prompt,
                    reply_to_event_id,
                    thread_id,
                    thread_history,
                    message_id,  # Edit the thinking message or existing
                    user_id=user_id,
                    media=media_inputs,
                    attachment_ids=attachment_ids,
                )

        # Use unified handler for cancellation support
        # Always send "Thinking..." message unless we're editing an existing message
        thinking_msg = None
        if not existing_event_id:
            thinking_msg = "Thinking..."

        event_id = await self._run_cancellable_response(
            room_id=room_id,
            reply_to_event_id=reply_to_event_id,
            thread_id=thread_id,
            response_function=generate,
            thinking_message=thinking_msg,
            existing_event_id=existing_event_id,
            user_id=user_id,
        )

        # Store memory after response generation.
        try:
            mark_auto_flush_dirty_session(
                self.storage_path,
                self.config,
                agent_name=self.agent_name,
                session_id=session_id,
            )
            if self.config.get_agent_memory_backend(self.agent_name) == "mem0":
                create_background_task(
                    store_conversation_memory(
                        prompt,
                        self.agent_name,
                        self.storage_path,
                        session_id,
                        self.config,
                        self.runtime_paths,
                        thread_history,
                        user_id,
                        execution_identity=execution_identity,
                    ),
                    name=f"memory_save_{self.agent_name}_{session_id}",
                )
        except Exception:
            self.logger.exception(
                "Failed to queue memory persistence after response",
                agent_name=self.agent_name,
                session_id=session_id,
                room_id=room_id,
                thread_id=thread_id,
            )

        return event_id

    async def _send_response(
        self,
        room_id: str,
        reply_to_event_id: str | None,
        response_text: str,
        thread_id: str | None,
        reply_to_event: nio.RoomMessageText | None = None,
        skip_mentions: bool = False,
        tool_trace: list[ToolTraceEntry] | None = None,
        extra_content: dict[str, Any] | None = None,
        thread_mode_override: Literal["thread", "room"] | None = None,
    ) -> str | None:
        """Send a response message to a room.

        Args:
            room_id: The room id to send to
            reply_to_event_id: The event ID to reply to (can be None when in a thread)
            response_text: The text to send
            thread_id: The thread ID if already in a thread
            reply_to_event: Optional event object for the message we're replying to (used to check for safe thread root)
            skip_mentions: If True, add metadata to indicate mentions should not trigger responses
            tool_trace: Optional structured tool trace metadata for message content
            extra_content: Optional content fields merged into the outgoing Matrix event
            thread_mode_override: Optional thread mode to enforce for this reply

        Returns:
            Event ID if message was sent successfully, None otherwise.

        """
        sender_id = self.matrix_id
        sender_domain = sender_id.domain

        effective_thread_id = self._resolve_reply_thread_id(
            thread_id,
            reply_to_event_id,
            room_id=room_id,
            event_source=reply_to_event.source if reply_to_event else None,
            thread_mode_override=thread_mode_override,
        )

        if effective_thread_id is None:
            # Room mode: plain message, no thread metadata
            content = format_message_with_mentions(
                self.config,
                self.runtime_paths,
                response_text,
                sender_domain=sender_domain,
                thread_event_id=None,
                reply_to_event_id=None,
                latest_thread_event_id=None,
                tool_trace=tool_trace,
                extra_content=extra_content,
            )
        else:
            # Get the latest message in thread for MSC3440 fallback compatibility
            latest_thread_event_id = await get_latest_thread_event_id_if_needed(
                self.client,
                room_id,
                effective_thread_id,
                reply_to_event_id,
            )

            content = format_message_with_mentions(
                self.config,
                self.runtime_paths,
                response_text,
                sender_domain=sender_domain,
                thread_event_id=effective_thread_id,
                reply_to_event_id=reply_to_event_id,
                latest_thread_event_id=latest_thread_event_id,
                tool_trace=tool_trace,
                extra_content=extra_content,
            )

        # Add metadata to indicate mentions should be ignored for responses
        if skip_mentions:
            content["com.mindroom.skip_mentions"] = True

        assert self.client is not None
        event_id = await send_message(self.client, room_id, content)
        if event_id:
            self.logger.info("Sent response", event_id=event_id, room_id=room_id)
            return event_id
        self.logger.error("Failed to send response to room", room_id=room_id)
        return None

    async def _edit_message(
        self,
        room_id: str,
        event_id: str,
        new_text: str,
        thread_id: str | None,
        tool_trace: list[ToolTraceEntry] | None = None,
        extra_content: dict[str, Any] | None = None,
    ) -> bool:
        """Edit an existing message.

        Returns:
            True if edit was successful, False otherwise.

        """
        sender_id = self.matrix_id
        sender_domain = sender_id.domain

        if self.config.get_entity_thread_mode(self.agent_name, self.runtime_paths, room_id=room_id) == "room":
            # Room mode: no thread metadata on edits
            content = format_message_with_mentions(
                self.config,
                self.runtime_paths,
                new_text,
                sender_domain=sender_domain,
                tool_trace=tool_trace,
                extra_content=extra_content,
            )
        else:
            # For edits in threads, we need to get the latest thread event ID for MSC3440 compliance
            # When editing, we still need the latest thread event for the fallback behavior
            # So we fetch it directly rather than using get_latest_thread_event_id_if_needed
            latest_thread_event_id = None
            if thread_id:
                assert self.client is not None
                # For edits, we always need the latest thread event ID
                # We can use the event being edited as the fallback if we can't get the latest
                latest_thread_event_id = await _latest_thread_event_id(self.client, room_id, thread_id)
                # If we couldn't get the latest, use the event being edited as fallback
                if latest_thread_event_id is None:
                    latest_thread_event_id = event_id

            content = format_message_with_mentions(
                self.config,
                self.runtime_paths,
                new_text,
                sender_domain=sender_domain,
                thread_event_id=thread_id,
                latest_thread_event_id=latest_thread_event_id,
                tool_trace=tool_trace,
                extra_content=extra_content,
            )

        assert self.client is not None
        response = await edit_message(self.client, room_id, event_id, content, new_text)

        if isinstance(response, nio.RoomSendResponse):
            self.logger.info("Edited message", event_id=event_id)
            return True
        self.logger.error("Failed to edit message", event_id=event_id, error=str(response))
        return False

    async def _handle_ai_routing(
        self,
        room: nio.MatrixRoom,
        event: _DispatchEvent,
        thread_history: list[dict],
        thread_id: str | None = None,
        message: str | None = None,
        requester_user_id: str | None = None,
        extra_content: dict[str, Any] | None = None,
    ) -> None:
        # Only router agent should handle routing
        assert self.agent_name == ROUTER_AGENT_NAME

        # Use configured agents only - router should not suggest random agents
        permission_sender_id = requester_user_id or event.sender
        available_agents = get_configured_agents_for_room(room.room_id, self.config, self.runtime_paths)
        available_agents = filter_agents_by_sender_permissions(
            available_agents,
            permission_sender_id,
            self.config,
            self.runtime_paths,
        )
        if not available_agents:
            self.logger.debug("No configured agents to route to in this room for sender", sender=permission_sender_id)
            return

        self.logger.info("Handling AI routing", event_id=event.event_id)

        routing_text = message or event.body
        suggested_agent = await suggest_agent_for_message(
            routing_text,
            available_agents,
            self.config,
            self.runtime_paths,
            thread_history,
        )

        if not suggested_agent:
            # Send error message when routing fails
            response_text = "⚠️ I couldn't determine which agent should help with this. Please try mentioning an agent directly with @ or rephrase your request."
            self.logger.warning("Router failed to determine agent")
        else:
            # Router mentions the suggested agent and asks them to help
            response_text = f"@{suggested_agent} could you help with this?"

        target_thread_mode = (
            self.config.get_entity_thread_mode(suggested_agent, self.runtime_paths, room_id=room.room_id)
            if suggested_agent
            else None
        )
        thread_event_id = self._resolve_reply_thread_id(
            thread_id,
            event.event_id,
            room_id=room.room_id,
            event_source=event.source,
            thread_mode_override=target_thread_mode,
        )
        routed_extra_content = dict(extra_content) if extra_content is not None else {}
        if isinstance(
            event,
            nio.RoomMessageFile
            | nio.RoomEncryptedFile
            | nio.RoomMessageVideo
            | nio.RoomEncryptedVideo
            | nio.RoomMessageImage
            | nio.RoomEncryptedImage,
        ):
            attachment_id = await self._register_routed_attachment(
                room_id=room.room_id,
                thread_id=thread_event_id,
                event=event,
            )
            if attachment_id is None:
                routed_extra_content.pop(ATTACHMENT_IDS_KEY, None)
            else:
                routed_extra_content[ATTACHMENT_IDS_KEY] = [attachment_id]

        event_id = await self._send_response(
            room_id=room.room_id,
            reply_to_event_id=event.event_id,
            response_text=response_text,
            thread_id=thread_event_id,
            extra_content=routed_extra_content or None,
            thread_mode_override=target_thread_mode,
        )
        if event_id:
            self.logger.info("Routed to agent", suggested_agent=suggested_agent)
            self.response_tracker.mark_responded(event.event_id)
        else:
            self.logger.error("Failed to route to agent", agent=suggested_agent)

    async def _handle_message_edit(
        self,
        room: nio.MatrixRoom,
        event: nio.RoomMessageText,
        event_info: EventInfo,
    ) -> None:
        """Handle an edited message by regenerating the agent's response.

        Args:
            room: The Matrix room
            event: The edited message event
            event_info: Information about the edit event

        """
        if not event_info.original_event_id:
            self.logger.debug("Edit event has no original event ID")
            return

        # Skip edits from other agents
        sender_agent_name = extract_agent_name(event.sender, self.config, self.runtime_paths)
        if sender_agent_name:
            self.logger.debug(f"Ignoring edit from other agent: {sender_agent_name}")
            return

        response_event_id = self.response_tracker.get_response_event_id(event_info.original_event_id)
        if not response_event_id:
            self.logger.debug(f"No previous response found for edited message {event_info.original_event_id}")
            return

        self.logger.info(
            "Regenerating response for edited message",
            original_event_id=event_info.original_event_id,
            response_event_id=response_event_id,
        )

        context = await self._extract_message_context(room, event)
        requester_user_id = self._requester_user_id_for_event(event)

        # Check if we should respond to the edited message
        # KNOWN LIMITATION: This doesn't work correctly for the router suggestion case.
        # When: User asks question → Router suggests agent → Agent responds → User edits
        # The agent won't regenerate because it's not mentioned in the edited message.
        # Proper fix would require tracking response chains (user → router → agent).
        should_respond = should_agent_respond(
            agent_name=self.agent_name,
            am_i_mentioned=context.am_i_mentioned,
            is_thread=context.is_thread,
            room=room,
            thread_history=context.thread_history,
            config=self.config,
            runtime_paths=self.runtime_paths,
            mentioned_agents=context.mentioned_agents,
            has_non_agent_mentions=context.has_non_agent_mentions,
            sender_id=requester_user_id,
        )

        if not should_respond:
            self.logger.debug("Agent should not respond to edited message")
            return

        # These keys must be present according to MSC2676
        # https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/2676-message-editing.md
        edited_content = event.source["content"]["m.new_content"]["body"]

        # Remove the stale run from Agno history before regenerating.
        # The original run stored reply_to_event_id (= original_event_id) as
        # matrix_event_id in its metadata, so we look up by that key.
        session_contexts = [
            (context.thread_id, create_session_id(room.room_id, context.thread_id)),
            (None, create_session_id(room.room_id, None)),
        ]
        checked_session_ids: set[str] = set()
        for candidate_thread_id, session_id in session_contexts:
            if session_id in checked_session_ids:
                continue
            checked_session_ids.add(session_id)
            execution_identity = self._build_tool_execution_identity(
                room_id=room.room_id,
                thread_id=candidate_thread_id,
                reply_to_event_id=event_info.original_event_id,
                user_id=requester_user_id,
                session_id=session_id,
            )
            with tool_execution_identity(execution_identity):
                storage = create_session_storage(self.agent_name, self.storage_path)
                removed = remove_run_by_event_id(storage, session_id, event_info.original_event_id)
            if removed:
                self.logger.info(
                    "Removed stale run for edited message",
                    event_id=event_info.original_event_id,
                    session_id=session_id,
                )

        # Generate new response
        await self._generate_response(
            room_id=room.room_id,
            prompt=edited_content,
            reply_to_event_id=event_info.original_event_id,
            thread_id=context.thread_id,
            thread_history=context.thread_history,
            existing_event_id=response_event_id,
            user_id=requester_user_id,
        )

        # Update the response tracker
        self.response_tracker.mark_responded(event_info.original_event_id, response_event_id)
        self.logger.info("Successfully regenerated response for edited message")

    async def _handle_command(self, room: nio.MatrixRoom, event: _TextDispatchEvent, command: Command) -> None:
        assert self.client is not None
        context = CommandHandlerContext(
            client=self.client,
            config=self.config,
            runtime_paths=self.runtime_paths,
            logger=self.logger,
            response_tracker=self.response_tracker,
            derive_conversation_context=self._derive_conversation_context,
            requester_user_id_for_event=self._requester_user_id_for_event,
            resolve_reply_thread_id=self._resolve_reply_thread_id,
            send_response=self._send_response,
            send_skill_command_response=self._send_skill_command_response,
        )
        await handle_command(
            context=context,
            room=room,
            event=event,
            command=command,
        )


@dataclass
class TeamBot(AgentBot):
    """A bot that represents a team of agents working together."""

    team_agents: list[MatrixID] = field(default_factory=list)
    team_mode: str = field(default="coordinate")
    team_model: str | None = field(default=None)

    @cached_property
    def agent(self) -> Agent | None:
        """Teams don't have individual agents, return None."""
        return None

    async def _generate_response(
        self,
        room_id: str,
        prompt: str,
        reply_to_event_id: str,
        thread_id: str | None,
        thread_history: list[dict],
        existing_event_id: str | None = None,
        user_id: str | None = None,
        media: MediaInputs | None = None,
        attachment_ids: list[str] | None = None,
    ) -> None:
        """Generate a team response instead of individual agent response."""
        if not prompt.strip():
            return

        assert self.client is not None

        # Store memory once for the entire team (avoids duplicate LLM processing)
        session_id = create_session_id(room_id, thread_id)
        execution_identity = self._build_tool_execution_identity(
            room_id=room_id,
            thread_id=thread_id,
            reply_to_event_id=reply_to_event_id,
            user_id=user_id,
            session_id=session_id,
        )
        # Convert MatrixID list to agent names for memory storage
        agent_names = [mid.agent_name(self.config, self.runtime_paths) or mid.username for mid in self.team_agents]
        with tool_execution_identity(execution_identity):
            create_background_task(
                store_conversation_memory(
                    prompt,
                    agent_names,  # Pass list of agent names for team storage
                    self.storage_path,
                    session_id,
                    self.config,
                    self.runtime_paths,
                    thread_history,
                    user_id,
                    execution_identity=execution_identity,
                ),
                name=f"memory_save_team_{session_id}",
            )
        self.logger.info(f"Storing memory for team: {agent_names}")

        media_inputs = media or MediaInputs()

        # Use the shared team response helper
        await self._generate_team_response_helper(
            room_id=room_id,
            reply_to_event_id=reply_to_event_id,
            thread_id=thread_id,
            payload=_DispatchPayload(
                prompt=prompt,
                media=media_inputs,
                attachment_ids=attachment_ids,
            ),
            team_agents=self.team_agents,
            team_mode=self.team_mode,
            thread_history=thread_history,
            requester_user_id=user_id or "",
            existing_event_id=existing_event_id,
        )
