//! Channel subsystem for messaging platform integrations.
//!
//! This module provides the multi-channel messaging infrastructure that connects
//! Revka to external platforms. Each channel implements the [`Channel`] trait
//! defined in [`traits`], which provides a uniform interface for sending messages,
//! listening for incoming messages, health checking, and typing indicators.
//!
//! Channels are instantiated by [`start_channels`] based on the runtime configuration.
//! The subsystem manages per-sender conversation history, concurrent message processing
//! with configurable parallelism, and exponential-backoff reconnection for resilience.
//!
//! # Extension
//!
//! To add a new channel, implement [`Channel`] in a new submodule and wire it into
//! [`start_channels`]. See `AGENTS.md` §7.2 for the full change playbook.

pub mod acp_server;
pub mod bluesky;
pub mod clawdtalk;
pub mod cli;
pub mod debounce;
pub mod dingtalk;
pub mod discord;
pub mod discord_history;
pub mod email_channel;
pub mod gmail_push;
pub mod imessage;
pub mod irc;
#[cfg(feature = "channel-lark")]
pub mod lark;
pub mod link_enricher;
pub mod linq;
#[cfg(feature = "channel-matrix")]
pub mod matrix;
pub mod mattermost;
pub mod media_pipeline;
pub mod mochat;
pub mod nextcloud_talk;
#[cfg(feature = "channel-nostr")]
pub mod nostr;
pub mod notion;
pub mod qq;
pub mod reddit;
pub mod session_backend;
pub mod session_sqlite;
pub mod session_store;
pub mod signal;
pub mod slack;
pub mod telegram;
pub mod traits;
pub mod transcription;
pub mod tts;
pub mod twitter;
pub mod voice_call;
#[cfg(feature = "voice-wake")]
pub mod voice_wake;
pub mod wati;
pub mod webhook;
pub mod wecom;
pub mod whatsapp;
#[cfg(feature = "whatsapp-web")]
pub mod whatsapp_storage;
#[cfg(feature = "whatsapp-web")]
pub mod whatsapp_web;

pub use bluesky::BlueskyChannel;
pub use clawdtalk::{ClawdTalkChannel, ClawdTalkConfig};
pub use cli::CliChannel;
pub use dingtalk::DingTalkChannel;
pub use discord::DiscordChannel;
#[allow(unused_imports)]
pub use discord_history::DiscordHistoryChannel;
pub use email_channel::EmailChannel;
pub use gmail_push::GmailPushChannel;
pub use imessage::IMessageChannel;
pub use irc::IrcChannel;
#[cfg(feature = "channel-lark")]
pub use lark::LarkChannel;
pub use linq::LinqChannel;
#[cfg(feature = "channel-matrix")]
pub use matrix::MatrixChannel;
pub use mattermost::MattermostChannel;
pub use mochat::MochatChannel;
pub use nextcloud_talk::NextcloudTalkChannel;
#[cfg(feature = "channel-nostr")]
pub use nostr::NostrChannel;
pub use notion::NotionChannel;
pub use qq::QQChannel;
pub use reddit::RedditChannel;
pub use signal::SignalChannel;
pub use slack::SlackChannel;
pub use telegram::TelegramChannel;
pub use traits::{Channel, SendMessage};
#[allow(unused_imports)]
pub use tts::{TtsManager, TtsProvider};
pub use twitter::TwitterChannel;
#[allow(unused_imports)]
pub use voice_call::{VoiceCallChannel, VoiceCallConfig};
#[cfg(feature = "voice-wake")]
pub use voice_wake::VoiceWakeChannel;
pub use wati::WatiChannel;
pub use webhook::WebhookChannel;
pub use wecom::WeComChannel;
pub use whatsapp::WhatsAppChannel;
#[cfg(feature = "whatsapp-web")]
pub use whatsapp_web::WhatsAppWebChannel;

use crate::agent::loop_::{
    build_tool_instructions, clear_model_switch_request, get_model_switch_state,
    is_model_switch_requested, run_tool_call_loop, scrub_credentials,
};
use crate::approval::ApprovalManager;
use crate::config::Config;
use crate::memory::{self, Memory};
use crate::observability::traits::{ObserverEvent, ObserverMetric};
use crate::observability::{self, Observer, runtime_trace};
use crate::providers::reliable::{scope_provider_fallback, take_last_provider_fallback};
use crate::providers::{self, ChatMessage, Provider};
use crate::runtime;
use crate::security::{AutonomyLevel, SecurityPolicy};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::{Context, Result};
use portable_atomic::{AtomicU64, Ordering};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio_util::sync::CancellationToken;

/// Observer wrapper that forwards tool-call events to a channel sender
/// for real-time threaded notifications.
struct ChannelNotifyObserver {
    inner: Arc<dyn Observer>,
    tx: tokio::sync::mpsc::UnboundedSender<String>,
    tools_used: AtomicBool,
}

impl Observer for ChannelNotifyObserver {
    fn record_event(&self, event: &ObserverEvent) {
        if let ObserverEvent::ToolCallStart { tool, arguments } = event {
            self.tools_used.store(true, Ordering::Relaxed);
            let detail = match arguments {
                Some(args) if !args.is_empty() => {
                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
                        if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
                            format!(": `{}`", truncate_with_ellipsis(cmd, 200))
                        } else if let Some(q) = v.get("query").and_then(|c| c.as_str()) {
                            format!(": {}", truncate_with_ellipsis(q, 200))
                        } else if let Some(p) = v.get("path").and_then(|c| c.as_str()) {
                            format!(": {p}")
                        } else if let Some(u) = v.get("url").and_then(|c| c.as_str()) {
                            format!(": {u}")
                        } else {
                            let s = args.to_string();
                            format!(": {}", truncate_with_ellipsis(&s, 120))
                        }
                    } else {
                        let s = args.to_string();
                        format!(": {}", truncate_with_ellipsis(&s, 120))
                    }
                }
                _ => String::new(),
            };
            let _ = self.tx.send(format!("\u{1F527} `{tool}`{detail}"));
        }
        self.inner.record_event(event);
    }
    fn record_metric(&self, metric: &ObserverMetric) {
        self.inner.record_metric(metric);
    }
    fn flush(&self) {
        self.inner.flush();
    }
    fn name(&self) -> &str {
        "channel-notify"
    }
    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}

/// Per-sender conversation history for channel messages.
type ConversationHistoryMap = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
/// Senders that requested `/new` and must force a fresh prompt on their next message.
type PendingNewSessionSet = Arc<Mutex<HashSet<String>>>;
/// Maximum history messages to keep per sender.
const MAX_CHANNEL_HISTORY: usize = 50;
/// Minimum user-message length (in chars) for auto-save to memory.
/// Messages shorter than this (e.g. "ok", "thanks") are not stored,
/// reducing noise in memory recall.
const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;

// Re-export the per-file char cap from the unified prompt builder so existing
// channel-side references (incl. tests) keep their import path.
use crate::agent::prompt::BOOTSTRAP_MAX_CHARS;

const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
const MIN_CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 30;
/// Default timeout for processing a single channel message (LLM + tools).
/// Used as fallback when not configured in channels_config.message_timeout_secs.
const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
/// Cap timeout scaling so large max_tool_iterations values do not create unbounded waits.
const CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP: u64 = 4;
const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4;
const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8;
const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
const CHANNEL_PROGRESS_HEARTBEAT_DELAY_SECS: u64 = 5;
const CHANNEL_PROGRESS_HEARTBEAT_TEXT: &str = "Working on it...";
const CHANNEL_TYPING_REFRESH_INTERVAL_SECS: u64 = 4;
const CHANNEL_HEALTH_HEARTBEAT_SECS: u64 = 30;
const MODEL_CACHE_FILE: &str = "models_cache.json";
const MODEL_CACHE_PREVIEW_LIMIT: usize = 10;
const MEMORY_CONTEXT_MAX_ENTRIES: usize = 4;
const MEMORY_CONTEXT_ENTRY_MAX_CHARS: usize = 800;
const MEMORY_CONTEXT_MAX_CHARS: usize = 4_000;
const CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES: usize = 12;
const CHANNEL_HISTORY_COMPACT_CONTENT_CHARS: usize = 600;
/// Proactive context-window budget in estimated characters (~4 chars/token).
/// When the total character count of conversation history exceeds this limit,
/// older turns are dropped before the request is sent to the provider,
/// preventing context-window-exceeded errors.  Set conservatively below
/// common context windows (128 k tokens ≈ 512 k chars) to leave room for
/// system prompt, memory context, and model output.
const PROACTIVE_CONTEXT_BUDGET_CHARS: usize = 400_000;
/// Guardrail for hook-modified outbound channel content.
const CHANNEL_HOOK_MAX_OUTBOUND_CHARS: usize = 20_000;

type ProviderCacheMap = Arc<Mutex<HashMap<String, Arc<dyn Provider>>>>;
type RouteSelectionMap = Arc<Mutex<HashMap<String, ChannelRouteSelection>>>;

fn effective_channel_message_timeout_secs(configured: u64) -> u64 {
    configured.max(MIN_CHANNEL_MESSAGE_TIMEOUT_SECS)
}

fn channel_message_timeout_budget_secs(
    message_timeout_secs: u64,
    max_tool_iterations: usize,
) -> u64 {
    channel_message_timeout_budget_secs_with_cap(
        message_timeout_secs,
        max_tool_iterations,
        CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP,
    )
}

fn channel_message_timeout_budget_secs_with_cap(
    message_timeout_secs: u64,
    max_tool_iterations: usize,
    scale_cap: u64,
) -> u64 {
    let iterations = max_tool_iterations.max(1) as u64;
    let scale = iterations.min(scale_cap);
    message_timeout_secs.saturating_mul(scale)
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ChannelRouteSelection {
    provider: String,
    model: String,
    /// Route-specific API key override. When set, this takes precedence over
    /// the global `api_key` in [`ChannelRuntimeContext`] when creating the
    /// provider for this route.
    api_key: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum ChannelRuntimeCommand {
    ShowProviders,
    SetProvider(String),
    ShowModel,
    SetModel(String),
    ShowConfig,
    NewSession,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct ModelCacheState {
    entries: Vec<ModelCacheEntry>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct ModelCacheEntry {
    provider: String,
    models: Vec<String>,
}

#[derive(Debug, Clone)]
struct ChannelRuntimeDefaults {
    default_provider: String,
    model: String,
    temperature: f64,
    api_key: Option<String>,
    api_url: Option<String>,
    reliability: crate::config::ReliabilityConfig,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ConfigFileStamp {
    modified: SystemTime,
    len: u64,
}

#[derive(Debug, Clone)]
struct RuntimeConfigState {
    defaults: ChannelRuntimeDefaults,
    last_applied_stamp: Option<ConfigFileStamp>,
}

fn runtime_config_store() -> &'static Mutex<HashMap<PathBuf, RuntimeConfigState>> {
    static STORE: OnceLock<Mutex<HashMap<PathBuf, RuntimeConfigState>>> = OnceLock::new();
    STORE.get_or_init(|| Mutex::new(HashMap::new()))
}

const SYSTEMD_STATUS_ARGS: [&str; 3] = ["--user", "is-active", "revka.service"];
const SYSTEMD_RESTART_ARGS: [&str; 3] = ["--user", "restart", "revka.service"];
const OPENRC_STATUS_ARGS: [&str; 2] = ["revka", "status"];
const OPENRC_RESTART_ARGS: [&str; 2] = ["revka", "restart"];

#[derive(Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
struct InterruptOnNewMessageConfig {
    telegram: bool,
    slack: bool,
    discord: bool,
    mattermost: bool,
    matrix: bool,
}

impl InterruptOnNewMessageConfig {
    fn enabled_for_channel(self, channel: &str) -> bool {
        match channel {
            "telegram" => self.telegram,
            "slack" => self.slack,
            "discord" => self.discord,
            "mattermost" => self.mattermost,
            "matrix" => self.matrix,
            _ => false,
        }
    }
}

#[derive(Clone)]
struct ChannelCostTrackingState {
    tracker: Arc<crate::cost::CostTracker>,
}

#[derive(Clone)]
struct ChannelRuntimeContext {
    channels_by_name: Arc<HashMap<String, Arc<dyn Channel>>>,
    provider: Arc<dyn Provider>,
    default_provider: Arc<String>,
    prompt_config: Arc<crate::config::Config>,
    memory: Arc<dyn Memory>,
    tools_registry: Arc<Vec<Box<dyn Tool>>>,
    observer: Arc<dyn Observer>,
    system_prompt: Arc<String>,
    model: Arc<String>,
    audit_logger: Option<Arc<crate::security::audit::AuditLogger>>,
    temperature: f64,
    auto_save_memory: bool,
    max_tool_iterations: usize,
    min_relevance_score: f64,
    conversation_histories: ConversationHistoryMap,
    pending_new_sessions: PendingNewSessionSet,
    provider_cache: ProviderCacheMap,
    route_overrides: RouteSelectionMap,
    api_key: Option<String>,
    api_url: Option<String>,
    reliability: Arc<crate::config::ReliabilityConfig>,
    provider_runtime_options: providers::ProviderRuntimeOptions,
    workspace_dir: Arc<PathBuf>,
    message_timeout_secs: u64,
    interrupt_on_new_message: InterruptOnNewMessageConfig,
    multimodal: crate::config::MultimodalConfig,
    media_pipeline: crate::config::MediaPipelineConfig,
    transcription_config: crate::config::TranscriptionConfig,
    hooks: Option<Arc<crate::hooks::HookRunner>>,
    non_cli_excluded_tools: Arc<Vec<String>>,
    autonomy_level: AutonomyLevel,
    tool_call_dedup_exempt: Arc<Vec<String>>,
    model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
    query_classification: crate::config::QueryClassificationConfig,
    ack_reactions: bool,
    show_tool_calls: bool,
    session_store: Option<Arc<session_store::SessionStore>>,
    /// Non-interactive approval manager for channel-driven runs.
    /// Enforces `auto_approve` / `always_ask` / supervised policy from
    /// `[autonomy]` config; auto-denies tools that would need interactive
    /// approval since no operator is present on channel runs.
    approval_manager: Arc<ApprovalManager>,
    activated_tools: Option<std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
    /// MCP registry for direct tool calls (e.g. kumiho memory engage).
    mcp_registry: Option<Arc<crate::tools::McpRegistry>>,
    cost_tracking: Option<ChannelCostTrackingState>,
    pacing: crate::config::PacingConfig,
    max_tool_result_chars: usize,
    context_token_budget: usize,
    debouncer: Arc<debounce::MessageDebouncer>,
}

#[derive(Clone)]
struct InFlightSenderTaskState {
    task_id: u64,
    cancellation: CancellationToken,
    completion: Arc<InFlightTaskCompletion>,
}

struct InFlightTaskCompletion {
    done: AtomicBool,
    notify: tokio::sync::Notify,
}

impl InFlightTaskCompletion {
    fn new() -> Self {
        Self {
            done: AtomicBool::new(false),
            notify: tokio::sync::Notify::new(),
        }
    }

    fn mark_done(&self) {
        self.done.store(true, Ordering::Release);
        self.notify.notify_waiters();
    }

    async fn wait(&self) {
        if self.done.load(Ordering::Acquire) {
            return;
        }
        self.notify.notified().await;
    }
}

fn conversation_memory_key(msg: &traits::ChannelMessage) -> String {
    // Include thread_ts for per-topic memory isolation in forum groups
    match &msg.thread_ts {
        Some(tid) => format!("{}_{}_{}_{}", msg.channel, tid, msg.sender, msg.id),
        None => format!("{}_{}_{}", msg.channel, msg.sender, msg.id),
    }
}

fn conversation_history_key(msg: &traits::ChannelMessage) -> String {
    // Include reply_target for per-channel isolation (e.g. distinct Discord/Slack
    // channels) and thread_ts for per-topic isolation in forum groups.
    match &msg.thread_ts {
        Some(tid) => format!(
            "{}_{}_{}_{}",
            msg.channel, msg.reply_target, tid, msg.sender
        ),
        None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
    }
}

fn followup_thread_id(msg: &traits::ChannelMessage) -> Option<String> {
    msg.thread_ts.clone().or_else(|| Some(msg.id.clone()))
}

fn interruption_scope_key(msg: &traits::ChannelMessage) -> String {
    match &msg.interruption_scope_id {
        Some(scope) => format!(
            "{}_{}_{}_{}",
            msg.channel, msg.reply_target, msg.sender, scope
        ),
        None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
    }
}

/// Returns `true` when `content` is a `/stop` command (with optional `@botname` suffix).
/// Not gated on channel type — all non-CLI channels support `/stop`.
fn is_stop_command(content: &str) -> bool {
    let trimmed = content.trim();
    if !trimmed.starts_with('/') {
        return false;
    }
    let cmd = trimmed.split_whitespace().next().unwrap_or("");
    let base = cmd.split('@').next().unwrap_or(cmd);
    base.eq_ignore_ascii_case("/stop")
}

fn base_channel_name(channel: &str) -> &str {
    channel.split_once(':').map_or(channel, |(base, _)| base)
}

fn is_low_latency_chat_channel(channel: &str) -> bool {
    matches!(base_channel_name(channel), "telegram" | "discord")
}

fn normalized_short_chat(content: &str) -> Option<String> {
    let trimmed = content.trim();
    if trimmed.is_empty()
        || trimmed.chars().count() > 48
        || trimmed.contains('\n')
        || trimmed.starts_with('/')
        || trimmed.contains('?')
        || trimmed.contains('？')
    {
        return None;
    }

    let lowered = trimmed.to_lowercase();
    if lowered.contains("http://")
        || lowered.contains("https://")
        || lowered.contains("www.")
        || lowered.contains("```")
        || lowered.contains('@')
    {
        return None;
    }

    let normalized = lowered
        .trim_matches(|c: char| {
            c.is_whitespace() || matches!(c, '.' | ',' | '!' | '~' | '…' | '。' | '！' | ':' | ';')
        })
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ");

    if normalized.is_empty() || normalized.split_whitespace().count() > 5 {
        return None;
    }

    Some(normalized)
}

fn casual_fast_path_reply(
    channel: &str,
    content: &str,
    has_attachments: bool,
    has_prior_history: bool,
) -> Option<&'static str> {
    if has_attachments || !is_low_latency_chat_channel(channel) {
        return None;
    }

    let normalized = normalized_short_chat(content)?;
    let english_greeting = matches!(
        normalized.as_str(),
        "hi" | "hello" | "hey" | "hey there" | "good morning" | "good afternoon" | "good evening"
    );
    let korean_greeting = matches!(
        normalized.as_str(),
        "안녕" | "안녕하세요" | "안녕하세여" | "하이" | "ㅎㅇ"
    );
    if english_greeting {
        return Some("Hi.");
    }
    if korean_greeting {
        return Some("안녕하세요.");
    }

    let english_ack = matches!(
        normalized.as_str(),
        "ok" | "okay"
            | "k"
            | "yes"
            | "yep"
            | "yeah"
            | "yup"
            | "got it"
            | "understood"
            | "sounds good"
            | "thanks"
            | "thank you"
            | "thx"
            | "cool"
            | "nice"
            | "great"
    );
    if english_ack {
        if has_prior_history
            && matches!(
                normalized.as_str(),
                "ok" | "okay" | "k" | "yes" | "yep" | "yeah" | "yup" | "got it" | "understood"
            )
        {
            return None;
        }
        return Some("Got it.");
    }

    let korean_ack = matches!(
        normalized.as_str(),
        "네" | "넵"
            | "예"
            | "응"
            | "ㅇㅇ"
            | "ㅇㅋ"
            | "오케이"
            | "확인"
            | "확인했습니다"
            | "알겠습니다"
            | "좋아"
            | "좋습니다"
            | "고마워"
            | "고마워요"
            | "감사"
            | "감사합니다"
            | "ㄱㅅ"
    );
    if korean_ack {
        if has_prior_history
            && matches!(
                normalized.as_str(),
                "네" | "넵"
                    | "예"
                    | "응"
                    | "ㅇㅇ"
                    | "ㅇㅋ"
                    | "오케이"
                    | "확인"
                    | "확인했습니다"
                    | "알겠습니다"
                    | "좋아"
                    | "좋습니다"
            )
        {
            return None;
        }
        return Some("확인했습니다.");
    }

    None
}

/// Strip tool-call XML tags from outgoing messages.
///
/// LLM responses may contain `<function_calls>`, `<function_call>`,
/// `<tool_call>`, `<toolcall>`, `<tool-call>`, `<tool>`, or `<invoke>`
/// blocks that are internal protocol and must not be forwarded to end
/// users on any channel.
fn strip_tool_call_tags(message: &str) -> String {
    const TOOL_CALL_OPEN_TAGS: [&str; 7] = [
        "<function_calls>",
        "<function_call>",
        "<tool_call>",
        "<toolcall>",
        "<tool-call>",
        "<tool>",
        "<invoke>",
    ];

    fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
        tags.iter()
            .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
            .min_by_key(|(idx, _)| *idx)
    }

    fn matching_close_tag(open_tag: &str) -> Option<&'static str> {
        match open_tag {
            "<function_calls>" => Some("</function_calls>"),
            "<function_call>" => Some("</function_call>"),
            "<tool_call>" => Some("</tool_call>"),
            "<toolcall>" => Some("</toolcall>"),
            "<tool-call>" => Some("</tool-call>"),
            "<tool>" => Some("</tool>"),
            "<invoke>" => Some("</invoke>"),
            _ => None,
        }
    }

    fn extract_first_json_end(input: &str) -> Option<usize> {
        let trimmed = input.trim_start();
        let trim_offset = input.len().saturating_sub(trimmed.len());

        for (byte_idx, ch) in trimmed.char_indices() {
            if ch != '{' && ch != '[' {
                continue;
            }

            let slice = &trimmed[byte_idx..];
            let mut stream =
                serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
            if let Some(Ok(_value)) = stream.next() {
                let consumed = stream.byte_offset();
                if consumed > 0 {
                    return Some(trim_offset + byte_idx + consumed);
                }
            }
        }

        None
    }

    fn strip_leading_close_tags(mut input: &str) -> &str {
        loop {
            let trimmed = input.trim_start();
            if !trimmed.starts_with("</") {
                return trimmed;
            }

            let Some(close_end) = trimmed.find('>') else {
                return "";
            };
            input = &trimmed[close_end + 1..];
        }
    }

    let mut kept_segments = Vec::new();
    let mut remaining = message;

    while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
        let before = &remaining[..start];
        if !before.is_empty() {
            kept_segments.push(before.to_string());
        }

        let Some(close_tag) = matching_close_tag(open_tag) else {
            break;
        };
        let after_open = &remaining[start + open_tag.len()..];

        if let Some(close_idx) = after_open.find(close_tag) {
            remaining = &after_open[close_idx + close_tag.len()..];
            continue;
        }

        if let Some(consumed_end) = extract_first_json_end(after_open) {
            remaining = strip_leading_close_tags(&after_open[consumed_end..]);
            continue;
        }

        kept_segments.push(remaining[start..].to_string());
        remaining = "";
        break;
    }

    if !remaining.is_empty() {
        kept_segments.push(remaining.to_string());
    }

    let mut result = kept_segments.concat();

    // Clean up any resulting blank lines (but preserve paragraphs)
    while result.contains("\n\n\n") {
        result = result.replace("\n\n\n", "\n\n");
    }

    result.trim().to_string()
}

fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
    match channel_name {
        "matrix" => Some(
            "When responding on Matrix:\n\
             - Use Markdown formatting (bold, italic, code blocks)\n\
             - Be concise and direct\n\
             - When you receive a [Voice message], the user spoke to you. Respond naturally as in conversation.\n\
             - Your text reply will automatically be converted to audio and sent back as a voice message.\n",
        ),
        "telegram" => Some(
            "When responding on Telegram:\n\
             - Include media markers for files or URLs that should be sent as attachments\n\
             - Use **bold** for key terms, section titles, and important info (renders as <b>)\n\
             - Use *italic* for emphasis (renders as <i>)\n\
             - Use `backticks` for inline code, commands, or technical terms\n\
             - Use triple backticks for code blocks\n\
             - Use emoji naturally to add personality — but don't overdo it\n\
             - Be concise and direct. Skip filler phrases like 'Great question!' or 'Certainly!'\n\
             - Structure longer answers with bold headers, not raw markdown ## headers\n\
             - For media attachments use markers: [IMAGE:<path-or-url>], [DOCUMENT:<path-or-url>], [VIDEO:<path-or-url>], [AUDIO:<path-or-url>], or [VOICE:<path-or-url>]\n\
             - Keep normal text outside markers and never wrap markers in code fences.\n\
             - Use tool results silently: answer the latest user message directly, and do not narrate delayed/internal tool execution bookkeeping.",
        ),
        "qq" => Some(
            "When responding on QQ:\n\
             - Use Markdown formatting\n\
             - Be concise and direct\n\
             - For media attachments use markers: [IMAGE:<path-or-url>], [DOCUMENT:<path-or-url>], \
               [VIDEO:<path-or-url>], [VOICE:<path-or-url>]\n\
             - Voice supports .wav, .mp3, .silk formats only. Other audio formats use [DOCUMENT:]\n\
             - Keep normal text outside markers and never wrap markers in code fences.\n",
        ),
        _ => None,
    }
}

fn build_channel_system_prompt(
    base_prompt: &str,
    channel_name: &str,
    reply_target: &str,
) -> String {
    let mut prompt = base_prompt.to_string();

    // Refresh the stale datetime in the cached system prompt
    {
        let now = chrono::Local::now();
        let fresh = format!(
            "## Current Date & Time\n\n{} ({})\n",
            now.format("%Y-%m-%d %H:%M:%S"),
            now.format("%Z"),
        );
        if let Some(start) = prompt.find("## Current Date & Time\n\n") {
            // Find the end of this section (next "## " heading or end of string)
            let rest = &prompt[start + 24..]; // skip past "## Current Date & Time\n\n"
            let section_end = rest
                .find("\n## ")
                .map(|i| start + 24 + i)
                .unwrap_or(prompt.len());
            prompt.replace_range(start..section_end, fresh.trim_end());
        }
    }

    if let Some(instructions) = channel_delivery_instructions(channel_name) {
        if prompt.is_empty() {
            prompt = instructions.to_string();
        } else {
            prompt = format!("{prompt}\n\n{instructions}");
        }
    }

    if !reply_target.is_empty() {
        let context = format!(
            "\n\nChannel context: You are currently responding on channel={channel_name}, \
             reply_target={reply_target}. When scheduling delayed messages or reminders \
             via cron_add for this conversation, use delivery={{\"mode\":\"announce\",\
             \"channel\":\"{channel_name}\",\"to\":\"{reply_target}\"}} so the message \
             reaches the user."
        );
        prompt.push_str(&context);
    }

    prompt
}

fn normalize_cached_channel_turns(turns: Vec<ChatMessage>) -> Vec<ChatMessage> {
    let mut normalized = Vec::with_capacity(turns.len());
    let mut expecting_user = true;

    for turn in turns {
        match (expecting_user, turn.role.as_str()) {
            // Pass through tool-role messages preserved by
            // keep_tool_context_turns (#4827).  After a tool result the
            // next expected message is an assistant response, same as
            // after a user message.
            (_, "tool") | (true, "user") => {
                normalized.push(turn);
                expecting_user = false;
            }
            (false, "assistant") => {
                normalized.push(turn);
                expecting_user = true;
            }
            // Interrupted channel turns can produce consecutive user messages
            // (no assistant persisted yet). Merge instead of dropping.
            (false, "user") | (true, "assistant") => {
                if let Some(last_turn) = normalized.last_mut() {
                    if !turn.content.is_empty() {
                        if !last_turn.content.is_empty() {
                            last_turn.content.push_str("\n\n");
                        }
                        last_turn.content.push_str(&turn.content);
                    }
                }
            }
            _ => {}
        }
    }

    normalized
}

/// Remove `<tool_result …>…</tool_result>` blocks (and a leading `[Tool results]`
/// header, if present) from a conversation-history entry so that stale tool
/// output is never presented to the LLM without the corresponding `<tool_call>`.
fn strip_tool_result_content(text: &str) -> String {
    static TOOL_RESULT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
        regex::Regex::new(r"(?s)<tool_result[^>]*>.*?</tool_result>").unwrap()
    });

    let cleaned = TOOL_RESULT_RE.replace_all(text, "");
    let cleaned = cleaned.trim();

    // If the only remaining content is the header, drop it entirely.
    if cleaned == "[Tool results]" || cleaned.is_empty() {
        return String::new();
    }

    cleaned.to_string()
}

/// Remove a leading `[Used tools: ...]` line from a cached assistant turn.
///
/// The tool-context summary is prepended to history entries so the LLM retains
/// awareness of prior tool usage. However, when these entries are loaded back
/// into the LLM context, the bracket-format leaks into generated output and
/// gets forwarded to end users as-is (bug #4400). Stripping the prefix on
/// reload prevents the model from learning and reproducing this internal format.
fn strip_tool_summary_prefix(text: &str) -> String {
    if let Some(rest) = text.strip_prefix("[Used tools:") {
        // Find the closing bracket, then skip it and any leading newline(s).
        if let Some(bracket_end) = rest.find(']') {
            let after_bracket = &rest[bracket_end + 1..];
            let trimmed = after_bracket.trim_start_matches('\n');
            if trimmed.is_empty() {
                return String::new();
            }
            return trimmed.to_string();
        }
    }
    text.to_string()
}

fn supports_runtime_model_switch(channel_name: &str) -> bool {
    matches!(channel_name, "telegram" | "discord" | "matrix" | "slack")
}

fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRuntimeCommand> {
    let trimmed = content.trim();
    if !trimmed.starts_with('/') {
        return None;
    }

    let mut parts = trimmed.split_whitespace();
    let command_token = parts.next()?;
    let base_command = command_token
        .split('@')
        .next()
        .unwrap_or(command_token)
        .to_ascii_lowercase();

    match base_command.as_str() {
        // `/new` is available on every channel — no model-switch gate.
        "/new" => Some(ChannelRuntimeCommand::NewSession),
        // Model/provider switching is channel-gated.
        "/models" if supports_runtime_model_switch(channel_name) => {
            if let Some(provider) = parts.next() {
                Some(ChannelRuntimeCommand::SetProvider(
                    provider.trim().to_string(),
                ))
            } else {
                Some(ChannelRuntimeCommand::ShowProviders)
            }
        }
        "/model" if supports_runtime_model_switch(channel_name) => {
            let model = parts.collect::<Vec<_>>().join(" ").trim().to_string();
            if model.is_empty() {
                Some(ChannelRuntimeCommand::ShowModel)
            } else {
                Some(ChannelRuntimeCommand::SetModel(model))
            }
        }
        "/config" if supports_runtime_model_switch(channel_name) => {
            Some(ChannelRuntimeCommand::ShowConfig)
        }
        _ => None,
    }
}

fn resolve_provider_alias(name: &str) -> Option<String> {
    let candidate = name.trim();
    if candidate.is_empty() {
        return None;
    }

    let providers_list = providers::list_providers();
    for provider in providers_list {
        if provider.name.eq_ignore_ascii_case(candidate)
            || provider
                .aliases
                .iter()
                .any(|alias| alias.eq_ignore_ascii_case(candidate))
        {
            return Some(provider.name.to_string());
        }
    }

    None
}

fn resolved_default_provider(config: &Config) -> String {
    config
        .default_provider
        .clone()
        .unwrap_or_else(|| "openrouter".to_string())
}

fn resolved_default_model(config: &Config) -> String {
    config
        .default_model
        .clone()
        .unwrap_or_else(|| "anthropic/claude-sonnet-4.6".to_string())
}

fn runtime_defaults_from_config(config: &Config) -> ChannelRuntimeDefaults {
    ChannelRuntimeDefaults {
        default_provider: resolved_default_provider(config),
        model: resolved_default_model(config),
        temperature: config.default_temperature,
        api_key: config.api_key.clone(),
        api_url: config.api_url.clone(),
        reliability: config.reliability.clone(),
    }
}

fn runtime_config_path(ctx: &ChannelRuntimeContext) -> Option<PathBuf> {
    ctx.provider_runtime_options
        .revka_dir
        .as_ref()
        .map(|dir| dir.join("config.toml"))
}

fn runtime_defaults_snapshot(ctx: &ChannelRuntimeContext) -> ChannelRuntimeDefaults {
    if let Some(config_path) = runtime_config_path(ctx) {
        let store = runtime_config_store()
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        if let Some(state) = store.get(&config_path) {
            return state.defaults.clone();
        }
    }

    ChannelRuntimeDefaults {
        default_provider: ctx.default_provider.as_str().to_string(),
        model: ctx.model.as_str().to_string(),
        temperature: ctx.temperature,
        api_key: ctx.api_key.clone(),
        api_url: ctx.api_url.clone(),
        reliability: (*ctx.reliability).clone(),
    }
}

async fn config_file_stamp(path: &Path) -> Option<ConfigFileStamp> {
    let metadata = tokio::fs::metadata(path).await.ok()?;
    let modified = metadata.modified().ok()?;
    Some(ConfigFileStamp {
        modified,
        len: metadata.len(),
    })
}

fn decrypt_optional_secret_for_runtime_reload(
    store: &crate::security::SecretStore,
    value: &mut Option<String>,
    field_name: &str,
) -> Result<()> {
    if let Some(raw) = value.clone() {
        if crate::security::SecretStore::is_encrypted(&raw) {
            *value = Some(
                store
                    .decrypt(&raw)
                    .with_context(|| format!("Failed to decrypt {field_name}"))?,
            );
        }
    }
    Ok(())
}

async fn load_runtime_defaults_from_config_file(path: &Path) -> Result<ChannelRuntimeDefaults> {
    let contents = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("Failed to read {}", path.display()))?;
    let mut parsed: Config =
        toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))?;
    parsed.config_path = path.to_path_buf();

    if let Some(revka_dir) = path.parent() {
        let store = crate::security::SecretStore::new(revka_dir, parsed.secrets.encrypt);
        decrypt_optional_secret_for_runtime_reload(&store, &mut parsed.api_key, "config.api_key")?;
        // Decrypt TTS provider API keys for runtime reload
        if let Some(ref mut openai) = parsed.tts.openai {
            decrypt_optional_secret_for_runtime_reload(
                &store,
                &mut openai.api_key,
                "config.tts.openai.api_key",
            )?;
        }
        if let Some(ref mut elevenlabs) = parsed.tts.elevenlabs {
            decrypt_optional_secret_for_runtime_reload(
                &store,
                &mut elevenlabs.api_key,
                "config.tts.elevenlabs.api_key",
            )?;
        }
        if let Some(ref mut google) = parsed.tts.google {
            decrypt_optional_secret_for_runtime_reload(
                &store,
                &mut google.api_key,
                "config.tts.google.api_key",
            )?;
        }
    }

    parsed.apply_env_overrides();
    Ok(runtime_defaults_from_config(&parsed))
}

async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Result<()> {
    let Some(config_path) = runtime_config_path(ctx) else {
        return Ok(());
    };

    let Some(stamp) = config_file_stamp(&config_path).await else {
        return Ok(());
    };

    {
        let store = runtime_config_store()
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        if let Some(state) = store.get(&config_path) {
            if state.last_applied_stamp == Some(stamp) {
                return Ok(());
            }
        }
    }

    let next_defaults = load_runtime_defaults_from_config_file(&config_path).await?;
    let next_default_provider = providers::create_resilient_provider_with_options(
        &next_defaults.default_provider,
        next_defaults.api_key.as_deref(),
        next_defaults.api_url.as_deref(),
        &next_defaults.reliability,
        &ctx.provider_runtime_options,
    )?;
    let next_default_provider: Arc<dyn Provider> = Arc::from(next_default_provider);

    if let Err(err) = next_default_provider.warmup().await {
        if crate::providers::reliable::is_non_retryable(&err) {
            tracing::warn!(
                provider = %next_defaults.default_provider,
                model = %next_defaults.model,
                "Rejecting config reload: model not available (non-retryable): {err}"
            );
            return Ok(());
        }
        tracing::warn!(
            provider = %next_defaults.default_provider,
            "Provider warmup failed after config reload (retryable, applying anyway): {err}"
        );
    }

    {
        let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
        cache.clear();
        cache.insert(
            next_defaults.default_provider.clone(),
            Arc::clone(&next_default_provider),
        );
    }

    {
        let mut store = runtime_config_store()
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        store.insert(
            config_path.clone(),
            RuntimeConfigState {
                defaults: next_defaults.clone(),
                last_applied_stamp: Some(stamp),
            },
        );
    }

    tracing::info!(
        path = %config_path.display(),
        provider = %next_defaults.default_provider,
        model = %next_defaults.model,
        temperature = next_defaults.temperature,
        "Applied updated channel runtime config from disk"
    );

    Ok(())
}

fn default_route_selection(ctx: &ChannelRuntimeContext) -> ChannelRouteSelection {
    let defaults = runtime_defaults_snapshot(ctx);
    ChannelRouteSelection {
        provider: defaults.default_provider,
        model: defaults.model,
        api_key: None,
    }
}

fn get_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str) -> ChannelRouteSelection {
    ctx.route_overrides
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .get(sender_key)
        .cloned()
        .unwrap_or_else(|| default_route_selection(ctx))
}

fn set_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str, next: ChannelRouteSelection) {
    let default_route = default_route_selection(ctx);
    let mut routes = ctx
        .route_overrides
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    if next == default_route {
        routes.remove(sender_key);
    } else {
        routes.insert(sender_key.to_string(), next);
    }
}

fn clear_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) {
    ctx.conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .remove(sender_key);
}

fn mark_sender_for_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) {
    ctx.pending_new_sessions
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .insert(sender_key.to_string());
}

fn take_pending_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
    ctx.pending_new_sessions
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .remove(sender_key)
}

fn replace_available_skills_section(base_prompt: &str, refreshed_skills: &str) -> String {
    const SKILLS_HEADER: &str = "## Available Skills\n\n";
    const SKILLS_END: &str = "</available_skills>";
    const WORKSPACE_HEADER: &str = "## Workspace\n\n";

    if let Some(start) = base_prompt.find(SKILLS_HEADER) {
        if let Some(rel_end) = base_prompt[start..].find(SKILLS_END) {
            let end = start + rel_end + SKILLS_END.len();
            let tail = base_prompt[end..]
                .strip_prefix("\n\n")
                .unwrap_or(&base_prompt[end..]);

            let mut refreshed = String::with_capacity(
                base_prompt.len().saturating_sub(end.saturating_sub(start))
                    + refreshed_skills.len()
                    + 2,
            );
            refreshed.push_str(&base_prompt[..start]);
            if !refreshed_skills.is_empty() {
                refreshed.push_str(refreshed_skills);
                refreshed.push_str("\n\n");
            }
            refreshed.push_str(tail);
            return refreshed;
        }
    }

    if refreshed_skills.is_empty() {
        return base_prompt.to_string();
    }

    if let Some(workspace_start) = base_prompt.find(WORKSPACE_HEADER) {
        let mut refreshed = String::with_capacity(base_prompt.len() + refreshed_skills.len() + 2);
        refreshed.push_str(&base_prompt[..workspace_start]);
        refreshed.push_str(refreshed_skills);
        refreshed.push_str("\n\n");
        refreshed.push_str(&base_prompt[workspace_start..]);
        return refreshed;
    }

    format!("{base_prompt}\n\n{refreshed_skills}")
}

fn refreshed_new_session_system_prompt(ctx: &ChannelRuntimeContext) -> String {
    let refreshed_skills = crate::skills::skills_to_prompt_with_mode(
        &crate::skills::load_skills_with_config(
            ctx.workspace_dir.as_ref(),
            ctx.prompt_config.as_ref(),
        ),
        ctx.workspace_dir.as_ref(),
        ctx.prompt_config.skills.prompt_injection_mode,
    );
    replace_available_skills_section(ctx.system_prompt.as_str(), &refreshed_skills)
}

fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
    let mut histories = ctx
        .conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner());

    let Some(turns) = histories.get_mut(sender_key) else {
        return false;
    };

    if turns.is_empty() {
        return false;
    }

    let keep_from = turns
        .len()
        .saturating_sub(CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
    let mut compacted = normalize_cached_channel_turns(turns[keep_from..].to_vec());

    for turn in &mut compacted {
        if turn.content.chars().count() > CHANNEL_HISTORY_COMPACT_CONTENT_CHARS {
            turn.content =
                truncate_with_ellipsis(&turn.content, CHANNEL_HISTORY_COMPACT_CONTENT_CHARS);
        }
    }

    if compacted.is_empty() {
        turns.clear();
        return false;
    }

    *turns = compacted;
    true
}

/// Proactively trim conversation turns so that the total estimated character
/// count stays within [`PROACTIVE_CONTEXT_BUDGET_CHARS`].  Drops the oldest
/// turns first, but always preserves the most recent turn (the current user
/// message).  Returns the number of turns dropped.
fn proactive_trim_turns(turns: &mut Vec<ChatMessage>, budget: usize) -> usize {
    let total_chars: usize = turns.iter().map(|t| t.content.chars().count()).sum();
    if total_chars <= budget || turns.len() <= 1 {
        return 0;
    }

    let mut excess = total_chars.saturating_sub(budget);
    let mut drop_count = 0;

    // Walk from the oldest turn forward, but never drop the very last turn.
    while excess > 0 && drop_count < turns.len().saturating_sub(1) {
        excess = excess.saturating_sub(turns[drop_count].content.chars().count());
        drop_count += 1;
    }

    if drop_count > 0 {
        turns.drain(..drop_count);
    }
    drop_count
}

fn append_sender_turn(ctx: &ChannelRuntimeContext, sender_key: &str, turn: ChatMessage) {
    // Persist to JSONL before adding to in-memory history.
    if let Some(ref store) = ctx.session_store {
        if let Err(e) = store.append(sender_key, &turn) {
            tracing::warn!("Failed to persist session turn: {e}");
        }
    }

    // Use the user-configured max_history_messages (fall back to
    // MAX_CHANNEL_HISTORY when the config value is 0 or absent).
    let max_history = {
        let configured = ctx.prompt_config.agent.max_history_messages;
        if configured > 0 {
            configured
        } else {
            MAX_CHANNEL_HISTORY
        }
    };

    let mut histories = ctx
        .conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let turns = histories.entry(sender_key.to_string()).or_default();
    turns.push(turn);
    while turns.len() > max_history {
        turns.remove(0);
    }
}

/// Extract tool-call (assistant with tool_call content) and tool-result
/// messages from the current turn in the LLM history, excluding the final
/// assistant text response.  "Current turn" = everything after the last
/// user-role message.
fn extract_current_turn_tool_messages(history: &[ChatMessage]) -> Vec<ChatMessage> {
    // Find the index of the last user message — tool messages for the
    // current turn come after it.
    let last_user_idx = history.iter().rposition(|m| m.role == "user").unwrap_or(0);

    let tail = &history[last_user_idx + 1..];
    if tail.is_empty() {
        return Vec::new();
    }

    // Everything except the very last assistant message (which is the
    // final text response that gets stored separately).
    let end = if tail.last().is_some_and(|m| m.role == "assistant") {
        tail.len() - 1
    } else {
        tail.len()
    };

    tail[..end]
        .iter()
        .filter(|m| m.role == "assistant" || m.role == "tool")
        .cloned()
        .collect()
}

/// Remove tool-role and intermediate assistant tool-call messages from
/// conversation turns older than the most recent `keep_turns` user→assistant
/// exchanges.  This prevents unbounded history growth while preserving
/// tool context for the N most recent turns.
fn strip_old_tool_context(ctx: &ChannelRuntimeContext, sender_key: &str, keep_turns: usize) {
    let mut histories = ctx
        .conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner());

    let Some(turns) = histories.get_mut(sender_key) else {
        return;
    };

    // Walk backwards to find the boundary: count user messages to
    // identify which turns are "recent" (protected from stripping).
    let mut user_count = 0;
    let mut protect_from = turns.len();
    for (i, turn) in turns.iter().enumerate().rev() {
        if turn.role == "user" {
            user_count += 1;
            if user_count > keep_turns {
                // Everything before this index is old enough to strip.
                protect_from = i + 1; // protect from next message onward
                break;
            }
        }
    }

    // Remove tool and intermediate assistant messages before the boundary.
    // An "intermediate assistant" is one whose content looks like a tool
    // call (contains `<tool_call>` or starts with `{\"tool_call`).
    let mut i = 0;
    while i < protect_from && i < turns.len() {
        let dominated = turns[i].role == "tool"
            || (turns[i].role == "assistant" && is_tool_call_content(&turns[i].content));
        if dominated {
            turns.remove(i);
            // Adjust boundary since we removed an element.
            protect_from = protect_from.saturating_sub(1);
        } else {
            i += 1;
        }
    }
}

/// Heuristic: does this assistant message content represent a tool call
/// rather than a final text response?
fn is_tool_call_content(content: &str) -> bool {
    let trimmed = content.trim();
    trimmed.contains("<tool_call>")
        || trimmed.starts_with("{\"tool_call\"")
        || trimmed.starts_with("{\"name\"")
}

fn rollback_orphan_user_turn(
    ctx: &ChannelRuntimeContext,
    sender_key: &str,
    expected_content: &str,
) -> bool {
    let mut histories = ctx
        .conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    let Some(turns) = histories.get_mut(sender_key) else {
        return false;
    };

    let should_pop = turns
        .last()
        .is_some_and(|turn| turn.role == "user" && turn.content == expected_content);
    if !should_pop {
        return false;
    }

    turns.pop();
    if turns.is_empty() {
        histories.remove(sender_key);
    }

    // Also remove the orphan turn from the persisted JSONL session store so
    // it doesn't resurface after a daemon restart (fixes #3674).
    if let Some(ref store) = ctx.session_store {
        if let Err(e) = store.remove_last(sender_key) {
            tracing::warn!("Failed to rollback session store entry: {e}");
        }
    }

    true
}

fn should_rollback_failed_user_turn(error: &anyhow::Error) -> bool {
    if error
        .downcast_ref::<providers::ProviderCapabilityError>()
        .is_some_and(|capability| capability.capability.eq_ignore_ascii_case("vision"))
    {
        return true;
    }

    crate::providers::reliable::is_non_retryable(error)
}

fn should_skip_memory_context_entry(key: &str, content: &str) -> bool {
    if memory::is_assistant_autosave_key(key) {
        return true;
    }

    if memory::should_skip_autosave_content(content) {
        return true;
    }

    if key.trim().to_ascii_lowercase().ends_with("_history") {
        return true;
    }

    // Skip entries containing image markers to prevent duplication.
    // When auto_save stores a photo message to memory, a subsequent
    // memory recall on the same turn would surface the marker again,
    // causing two identical image blocks in the provider request.
    if content.contains("[IMAGE:") {
        return true;
    }

    // Skip entries containing tool_result blocks. After a daemon restart
    // these can be recalled from SQLite and injected as memory context,
    // presenting the LLM with a `<tool_result>` without a preceding
    // `<tool_call>` and triggering hallucinated output.
    if content.contains("<tool_result") {
        return true;
    }

    content.chars().count() > MEMORY_CONTEXT_MAX_CHARS
}

fn is_context_window_overflow_error(err: &anyhow::Error) -> bool {
    let lower = err.to_string().to_lowercase();
    [
        "exceeds the context window",
        "context window of this model",
        "maximum context length",
        "context length exceeded",
        "too many tokens",
        "token limit exceeded",
        "prompt is too long",
        "input is too long",
    ]
    .iter()
    .any(|hint| lower.contains(hint))
}

fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec<String> {
    let cache_path = workspace_dir.join("state").join(MODEL_CACHE_FILE);
    let Ok(raw) = std::fs::read_to_string(cache_path) else {
        return Vec::new();
    };
    let Ok(state) = serde_json::from_str::<ModelCacheState>(&raw) else {
        return Vec::new();
    };

    state
        .entries
        .into_iter()
        .find(|entry| entry.provider == provider_name)
        .map(|entry| {
            entry
                .models
                .into_iter()
                .take(MODEL_CACHE_PREVIEW_LIMIT)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default()
}

/// Build a cache key that includes the provider name and, when a
/// route-specific API key is supplied, a hash of that key. This prevents
/// cache poisoning when multiple routes target the same provider with
/// different credentials.
fn provider_cache_key(provider_name: &str, route_api_key: Option<&str>) -> String {
    match route_api_key {
        Some(key) => {
            use std::hash::{Hash, Hasher};
            let mut hasher = std::collections::hash_map::DefaultHasher::new();
            key.hash(&mut hasher);
            format!("{provider_name}@{:x}", hasher.finish())
        }
        None => provider_name.to_string(),
    }
}

async fn get_or_create_provider(
    ctx: &ChannelRuntimeContext,
    provider_name: &str,
    route_api_key: Option<&str>,
) -> anyhow::Result<Arc<dyn Provider>> {
    let cache_key = provider_cache_key(provider_name, route_api_key);

    if let Some(existing) = ctx
        .provider_cache
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .get(&cache_key)
        .cloned()
    {
        return Ok(existing);
    }

    // Only return the pre-built default provider when there is no
    // route-specific credential override — otherwise the default was
    // created with the global key and would be wrong.
    if route_api_key.is_none() && provider_name == ctx.default_provider.as_str() {
        return Ok(Arc::clone(&ctx.provider));
    }

    let defaults = runtime_defaults_snapshot(ctx);
    let api_url = if provider_name == defaults.default_provider.as_str() {
        defaults.api_url.as_deref()
    } else {
        None
    };

    // Prefer route-specific credential; fall back to the global key.
    let effective_api_key = route_api_key
        .map(ToString::to_string)
        .or_else(|| ctx.api_key.clone());

    let provider = create_resilient_provider_nonblocking(
        provider_name,
        effective_api_key,
        api_url.map(ToString::to_string),
        ctx.reliability.as_ref().clone(),
        ctx.provider_runtime_options.clone(),
    )
    .await?;
    let provider: Arc<dyn Provider> = Arc::from(provider);

    if let Err(err) = provider.warmup().await {
        tracing::warn!(provider = provider_name, "Provider warmup failed: {err}");
    }

    let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
    let cached = cache
        .entry(cache_key)
        .or_insert_with(|| Arc::clone(&provider));
    Ok(Arc::clone(cached))
}

async fn create_resilient_provider_nonblocking(
    provider_name: &str,
    api_key: Option<String>,
    api_url: Option<String>,
    reliability: crate::config::ReliabilityConfig,
    provider_runtime_options: providers::ProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn Provider>> {
    let provider_name = provider_name.to_string();
    tokio::task::spawn_blocking(move || {
        providers::create_resilient_provider_with_options(
            &provider_name,
            api_key.as_deref(),
            api_url.as_deref(),
            &reliability,
            &provider_runtime_options,
        )
    })
    .await
    .context("failed to join provider initialization task")?
}

fn build_models_help_response(
    current: &ChannelRouteSelection,
    workspace_dir: &Path,
    model_routes: &[crate::config::ModelRouteConfig],
) -> String {
    let mut response = String::new();
    let _ = writeln!(
        response,
        "Current provider: `{}`\nCurrent model: `{}`",
        current.provider, current.model
    );
    response.push_str("\nSwitch model with `/model <model-id>` or `/model <hint>`.\n");

    if !model_routes.is_empty() {
        response.push_str("\nConfigured model routes:\n");
        for route in model_routes {
            let _ = writeln!(
                response,
                "  `{}` → {} ({})",
                route.hint, route.model, route.provider
            );
        }
    }

    let cached_models = load_cached_model_preview(workspace_dir, &current.provider);
    if cached_models.is_empty() {
        let _ = writeln!(
            response,
            "\nNo cached model list found for `{}`. Ask the operator to run `revka models refresh --provider {}`.",
            current.provider, current.provider
        );
    } else {
        let _ = writeln!(
            response,
            "\nCached model IDs (top {}):",
            cached_models.len()
        );
        for model in cached_models {
            let _ = writeln!(response, "- `{model}`");
        }
    }

    response
}

fn build_providers_help_response(current: &ChannelRouteSelection) -> String {
    let mut response = String::new();
    let _ = writeln!(
        response,
        "Current provider: `{}`\nCurrent model: `{}`",
        current.provider, current.model
    );
    response.push_str("\nSwitch provider with `/models <provider>`.\n");
    response.push_str("Switch model with `/model <model-id>`.\n\n");
    response.push_str("Available providers:\n");
    for provider in providers::list_providers() {
        if provider.aliases.is_empty() {
            let _ = writeln!(response, "- {}", provider.name);
        } else {
            let _ = writeln!(
                response,
                "- {} (aliases: {})",
                provider.name,
                provider.aliases.join(", ")
            );
        }
    }
    response
}

/// Build a plain-text `/config` response for non-Slack channels.
fn build_config_text_response(
    current: &ChannelRouteSelection,
    _workspace_dir: &Path,
    model_routes: &[crate::config::ModelRouteConfig],
) -> String {
    let mut resp = String::new();
    let _ = writeln!(
        resp,
        "Current provider: `{}`\nCurrent model: `{}`",
        current.provider, current.model
    );
    resp.push_str("\nAvailable providers:\n");
    for p in providers::list_providers() {
        let _ = writeln!(resp, "- `{}`", p.name);
    }
    if !model_routes.is_empty() {
        resp.push_str("\nConfigured model routes:\n");
        for route in model_routes {
            let _ = writeln!(
                resp,
                "  `{}` -> {} ({})",
                route.hint, route.model, route.provider
            );
        }
    }
    resp.push_str(
        "\nUse `/models <provider>` to switch provider.\nUse `/model <model-id>` to switch model.",
    );
    resp
}

/// Prefix used to signal that a runtime command response contains raw Block Kit
/// JSON instead of plain text. [`SlackChannel::send`] detects this and posts
/// the blocks directly via `chat.postMessage`.
const BLOCK_KIT_PREFIX: &str = "__REVKA_BLOCK_KIT__";

/// Build a Slack Block Kit JSON payload for the `/config` interactive UI.
fn build_config_block_kit(
    current: &ChannelRouteSelection,
    workspace_dir: &Path,
    model_routes: &[crate::config::ModelRouteConfig],
) -> String {
    let provider_options: Vec<serde_json::Value> = providers::list_providers()
        .iter()
        .map(|p| {
            serde_json::json!({
                "text": { "type": "plain_text", "text": p.display_name },
                "value": p.name
            })
        })
        .collect();

    // Build model options from model_routes + cached models.
    let mut model_options: Vec<serde_json::Value> = model_routes
        .iter()
        .map(|r| {
            let label = if r.hint.is_empty() {
                r.model.clone()
            } else {
                format!("{} ({})", r.model, r.hint)
            };
            serde_json::json!({
                "text": { "type": "plain_text", "text": label },
                "value": r.model
            })
        })
        .collect();

    let cached = load_cached_model_preview(workspace_dir, &current.provider);
    for model_id in cached {
        if !model_options.iter().any(|o| {
            o.get("value")
                .and_then(|v| v.as_str())
                .is_some_and(|v| v == model_id)
        }) {
            model_options.push(serde_json::json!({
                "text": { "type": "plain_text", "text": model_id },
                "value": model_id
            }));
        }
    }

    // If the current model is not in the list, prepend it.
    if !model_options.iter().any(|o| {
        o.get("value")
            .and_then(|v| v.as_str())
            .is_some_and(|v| v == current.model)
    }) {
        model_options.insert(
            0,
            serde_json::json!({
                "text": { "type": "plain_text", "text": &current.model },
                "value": &current.model
            }),
        );
    }

    // Find initial options matching current selection.
    let initial_provider = provider_options
        .iter()
        .find(|o| {
            o.get("value")
                .and_then(|v| v.as_str())
                .is_some_and(|v| v == current.provider)
        })
        .cloned();

    let initial_model = model_options
        .iter()
        .find(|o| {
            o.get("value")
                .and_then(|v| v.as_str())
                .is_some_and(|v| v == current.model)
        })
        .cloned();

    let mut provider_select = serde_json::json!({
        "type": "static_select",
        "action_id": "revka_config_provider",
        "placeholder": { "type": "plain_text", "text": "Select provider" },
        "options": provider_options
    });
    if let Some(init) = initial_provider {
        provider_select["initial_option"] = init;
    }

    let mut model_select = serde_json::json!({
        "type": "static_select",
        "action_id": "revka_config_model",
        "placeholder": { "type": "plain_text", "text": "Select model" },
        "options": model_options
    });
    if let Some(init) = initial_model {
        model_select["initial_option"] = init;
    }

    let blocks = serde_json::json!([
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": format!(
                    "*Model Configuration*\nCurrent: `{}` / `{}`",
                    current.provider, current.model
                )
            }
        },
        {
            "type": "section",
            "block_id": "config_provider_block",
            "text": { "type": "mrkdwn", "text": "*Provider*" },
            "accessory": provider_select
        },
        {
            "type": "section",
            "block_id": "config_model_block",
            "text": { "type": "mrkdwn", "text": "*Model*" },
            "accessory": model_select
        }
    ]);

    blocks.to_string()
}

async fn handle_runtime_command_if_needed(
    ctx: &ChannelRuntimeContext,
    msg: &traits::ChannelMessage,
    target_channel: Option<&Arc<dyn Channel>>,
) -> bool {
    let Some(command) = parse_runtime_command(&msg.channel, &msg.content) else {
        return false;
    };

    let Some(channel) = target_channel else {
        return true;
    };

    let sender_key = conversation_history_key(msg);
    let mut current = get_route_selection(ctx, &sender_key);

    let response = match command {
        ChannelRuntimeCommand::ShowProviders => build_providers_help_response(&current),
        ChannelRuntimeCommand::SetProvider(raw_provider) => {
            match resolve_provider_alias(&raw_provider) {
                Some(provider_name) => {
                    match get_or_create_provider(ctx, &provider_name, None).await {
                        Ok(_) => {
                            if provider_name != current.provider {
                                current.provider = provider_name.clone();
                                set_route_selection(ctx, &sender_key, current.clone());
                            }

                            format!(
                                "Provider switched to `{provider_name}` for this sender session. Current model is `{}`.\nUse `/model <model-id>` to set a provider-compatible model.",
                                current.model
                            )
                        }
                        Err(err) => {
                            let safe_err = providers::sanitize_api_error(&err.to_string());
                            format!(
                                "Failed to initialize provider `{provider_name}`. Route unchanged.\nDetails: {safe_err}"
                            )
                        }
                    }
                }
                None => format!(
                    "Unknown provider `{raw_provider}`. Use `/models` to list valid providers."
                ),
            }
        }
        ChannelRuntimeCommand::ShowModel => {
            build_models_help_response(&current, ctx.workspace_dir.as_path(), &ctx.model_routes)
        }
        ChannelRuntimeCommand::SetModel(raw_model) => {
            let model = raw_model.trim().trim_matches('`').to_string();
            if model.is_empty() {
                "Model ID cannot be empty. Use `/model <model-id>`.".to_string()
            } else {
                // Resolve provider+model from model_routes (match by model name or hint)
                if let Some(route) = ctx.model_routes.iter().find(|r| {
                    r.model.eq_ignore_ascii_case(&model) || r.hint.eq_ignore_ascii_case(&model)
                }) {
                    current.provider = route.provider.clone();
                    current.model = route.model.clone();
                    current.api_key = route.api_key.clone();
                } else {
                    current.model = model.clone();
                }
                set_route_selection(ctx, &sender_key, current.clone());

                format!(
                    "Model switched to `{}` (provider: `{}`). Context preserved.",
                    current.model, current.provider
                )
            }
        }
        ChannelRuntimeCommand::ShowConfig => {
            if msg.channel == "slack" {
                let blocks_json = build_config_block_kit(
                    &current,
                    ctx.workspace_dir.as_path(),
                    &ctx.model_routes,
                );
                // Use a magic prefix so SlackChannel::send() can detect Block Kit JSON.
                format!("__REVKA_BLOCK_KIT__{blocks_json}")
            } else {
                build_config_text_response(&current, ctx.workspace_dir.as_path(), &ctx.model_routes)
            }
        }
        ChannelRuntimeCommand::NewSession => {
            clear_sender_history(ctx, &sender_key);
            if let Some(ref store) = ctx.session_store {
                if let Err(e) = store.delete_session(&sender_key) {
                    tracing::warn!("Failed to delete persisted session for {sender_key}: {e}");
                }
            }
            mark_sender_for_new_session(ctx, &sender_key);
            "Conversation history cleared. Starting fresh.".to_string()
        }
    };

    if let Err(err) = channel
        .send(&SendMessage::new(response, &msg.reply_target).in_thread(msg.thread_ts.clone()))
        .await
    {
        tracing::warn!(
            "Failed to send runtime command response on {}: {err}",
            channel.name()
        );
    }

    true
}

/// Call `kumiho_memory_engage` via MCP before the LLM turn.
///
/// Returns a formatted memory context string to inject into the system prompt,
/// or an empty string if the call fails or returns no results.
///
/// MCP tools return `{"content": [{"type": "text", "text": "<json>"}]}`.
/// The inner `text` field contains the actual kumiho response JSON with
/// `context`, `results`, and `source_krefs` fields.
/// Maximum characters of kumiho engage context to inject into the system

async fn kumiho_engage_for_channel(
    registry: &crate::tools::McpRegistry,
    user_msg: &str,
    memory_project: &str,
    retrieval_limit: usize,
) -> String {
    let args = serde_json::json!({
        "query": user_msg,
        "space_paths": [memory_project],
        "recall_mode": "summarized",
        "limit": retrieval_limit.max(1),
    });
    match registry
        .call_tool("kumiho-memory__kumiho_memory_engage", args)
        .await
    {
        Ok(raw) => {
            // MCP result is JSON: {"content": [{"type": "text", "text": "..."}]}
            // First unwrap the MCP content envelope to get the inner text.
            let inner_text = extract_mcp_text(&raw);
            tracing::debug!(
                raw_len = raw.len(),
                inner_len = inner_text.len(),
                "Kumiho engage raw response"
            );

            if inner_text.is_empty() {
                tracing::info!("Kumiho engage returned empty response");
                return String::new();
            }

            // The inner text is the kumiho response — could be JSON or plain text.
            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&inner_text) {
                // Try `context` field first (formatted summary from engage).
                if let Some(ctx_text) = parsed.get("context").and_then(|v| v.as_str()) {
                    if !ctx_text.is_empty() {
                        tracing::info!(
                            context_chars = ctx_text.len(),
                            "Kumiho engage returned context"
                        );
                        return format!("[Memory context (Kumiho)]\n{ctx_text}");
                    }
                }
                // Fall back to `results` array.
                if let Some(results) = parsed.get("results").and_then(|v| v.as_array()) {
                    if !results.is_empty() {
                        let mut buf = String::from("[Memory context (Kumiho)]\n");
                        for r in results.iter().take(10) {
                            if let Some(content) = r.get("content").and_then(|v| v.as_str()) {
                                let title =
                                    r.get("title").and_then(|v| v.as_str()).unwrap_or("memory");
                                buf.push_str(&format!("- {title}: {content}\n"));
                            }
                        }
                        if buf.len() > "[Memory context (Kumiho)]\n".len() {
                            tracing::info!(
                                results_count = results.len(),
                                "Kumiho engage returned results"
                            );
                            return buf;
                        }
                    }
                }
            }

            // If not valid JSON, treat the whole inner text as context.
            if !inner_text.is_empty() {
                tracing::info!(
                    text_len = inner_text.len(),
                    "Kumiho engage returned plain text"
                );
                return format!("[Memory context (Kumiho)]\n{inner_text}");
            }

            String::new()
        }
        Err(e) => {
            tracing::warn!("Kumiho engage failed: {e:#}");
            String::new()
        }
    }
}

/// Extract the text payload from an MCP tool result envelope.
///
/// MCP results are shaped as:
/// ```json
/// {"content": [{"type": "text", "text": "...actual content..."}]}
/// ```
/// This function returns the inner `text` string, or falls back to
/// treating the raw input as direct content.
fn extract_mcp_text(raw: &str) -> String {
    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw) {
        // Standard MCP content array
        if let Some(content_arr) = parsed.get("content").and_then(|v| v.as_array()) {
            let mut texts = Vec::new();
            for item in content_arr {
                if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
                    texts.push(text.to_string());
                }
            }
            if !texts.is_empty() {
                return texts.join("\n");
            }
        }
        // Maybe it's already a direct response (no content wrapper)
        if parsed.get("context").and_then(|v| v.as_str()).is_some() {
            return serde_json::to_string(&parsed).unwrap_or_default();
        }
        if parsed.get("results").is_some() {
            return serde_json::to_string(&parsed).unwrap_or_default();
        }
    }
    // Last resort: return raw
    raw.to_string()
}

async fn build_memory_context(
    mem: &dyn Memory,
    user_msg: &str,
    retrieval_limit: usize,
    min_relevance_score: f64,
    session_id: Option<&str>,
) -> String {
    let mut context = String::new();

    if let Ok(entries) = mem
        .recall(user_msg, retrieval_limit.max(1), session_id, None, None)
        .await
    {
        let mut included = 0usize;
        let mut used_chars = 0usize;

        for entry in entries.iter().filter(|e| match e.score {
            Some(score) => score >= min_relevance_score,
            None => true, // keep entries without a score (e.g. non-vector backends)
        }) {
            if included >= MEMORY_CONTEXT_MAX_ENTRIES {
                break;
            }

            if should_skip_memory_context_entry(&entry.key, &entry.content) {
                continue;
            }

            let content = if entry.content.chars().count() > MEMORY_CONTEXT_ENTRY_MAX_CHARS {
                truncate_with_ellipsis(&entry.content, MEMORY_CONTEXT_ENTRY_MAX_CHARS)
            } else {
                entry.content.clone()
            };

            let line = format!("- {}: {}\n", entry.key, content);
            let line_chars = line.chars().count();
            if used_chars + line_chars > MEMORY_CONTEXT_MAX_CHARS {
                break;
            }

            if included == 0 {
                context.push_str("[Memory context]\n");
            }

            context.push_str(&line);
            used_chars += line_chars;
            included += 1;
        }

        if included > 0 {
            context.push_str("[/Memory context]\n\n");
        }
    }

    context
}

/// Extract a compact summary of tool interactions from history messages added
/// during `run_tool_call_loop`. Scans assistant messages for `<tool_call>` tags
/// or native tool-call JSON to collect tool names used.
/// Returns an empty string when no tools were invoked.
#[cfg(test)]
fn extract_tool_context_summary(history: &[ChatMessage], start_index: usize) -> String {
    fn push_unique_tool_name(tool_names: &mut Vec<String>, name: &str) {
        let candidate = name.trim();
        if candidate.is_empty() {
            return;
        }
        if !tool_names.iter().any(|existing| existing == candidate) {
            tool_names.push(candidate.to_string());
        }
    }

    fn collect_tool_names_from_tool_call_tags(content: &str, tool_names: &mut Vec<String>) {
        const TAG_PAIRS: [(&str, &str); 4] = [
            ("<tool_call>", "</tool_call>"),
            ("<toolcall>", "</toolcall>"),
            ("<tool-call>", "</tool-call>"),
            ("<invoke>", "</invoke>"),
        ];

        for (open_tag, close_tag) in TAG_PAIRS {
            for segment in content.split(open_tag) {
                if let Some(json_end) = segment.find(close_tag) {
                    let json_str = segment[..json_end].trim();
                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(json_str) {
                        if let Some(name) = val.get("name").and_then(|n| n.as_str()) {
                            push_unique_tool_name(tool_names, name);
                        }
                    }
                }
            }
        }
    }

    fn collect_tool_names_from_native_json(content: &str, tool_names: &mut Vec<String>) {
        if let Ok(val) = serde_json::from_str::<serde_json::Value>(content) {
            if let Some(calls) = val.get("tool_calls").and_then(|c| c.as_array()) {
                for call in calls {
                    let name = call
                        .get("function")
                        .and_then(|f| f.get("name"))
                        .and_then(|n| n.as_str())
                        .or_else(|| call.get("name").and_then(|n| n.as_str()));
                    if let Some(name) = name {
                        push_unique_tool_name(tool_names, name);
                    }
                }
            }
        }
    }

    fn collect_tool_names_from_tool_results(content: &str, tool_names: &mut Vec<String>) {
        let marker = "<tool_result name=\"";
        let mut remaining = content;
        while let Some(start) = remaining.find(marker) {
            let name_start = start + marker.len();
            let after_name_start = &remaining[name_start..];
            if let Some(name_end) = after_name_start.find('"') {
                let name = &after_name_start[..name_end];
                push_unique_tool_name(tool_names, name);
                remaining = &after_name_start[name_end + 1..];
            } else {
                break;
            }
        }
    }

    let mut tool_names: Vec<String> = Vec::new();

    for msg in history.iter().skip(start_index) {
        match msg.role.as_str() {
            "assistant" => {
                collect_tool_names_from_tool_call_tags(&msg.content, &mut tool_names);
                collect_tool_names_from_native_json(&msg.content, &mut tool_names);
            }
            "user" => {
                // Prompt-mode tool calls are always followed by [Tool results] entries
                // containing `<tool_result name="...">` tags with canonical tool names.
                collect_tool_names_from_tool_results(&msg.content, &mut tool_names);
            }
            _ => {}
        }
    }

    if tool_names.is_empty() {
        return String::new();
    }

    format!("[Used tools: {}]", tool_names.join(", "))
}

fn sanitize_channel_response(response: &str, tools: &[Box<dyn Tool>]) -> String {
    let known_tool_names: HashSet<String> = tools
        .iter()
        .map(|tool| tool.name().to_ascii_lowercase())
        .collect();
    // Strip any [Used tools: ...] prefix that the LLM may have echoed from
    // history context (#4400). Trim first to handle leading/trailing whitespace.
    let trimmed_response = response.trim();
    let stripped_summary = strip_tool_summary_prefix(trimmed_response);
    // Strip XML-style tool-call tags (e.g. <tool_call>...</tool_call>)
    let stripped_xml = strip_tool_call_tags(&stripped_summary);
    // Strip isolated tool-call JSON artifacts
    let stripped_json = strip_isolated_tool_json_artifacts(&stripped_xml, &known_tool_names);
    // Strip leading narration lines that announce tool usage
    let sanitized = strip_tool_narration(&stripped_json);

    // Scan for credential leaks before returning to caller
    match crate::security::LeakDetector::new().scan(&sanitized) {
        crate::security::LeakResult::Clean => sanitized,
        crate::security::LeakResult::Detected { patterns, redacted } => {
            tracing::warn!(
                patterns = ?patterns,
                "output guardrail: credential leak detected in outbound channel response"
            );
            redacted
        }
    }
}

/// Remove leading lines that narrate tool usage (e.g. "Let me check the weather for you.").
///
/// Only strips lines from the very beginning of the message that match common
/// narration patterns, so genuine content is preserved.
fn strip_tool_narration(message: &str) -> String {
    let narration_prefixes: &[&str] = &[
        "let me ",
        "i'll ",
        "i will ",
        "i am going to ",
        "i'm going to ",
        "searching ",
        "looking up ",
        "fetching ",
        "checking ",
        "using the ",
        "using my ",
        "one moment",
        "hold on",
        "just a moment",
        "give me a moment",
        "allow me to ",
    ];

    let mut result_lines: Vec<&str> = Vec::new();
    let mut past_narration = false;

    for line in message.lines() {
        if past_narration {
            result_lines.push(line);
            continue;
        }
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let lower = trimmed.to_lowercase();
        if narration_prefixes.iter().any(|p| lower.starts_with(p)) {
            // Skip this narration line
            continue;
        }
        // First non-narration, non-empty line — keep everything from here
        past_narration = true;
        result_lines.push(line);
    }

    let joined = result_lines.join("\n");
    let trimmed = joined.trim();
    if trimmed.is_empty() && !message.trim().is_empty() {
        // If stripping removed everything, return original to avoid empty reply
        message.to_string()
    } else {
        trimmed.to_string()
    }
}

fn is_tool_call_payload(value: &serde_json::Value, known_tool_names: &HashSet<String>) -> bool {
    let Some(object) = value.as_object() else {
        return false;
    };

    let (name, has_args) =
        if let Some(function) = object.get("function").and_then(|f| f.as_object()) {
            (
                function
                    .get("name")
                    .and_then(|v| v.as_str())
                    .or_else(|| object.get("name").and_then(|v| v.as_str())),
                function.contains_key("arguments")
                    || function.contains_key("parameters")
                    || object.contains_key("arguments")
                    || object.contains_key("parameters"),
            )
        } else {
            (
                object.get("name").and_then(|v| v.as_str()),
                object.contains_key("arguments") || object.contains_key("parameters"),
            )
        };

    let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
        return false;
    };

    has_args && known_tool_names.contains(&name.to_ascii_lowercase())
}

fn is_tool_result_payload(
    object: &serde_json::Map<String, serde_json::Value>,
    saw_tool_call_payload: bool,
) -> bool {
    if !saw_tool_call_payload || !object.contains_key("result") {
        return false;
    }

    object.keys().all(|key| {
        matches!(
            key.as_str(),
            "result" | "id" | "tool_call_id" | "name" | "tool"
        )
    })
}

fn sanitize_tool_json_value(
    value: &serde_json::Value,
    known_tool_names: &HashSet<String>,
    saw_tool_call_payload: bool,
) -> Option<(String, bool)> {
    if is_tool_call_payload(value, known_tool_names) {
        return Some((String::new(), true));
    }

    if let Some(array) = value.as_array() {
        if !array.is_empty()
            && array
                .iter()
                .all(|item| is_tool_call_payload(item, known_tool_names))
        {
            return Some((String::new(), true));
        }
        return None;
    }

    let object = value.as_object()?;

    if let Some(tool_calls) = object.get("tool_calls").and_then(|value| value.as_array()) {
        if !tool_calls.is_empty()
            && tool_calls
                .iter()
                .all(|call| is_tool_call_payload(call, known_tool_names))
        {
            let content = object
                .get("content")
                .and_then(|value| value.as_str())
                .unwrap_or("")
                .trim()
                .to_string();
            return Some((content, true));
        }
    }

    if is_tool_result_payload(object, saw_tool_call_payload) {
        return Some((String::new(), false));
    }

    None
}

fn is_line_isolated_json_segment(message: &str, start: usize, end: usize) -> bool {
    let line_start = message[..start].rfind('\n').map_or(0, |idx| idx + 1);
    let line_end = message[end..]
        .find('\n')
        .map_or(message.len(), |idx| end + idx);

    message[line_start..start].trim().is_empty() && message[end..line_end].trim().is_empty()
}

fn strip_isolated_tool_json_artifacts(message: &str, known_tool_names: &HashSet<String>) -> String {
    let mut cleaned = String::with_capacity(message.len());
    let mut cursor = 0usize;
    let mut saw_tool_call_payload = false;

    while cursor < message.len() {
        let Some(rel_start) = message[cursor..].find(['{', '[']) else {
            cleaned.push_str(&message[cursor..]);
            break;
        };

        let start = cursor + rel_start;
        cleaned.push_str(&message[cursor..start]);

        let candidate = &message[start..];
        let mut stream =
            serde_json::Deserializer::from_str(candidate).into_iter::<serde_json::Value>();

        if let Some(Ok(value)) = stream.next() {
            let consumed = stream.byte_offset();
            if consumed > 0 {
                let end = start + consumed;
                if is_line_isolated_json_segment(message, start, end) {
                    if let Some((replacement, marks_tool_call)) =
                        sanitize_tool_json_value(&value, known_tool_names, saw_tool_call_payload)
                    {
                        if marks_tool_call {
                            saw_tool_call_payload = true;
                        }
                        if !replacement.trim().is_empty() {
                            cleaned.push_str(replacement.trim());
                        }
                        cursor = end;
                        continue;
                    }
                }
            }
        }

        let Some(ch) = message[start..].chars().next() else {
            break;
        };
        cleaned.push(ch);
        cursor = start + ch.len_utf8();
    }

    let mut result = cleaned.replace("\r\n", "\n");
    while result.contains("\n\n\n") {
        result = result.replace("\n\n\n", "\n\n");
    }
    result.trim().to_string()
}

fn spawn_supervised_listener(
    ch: Arc<dyn Channel>,
    tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
    initial_backoff_secs: u64,
    max_backoff_secs: u64,
) -> tokio::task::JoinHandle<()> {
    spawn_supervised_listener_with_health_interval(
        ch,
        tx,
        initial_backoff_secs,
        max_backoff_secs,
        Duration::from_secs(CHANNEL_HEALTH_HEARTBEAT_SECS),
    )
}

fn spawn_supervised_listener_with_health_interval(
    ch: Arc<dyn Channel>,
    tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
    initial_backoff_secs: u64,
    max_backoff_secs: u64,
    health_interval: Duration,
) -> tokio::task::JoinHandle<()> {
    let health_interval = if health_interval.is_zero() {
        Duration::from_secs(1)
    } else {
        health_interval
    };

    tokio::spawn(async move {
        let component = format!("channel:{}", ch.name());
        let mut backoff = initial_backoff_secs.max(1);
        let max_backoff = max_backoff_secs.max(backoff);

        loop {
            crate::health::mark_component_ok(&component);
            let mut health = tokio::time::interval(health_interval);
            health.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
            let result = {
                let listen_future = ch.listen(tx.clone());
                tokio::pin!(listen_future);

                loop {
                    tokio::select! {
                        _ = health.tick() => {
                            crate::health::mark_component_ok(&component);
                        }
                        result = &mut listen_future => break result,
                    }
                }
            };

            if tx.is_closed() {
                break;
            }

            match result {
                Ok(()) => {
                    tracing::warn!("Channel {} exited unexpectedly; restarting", ch.name());
                    crate::health::mark_component_error(&component, "listener exited unexpectedly");
                    // Clean exit — reset backoff since the listener ran successfully
                    backoff = initial_backoff_secs.max(1);
                }
                Err(e) => {
                    tracing::error!("Channel {} error: {e}; restarting", ch.name());
                    crate::health::mark_component_error(&component, e.to_string());
                }
            }

            crate::health::bump_component_restart(&component);
            tokio::time::sleep(Duration::from_secs(backoff)).await;
            // Double backoff AFTER sleeping so first error uses initial_backoff
            backoff = backoff.saturating_mul(2).min(max_backoff);
        }
    })
}

fn compute_max_in_flight_messages(channel_count: usize) -> usize {
    channel_count
        .saturating_mul(CHANNEL_PARALLELISM_PER_CHANNEL)
        .clamp(
            CHANNEL_MIN_IN_FLIGHT_MESSAGES,
            CHANNEL_MAX_IN_FLIGHT_MESSAGES,
        )
}

fn log_worker_join_result(result: Result<(), tokio::task::JoinError>) {
    if let Err(error) = result {
        tracing::error!("Channel message worker crashed: {error}");
    }
}

fn spawn_scoped_typing_task(
    channel: Arc<dyn Channel>,
    recipient: String,
    cancellation_token: CancellationToken,
) -> tokio::task::JoinHandle<()> {
    let stop_signal = cancellation_token;
    let refresh_interval = Duration::from_secs(CHANNEL_TYPING_REFRESH_INTERVAL_SECS);
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(refresh_interval);
        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

        loop {
            tokio::select! {
                () = stop_signal.cancelled() => break,
                _ = interval.tick() => {
                    if let Err(e) = channel.start_typing(&recipient).await {
                        tracing::debug!("Failed to start typing on {}: {e}", channel.name());
                    }
                }
            }
        }

        if let Err(e) = channel.stop_typing(&recipient).await {
            tracing::debug!("Failed to stop typing on {}: {e}", channel.name());
        }
    })
}

fn log_channel_latency_stage(
    channel: &str,
    sender: &str,
    message_id: &str,
    stage: &str,
    stage_elapsed: Duration,
    total_elapsed: Duration,
) {
    #[allow(clippy::cast_possible_truncation)]
    let stage_ms = stage_elapsed.as_millis() as u64;
    #[allow(clippy::cast_possible_truncation)]
    let total_ms = total_elapsed.as_millis() as u64;
    let unix_ms = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or_default();
    tracing::info!(
        channel = %channel,
        sender = %sender,
        message_id = %message_id,
        stage = %stage,
        unix_ms,
        stage_ms,
        total_ms,
        "channel latency stage"
    );
}

struct ChannelProgressHeartbeat {
    cancellation: CancellationToken,
    handle: Option<tokio::task::JoinHandle<()>>,
}

impl ChannelProgressHeartbeat {
    fn spawn(
        started_at: Instant,
        channel: Arc<dyn Channel>,
        channel_name: String,
        sender: String,
        message_id: String,
        recipient: String,
        thread_ts: Option<String>,
    ) -> Self {
        let cancellation = CancellationToken::new();
        let handle = spawn_channel_progress_heartbeat(
            started_at,
            channel,
            channel_name,
            sender,
            message_id,
            recipient,
            thread_ts,
            cancellation.clone(),
        );
        Self {
            cancellation,
            handle: Some(handle),
        }
    }

    fn cancel(&self) {
        self.cancellation.cancel();
    }
}

impl Drop for ChannelProgressHeartbeat {
    fn drop(&mut self) {
        self.cancel();
        if let Some(handle) = self.handle.take() {
            handle.abort();
        }
    }
}

fn spawn_channel_progress_heartbeat(
    started_at: Instant,
    channel: Arc<dyn Channel>,
    channel_name: String,
    sender: String,
    message_id: String,
    recipient: String,
    thread_ts: Option<String>,
    cancellation_token: CancellationToken,
) -> tokio::task::JoinHandle<()> {
    spawn_channel_progress_heartbeat_after(
        started_at,
        channel,
        channel_name,
        sender,
        message_id,
        recipient,
        thread_ts,
        cancellation_token,
        Duration::from_secs(CHANNEL_PROGRESS_HEARTBEAT_DELAY_SECS),
    )
}

#[allow(clippy::too_many_arguments)]
fn spawn_channel_progress_heartbeat_after(
    started_at: Instant,
    channel: Arc<dyn Channel>,
    channel_name: String,
    sender: String,
    message_id: String,
    recipient: String,
    thread_ts: Option<String>,
    cancellation_token: CancellationToken,
    delay: Duration,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        tokio::select! {
            () = cancellation_token.cancelled() => {}
            () = tokio::time::sleep(delay) => {
                if cancellation_token.is_cancelled() {
                    return;
                }
                let send_start = Instant::now();
                match channel
                    .send(
                        &SendMessage::new(CHANNEL_PROGRESS_HEARTBEAT_TEXT, &recipient)
                            .in_thread(thread_ts),
                    )
                    .await
                {
                    Ok(()) => log_channel_latency_stage(
                        &channel_name,
                        &sender,
                        &message_id,
                        "progress_heartbeat_send",
                        send_start.elapsed(),
                        started_at.elapsed(),
                    ),
                    Err(e) => tracing::debug!(
                        channel = %channel_name,
                        sender = %sender,
                        message_id = %message_id,
                        "Failed to send channel progress heartbeat: {e}"
                    ),
                }
            }
        }
    })
}

async fn process_channel_message(
    ctx: Arc<ChannelRuntimeContext>,
    msg: traits::ChannelMessage,
    cancellation_token: CancellationToken,
) {
    let started_at = Instant::now();
    if cancellation_token.is_cancelled() {
        return;
    }
    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "channel_receive",
        Duration::ZERO,
        Duration::ZERO,
    );

    println!(
        "  💬 [{}] from {}: {}",
        msg.channel,
        msg.sender,
        truncate_with_ellipsis(&msg.content, 80)
    );
    runtime_trace::record_event(
        "channel_message_inbound",
        Some(msg.channel.as_str()),
        None,
        None,
        None,
        None,
        None,
        serde_json::json!({
            "sender": msg.sender,
            "message_id": msg.id,
            "reply_target": msg.reply_target,
            "content_preview": truncate_with_ellipsis(&msg.content, 160),
        }),
    );

    // ── Hook: on_message_received (modifying) ────────────
    let mut msg = if let Some(hooks) = &ctx.hooks {
        match hooks.run_on_message_received(msg).await {
            crate::hooks::HookResult::Cancel(reason) => {
                tracing::info!(%reason, "incoming message dropped by hook");
                return;
            }
            crate::hooks::HookResult::Continue(modified) => modified,
        }
    } else {
        msg
    };

    let target_channel = ctx
        .channels_by_name
        .get(&msg.channel)
        .or_else(|| {
            // Multi-room channels use "name:qualifier" format (e.g. "matrix:!roomId");
            // fall back to base channel name for routing.
            msg.channel
                .split_once(':')
                .and_then(|(base, _)| ctx.channels_by_name.get(base))
        })
        .cloned();
    let history_key = conversation_history_key(&msg);
    let has_prior_history = ctx
        .conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .get(&history_key)
        .is_some_and(|turns| !turns.is_empty());

    if let (Some(reply), Some(channel)) = (
        casual_fast_path_reply(
            &msg.channel,
            &msg.content,
            !msg.attachments.is_empty(),
            has_prior_history,
        ),
        target_channel.as_ref(),
    ) {
        let send_start = Instant::now();
        if let Err(e) = channel
            .send(&SendMessage::new(reply, &msg.reply_target).in_thread(msg.thread_ts.clone()))
            .await
        {
            tracing::debug!(
                channel = %msg.channel,
                sender = %msg.sender,
                message_id = %msg.id,
                "Failed to send casual fast-path reply: {e}"
            );
        } else {
            log_channel_latency_stage(
                &msg.channel,
                &msg.sender,
                &msg.id,
                "fast_path_send",
                send_start.elapsed(),
                started_at.elapsed(),
            );
            runtime_trace::record_event(
                "channel_message_fast_path",
                Some(msg.channel.as_str()),
                None,
                None,
                None,
                Some(true),
                None,
                serde_json::json!({
                    "sender": msg.sender,
                    "message_id": msg.id,
                    "reply_target": msg.reply_target,
                    "elapsed_ms": started_at.elapsed().as_millis(),
                }),
            );
            return;
        }
    }

    // ── Media pipeline: enrich inbound message with media annotations ──
    if ctx.media_pipeline.enabled && !msg.attachments.is_empty() {
        let vision = ctx.provider.supports_vision();
        let pipeline = media_pipeline::MediaPipeline::new(
            &ctx.media_pipeline,
            &ctx.transcription_config,
            vision,
        );
        msg.content = Box::pin(pipeline.process(&msg.content, &msg.attachments)).await;
    }

    // ── Link enricher: prepend URL summaries before agent sees the message ──
    let le_config = &ctx.prompt_config.link_enricher;
    if le_config.enabled {
        let enricher_cfg = link_enricher::LinkEnricherConfig {
            enabled: le_config.enabled,
            max_links: le_config.max_links,
            timeout_secs: le_config.timeout_secs,
        };
        let enriched = link_enricher::enrich_message(&msg.content, &enricher_cfg).await;
        if enriched != msg.content {
            tracing::info!(
                channel = %msg.channel,
                sender = %msg.sender,
                "Link enricher: prepended URL summaries to message"
            );
            msg.content = enriched;
        }
    }

    let preprocess_done_at = Instant::now();

    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "preprocess",
        preprocess_done_at.duration_since(started_at),
        started_at.elapsed(),
    );

    let runtime_update_start = Instant::now();
    if let Err(err) = maybe_apply_runtime_config_update(ctx.as_ref()).await {
        tracing::warn!("Failed to apply runtime config update: {err}");
    }
    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "runtime_config_update",
        runtime_update_start.elapsed(),
        started_at.elapsed(),
    );

    if handle_runtime_command_if_needed(ctx.as_ref(), &msg, target_channel.as_ref()).await {
        return;
    }

    let progress_heartbeat = if is_low_latency_chat_channel(&msg.channel) {
        target_channel.as_ref().map(|channel| {
            ChannelProgressHeartbeat::spawn(
                started_at,
                Arc::clone(channel),
                msg.channel.clone(),
                msg.sender.clone(),
                msg.id.clone(),
                msg.reply_target.clone(),
                msg.thread_ts.clone(),
            )
        })
    } else {
        None
    };

    let mut route = get_route_selection(ctx.as_ref(), &history_key);

    // ── Query classification: override route when a rule matches ──
    if let Some(hint) = crate::agent::classifier::classify(&ctx.query_classification, &msg.content)
    {
        if let Some(matched_route) = ctx
            .model_routes
            .iter()
            .find(|r| r.hint.eq_ignore_ascii_case(&hint))
        {
            tracing::info!(
                target: "query_classification",
                hint = hint.as_str(),
                provider = matched_route.provider.as_str(),
                model = matched_route.model.as_str(),
                channel = %msg.channel,
                "Channel message classified — overriding route"
            );
            route = ChannelRouteSelection {
                provider: matched_route.provider.clone(),
                model: matched_route.model.clone(),
                api_key: matched_route.api_key.clone(),
            };
        }
    }

    let runtime_defaults = runtime_defaults_snapshot(ctx.as_ref());
    let mut active_provider = match get_or_create_provider(
        ctx.as_ref(),
        &route.provider,
        route.api_key.as_deref(),
    )
    .await
    {
        Ok(provider) => provider,
        Err(err) => {
            let safe_err = providers::sanitize_api_error(&err.to_string());
            let message = format!(
                "⚠️ Failed to initialize provider `{}`. Please run `/models` to choose another provider.\nDetails: {safe_err}",
                route.provider
            );
            if let Some(channel) = target_channel.as_ref() {
                let _ = channel
                    .send(
                        &SendMessage::new(message, &msg.reply_target)
                            .in_thread(msg.thread_ts.clone()),
                    )
                    .await;
            }
            return;
        }
    };
    if ctx.auto_save_memory
        && msg.content.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
        && !memory::should_skip_autosave_content(&msg.content)
    {
        let autosave_key = conversation_memory_key(&msg);
        let _ = ctx
            .memory
            .store(
                &autosave_key,
                &msg.content,
                crate::memory::MemoryCategory::Conversation,
                Some(&history_key),
            )
            .await;
    }

    println!("  ⏳ Processing message...");
    let agent_started_at = Instant::now();
    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "agent_start",
        agent_started_at.duration_since(started_at),
        started_at.elapsed(),
    );

    let force_fresh_session = take_pending_new_session(ctx.as_ref(), &history_key);
    if force_fresh_session {
        // `/new` should make the next user turn completely fresh even if
        // older cached turns reappear before this message starts.
        clear_sender_history(ctx.as_ref(), &history_key);
    }

    let had_prior_history = if force_fresh_session {
        false
    } else {
        ctx.conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get(&history_key)
            .is_some_and(|turns| !turns.is_empty())
    };

    // Preserve user turn before the LLM call so interrupted requests keep context.
    append_sender_turn(ctx.as_ref(), &history_key, ChatMessage::user(&msg.content));

    // Build history from per-sender conversation cache.
    let prior_turns_raw = if force_fresh_session {
        vec![ChatMessage::user(&msg.content)]
    } else {
        ctx.conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get(&history_key)
            .cloned()
            .unwrap_or_default()
    };
    let mut prior_turns = normalize_cached_channel_turns(prior_turns_raw);

    // Strip stale tool_result blocks from cached turns so the LLM never
    // sees a `<tool_result>` without a preceding `<tool_call>`, which
    // causes hallucinated output on subsequent heartbeat ticks or sessions.
    for turn in &mut prior_turns {
        if turn.content.contains("<tool_result") {
            turn.content = strip_tool_result_content(&turn.content);
        }
    }

    // Strip [Used tools: ...] prefixes from cached assistant turns so the
    // LLM never sees (and reproduces) this internal summary format (#4400).
    for turn in &mut prior_turns {
        if turn.role == "assistant" && turn.content.starts_with("[Used tools:") {
            turn.content = strip_tool_summary_prefix(&turn.content);
        }
    }

    // Strip [IMAGE:] markers from *older* history messages when the active
    // provider does not support vision. This prevents "history poisoning"
    // where a previously-sent image marker gets reloaded from the JSONL
    // session file and permanently breaks the conversation (fixes #3674).
    // We skip the last turn (the current message) so the vision check can
    // still reject fresh image sends with a proper error.
    if !active_provider.supports_vision() && prior_turns.len() > 1 {
        let last_idx = prior_turns.len() - 1;
        for turn in &mut prior_turns[..last_idx] {
            if turn.content.contains("[IMAGE:") {
                let (cleaned, _refs) = crate::multimodal::parse_image_markers(&turn.content);
                turn.content = cleaned;
            }
        }
        // Drop older turns that became empty after marker removal (e.g. image-only messages).
        // Keep the last turn (current message) intact.
        let current = prior_turns.pop();
        prior_turns.retain(|turn| !turn.content.trim().is_empty());
        if let Some(current) = current {
            prior_turns.push(current);
        }
    }

    // Proactively trim conversation history before sending to the provider
    // to prevent context-window-exceeded errors (bug #3460).
    let dropped = proactive_trim_turns(&mut prior_turns, PROACTIVE_CONTEXT_BUDGET_CHARS);
    if dropped > 0 {
        tracing::info!(
            channel = %msg.channel,
            sender = %msg.sender,
            dropped_turns = dropped,
            remaining_turns = prior_turns.len(),
            "Proactively trimmed conversation history to fit context budget"
        );
    }

    // ── Memory recall (Kumiho MCP or legacy) ───────────────────────
    // When an MCP registry with kumiho-memory is available, call
    // kumiho_memory_engage directly before the LLM call.  The result
    // is injected into the system prompt so the model has context
    // without needing to spend a tool-call turn on recall.
    let mem_recall_start = Instant::now();
    let memory_context = if let Some(ref registry) = ctx.mcp_registry {
        kumiho_engage_for_channel(
            registry,
            &msg.content,
            &ctx.prompt_config.kumiho.memory_project,
            ctx.prompt_config.kumiho.memory_retrieval_limit,
        )
        .await
    } else {
        // Fallback to legacy dual-scope recall for non-MCP setups.
        let is_group_chat =
            msg.reply_target.contains("@g.us") || msg.reply_target.starts_with("group:");
        let sender_memory_fut = build_memory_context(
            ctx.memory.as_ref(),
            &msg.content,
            ctx.prompt_config.kumiho.memory_retrieval_limit,
            ctx.min_relevance_score,
            Some(&msg.sender),
        );
        let (sender_memory, group_memory) = if is_group_chat {
            let group_memory_fut = build_memory_context(
                ctx.memory.as_ref(),
                &msg.content,
                ctx.prompt_config.kumiho.memory_retrieval_limit,
                ctx.min_relevance_score,
                Some(&history_key),
            );
            tokio::join!(sender_memory_fut, group_memory_fut)
        } else {
            (sender_memory_fut.await, String::new())
        };
        if group_memory.is_empty() {
            sender_memory
        } else if sender_memory.is_empty() {
            group_memory
        } else {
            format!("{sender_memory}\n{group_memory}")
        }
    };
    #[allow(clippy::cast_possible_truncation)]
    let mem_recall_ms = mem_recall_start.elapsed().as_millis() as u64;
    tracing::info!(
        mem_recall_ms,
        kumiho = ctx.mcp_registry.is_some(),
        context_len = memory_context.len(),
        "⏱ Memory recall completed"
    );
    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "context_retrieval",
        mem_recall_start.elapsed(),
        started_at.elapsed(),
    );

    // Use refreshed system prompt for new sessions (master's /new support),
    // and inject memory into system prompt (not user message) so it
    // doesn't pollute session history and is re-fetched each turn.
    let base_system_prompt = if had_prior_history {
        ctx.system_prompt.as_str().to_string()
    } else {
        refreshed_new_session_system_prompt(ctx.as_ref())
    };
    let mut system_prompt =
        build_channel_system_prompt(&base_system_prompt, &msg.channel, &msg.reply_target);
    if !memory_context.is_empty() {
        let _ = write!(system_prompt, "\n\n{memory_context}");
    }
    let mut history = vec![ChatMessage::system(system_prompt)];
    history.extend(prior_turns);

    // ── Proactive context compression ────────────────────────────
    // Use the existing ContextCompressor to summarize older history
    // before the LLM call, preventing context-window-exceeded errors
    // and preserving key decisions through LLM-driven summarization.
    let context_compression_start = Instant::now();
    {
        let cc_config = ctx.prompt_config.agent.context_compression.clone();
        let compressor = crate::agent::context_compressor::ContextCompressor::new(
            cc_config,
            ctx.context_token_budget,
        )
        .with_memory(Arc::clone(&ctx.memory));
        match compressor
            .compress_if_needed(&mut history, active_provider.as_ref(), route.model.as_str())
            .await
        {
            Ok(result) if result.compressed => {
                tracing::info!(
                    channel = %msg.channel,
                    sender = %msg.sender,
                    tokens_before = result.tokens_before,
                    tokens_after = result.tokens_after,
                    passes = result.passes_used,
                    "Proactive context compression applied before LLM call"
                );
            }
            Err(e) => {
                tracing::warn!("Context compression failed, proceeding without: {e}");
            }
            _ => {}
        }
    }
    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "context_compression",
        context_compression_start.elapsed(),
        started_at.elapsed(),
    );

    let use_draft_streaming = target_channel
        .as_ref()
        .is_some_and(|ch| ch.supports_draft_updates());

    tracing::debug!(
        channel = %msg.channel,
        has_target_channel = target_channel.is_some(),
        use_draft_streaming,
        "Streaming decision"
    );

    // Partial mode: delta channel for draft updates (progress + text).
    let (delta_tx, delta_rx) = if use_draft_streaming {
        let (tx, rx) = tokio::sync::mpsc::channel::<crate::agent::loop_::DraftEvent>(64);
        (Some(tx), Some(rx))
    } else {
        (None, None)
    };

    // Partial mode: send an initial draft message for progressive editing.
    let draft_message_id = if use_draft_streaming {
        if let Some(channel) = target_channel.as_ref() {
            match channel
                .send_draft(
                    &SendMessage::new("...", &msg.reply_target).in_thread(msg.thread_ts.clone()),
                )
                .await
            {
                Ok(id) => id,
                Err(e) => {
                    tracing::debug!("Failed to send draft on {}: {e}", channel.name());
                    None
                }
            }
        } else {
            None
        }
    } else {
        None
    };
    if draft_message_id.is_some() {
        if let Some(heartbeat) = progress_heartbeat.as_ref() {
            heartbeat.cancel();
        }
    }

    // Spawn the appropriate handler for the delta channel.
    let draft_updater = if use_draft_streaming {
        // Partial: accumulate text and edit a single draft message.
        if let (Some(mut rx), Some(draft_id_ref), Some(channel_ref)) = (
            delta_rx,
            draft_message_id.as_deref(),
            target_channel.as_ref(),
        ) {
            let channel = Arc::clone(channel_ref);
            let reply_target = msg.reply_target.clone();
            let draft_id = draft_id_ref.to_string();
            let latency_channel = msg.channel.clone();
            let latency_sender = msg.sender.clone();
            let latency_message_id = msg.id.clone();
            let latency_started_at = started_at;
            let progress_cancellation = progress_heartbeat
                .as_ref()
                .map(|heartbeat| heartbeat.cancellation.clone());
            Some(tokio::spawn(async move {
                use crate::agent::loop_::DraftEvent;
                let mut accumulated = String::new();
                let mut saw_first_content = false;
                while let Some(event) = rx.recv().await {
                    match event {
                        DraftEvent::Clear => {
                            accumulated.clear();
                        }
                        DraftEvent::Progress(text) => {
                            if let Err(e) = channel
                                .update_draft_progress(&reply_target, &draft_id, &text)
                                .await
                            {
                                tracing::debug!("Draft progress update failed: {e}");
                            }
                        }
                        DraftEvent::Content(text) => {
                            if !saw_first_content {
                                saw_first_content = true;
                                if let Some(token) = progress_cancellation.as_ref() {
                                    token.cancel();
                                }
                                log_channel_latency_stage(
                                    &latency_channel,
                                    &latency_sender,
                                    &latency_message_id,
                                    "model_first_token",
                                    latency_started_at.elapsed(),
                                    latency_started_at.elapsed(),
                                );
                            }
                            accumulated.push_str(&text);
                            if let Err(e) = channel
                                .update_draft(&reply_target, &draft_id, &accumulated)
                                .await
                            {
                                tracing::debug!("Draft update failed: {e}");
                            }
                        }
                    }
                }
            }))
        } else {
            None
        }
    } else {
        None
    };

    // React with 👀 to acknowledge the incoming message
    if ctx.ack_reactions {
        if let Some(channel) = target_channel.as_ref() {
            if let Err(e) = channel
                .add_reaction(&msg.reply_target, &msg.id, "\u{1F440}")
                .await
            {
                tracing::debug!("Failed to add reaction: {e}");
            }
        }
    }

    // Skip typing only for Partial mode — the draft message itself provides
    // visual feedback. MultiMessage and Off both keep typing active.
    let is_partial_draft = target_channel
        .as_ref()
        .is_some_and(|ch| ch.supports_draft_updates() && !ch.supports_multi_message_streaming());
    let typing_cancellation = if is_partial_draft {
        None
    } else {
        target_channel.as_ref().map(|_| CancellationToken::new())
    };
    let typing_task = match (target_channel.as_ref(), typing_cancellation.as_ref()) {
        (Some(channel), Some(token)) => Some(spawn_scoped_typing_task(
            Arc::clone(channel),
            msg.reply_target.clone(),
            token.clone(),
        )),
        _ => None,
    };

    // Wrap observer to forward tool events as live thread messages
    let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
    let notify_observer: Arc<ChannelNotifyObserver> = Arc::new(ChannelNotifyObserver {
        inner: Arc::clone(&ctx.observer),
        tx: notify_tx,
        tools_used: AtomicBool::new(false),
    });
    let notify_observer_flag = Arc::clone(&notify_observer);
    let notify_channel = target_channel.clone();
    let notify_reply_target = msg.reply_target.clone();
    let notify_thread_root = followup_thread_id(&msg);
    let notify_task = if msg.channel == "cli" || !ctx.show_tool_calls {
        Some(tokio::spawn(async move {
            while notify_rx.recv().await.is_some() {}
        }))
    } else {
        Some(tokio::spawn(async move {
            let thread_ts = notify_thread_root;
            while let Some(text) = notify_rx.recv().await {
                if let Some(ref ch) = notify_channel {
                    let _ = ch
                        .send(
                            &SendMessage::new(&text, &notify_reply_target)
                                .in_thread(thread_ts.clone()),
                        )
                        .await;
                }
            }
        }))
    };

    enum LlmExecutionResult {
        Completed(Result<Result<String, anyhow::Error>, tokio::time::error::Elapsed>),
        Cancelled,
    }

    let model_switch_callback = get_model_switch_state();
    let scale_cap = ctx
        .pacing
        .message_timeout_scale_max
        .unwrap_or(CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP);
    let timeout_budget_secs = channel_message_timeout_budget_secs_with_cap(
        ctx.message_timeout_secs,
        ctx.max_tool_iterations,
        scale_cap,
    );
    let cost_tracking_context = ctx.cost_tracking.clone().map(|state| {
        crate::agent::loop_::ToolLoopCostTrackingContext::new(state.tracker, "channel")
    });
    let llm_call_start = Instant::now();
    #[allow(clippy::cast_possible_truncation)]
    let elapsed_before_llm_ms = started_at.elapsed().as_millis() as u64;
    tracing::info!(elapsed_before_llm_ms, "⏱ Starting LLM call");
    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "model_start",
        llm_call_start.duration_since(started_at),
        started_at.elapsed(),
    );
    let (llm_result, fallback_info) = scope_provider_fallback(async {
        let llm_result = loop {
            let loop_result = tokio::select! {
                () = cancellation_token.cancelled() => LlmExecutionResult::Cancelled,
                result = tokio::time::timeout(
                    Duration::from_secs(timeout_budget_secs),
                    crate::agent::loop_::TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
                        cost_tracking_context.clone(),
                    run_tool_call_loop(
                        active_provider.as_ref(),
                        &mut history,
                        ctx.tools_registry.as_ref(),
                        notify_observer.as_ref() as &dyn Observer,
                        route.provider.as_str(),
                        route.model.as_str(),
                        runtime_defaults.temperature,
                        true,
                        Some(&*ctx.approval_manager),
                        msg.channel.as_str(),
                        Some(msg.reply_target.as_str()),
                        &ctx.multimodal,
                        ctx.max_tool_iterations,
                        Some(cancellation_token.clone()),
                        delta_tx.clone(),
                        ctx.hooks.as_deref(),
                        if msg.channel == "cli"
                            || ctx.autonomy_level == AutonomyLevel::Full
                        {
                            &[]
                        } else {
                            ctx.non_cli_excluded_tools.as_ref()
                        },
                        ctx.tool_call_dedup_exempt.as_ref(),
                        ctx.activated_tools.as_ref(),
                        Some(model_switch_callback.clone()),
                        &ctx.pacing,
                        ctx.max_tool_result_chars,
                        ctx.context_token_budget,
                        None, // shared_budget
                    ),
                    ),
                ) => LlmExecutionResult::Completed(result),
            };

            // Handle model switch: re-create the provider and retry
            if let LlmExecutionResult::Completed(Ok(Err(ref e))) = loop_result {
                if let Some((new_provider, new_model)) = is_model_switch_requested(e) {
                    tracing::info!(
                        "Model switch requested, switching from {} {} to {} {}",
                        route.provider,
                        route.model,
                        new_provider,
                        new_model
                    );

                    match create_resilient_provider_nonblocking(
                        &new_provider,
                        ctx.api_key.clone(),
                        ctx.api_url.clone(),
                        ctx.reliability.as_ref().clone(),
                        ctx.provider_runtime_options.clone(),
                    )
                    .await
                    {
                        Ok(new_prov) => {
                            active_provider = Arc::from(new_prov);
                            route.provider = new_provider;
                            route.model = new_model;
                            clear_model_switch_request();

                            ctx.observer.record_event(&ObserverEvent::AgentStart {
                                provider: route.provider.clone(),
                                model: route.model.clone(),
                            });

                            continue;
                        }
                        Err(err) => {
                            tracing::error!("Failed to create provider after model switch: {err}");
                            clear_model_switch_request();
                            // Fall through with the original error
                        }
                    }
                }
            }

            break loop_result;
        };
        let fb = take_last_provider_fallback();
        (llm_result, fb)
    })
    .await;

    // Drop all senders so updater tasks can exit (rx.recv() returns None).
    tracing::debug!("Post-loop: dropping delta_tx and awaiting draft updater");
    drop(delta_tx);
    if let Some(handle) = draft_updater {
        let _ = handle.await;
    }
    tracing::debug!("Post-loop: draft updater completed");

    // Thread the final reply only if tools were used (multi-message response)
    if notify_observer_flag.tools_used.load(Ordering::Relaxed) && msg.channel != "cli" {
        msg.thread_ts = followup_thread_id(&msg);
    }
    // Drop the notify sender so the forwarder task finishes
    drop(notify_observer);
    drop(notify_observer_flag);
    if let Some(handle) = notify_task {
        let _ = handle.await;
    }

    #[allow(clippy::cast_possible_truncation)]
    let llm_call_ms = llm_call_start.elapsed().as_millis() as u64;
    #[allow(clippy::cast_possible_truncation)]
    let total_ms = started_at.elapsed().as_millis() as u64;
    tracing::info!(llm_call_ms, total_ms, "⏱ LLM call completed");
    log_channel_latency_stage(
        &msg.channel,
        &msg.sender,
        &msg.id,
        "model_response_ready",
        llm_call_start.elapsed(),
        started_at.elapsed(),
    );

    if let Some(token) = typing_cancellation.as_ref() {
        token.cancel();
    }
    if let Some(handle) = typing_task {
        log_worker_join_result(handle.await);
    }

    let llm_success = matches!(&llm_result, LlmExecutionResult::Completed(Ok(Ok(_))));
    let reaction_done_emoji = if llm_success {
        "\u{2705}" // ✅
    } else {
        "\u{26A0}\u{FE0F}" // ⚠️
    };

    match llm_result {
        LlmExecutionResult::Cancelled => {
            tracing::info!(
                channel = %msg.channel,
                sender = %msg.sender,
                "Cancelled in-flight channel request due to newer message"
            );
            runtime_trace::record_event(
                "channel_message_cancelled",
                Some(msg.channel.as_str()),
                Some(route.provider.as_str()),
                Some(route.model.as_str()),
                None,
                Some(false),
                Some("cancelled due to newer inbound message"),
                serde_json::json!({
                    "sender": msg.sender,
                    "elapsed_ms": started_at.elapsed().as_millis(),
                }),
            );
            if let (Some(channel), Some(draft_id)) =
                (target_channel.as_ref(), draft_message_id.as_deref())
            {
                if let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await {
                    tracing::debug!("Failed to cancel draft on {}: {err}", channel.name());
                }
            }
        }
        LlmExecutionResult::Completed(Ok(Ok(response))) => {
            // ── Hook: on_message_sending (modifying) ─────────
            let mut outbound_response = response;
            if let Some(hooks) = &ctx.hooks {
                match hooks
                    .run_on_message_sending(
                        msg.channel.clone(),
                        msg.reply_target.clone(),
                        outbound_response.clone(),
                    )
                    .await
                {
                    crate::hooks::HookResult::Cancel(reason) => {
                        tracing::info!(%reason, "outgoing message suppressed by hook");
                        if let (Some(channel), Some(draft_id)) =
                            (target_channel.as_ref(), draft_message_id.as_deref())
                        {
                            let _ = channel.cancel_draft(&msg.reply_target, draft_id).await;
                        }
                        return;
                    }
                    crate::hooks::HookResult::Continue((
                        hook_channel,
                        hook_recipient,
                        mut modified_content,
                    )) => {
                        if hook_channel != msg.channel || hook_recipient != msg.reply_target {
                            tracing::warn!(
                                from_channel = %msg.channel,
                                from_recipient = %msg.reply_target,
                                to_channel = %hook_channel,
                                to_recipient = %hook_recipient,
                                "on_message_sending attempted to rewrite channel routing; only content mutation is applied"
                            );
                        }

                        let modified_len = modified_content.chars().count();
                        if modified_len > CHANNEL_HOOK_MAX_OUTBOUND_CHARS {
                            tracing::warn!(
                                limit = CHANNEL_HOOK_MAX_OUTBOUND_CHARS,
                                attempted = modified_len,
                                "hook-modified outbound content exceeded limit; truncating"
                            );
                            modified_content = truncate_with_ellipsis(
                                &modified_content,
                                CHANNEL_HOOK_MAX_OUTBOUND_CHARS,
                            );
                        }

                        if modified_content != outbound_response {
                            tracing::info!(
                                channel = %msg.channel,
                                sender = %msg.sender,
                                before_len = outbound_response.chars().count(),
                                after_len = modified_content.chars().count(),
                                "outgoing message content modified by hook"
                            );
                        }

                        outbound_response = modified_content;
                    }
                }
            }

            let sanitized_response =
                sanitize_channel_response(&outbound_response, ctx.tools_registry.as_ref());
            let mut delivered_response = if sanitized_response.is_empty()
                && !outbound_response.trim().is_empty()
            {
                "I encountered malformed tool-call output and could not produce a safe reply. Please try again.".to_string()
            } else {
                sanitized_response
            };

            // Append a footer when the response was served by a different provider family.
            // Intra-family fallbacks (e.g. minimax → minimax-cn) are suppressed.
            if let Some(fb) = fallback_info.as_ref() {
                let req_base = fb.requested_provider.split(':').next().unwrap_or("");
                let act_base = fb.actual_provider.split(':').next().unwrap_or("");
                let same_family = req_base == act_base
                    || req_base.starts_with(act_base)
                    || act_base.starts_with(req_base);
                if !same_family {
                    use std::fmt::Write as _;
                    write!(
                        delivered_response,
                        "\n\n---\n\u{26A1} `{}` unavailable \u{2014} response from **{}** (`{}`)\nSwitch model: /models",
                        fb.requested_provider, fb.actual_provider, fb.actual_model,
                    )
                    .ok();
                }
            }

            runtime_trace::record_event(
                "channel_message_outbound",
                Some(msg.channel.as_str()),
                Some(route.provider.as_str()),
                Some(route.model.as_str()),
                None,
                Some(true),
                None,
                serde_json::json!({
                    "sender": msg.sender,
                    "elapsed_ms": started_at.elapsed().as_millis(),
                    "response": scrub_credentials(&delivered_response),
                }),
            );

            // Persist intermediate tool-call/result messages from this turn
            // so the model retains concrete "I used tools" examples in
            // context, preventing drift toward tool-less responses (#4827).
            let keep_tool_turns = ctx.prompt_config.agent.keep_tool_context_turns;
            if keep_tool_turns > 0 {
                // Find tool messages for the current turn: everything after
                // the last user message up to (but not including) the final
                // assistant response that matches our delivered text.
                let tool_messages: Vec<ChatMessage> = extract_current_turn_tool_messages(&history);
                for tool_msg in tool_messages {
                    append_sender_turn(ctx.as_ref(), &history_key, tool_msg);
                }
            }

            let history_response = delivered_response.clone();
            append_sender_turn(
                ctx.as_ref(),
                &history_key,
                ChatMessage::assistant(&history_response),
            );

            // Strip tool-call messages from turns older than
            // keep_tool_context_turns to prevent unbounded growth.
            if keep_tool_turns > 0 {
                strip_old_tool_context(ctx.as_ref(), &history_key, keep_tool_turns);
            }

            println!(
                "  🤖 Reply ({}ms): {}",
                started_at.elapsed().as_millis(),
                truncate_with_ellipsis(&delivered_response, 80)
            );
            if let Some(channel) = target_channel.as_ref() {
                let final_send_start = Instant::now();
                if let Some(ref draft_id) = draft_message_id {
                    if let Err(e) = channel
                        .finalize_draft(&msg.reply_target, draft_id, &delivered_response)
                        .await
                    {
                        tracing::warn!("Failed to finalize draft: {e}; sending as new message");
                        if let Err(send_err) = channel
                            .send(
                                &SendMessage::new(&delivered_response, &msg.reply_target)
                                    .in_thread(msg.thread_ts.clone()),
                            )
                            .await
                        {
                            tracing::warn!("Failed to send fallback channel reply: {send_err}");
                        }
                    }
                } else if let Err(e) = channel
                    .send(
                        &SendMessage::new(&delivered_response, &msg.reply_target)
                            .in_thread(msg.thread_ts.clone())
                            .with_cancellation(cancellation_token.clone()),
                    )
                    .await
                {
                    eprintln!("  ❌ Failed to reply on {}: {e}", channel.name());
                }
                if let Some(heartbeat) = progress_heartbeat.as_ref() {
                    heartbeat.cancel();
                }
                log_channel_latency_stage(
                    &msg.channel,
                    &msg.sender,
                    &msg.id,
                    "final_send",
                    final_send_start.elapsed(),
                    started_at.elapsed(),
                );
            }
        }
        LlmExecutionResult::Completed(Ok(Err(e))) => {
            if crate::agent::loop_::is_tool_loop_cancelled(&e) || cancellation_token.is_cancelled()
            {
                tracing::info!(
                    channel = %msg.channel,
                    sender = %msg.sender,
                    "Cancelled in-flight channel request due to newer message"
                );
                runtime_trace::record_event(
                    "channel_message_cancelled",
                    Some(msg.channel.as_str()),
                    Some(route.provider.as_str()),
                    Some(route.model.as_str()),
                    None,
                    Some(false),
                    Some("cancelled during tool-call loop"),
                    serde_json::json!({
                        "sender": msg.sender,
                        "elapsed_ms": started_at.elapsed().as_millis(),
                    }),
                );
                if let (Some(channel), Some(draft_id)) =
                    (target_channel.as_ref(), draft_message_id.as_deref())
                {
                    if let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await {
                        tracing::debug!("Failed to cancel draft on {}: {err}", channel.name());
                    }
                }
            } else if is_context_window_overflow_error(&e) {
                let compacted = compact_sender_history(ctx.as_ref(), &history_key);
                let error_text = if compacted {
                    "⚠️ Context window exceeded for this conversation. I compacted recent history and kept the latest context. Please resend your last message."
                } else {
                    "⚠️ Context window exceeded for this conversation. Please resend your last message."
                };
                eprintln!(
                    "  ⚠️ Context window exceeded after {}ms; sender history compacted={}",
                    started_at.elapsed().as_millis(),
                    compacted
                );
                runtime_trace::record_event(
                    "channel_message_error",
                    Some(msg.channel.as_str()),
                    Some(route.provider.as_str()),
                    Some(route.model.as_str()),
                    None,
                    Some(false),
                    Some("context window exceeded"),
                    serde_json::json!({
                        "sender": msg.sender,
                        "elapsed_ms": started_at.elapsed().as_millis(),
                        "history_compacted": compacted,
                    }),
                );
                if let Some(channel) = target_channel.as_ref() {
                    if let Some(ref draft_id) = draft_message_id {
                        let _ = channel
                            .finalize_draft(&msg.reply_target, draft_id, error_text)
                            .await;
                    } else {
                        let _ = channel
                            .send(
                                &SendMessage::new(error_text, &msg.reply_target)
                                    .in_thread(msg.thread_ts.clone()),
                            )
                            .await;
                    }
                }
            } else {
                eprintln!(
                    "  ❌ LLM error after {}ms: {e}",
                    started_at.elapsed().as_millis()
                );
                let safe_error = providers::sanitize_api_error(&e.to_string());
                runtime_trace::record_event(
                    "channel_message_error",
                    Some(msg.channel.as_str()),
                    Some(route.provider.as_str()),
                    Some(route.model.as_str()),
                    None,
                    Some(false),
                    Some(&safe_error),
                    serde_json::json!({
                        "sender": msg.sender,
                        "elapsed_ms": started_at.elapsed().as_millis(),
                    }),
                );
                let should_rollback_user_turn = should_rollback_failed_user_turn(&e);
                let rolled_back = should_rollback_user_turn
                    && rollback_orphan_user_turn(ctx.as_ref(), &history_key, &msg.content);

                if !rolled_back {
                    // Close the orphan user turn so subsequent messages don't
                    // inherit this failed request as unfinished context.
                    append_sender_turn(
                        ctx.as_ref(),
                        &history_key,
                        ChatMessage::assistant("[Task failed — not continuing this request]"),
                    );
                }
                if let Some(channel) = target_channel.as_ref() {
                    if let Some(ref draft_id) = draft_message_id {
                        let _ = channel
                            .finalize_draft(&msg.reply_target, draft_id, &format!("⚠️ Error: {e}"))
                            .await;
                    } else {
                        let _ = channel
                            .send(
                                &SendMessage::new(format!("⚠️ Error: {e}"), &msg.reply_target)
                                    .in_thread(msg.thread_ts.clone()),
                            )
                            .await;
                    }
                }
            }
        }
        LlmExecutionResult::Completed(Err(_)) => {
            let timeout_msg = format!(
                "LLM response timed out after {}s (base={}s, max_tool_iterations={})",
                timeout_budget_secs, ctx.message_timeout_secs, ctx.max_tool_iterations
            );
            runtime_trace::record_event(
                "channel_message_timeout",
                Some(msg.channel.as_str()),
                Some(route.provider.as_str()),
                Some(route.model.as_str()),
                None,
                Some(false),
                Some(&timeout_msg),
                serde_json::json!({
                    "sender": msg.sender,
                    "elapsed_ms": started_at.elapsed().as_millis(),
                }),
            );
            eprintln!(
                "  ❌ {} (elapsed: {}ms)",
                timeout_msg,
                started_at.elapsed().as_millis()
            );
            // Close the orphan user turn so subsequent messages don't
            // inherit this timed-out request as unfinished context.
            append_sender_turn(
                ctx.as_ref(),
                &history_key,
                ChatMessage::assistant("[Task timed out — not continuing this request]"),
            );
            if let Some(channel) = target_channel.as_ref() {
                let error_text =
                    "⚠️ Request timed out while waiting for the model. Please try again.";
                if let Some(ref draft_id) = draft_message_id {
                    let _ = channel
                        .finalize_draft(&msg.reply_target, draft_id, error_text)
                        .await;
                } else {
                    let _ = channel
                        .send(
                            &SendMessage::new(error_text, &msg.reply_target)
                                .in_thread(msg.thread_ts.clone()),
                        )
                        .await;
                }
            }
        }
    }

    // Audit log the completed message processing
    if let Some(ref logger) = ctx.audit_logger {
        let _ = logger.log_command(
            &msg.channel,
            &truncate_with_ellipsis(&msg.content, 120),
            "low",
            true,
            true,
            llm_success,
            total_ms,
        );
    }

    // Swap 👀 → ✅ (or ⚠️ on error) to signal processing is complete
    if ctx.ack_reactions {
        if let Some(channel) = target_channel.as_ref() {
            let _ = channel
                .remove_reaction(&msg.reply_target, &msg.id, "\u{1F440}")
                .await;
            let _ = channel
                .add_reaction(&msg.reply_target, &msg.id, reaction_done_emoji)
                .await;
        }
    }
}

/// Shared worker body extracted so both the normal path and the debounce path
/// can reuse the same in-flight tracking / cancellation / process logic.
async fn dispatch_worker(
    ctx: Arc<ChannelRuntimeContext>,
    msg: traits::ChannelMessage,
    in_flight: Arc<tokio::sync::Mutex<HashMap<String, InFlightSenderTaskState>>>,
    task_sequence: Arc<AtomicU64>,
    permit: tokio::sync::OwnedSemaphorePermit,
) {
    let _permit = permit;
    let interrupt_enabled = ctx
        .interrupt_on_new_message
        .enabled_for_channel(msg.channel.as_str());
    let sender_scope_key = interruption_scope_key(&msg);
    let cancellation_token = CancellationToken::new();
    let completion = Arc::new(InFlightTaskCompletion::new());
    let task_id = task_sequence.fetch_add(1, Ordering::Relaxed) as u64;

    let register_in_flight = msg.channel != "cli";

    if register_in_flight {
        let previous = {
            let mut active = in_flight.lock().await;
            active.insert(
                sender_scope_key.clone(),
                InFlightSenderTaskState {
                    task_id,
                    cancellation: cancellation_token.clone(),
                    completion: Arc::clone(&completion),
                },
            )
        };

        if interrupt_enabled {
            if let Some(previous) = previous {
                tracing::info!(
                    channel = %msg.channel,
                    sender = %msg.sender,
                    "Interrupting previous in-flight request for sender"
                );
                previous.cancellation.cancel();
                previous.completion.wait().await;
            }
        }
    }

    process_channel_message(ctx, msg, cancellation_token).await;

    if register_in_flight {
        let mut active = in_flight.lock().await;
        if active
            .get(&sender_scope_key)
            .is_some_and(|state| state.task_id == task_id)
        {
            active.remove(&sender_scope_key);
        }
    }

    completion.mark_done();
}

async fn run_message_dispatch_loop(
    mut rx: tokio::sync::mpsc::Receiver<traits::ChannelMessage>,
    ctx: Arc<ChannelRuntimeContext>,
    max_in_flight_messages: usize,
) {
    let semaphore = Arc::new(tokio::sync::Semaphore::new(max_in_flight_messages));
    let mut workers = tokio::task::JoinSet::new();
    let in_flight_by_sender = Arc::new(tokio::sync::Mutex::new(HashMap::<
        String,
        InFlightSenderTaskState,
    >::new()));
    let task_sequence = Arc::new(AtomicU64::new(1));

    while let Some(msg) = rx.recv().await {
        // Fast path: /stop cancels the in-flight task for this sender scope without
        // spawning a worker or registering a new task. Handled here — before semaphore
        // acquisition — so the target task is still in the store and is never replaced.
        if msg.channel != "cli" && is_stop_command(&msg.content) {
            let scope_key = interruption_scope_key(&msg);
            let previous = {
                let mut active = in_flight_by_sender.lock().await;
                active.remove(&scope_key)
            };
            let reply = if let Some(state) = previous {
                state.cancellation.cancel();
                "Stop signal sent.".to_string()
            } else {
                "No in-flight task for this sender scope.".to_string()
            };
            let channel = ctx
                .channels_by_name
                .get(&msg.channel)
                .or_else(|| {
                    // Multi-room channels use "name:qualifier" format (e.g. "matrix:!roomId");
                    // fall back to base channel name for routing.
                    msg.channel
                        .split_once(':')
                        .and_then(|(base, _)| ctx.channels_by_name.get(base))
                })
                .cloned();
            if let Some(channel) = channel {
                let reply_target = msg.reply_target.clone();
                let thread_ts = msg.thread_ts.clone();
                tokio::spawn(async move {
                    let _ = channel
                        .send(&SendMessage::new(reply, &reply_target).in_thread(thread_ts))
                        .await;
                });
            } else {
                tracing::warn!(
                    channel = %msg.channel,
                    "stop command: no registered channel found for reply"
                );
            }
            continue;
        }

        // ── Debounce: accumulate rapid messages per sender ──────────
        // CLI messages bypass debouncing so the interactive loop stays responsive.
        let msg = if msg.channel != "cli" && ctx.debouncer.enabled() {
            let debounce_key = conversation_history_key(&msg);
            match ctx.debouncer.debounce(&debounce_key, &msg.content).await {
                debounce::DebounceResult::Pending(rx) => {
                    // Spawn a lightweight task that waits for the debounce window
                    // to expire, then feeds the combined message through the normal
                    // worker path below.
                    let debounce_ctx = Arc::clone(&ctx);
                    let debounce_in_flight = Arc::clone(&in_flight_by_sender);
                    let debounce_semaphore = Arc::clone(&semaphore);
                    let debounce_task_seq = Arc::clone(&task_sequence);
                    let mut debounce_msg = msg;
                    workers.spawn(async move {
                        let combined = match rx.await {
                            Ok(combined) => combined,
                            Err(_) => {
                                // Receiver dropped — a newer message superseded this one.
                                return;
                            }
                        };
                        debounce_msg.content = combined;
                        tracing::info!(
                            channel = %debounce_msg.channel,
                            sender = %debounce_msg.sender,
                            "Debounced message ready — dispatching combined message"
                        );

                        let permit = match debounce_semaphore.acquire_owned().await {
                            Ok(permit) => permit,
                            Err(_) => return,
                        };

                        dispatch_worker(
                            debounce_ctx,
                            debounce_msg,
                            debounce_in_flight,
                            debounce_task_seq,
                            permit,
                        )
                        .await;
                    });
                    continue;
                }
                debounce::DebounceResult::Passthrough(content) => {
                    let mut m = msg;
                    m.content = content;
                    m
                }
            }
        } else {
            msg
        };

        let permit = match Arc::clone(&semaphore).acquire_owned().await {
            Ok(permit) => permit,
            Err(_) => break,
        };

        let worker_ctx = Arc::clone(&ctx);
        let in_flight = Arc::clone(&in_flight_by_sender);
        let task_sequence = Arc::clone(&task_sequence);
        workers.spawn(async move {
            dispatch_worker(worker_ctx, msg, in_flight, task_sequence, permit).await;
        });

        while let Some(result) = workers.try_join_next() {
            log_worker_join_result(result);
        }
    }

    while let Some(result) = workers.join_next().await {
        log_worker_join_result(result);
    }
}

/// Build a channel-mode system prompt by delegating to the unified
/// [`crate::agent::prompt::SystemPromptBuilder`].  Replaces the previous
/// ad-hoc string builder (`build_system_prompt_with_mode_and_autonomy`)
/// per audit row 6 — see `docs/coherence-audit-2026-05.md`.
#[allow(clippy::too_many_arguments)]
pub fn assemble_channel_system_prompt(
    workspace_dir: &std::path::Path,
    model_name: &str,
    tools: &[(&str, &str)],
    skills: &[crate::skills::Skill],
    identity_config: Option<&crate::config::IdentityConfig>,
    bootstrap_max_chars: Option<usize>,
    autonomy_config: Option<&crate::config::AutonomyConfig>,
    native_tools: bool,
    skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
    compact_context: bool,
    max_system_prompt_chars: usize,
) -> String {
    use crate::agent::prompt::{
        BuilderMode, ChannelOptions, PromptContext, PromptTools, SystemPromptBuilder,
    };

    let opts = ChannelOptions {
        native_tools,
        compact_context,
        max_system_prompt_chars,
        bootstrap_max_chars: bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS),
        ..ChannelOptions::default()
    };

    let ctx = PromptContext {
        workspace_dir,
        model_name,
        tools: PromptTools::Simple(tools),
        skills,
        skills_prompt_mode,
        skill_effectiveness: crate::skills::effectiveness_cache::global_provider(),
        identity_config,
        dispatcher_instructions: "",
        tool_descriptions: None,
        security_summary: None,
        autonomy_level: autonomy_config.map(|c| c.level).unwrap_or_default(),
        operator_enabled: false,
        // Channel callers post-process Kumiho via `append_kumiho_channel_bootstrap`,
        // so the in-builder section stays empty here.
        kumiho_enabled: false,
        kumiho_memory_advanced_available: true,
        compact_tool_docs: false,
        mode: BuilderMode::Channel(opts),
    };

    SystemPromptBuilder::with_defaults()
        .build(&ctx)
        .unwrap_or_default()
}

/// Load workspace identity files and build a system prompt for channel use.
///
/// Thin wrapper around [`build_channel_system_prompt`] with default options.
/// When `identity_config` is set to AIEOS format, the personality/bootstrap
/// section is replaced with the AIEOS identity data loaded from file or
/// inline JSON.
///
/// Daily memory files (`memory/*.md`) are NOT injected.
pub fn build_system_prompt(
    workspace_dir: &std::path::Path,
    model_name: &str,
    tools: &[(&str, &str)],
    skills: &[crate::skills::Skill],
    identity_config: Option<&crate::config::IdentityConfig>,
    bootstrap_max_chars: Option<usize>,
) -> String {
    build_system_prompt_with_mode(
        workspace_dir,
        model_name,
        tools,
        skills,
        identity_config,
        bootstrap_max_chars,
        false,
        crate::config::SkillsPromptInjectionMode::Full,
        AutonomyLevel::default(),
    )
}

pub fn build_system_prompt_with_mode(
    workspace_dir: &std::path::Path,
    model_name: &str,
    tools: &[(&str, &str)],
    skills: &[crate::skills::Skill],
    identity_config: Option<&crate::config::IdentityConfig>,
    bootstrap_max_chars: Option<usize>,
    native_tools: bool,
    skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
    autonomy_level: AutonomyLevel,
) -> String {
    let autonomy_cfg = crate::config::AutonomyConfig {
        level: autonomy_level,
        ..Default::default()
    };
    assemble_channel_system_prompt(
        workspace_dir,
        model_name,
        tools,
        skills,
        identity_config,
        bootstrap_max_chars,
        Some(&autonomy_cfg),
        native_tools,
        skills_prompt_mode,
        false,
        0,
    )
}

fn normalize_telegram_identity(value: &str) -> String {
    value.trim().trim_start_matches('@').to_string()
}

async fn bind_telegram_identity(config: &Config, identity: &str) -> Result<()> {
    let normalized = normalize_telegram_identity(identity);
    if normalized.is_empty() {
        anyhow::bail!("Telegram identity cannot be empty");
    }

    let mut updated = config.clone();
    let Some(telegram) = updated.channels_config.telegram.as_mut() else {
        anyhow::bail!(
            "Telegram channel is not configured. Run `revka onboard --channels-only` first"
        );
    };

    if telegram.allowed_users.iter().any(|u| u == "*") {
        println!(
            "⚠️ Telegram allowlist is currently wildcard (`*`) — binding is unnecessary until you remove '*'."
        );
    }

    if telegram
        .allowed_users
        .iter()
        .map(|entry| normalize_telegram_identity(entry))
        .any(|entry| entry == normalized)
    {
        println!("✅ Telegram identity already bound: {normalized}");
        return Ok(());
    }

    telegram.allowed_users.push(normalized.clone());
    updated.save().await?;
    println!("✅ Bound Telegram identity: {normalized}");
    println!("   Saved to {}", updated.config_path.display());
    match maybe_restart_managed_daemon_service() {
        Ok(true) => {
            println!("🔄 Detected running managed daemon service; reloaded automatically.");
        }
        Ok(false) => {
            println!(
                "ℹ️ No managed daemon service detected. If `revka daemon`/`channel start` is already running, restart it to load the updated allowlist."
            );
        }
        Err(e) => {
            eprintln!(
                "⚠️ Allowlist saved, but failed to reload daemon service automatically: {e}\n\
                 Restart service manually with `revka service stop && revka service start`."
            );
        }
    }
    Ok(())
}

fn maybe_restart_managed_daemon_service() -> Result<bool> {
    if cfg!(target_os = "macos") {
        let home = directories::UserDirs::new()
            .map(|u| u.home_dir().to_path_buf())
            .context("Could not find home directory")?;
        let plist = home
            .join("Library")
            .join("LaunchAgents")
            .join("com.revka.daemon.plist");
        if !plist.exists() {
            return Ok(false);
        }

        let list_output = Command::new("launchctl")
            .arg("list")
            .output()
            .context("Failed to query launchctl list")?;
        let listed = String::from_utf8_lossy(&list_output.stdout);
        if !listed.contains("com.revka.daemon") {
            return Ok(false);
        }

        let _ = Command::new("launchctl")
            .args(["stop", "com.revka.daemon"])
            .output();
        let start_output = Command::new("launchctl")
            .args(["start", "com.revka.daemon"])
            .output()
            .context("Failed to start launchd daemon service")?;
        if !start_output.status.success() {
            let stderr = String::from_utf8_lossy(&start_output.stderr);
            anyhow::bail!("launchctl start failed: {}", stderr.trim());
        }

        return Ok(true);
    }

    if cfg!(target_os = "linux") {
        // OpenRC (system-wide) takes precedence over systemd (user-level)
        let openrc_init_script = PathBuf::from("/etc/init.d/revka");
        if openrc_init_script.exists() {
            if let Ok(status_output) = Command::new("rc-service").args(OPENRC_STATUS_ARGS).output()
            {
                // rc-service exits 0 if running, non-zero otherwise
                if status_output.status.success() {
                    let restart_output = Command::new("rc-service")
                        .args(OPENRC_RESTART_ARGS)
                        .output()
                        .context("Failed to restart OpenRC daemon service")?;
                    if !restart_output.status.success() {
                        let stderr = String::from_utf8_lossy(&restart_output.stderr);
                        anyhow::bail!("rc-service restart failed: {}", stderr.trim());
                    }
                    return Ok(true);
                }
            }
        }

        // Systemd (user-level)
        let home = directories::UserDirs::new()
            .map(|u| u.home_dir().to_path_buf())
            .context("Could not find home directory")?;
        let unit_path: PathBuf = home
            .join(".config")
            .join("systemd")
            .join("user")
            .join("revka.service");
        if !unit_path.exists() {
            return Ok(false);
        }

        let active_output = Command::new("systemctl")
            .args(SYSTEMD_STATUS_ARGS)
            .output()
            .context("Failed to query systemd service state")?;
        let state = String::from_utf8_lossy(&active_output.stdout);
        if !state.trim().eq_ignore_ascii_case("active") {
            return Ok(false);
        }

        let restart_output = Command::new("systemctl")
            .args(SYSTEMD_RESTART_ARGS)
            .output()
            .context("Failed to restart systemd daemon service")?;
        if !restart_output.status.success() {
            let stderr = String::from_utf8_lossy(&restart_output.stderr);
            anyhow::bail!("systemctl restart failed: {}", stderr.trim());
        }

        return Ok(true);
    }

    Ok(false)
}

pub(crate) async fn handle_command(command: crate::ChannelCommands, config: &Config) -> Result<()> {
    match command {
        crate::ChannelCommands::Start => {
            anyhow::bail!("Start must be handled in main.rs (requires async runtime)")
        }
        crate::ChannelCommands::Doctor => {
            anyhow::bail!("Doctor must be handled in main.rs (requires async runtime)")
        }
        crate::ChannelCommands::List => {
            println!("Channels:");
            println!("  ✅ CLI (always available)");
            for (channel, configured) in config.channels_config.channels() {
                println!(
                    "  {} {}",
                    if configured { "✅" } else { "❌" },
                    channel.name()
                );
            }
            // Notion is a top-level config section, not part of ChannelsConfig
            {
                let notion_configured =
                    config.notion.enabled && !config.notion.database_id.trim().is_empty();
                println!("  {} Notion", if notion_configured { "✅" } else { "❌" });
            }
            if !cfg!(feature = "channel-matrix") {
                println!(
                    "  ℹ️ Matrix channel support is disabled in this build (enable `channel-matrix`)."
                );
            }
            if !cfg!(feature = "channel-lark") {
                println!(
                    "  ℹ️ Lark/Feishu channel support is disabled in this build (enable `channel-lark`)."
                );
            }
            println!("\nTo start channels: revka channel start");
            println!("To check health:    revka channel doctor");
            println!("To configure:      revka onboard");
            Ok(())
        }
        crate::ChannelCommands::Add {
            channel_type,
            config: _,
        } => {
            anyhow::bail!(
                "Channel type '{channel_type}' — use `revka onboard` to configure channels"
            );
        }
        crate::ChannelCommands::Remove { name } => {
            anyhow::bail!("Remove channel '{name}' — edit ~/.revka/config.toml directly");
        }
        crate::ChannelCommands::BindTelegram { identity } => {
            Box::pin(bind_telegram_identity(config, &identity)).await
        }
        crate::ChannelCommands::Send {
            message,
            channel_id,
            recipient,
        } => send_channel_message(config, &channel_id, &recipient, &message).await,
    }
}

/// Build a single channel instance by config section name (e.g. "telegram").
fn build_channel_by_id(config: &Config, channel_id: &str) -> Result<Arc<dyn Channel>> {
    match channel_id {
        "telegram" => {
            let tg = config
                .channels_config
                .telegram
                .as_ref()
                .context("Telegram channel is not configured")?;
            let ack = tg
                .ack_reactions
                .unwrap_or(config.channels_config.ack_reactions);
            Ok(Arc::new(
                TelegramChannel::new(
                    tg.bot_token.clone(),
                    tg.allowed_users.clone(),
                    tg.mention_only,
                )
                .with_ack_reactions(ack)
                .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
                .with_transcription(config.transcription.clone())
                .with_tts(config.tts.clone())
                .with_workspace_dir(config.workspace_dir.clone()),
            ))
        }
        "discord" => {
            let dc = config
                .channels_config
                .discord
                .as_ref()
                .context("Discord channel is not configured")?;
            Ok(Arc::new(
                DiscordChannel::new(
                    dc.bot_token.clone(),
                    dc.guild_id.clone(),
                    dc.allowed_users.clone(),
                    dc.listen_to_bots,
                    dc.mention_only,
                )
                .with_streaming(
                    dc.stream_mode,
                    dc.draft_update_interval_ms,
                    dc.multi_message_delay_ms,
                )
                .with_transcription(config.transcription.clone()),
            ))
        }
        "slack" => {
            let sl = config
                .channels_config
                .slack
                .as_ref()
                .context("Slack channel is not configured")?;
            Ok(Arc::new(
                SlackChannel::new(
                    sl.bot_token.clone(),
                    sl.app_token.clone(),
                    sl.channel_id.clone(),
                    sl.channel_ids.clone(),
                    sl.allowed_users.clone(),
                )
                .with_workspace_dir(config.workspace_dir.clone())
                .with_markdown_blocks(sl.use_markdown_blocks)
                .with_transcription(config.transcription.clone())
                .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
                .with_cancel_reaction(sl.cancel_reaction.clone()),
            ))
        }
        "mattermost" => {
            let mm = config
                .channels_config
                .mattermost
                .as_ref()
                .context("Mattermost channel is not configured")?;
            Ok(Arc::new(MattermostChannel::new(
                mm.url.clone(),
                mm.bot_token.clone(),
                mm.channel_id.clone(),
                mm.allowed_users.clone(),
                mm.thread_replies.unwrap_or(true),
                mm.mention_only.unwrap_or(false),
            )))
        }
        "signal" => {
            let sg = config
                .channels_config
                .signal
                .as_ref()
                .context("Signal channel is not configured")?;
            Ok(Arc::new(SignalChannel::new(
                sg.http_url.clone(),
                sg.account.clone(),
                sg.group_id.clone(),
                sg.allowed_from.clone(),
                sg.ignore_attachments,
                sg.ignore_stories,
            )))
        }
        "matrix" => {
            #[cfg(feature = "channel-matrix")]
            {
                let mx = config
                    .channels_config
                    .matrix
                    .as_ref()
                    .context("Matrix channel is not configured")?;
                Ok(Arc::new(MatrixChannel::new(
                    mx.homeserver.clone(),
                    mx.access_token.clone(),
                    mx.room_id.clone(),
                    mx.allowed_users.clone(),
                )))
            }
            #[cfg(not(feature = "channel-matrix"))]
            {
                anyhow::bail!("Matrix channel requires the `channel-matrix` feature");
            }
        }
        "whatsapp" | "whatsapp-web" | "whatsapp_web" => {
            #[cfg(feature = "whatsapp-web")]
            {
                let wa = config
                    .channels_config
                    .whatsapp
                    .as_ref()
                    .context("WhatsApp channel is not configured")?;
                if !wa.is_web_config() {
                    anyhow::bail!(
                        "WhatsApp channel send requires Web mode (session_path must be set)"
                    );
                }
                Ok(Arc::new(WhatsAppWebChannel::new(
                    wa.session_path.clone().unwrap_or_default(),
                    wa.pair_phone.clone(),
                    wa.pair_code.clone(),
                    wa.allowed_numbers.clone(),
                    wa.mode.clone(),
                    wa.dm_policy.clone(),
                    wa.group_policy.clone(),
                    wa.self_chat_mode,
                )))
            }
            #[cfg(not(feature = "whatsapp-web"))]
            {
                anyhow::bail!("WhatsApp channel requires the `whatsapp-web` feature");
            }
        }
        "qq" => {
            let qq = config
                .channels_config
                .qq
                .as_ref()
                .context("QQ channel is not configured")?;
            Ok(Arc::new(QQChannel::new(
                qq.app_id.clone(),
                qq.app_secret.clone(),
                qq.allowed_users.clone(),
            )))
        }
        other => anyhow::bail!(
            "Unknown channel '{other}'. Supported: telegram, discord, slack, mattermost, signal, matrix, whatsapp, qq"
        ),
    }
}

/// Send a one-off message to a configured channel.
async fn send_channel_message(
    config: &Config,
    channel_id: &str,
    recipient: &str,
    message: &str,
) -> Result<()> {
    let channel = build_channel_by_id(config, channel_id)?;
    let msg = SendMessage::new(message, recipient);
    channel
        .send(&msg)
        .await
        .with_context(|| format!("Failed to send message via {channel_id}"))?;
    println!("Message sent via {channel_id}.");
    Ok(())
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChannelHealthState {
    Healthy,
    Unhealthy,
    Timeout,
}

fn classify_health_result(
    result: &std::result::Result<bool, tokio::time::error::Elapsed>,
) -> ChannelHealthState {
    match result {
        Ok(true) => ChannelHealthState::Healthy,
        Ok(false) => ChannelHealthState::Unhealthy,
        Err(_) => ChannelHealthState::Timeout,
    }
}

struct ConfiguredChannel {
    display_name: &'static str,
    channel: Arc<dyn Channel>,
}

fn collect_configured_channels(
    config: &Config,
    matrix_skip_context: &str,
) -> Vec<ConfiguredChannel> {
    let _ = matrix_skip_context;
    let mut channels = Vec::new();

    if let Some(ref tg) = config.channels_config.telegram {
        let ack = tg
            .ack_reactions
            .unwrap_or(config.channels_config.ack_reactions);
        channels.push(ConfiguredChannel {
            display_name: "Telegram",
            channel: Arc::new(
                TelegramChannel::new(
                    tg.bot_token.clone(),
                    tg.allowed_users.clone(),
                    tg.mention_only,
                )
                .with_ack_reactions(ack)
                .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
                .with_transcription(config.transcription.clone())
                .with_tts(config.tts.clone())
                .with_workspace_dir(config.workspace_dir.clone())
                .with_proxy_url(tg.proxy_url.clone())
                .with_approval_registry(
                    crate::gateway::approval_registry::global(),
                    config.gateway.port,
                ),
            ),
        });
    }

    if let Some(ref dc) = config.channels_config.discord {
        channels.push(ConfiguredChannel {
            display_name: "Discord",
            channel: Arc::new(
                DiscordChannel::new(
                    dc.bot_token.clone(),
                    dc.guild_id.clone(),
                    dc.allowed_users.clone(),
                    dc.listen_to_bots,
                    dc.mention_only,
                )
                .with_streaming(
                    dc.stream_mode,
                    dc.draft_update_interval_ms,
                    dc.multi_message_delay_ms,
                )
                .with_proxy_url(dc.proxy_url.clone())
                .with_transcription(config.transcription.clone())
                .with_approval_registry(
                    crate::gateway::approval_registry::global(),
                    config.gateway.port,
                ),
            ),
        });
    }

    if config.channels_config.discord_history.is_some() {
        tracing::warn!(
            "discord_history: persistent history storage has been removed \
             (SQLite backend deleted; channel is disabled). \
             Use Kumiho MCP for persistent memory."
        );
    }

    if let Some(ref sl) = config.channels_config.slack {
        channels.push(ConfiguredChannel {
            display_name: "Slack",
            channel: Arc::new(
                SlackChannel::new(
                    sl.bot_token.clone(),
                    sl.app_token.clone(),
                    sl.channel_id.clone(),
                    sl.channel_ids.clone(),
                    sl.allowed_users.clone(),
                )
                .with_thread_replies(sl.thread_replies.unwrap_or(true))
                .with_group_reply_policy(sl.mention_only, Vec::new())
                .with_workspace_dir(config.workspace_dir.clone())
                .with_markdown_blocks(sl.use_markdown_blocks)
                .with_proxy_url(sl.proxy_url.clone())
                .with_transcription(config.transcription.clone())
                .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
                .with_cancel_reaction(sl.cancel_reaction.clone())
                .with_approval_registry(
                    crate::gateway::approval_registry::global(),
                    config.gateway.port,
                ),
            ),
        });
    }

    if let Some(ref mm) = config.channels_config.mattermost {
        channels.push(ConfiguredChannel {
            display_name: "Mattermost",
            channel: Arc::new(
                MattermostChannel::new(
                    mm.url.clone(),
                    mm.bot_token.clone(),
                    mm.channel_id.clone(),
                    mm.allowed_users.clone(),
                    mm.thread_replies.unwrap_or(true),
                    mm.mention_only.unwrap_or(false),
                )
                .with_proxy_url(mm.proxy_url.clone())
                .with_transcription(config.transcription.clone()),
            ),
        });
    }

    if let Some(ref im) = config.channels_config.imessage {
        channels.push(ConfiguredChannel {
            display_name: "iMessage",
            channel: Arc::new(IMessageChannel::new(im.allowed_contacts.clone())),
        });
    }

    #[cfg(feature = "channel-matrix")]
    if let Some(ref mx) = config.channels_config.matrix {
        channels.push(ConfiguredChannel {
            display_name: "Matrix",
            channel: Arc::new(
                MatrixChannel::new_full(
                    mx.homeserver.clone(),
                    mx.access_token.clone(),
                    mx.room_id.clone(),
                    mx.allowed_users.clone(),
                    mx.allowed_rooms.clone(),
                    mx.user_id.clone(),
                    mx.device_id.clone(),
                    config.config_path.parent().map(|path| path.to_path_buf()),
                    mx.recovery_key.clone(),
                )
                .with_streaming(
                    mx.stream_mode,
                    mx.draft_update_interval_ms,
                    mx.multi_message_delay_ms,
                )
                .with_transcription(config.transcription.clone()),
            ),
        });
    }

    #[cfg(not(feature = "channel-matrix"))]
    if config.channels_config.matrix.is_some() {
        tracing::warn!(
            "Matrix channel is configured but this build was compiled without `channel-matrix`; skipping Matrix {}.",
            matrix_skip_context
        );
    }

    if let Some(ref sig) = config.channels_config.signal {
        channels.push(ConfiguredChannel {
            display_name: "Signal",
            channel: Arc::new(
                SignalChannel::new(
                    sig.http_url.clone(),
                    sig.account.clone(),
                    sig.group_id.clone(),
                    sig.allowed_from.clone(),
                    sig.ignore_attachments,
                    sig.ignore_stories,
                )
                .with_proxy_url(sig.proxy_url.clone()),
            ),
        });
    }

    if let Some(ref wa) = config.channels_config.whatsapp {
        if wa.is_ambiguous_config() {
            tracing::warn!(
                "WhatsApp config has both phone_number_id and session_path set; preferring Cloud API mode. Remove one selector to avoid ambiguity."
            );
        }
        // Runtime negotiation: detect backend type from config
        match wa.backend_type() {
            "cloud" => {
                // Cloud API mode: requires phone_number_id, access_token, verify_token
                if wa.is_cloud_config() {
                    channels.push(ConfiguredChannel {
                        display_name: "WhatsApp",
                        channel: Arc::new(
                            WhatsAppChannel::new(
                                wa.access_token.clone().unwrap_or_default(),
                                wa.phone_number_id.clone().unwrap_or_default(),
                                wa.verify_token.clone().unwrap_or_default(),
                                wa.allowed_numbers.clone(),
                            )
                            .with_proxy_url(wa.proxy_url.clone())
                            .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
                            .with_group_mention_patterns(wa.group_mention_patterns.clone()),
                        ),
                    });
                } else {
                    tracing::warn!(
                        "WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)"
                    );
                }
            }
            "web" => {
                // Web mode: requires session_path
                #[cfg(feature = "whatsapp-web")]
                if wa.is_web_config() {
                    channels.push(ConfiguredChannel {
                        display_name: "WhatsApp",
                        channel: Arc::new(
                            WhatsAppWebChannel::new(
                                wa.session_path.clone().unwrap_or_default(),
                                wa.pair_phone.clone(),
                                wa.pair_code.clone(),
                                wa.allowed_numbers.clone(),
                                wa.mode.clone(),
                                wa.dm_policy.clone(),
                                wa.group_policy.clone(),
                                wa.self_chat_mode,
                            )
                            .with_transcription(config.transcription.clone())
                            .with_tts(config.tts.clone())
                            .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
                            .with_group_mention_patterns(wa.group_mention_patterns.clone()),
                        ),
                    });
                } else {
                    tracing::warn!("WhatsApp Web configured but session_path not set");
                }
                #[cfg(not(feature = "whatsapp-web"))]
                {
                    tracing::warn!(
                        "WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web"
                    );
                    eprintln!(
                        "  ⚠ WhatsApp Web is configured but the 'whatsapp-web' feature is not compiled in."
                    );
                    eprintln!("    Rebuild with: cargo build --features whatsapp-web");
                }
            }
            _ => {
                tracing::warn!(
                    "WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set"
                );
            }
        }
    }

    if let Some(ref lq) = config.channels_config.linq {
        channels.push(ConfiguredChannel {
            display_name: "Linq",
            channel: Arc::new(LinqChannel::new(
                lq.api_token.clone(),
                lq.from_phone.clone(),
                lq.allowed_senders.clone(),
            )),
        });
    }

    if let Some(ref wati_cfg) = config.channels_config.wati {
        let wati_channel = WatiChannel::new_with_proxy(
            wati_cfg.api_token.clone(),
            wati_cfg.api_url.clone(),
            wati_cfg.tenant_id.clone(),
            wati_cfg.allowed_numbers.clone(),
            wati_cfg.proxy_url.clone(),
        )
        .with_transcription(config.transcription.clone());

        channels.push(ConfiguredChannel {
            display_name: "WATI",
            channel: Arc::new(wati_channel),
        });
    }

    if let Some(ref nc) = config.channels_config.nextcloud_talk {
        channels.push(ConfiguredChannel {
            display_name: "Nextcloud Talk",
            channel: Arc::new(NextcloudTalkChannel::new_with_proxy(
                nc.base_url.clone(),
                nc.app_token.clone(),
                nc.bot_name.clone().unwrap_or_default(),
                nc.allowed_users.clone(),
                nc.proxy_url.clone(),
            )),
        });
    }

    if let Some(ref email_cfg) = config.channels_config.email {
        channels.push(ConfiguredChannel {
            display_name: "Email",
            channel: Arc::new(EmailChannel::new(email_cfg.clone())),
        });
    }

    if let Some(ref gp_cfg) = config.channels_config.gmail_push {
        if gp_cfg.enabled {
            channels.push(ConfiguredChannel {
                display_name: "Gmail Push",
                channel: Arc::new(GmailPushChannel::new(gp_cfg.clone())),
            });
        }
    }

    if let Some(ref irc) = config.channels_config.irc {
        channels.push(ConfiguredChannel {
            display_name: "IRC",
            channel: Arc::new(IrcChannel::new(irc::IrcChannelConfig {
                server: irc.server.clone(),
                port: irc.port,
                nickname: irc.nickname.clone(),
                username: irc.username.clone(),
                channels: irc.channels.clone(),
                allowed_users: irc.allowed_users.clone(),
                server_password: irc.server_password.clone(),
                nickserv_password: irc.nickserv_password.clone(),
                sasl_password: irc.sasl_password.clone(),
                verify_tls: irc.verify_tls.unwrap_or(true),
            })),
        });
    }

    #[cfg(feature = "channel-lark")]
    if let Some(ref lk) = config.channels_config.lark {
        if lk.use_feishu {
            if config.channels_config.feishu.is_some() {
                tracing::warn!(
                    "Both [channels_config.feishu] and legacy [channels_config.lark].use_feishu=true are configured; ignoring legacy Feishu fallback in lark."
                );
            } else {
                tracing::warn!(
                    "Using legacy [channels_config.lark].use_feishu=true compatibility path; prefer [channels_config.feishu]."
                );
                channels.push(ConfiguredChannel {
                    display_name: "Feishu",
                    channel: Arc::new(
                        LarkChannel::from_config(lk)
                            .with_transcription(config.transcription.clone()),
                    ),
                });
            }
        } else {
            channels.push(ConfiguredChannel {
                display_name: "Lark",
                channel: Arc::new(
                    LarkChannel::from_lark_config(lk)
                        .with_transcription(config.transcription.clone()),
                ),
            });
        }
    }

    #[cfg(feature = "channel-lark")]
    if let Some(ref fs) = config.channels_config.feishu {
        channels.push(ConfiguredChannel {
            display_name: "Feishu",
            channel: Arc::new(
                LarkChannel::from_feishu_config(fs)
                    .with_transcription(config.transcription.clone()),
            ),
        });
    }

    #[cfg(not(feature = "channel-lark"))]
    if config.channels_config.lark.is_some() || config.channels_config.feishu.is_some() {
        tracing::warn!(
            "Lark/Feishu channel is configured but this build was compiled without `channel-lark`; skipping Lark/Feishu health check."
        );
    }

    if let Some(ref dt) = config.channels_config.dingtalk {
        channels.push(ConfiguredChannel {
            display_name: "DingTalk",
            channel: Arc::new(
                DingTalkChannel::new(
                    dt.client_id.clone(),
                    dt.client_secret.clone(),
                    dt.allowed_users.clone(),
                )
                .with_proxy_url(dt.proxy_url.clone()),
            ),
        });
    }

    if let Some(ref qq) = config.channels_config.qq {
        channels.push(ConfiguredChannel {
            display_name: "QQ",
            channel: Arc::new(
                QQChannel::new(
                    qq.app_id.clone(),
                    qq.app_secret.clone(),
                    qq.allowed_users.clone(),
                )
                .with_workspace_dir(config.workspace_dir.clone())
                .with_proxy_url(qq.proxy_url.clone()),
            ),
        });
    }

    if let Some(ref tw) = config.channels_config.twitter {
        channels.push(ConfiguredChannel {
            display_name: "X/Twitter",
            channel: Arc::new(TwitterChannel::new(
                tw.bearer_token.clone(),
                tw.allowed_users.clone(),
            )),
        });
    }

    if let Some(ref mc) = config.channels_config.mochat {
        channels.push(ConfiguredChannel {
            display_name: "Mochat",
            channel: Arc::new(MochatChannel::new(
                mc.api_url.clone(),
                mc.api_token.clone(),
                mc.allowed_users.clone(),
                mc.poll_interval_secs,
            )),
        });
    }

    if let Some(ref wc) = config.channels_config.wecom {
        channels.push(ConfiguredChannel {
            display_name: "WeCom",
            channel: Arc::new(WeComChannel::new(
                wc.webhook_key.clone(),
                wc.allowed_users.clone(),
            )),
        });
    }

    if let Some(ref ct) = config.channels_config.clawdtalk {
        channels.push(ConfiguredChannel {
            display_name: "ClawdTalk",
            channel: Arc::new(ClawdTalkChannel::new(ct.clone())),
        });
    }

    // Notion database poller channel
    if config.notion.enabled && !config.notion.database_id.trim().is_empty() {
        let notion_api_key = if config.notion.api_key.trim().is_empty() {
            std::env::var("NOTION_API_KEY").unwrap_or_default()
        } else {
            config.notion.api_key.trim().to_string()
        };
        if notion_api_key.trim().is_empty() {
            tracing::warn!(
                "Notion channel enabled but no API key found (set notion.api_key or NOTION_API_KEY env var)"
            );
        } else {
            channels.push(ConfiguredChannel {
                display_name: "Notion",
                channel: Arc::new(NotionChannel::new(
                    notion_api_key,
                    config.notion.database_id.clone(),
                    config.notion.poll_interval_secs,
                    config.notion.status_property.clone(),
                    config.notion.input_property.clone(),
                    config.notion.result_property.clone(),
                    config.notion.max_concurrent,
                    config.notion.recover_stale,
                )),
            });
        }
    }

    if let Some(ref rd) = config.channels_config.reddit {
        channels.push(ConfiguredChannel {
            display_name: "Reddit",
            channel: Arc::new(RedditChannel::new(
                rd.client_id.clone(),
                rd.client_secret.clone(),
                rd.refresh_token.clone(),
                rd.username.clone(),
                rd.subreddit.clone(),
            )),
        });
    }

    if let Some(ref bs) = config.channels_config.bluesky {
        channels.push(ConfiguredChannel {
            display_name: "Bluesky",
            channel: Arc::new(BlueskyChannel::new(
                bs.handle.clone(),
                bs.app_password.clone(),
            )),
        });
    }

    #[cfg(feature = "voice-wake")]
    if let Some(ref vw) = config.channels_config.voice_wake {
        channels.push(ConfiguredChannel {
            display_name: "VoiceWake",
            channel: Arc::new(VoiceWakeChannel::new(
                vw.clone(),
                config.transcription.clone(),
            )),
        });
    }

    if let Some(ref wh) = config.channels_config.webhook {
        channels.push(ConfiguredChannel {
            display_name: "Webhook",
            channel: Arc::new(WebhookChannel::new(
                wh.port,
                wh.listen_path.clone(),
                wh.send_url.clone(),
                wh.send_method.clone(),
                wh.auth_header.clone(),
                wh.secret.clone(),
            )),
        });
    }

    channels
}

/// Run health checks for configured channels.
pub async fn doctor_channels(config: Config) -> Result<()> {
    #[allow(unused_mut)]
    let mut channels = collect_configured_channels(&config, "health check");

    #[cfg(feature = "channel-nostr")]
    if let Some(ref ns) = config.channels_config.nostr {
        channels.push(ConfiguredChannel {
            display_name: "Nostr",
            channel: Arc::new(
                NostrChannel::new(&ns.private_key, ns.relays.clone(), &ns.allowed_pubkeys).await?,
            ),
        });
    }

    if channels.is_empty() {
        println!("No real-time channels configured. Run `revka onboard` first.");
        return Ok(());
    }

    println!("🩺 Revka Channel Doctor");
    println!();

    let mut healthy = 0_u32;
    let mut unhealthy = 0_u32;
    let mut timeout = 0_u32;

    for configured in channels {
        let result =
            tokio::time::timeout(Duration::from_secs(10), configured.channel.health_check()).await;
        let state = classify_health_result(&result);

        match state {
            ChannelHealthState::Healthy => {
                healthy += 1;
                println!("  ✅ {:<9} healthy", configured.display_name);
            }
            ChannelHealthState::Unhealthy => {
                unhealthy += 1;
                println!(
                    "  ❌ {:<9} unhealthy (auth/config/network)",
                    configured.display_name
                );
            }
            ChannelHealthState::Timeout => {
                timeout += 1;
                println!("  ⏱️  {:<9} timed out (>10s)", configured.display_name);
            }
        }
    }

    if config.channels_config.webhook.is_some() {
        println!("  ℹ️  Webhook   check via `revka gateway` then GET /health");
    }

    println!();
    println!("Summary: {healthy} healthy, {unhealthy} unhealthy, {timeout} timed out");
    Ok(())
}

/// Start all configured channels and route messages to the agent.
pub async fn start_channels(config: Config) -> Result<()> {
    Box::pin(start_channels_with_mcp_registry(config, None)).await
}

/// Start all configured channels, optionally reusing a daemon-owned MCP registry.
///
/// Standalone `revka channel start` owns its registry. The long-running daemon
/// shares one registry between gateway and channels so stdio sidecars are not
/// spawned twice.
#[allow(clippy::too_many_lines)]
pub async fn start_channels_with_mcp_registry(
    config: Config,
    shared_mcp_registry: Option<Arc<crate::tools::McpRegistry>>,
) -> Result<()> {
    // Inject Kumiho + Operator MCP servers so channel agents have full
    // orchestration capabilities (user may issue operator orders via Discord).
    let config = crate::agent::kumiho::inject_kumiho(config, false);
    let config = crate::agent::operator::inject_operator(config, false);

    let provider_name = resolved_default_provider(&config);
    let provider_runtime_options = providers::ProviderRuntimeOptions {
        auth_profile_override: None,
        provider_api_url: config.api_url.clone(),
        revka_dir: config.config_path.parent().map(std::path::PathBuf::from),
        secrets_encrypt: config.secrets.encrypt,
        reasoning_enabled: config.runtime.reasoning_enabled,
        reasoning_effort: config.runtime.reasoning_effort.clone(),
        provider_timeout_secs: Some(config.provider_timeout_secs),
        extra_headers: config.extra_headers.clone(),
        api_path: config.api_path.clone(),
        provider_max_tokens: config.provider_max_tokens,
    };
    let provider: Arc<dyn Provider> = Arc::from(
        create_resilient_provider_nonblocking(
            &provider_name,
            config.api_key.clone(),
            config.api_url.clone(),
            config.reliability.clone(),
            provider_runtime_options.clone(),
        )
        .await?,
    );

    // Warm up the provider connection pool (TLS handshake, DNS, HTTP/2 setup)
    // so the first real message doesn't hit a cold-start timeout.
    if let Err(e) = provider.warmup().await {
        tracing::warn!("Provider warmup failed (non-fatal): {e}");
    }

    let initial_stamp = config_file_stamp(&config.config_path).await;
    {
        let mut store = runtime_config_store()
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        store.insert(
            config.config_path.clone(),
            RuntimeConfigState {
                defaults: runtime_defaults_from_config(&config),
                last_applied_stamp: initial_stamp,
            },
        );
    }

    let observer: Arc<dyn Observer> =
        Arc::from(observability::create_observer(&config.observability));
    let runtime: Arc<dyn runtime::RuntimeAdapter> =
        Arc::from(runtime::create_runtime(&config.runtime)?);
    let security = Arc::new(SecurityPolicy::from_config(
        &config.autonomy,
        &config.workspace_dir,
    ));
    let model = resolved_default_model(&config);
    let temperature = config.default_temperature;
    let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
        &config.memory,
        &config.embedding_routes,
        Some(&config.storage.provider.config),
        &config.workspace_dir,
        config.api_key.as_deref(),
    )?);
    let (composio_key, composio_entity_id) = if config.composio.enabled {
        (
            config.composio.api_key.as_deref(),
            Some(config.composio.entity_id.as_str()),
        )
    } else {
        (None, None)
    };
    // Build system prompt from workspace identity files + skills
    let workspace = config.workspace_dir.clone();
    let (
        mut built_tools,
        delegate_handle_ch,
        reaction_handle_ch,
        _channel_map_handle,
        ask_user_handle_ch,
        escalate_handle_ch,
    ) = tools::all_tools_with_runtime(
        Arc::new(config.clone()),
        &security,
        runtime,
        Arc::clone(&mem),
        composio_key,
        composio_entity_id,
        &config.browser,
        &config.http_request,
        &config.web_fetch,
        &workspace,
        &config.agents,
        config.api_key.as_deref(),
        &config,
        None,
    );

    // Wire MCP tools into the registry before freezing — non-fatal.
    // When `deferred_loading` is enabled, MCP tools are NOT added eagerly.
    // Instead, a `tool_search` built-in is registered for on-demand loading.
    let mut deferred_section = String::new();
    let mut ch_activated_handle: Option<
        std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
    > = None;
    let mut ch_mcp_registry: Option<Arc<crate::tools::McpRegistry>> = None;
    let mut kumiho_advanced = false;
    if let Some(registry) = shared_mcp_registry {
        tracing::info!(
            "Using shared MCP client — {} server(s), {} tool(s)",
            registry.server_count(),
            registry.tool_count()
        );
        ch_mcp_registry = Some(std::sync::Arc::clone(&registry));
        kumiho_advanced =
            crate::agent::kumiho::registry_has_advanced_kumiho_tools(&registry.tool_names());
        crate::agent::kumiho::warn_if_kumiho_advanced_missing(&config, kumiho_advanced);
        if config.mcp.deferred_loading {
            let is_local_provider = resolved_default_provider(&config) == "ollama";
            let all_names = registry.tool_names();
            let mut eager_count = 0usize;

            for name in &all_names {
                let should_eager = if is_local_provider {
                    crate::tools::mcp_deferred::is_local_model_eager_tool(name)
                } else {
                    crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
                };
                if should_eager {
                    if let Some(def) = registry.get_tool_def(name).await {
                        let wrapper: std::sync::Arc<dyn Tool> =
                            std::sync::Arc::new(crate::tools::McpToolWrapper::new(
                                name.clone(),
                                def,
                                std::sync::Arc::clone(&registry),
                            ));
                        if let Some(ref handle) = delegate_handle_ch {
                            handle.write().push(std::sync::Arc::clone(&wrapper));
                        }
                        built_tools.push(Box::new(crate::tools::ArcToolRef(wrapper)));
                        eager_count += 1;
                    }
                }
            }

            let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
                std::sync::Arc::clone(&registry),
                move |name: &str| {
                    if is_local_provider {
                        !crate::tools::mcp_deferred::is_local_model_eager_tool(name)
                    } else {
                        !crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
                    }
                },
            )
            .await;
            tracing::info!(
                "MCP hybrid: {} eager tool(s), {} deferred stub(s) from shared {} server(s) (local_provider={})",
                eager_count,
                deferred_set.len(),
                registry.server_count(),
                is_local_provider,
            );
            let server_instructions = registry.server_instructions().await;
            deferred_section =
                crate::tools::mcp_deferred::build_deferred_tools_section_with_instructions(
                    &deferred_set,
                    &server_instructions,
                );
            let activated =
                std::sync::Arc::new(std::sync::Mutex::new(crate::tools::ActivatedToolSet::new()));
            ch_activated_handle = Some(std::sync::Arc::clone(&activated));
            built_tools.push(Box::new(crate::tools::ToolSearchTool::new(
                deferred_set,
                activated,
            )));
        } else {
            let mut registered = 0usize;
            for name in registry.tool_names() {
                if let Some(def) = registry.get_tool_def(&name).await {
                    let wrapper: std::sync::Arc<dyn Tool> =
                        std::sync::Arc::new(crate::tools::McpToolWrapper::new(
                            name,
                            def,
                            std::sync::Arc::clone(&registry),
                        ));
                    if let Some(ref handle) = delegate_handle_ch {
                        handle.write().push(std::sync::Arc::clone(&wrapper));
                    }
                    built_tools.push(Box::new(crate::tools::ArcToolRef(wrapper)));
                    registered += 1;
                }
            }
            tracing::info!(
                "MCP: {} tool(s) registered from shared {} server(s)",
                registered,
                registry.server_count()
            );
        }
    } else if config.mcp.enabled && !config.mcp.servers.is_empty() {
        tracing::info!(
            "Initializing MCP client — {} server(s) configured",
            config.mcp.servers.len()
        );
        match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
            Ok(registry) => {
                let registry = std::sync::Arc::new(registry);
                ch_mcp_registry = Some(std::sync::Arc::clone(&registry));
                kumiho_advanced = crate::agent::kumiho::registry_has_advanced_kumiho_tools(
                    &registry.tool_names(),
                );
                crate::agent::kumiho::warn_if_kumiho_advanced_missing(&config, kumiho_advanced);
                if config.mcp.deferred_loading {
                    // Hybrid: eagerly load essential tools, defer the rest.
                    //
                    // Local models (Ollama) get the minimal local eager set
                    // because large tool sets cause hallucinated tool names.
                    // Cloud providers get the curated operator-seat eager set
                    // (operator essentials + Kumiho memory reflexes); the rest
                    // is discoverable via tool_search to keep per-turn input
                    // tokens bounded.
                    let is_local_provider = resolved_default_provider(&config) == "ollama";

                    let all_names = registry.tool_names();
                    let mut eager_count = 0usize;

                    for name in &all_names {
                        let should_eager = if is_local_provider {
                            crate::tools::mcp_deferred::is_local_model_eager_tool(name)
                        } else {
                            crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
                        };
                        if should_eager {
                            if let Some(def) = registry.get_tool_def(name).await {
                                let wrapper: std::sync::Arc<dyn Tool> =
                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
                                        name.clone(),
                                        def,
                                        std::sync::Arc::clone(&registry),
                                    ));
                                if let Some(ref handle) = delegate_handle_ch {
                                    handle.write().push(std::sync::Arc::clone(&wrapper));
                                }
                                built_tools.push(Box::new(crate::tools::ArcToolRef(wrapper)));
                                eager_count += 1;
                            }
                        }
                    }

                    let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
                        std::sync::Arc::clone(&registry),
                        move |name: &str| {
                            if is_local_provider {
                                !crate::tools::mcp_deferred::is_local_model_eager_tool(name)
                            } else {
                                !crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
                            }
                        },
                    )
                    .await;
                    tracing::info!(
                        "MCP hybrid: {} eager tool(s), {} deferred stub(s) from {} server(s) (local_provider={})",
                        eager_count,
                        deferred_set.len(),
                        registry.server_count(),
                        is_local_provider,
                    );
                    let server_instructions = registry.server_instructions().await;
                    deferred_section =
                        crate::tools::mcp_deferred::build_deferred_tools_section_with_instructions(
                            &deferred_set,
                            &server_instructions,
                        );
                    let activated = std::sync::Arc::new(std::sync::Mutex::new(
                        crate::tools::ActivatedToolSet::new(),
                    ));
                    ch_activated_handle = Some(std::sync::Arc::clone(&activated));
                    built_tools.push(Box::new(crate::tools::ToolSearchTool::new(
                        deferred_set,
                        activated,
                    )));
                } else {
                    let names = registry.tool_names();
                    let mut registered = 0usize;
                    for name in names {
                        if let Some(def) = registry.get_tool_def(&name).await {
                            let wrapper: std::sync::Arc<dyn Tool> =
                                std::sync::Arc::new(crate::tools::McpToolWrapper::new(
                                    name,
                                    def,
                                    std::sync::Arc::clone(&registry),
                                ));
                            if let Some(ref handle) = delegate_handle_ch {
                                handle.write().push(std::sync::Arc::clone(&wrapper));
                            }
                            built_tools.push(Box::new(crate::tools::ArcToolRef(wrapper)));
                            registered += 1;
                        }
                    }
                    tracing::info!(
                        "MCP: {} tool(s) registered from {} server(s)",
                        registered,
                        registry.server_count()
                    );
                }
            }
            Err(e) => {
                // Non-fatal — daemon continues with the tools registered above.
                tracing::error!("MCP registry failed to initialize: {e:#}");
            }
        }
    }

    let tools_registry = Arc::new(built_tools);

    let skills = crate::skills::load_skills_with_config(&workspace, &config);

    // ── Load locale-aware tool descriptions ────────────────────────
    let i18n_locale = config
        .locale
        .as_deref()
        .filter(|s| !s.is_empty())
        .map(ToString::to_string)
        .unwrap_or_else(crate::i18n::detect_locale);
    let i18n_search_dirs = crate::i18n::default_search_dirs(&workspace);
    let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);

    // Collect tool descriptions for the prompt.
    //
    // Audit row 11: bare `memory_*` tool names are intentionally not listed
    // here — there is no native `impl Tool` for them. Channel agents reach
    // memory via the Kumiho MCP (kumiho_memory_engage / reflect / store /
    // retrieve), advertised through the channel bootstrap prompt.
    let mut tool_descs: Vec<(&str, &str)> = vec![
        (
            "shell",
            "Execute terminal commands. Use when: running local checks, build/test commands, diagnostics. Don't use when: a safer dedicated tool exists, or command is destructive without approval.",
        ),
        (
            "file_read",
            "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
        ),
        (
            "file_write",
            "Write file contents. Use when: applying focused edits, scaffolding files, updating docs/code. Don't use when: side effects are unclear or file ownership is uncertain.",
        ),
    ];

    if matches!(
        config.skills.prompt_injection_mode,
        crate::config::SkillsPromptInjectionMode::Compact
    ) {
        tool_descs.push((
            "read_skill",
            "Load the full source for an available skill by name. Use when: compact mode only shows a summary and you need the complete skill instructions.",
        ));
    }

    if config.browser.enabled {
        tool_descs.push((
            "browser_open",
            "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
        ));
    }
    if config.composio.enabled {
        tool_descs.push((
            "composio",
            "Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover actions, 'list_accounts' to retrieve connected account IDs, 'execute' to run (optionally with connected_account_id), and 'connect' for OAuth.",
        ));
    }
    tool_descs.push((
        "schedule",
        "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
    ));
    tool_descs.push((
        "pushover",
        "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.",
    ));
    if !config.agents.is_empty() {
        tool_descs.push((
            "delegate",
            "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single prompt and returns its response.",
        ));
    }

    // Filter out tools excluded for non-CLI channels so the system prompt
    // does not advertise them for channel-driven runs.
    // Skip this filter when autonomy is `Full` — full-autonomy agents keep
    // all tools available regardless of channel.
    let excluded = &config.autonomy.non_cli_excluded_tools;
    if !excluded.is_empty() && config.autonomy.level != AutonomyLevel::Full {
        tool_descs.retain(|(name, _)| {
            !excluded.iter().any(|ex| {
                if let Some(prefix) = ex.strip_suffix('*') {
                    name.starts_with(prefix)
                } else {
                    ex == name
                }
            })
        });
    }

    let bootstrap_max_chars = if config.agent.compact_context {
        Some(6000)
    } else {
        None
    };
    let native_tools = provider.supports_native_tools();
    let mut system_prompt = assemble_channel_system_prompt(
        &workspace,
        &model,
        &tool_descs,
        &skills,
        Some(&config.identity),
        bootstrap_max_chars,
        Some(&config.autonomy),
        native_tools,
        config.skills.prompt_injection_mode,
        config.agent.compact_context,
        config.agent.max_system_prompt_chars,
    );
    if !native_tools {
        system_prompt.push_str(&build_tool_instructions(
            tools_registry.as_ref(),
            Some(&i18n_descs),
        ));
    }

    // Append deferred MCP tool names so the LLM knows what is available
    if !deferred_section.is_empty() {
        system_prompt.push('\n');
        system_prompt.push_str(&deferred_section);
    }

    // Append lightweight Kumiho + Operator bootstraps (~400 tokens total).
    // Full instructions are loaded on-demand via MCP skill on first turn,
    // following OpenClaw's one-shot pattern — no bloat on every message.
    // The `kumiho_advanced` flag was set above from the live MCP registry
    // probe — see coherence audit row 1+13.
    crate::agent::kumiho::append_kumiho_channel_bootstrap(
        &mut system_prompt,
        &config,
        false,
        kumiho_advanced,
    );
    crate::agent::operator::append_operator_channel_prompt(
        &mut system_prompt,
        &config,
        false,
        &model,
    );

    if !skills.is_empty() {
        println!(
            "  🧩 Skills:   {}",
            skills
                .iter()
                .map(|s| s.name.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        );
    }

    // Collect active channels from a shared builder to keep startup and doctor parity.
    #[allow(unused_mut)]
    let mut channels: Vec<Arc<dyn Channel>> =
        collect_configured_channels(&config, "runtime startup")
            .into_iter()
            .map(|configured| configured.channel)
            .collect();

    #[cfg(feature = "channel-nostr")]
    if let Some(ref ns) = config.channels_config.nostr {
        channels.push(Arc::new(
            NostrChannel::new(&ns.private_key, ns.relays.clone(), &ns.allowed_pubkeys).await?,
        ));
    }
    if channels.is_empty() {
        println!("No channels configured. Run `revka onboard` to set up channels.");
        return Ok(());
    }

    println!("🦀 Revka Channel Server");
    println!("  🤖 Model:    {model}");
    let effective_backend = memory::effective_memory_backend_name(
        &config.memory.backend,
        Some(&config.storage.provider.config),
    );
    println!(
        "  🧠 Memory:   {} (auto-save: {})",
        effective_backend,
        if config.memory.auto_save { "on" } else { "off" }
    );
    println!(
        "  📡 Channels: {}",
        channels
            .iter()
            .map(|c| c.name())
            .collect::<Vec<_>>()
            .join(", ")
    );
    println!();
    println!("  Listening for messages... (Ctrl+C to stop)");
    println!();

    crate::health::mark_component_ok("channels");

    let initial_backoff_secs = config
        .reliability
        .channel_initial_backoff_secs
        .max(DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS);
    let max_backoff_secs = config
        .reliability
        .channel_max_backoff_secs
        .max(DEFAULT_CHANNEL_MAX_BACKOFF_SECS);

    // Single message bus — all channels send messages here
    let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(100);

    // Spawn a listener for each channel
    let mut handles = Vec::new();
    for ch in &channels {
        handles.push(spawn_supervised_listener(
            ch.clone(),
            tx.clone(),
            initial_backoff_secs,
            max_backoff_secs,
        ));
    }
    drop(tx); // Drop our copy so rx closes when all channels stop

    let channels_by_name = Arc::new(
        channels
            .iter()
            .map(|ch| (ch.name().to_string(), Arc::clone(ch)))
            .collect::<HashMap<_, _>>(),
    );

    // Populate the reaction tool's channel map now that channels are initialized.
    if let Some(ref handle) = reaction_handle_ch {
        let mut map = handle.write();
        for (name, ch) in channels_by_name.as_ref() {
            map.insert(name.clone(), Arc::clone(ch));
        }
    }

    // Populate the ask_user tool's channel map now that channels are initialized.
    if let Some(ref handle) = ask_user_handle_ch {
        let mut map = handle.write();
        for (name, ch) in channels_by_name.as_ref() {
            map.insert(name.clone(), Arc::clone(ch));
        }
    }

    // Populate the escalate_to_human tool's channel map now that channels are initialized.
    if let Some(ref handle) = escalate_handle_ch {
        let mut map = handle.write();
        for (name, ch) in channels_by_name.as_ref() {
            map.insert(name.clone(), Arc::clone(ch));
        }
    }

    let max_in_flight_messages = compute_max_in_flight_messages(channels.len());

    println!("  🚦 In-flight message limit: {max_in_flight_messages}");

    let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
    provider_cache_seed.insert(provider_name.clone(), Arc::clone(&provider));
    let message_timeout_secs =
        effective_channel_message_timeout_secs(config.channels_config.message_timeout_secs);
    let interrupt_on_new_message = config
        .channels_config
        .telegram
        .as_ref()
        .is_some_and(|tg| tg.interrupt_on_new_message);
    let interrupt_on_new_message_slack = config
        .channels_config
        .slack
        .as_ref()
        .is_some_and(|sl| sl.interrupt_on_new_message);
    let interrupt_on_new_message_discord = config
        .channels_config
        .discord
        .as_ref()
        .is_some_and(|dc| dc.interrupt_on_new_message);
    let interrupt_on_new_message_mattermost = config
        .channels_config
        .mattermost
        .as_ref()
        .is_some_and(|mm| mm.interrupt_on_new_message);
    let interrupt_on_new_message_matrix = config
        .channels_config
        .matrix
        .as_ref()
        .is_some_and(|mx| mx.interrupt_on_new_message);

    // Create audit logger for channel message tracking (shares log file with gateway).
    let audit_logger: Option<Arc<crate::security::audit::AuditLogger>> =
        if config.security.audit.enabled {
            match crate::security::audit::AuditLogger::new(
                config.security.audit.clone(),
                std::path::PathBuf::from(&config.workspace_dir),
            ) {
                Ok(logger) => Some(Arc::new(logger)),
                Err(e) => {
                    tracing::warn!("Channel audit logger disabled: {e}");
                    None
                }
            }
        } else {
            None
        };

    let runtime_ctx = Arc::new(ChannelRuntimeContext {
        channels_by_name,
        provider: Arc::clone(&provider),
        default_provider: Arc::new(provider_name),
        prompt_config: Arc::new(config.clone()),
        memory: Arc::clone(&mem),
        tools_registry: Arc::clone(&tools_registry),
        observer,
        system_prompt: Arc::new(system_prompt),
        model: Arc::new(model.clone()),
        temperature,
        auto_save_memory: config.memory.auto_save,
        max_tool_iterations: crate::agent::loop_::effective_max_tool_iterations(&config),
        min_relevance_score: config.memory.min_relevance_score,
        conversation_histories: Arc::new(Mutex::new(HashMap::new())),
        pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
        provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
        route_overrides: Arc::new(Mutex::new(HashMap::new())),
        api_key: config.api_key.clone(),
        api_url: config.api_url.clone(),
        reliability: Arc::new(config.reliability.clone()),
        provider_runtime_options,
        workspace_dir: Arc::new(config.workspace_dir.clone()),
        message_timeout_secs,
        interrupt_on_new_message: InterruptOnNewMessageConfig {
            telegram: interrupt_on_new_message,
            slack: interrupt_on_new_message_slack,
            discord: interrupt_on_new_message_discord,
            mattermost: interrupt_on_new_message_mattermost,
            matrix: interrupt_on_new_message_matrix,
        },
        multimodal: config.multimodal.clone(),
        media_pipeline: config.media_pipeline.clone(),
        transcription_config: config.transcription.clone(),
        hooks: if config.hooks.enabled {
            let mut runner = crate::hooks::HookRunner::new();
            if config.hooks.builtin.command_logger {
                runner.register(Box::new(crate::hooks::builtin::CommandLoggerHook::new()));
            }
            if config.hooks.builtin.webhook_audit.enabled {
                runner.register(Box::new(crate::hooks::builtin::WebhookAuditHook::new(
                    config.hooks.builtin.webhook_audit.clone(),
                )));
            }
            Some(Arc::new(runner))
        } else {
            None
        },
        non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()),
        autonomy_level: config.autonomy.level,
        tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
        model_routes: Arc::new(config.model_routes.clone()),
        query_classification: config.query_classification.clone(),
        ack_reactions: config.channels_config.ack_reactions,
        show_tool_calls: config.channels_config.show_tool_calls,
        session_store: if config.channels_config.session_persistence {
            match session_store::SessionStore::new(&config.workspace_dir) {
                Ok(store) => {
                    tracing::info!("📂 Session persistence enabled");
                    Some(Arc::new(store))
                }
                Err(e) => {
                    tracing::warn!("Session persistence disabled: {e}");
                    None
                }
            }
        } else {
            None
        },
        approval_manager: Arc::new(ApprovalManager::for_non_interactive(&config.autonomy)),
        activated_tools: ch_activated_handle,
        mcp_registry: ch_mcp_registry,
        cost_tracking: crate::cost::CostTracker::get_or_init_global(
            config.cost.clone(),
            &config.workspace_dir,
        )
        .map(|tracker| ChannelCostTrackingState { tracker }),
        pacing: config.pacing.clone(),
        max_tool_result_chars: crate::agent::context_compressor::effective_live_tool_result_chars(
            &config.agent.context_compression,
            config.agent.max_tool_result_chars,
        ),
        context_token_budget: config.agent.max_context_tokens,
        audit_logger,
        debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::from_millis(
            config.channels_config.debounce_ms,
        ))),
    });

    // Hydrate in-memory conversation histories from persisted JSONL session files.
    // If the last persisted turn is a user message (orphan from a crash mid-query),
    // close it with a marker so the LLM doesn't try to continue the old request.
    if let Some(ref store) = runtime_ctx.session_store {
        let mut hydrated = 0usize;
        let mut orphans_closed = 0usize;
        let mut histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        for key in store.list_sessions() {
            let mut msgs = store.load(&key);
            if msgs.is_empty() {
                continue;
            }
            // Close orphaned user turns from crashed sessions.
            if msgs.last().is_some_and(|m| m.role == "user") {
                let closure =
                    ChatMessage::assistant("[Session interrupted — not continuing this request]");
                if let Err(e) = store.append(&key, &closure) {
                    tracing::debug!("Failed to persist orphan closure for {key}: {e}");
                }
                msgs.push(closure);
                orphans_closed += 1;
            }
            hydrated += 1;
            histories.insert(key, msgs);
        }
        drop(histories);
        if hydrated > 0 {
            tracing::info!("📂 Restored {hydrated} session(s) from disk");
        }
        if orphans_closed > 0 {
            tracing::info!(
                "🔒 Closed {orphans_closed} orphaned session turn(s) from previous crash"
            );
        }
    }

    run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await;

    // Wait for all channel tasks
    for h in handles {
        let _ = h.await;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::memory::Memory;
    use crate::observability::NoopObserver;
    use crate::providers::{ChatMessage, Provider};
    use crate::tools::{Tool, ToolResult};
    use std::collections::{HashMap, HashSet};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
    use tempfile::TempDir;

    fn make_workspace() -> TempDir {
        let tmp = TempDir::new().unwrap();
        // Create minimal workspace files
        std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap();
        std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: Revka").unwrap();
        std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap();
        std::fs::write(
            tmp.path().join("AGENTS.md"),
            "# Agents\nFollow instructions.",
        )
        .unwrap();
        std::fs::write(tmp.path().join("TOOLS.md"), "# Tools\nUse shell carefully.").unwrap();
        std::fs::write(
            tmp.path().join("HEARTBEAT.md"),
            "# Heartbeat\nCheck status.",
        )
        .unwrap();
        std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap();
        tmp
    }

    #[test]
    fn effective_channel_message_timeout_secs_clamps_to_minimum() {
        assert_eq!(
            effective_channel_message_timeout_secs(0),
            MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
        );
        assert_eq!(
            effective_channel_message_timeout_secs(15),
            MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
        );
        assert_eq!(effective_channel_message_timeout_secs(300), 300);
    }

    #[test]
    fn channel_message_timeout_budget_scales_with_tool_iterations() {
        assert_eq!(channel_message_timeout_budget_secs(300, 1), 300);
        assert_eq!(channel_message_timeout_budget_secs(300, 2), 600);
        assert_eq!(channel_message_timeout_budget_secs(300, 3), 900);
    }

    #[test]
    fn channel_message_timeout_budget_uses_safe_defaults_and_cap() {
        // 0 iterations falls back to 1x timeout budget.
        assert_eq!(channel_message_timeout_budget_secs(300, 0), 300);
        // Large iteration counts are capped to avoid runaway waits.
        assert_eq!(
            channel_message_timeout_budget_secs(300, 10),
            300 * CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
        );
    }

    #[test]
    fn channel_message_timeout_budget_with_custom_scale_cap() {
        assert_eq!(
            channel_message_timeout_budget_secs_with_cap(300, 8, 8),
            300 * 8
        );
        assert_eq!(
            channel_message_timeout_budget_secs_with_cap(300, 20, 8),
            300 * 8
        );
        assert_eq!(
            channel_message_timeout_budget_secs_with_cap(300, 10, 1),
            300
        );
    }

    #[test]
    fn pacing_config_defaults_preserve_existing_behavior() {
        let pacing = crate::config::PacingConfig::default();
        assert!(pacing.step_timeout_secs.is_none());
        assert!(pacing.loop_detection_min_elapsed_secs.is_none());
        assert!(pacing.loop_ignore_tools.is_empty());
        assert!(pacing.message_timeout_scale_max.is_none());
    }

    #[test]
    fn pacing_message_timeout_scale_max_overrides_default_cap() {
        // Custom cap of 8 scales budget proportionally
        assert_eq!(
            channel_message_timeout_budget_secs_with_cap(300, 10, 8),
            300 * 8
        );
        // Default cap produces the standard behavior
        assert_eq!(
            channel_message_timeout_budget_secs_with_cap(
                300,
                10,
                CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
            ),
            300 * CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
        );
    }

    #[test]
    fn casual_fast_path_only_targets_low_latency_channels() {
        assert_eq!(
            casual_fast_path_reply("telegram", "안녕하세요", false, false),
            Some("안녕하세요.")
        );
        assert_eq!(
            casual_fast_path_reply("discord:guild-1", "hello!", false, false),
            Some("Hi.")
        );
        assert_eq!(casual_fast_path_reply("slack", "hello", false, false), None);
    }

    #[test]
    fn casual_fast_path_skips_work_intent_and_attachments() {
        assert_eq!(
            casual_fast_path_reply("telegram", "확인?", false, false),
            None
        );
        assert_eq!(
            casual_fast_path_reply("telegram", "안녕하세요", true, false),
            None
        );
        assert_eq!(
            casual_fast_path_reply("discord", "/models", false, false),
            None
        );
    }

    #[test]
    fn casual_fast_path_preserves_contextual_ack_turns() {
        assert_eq!(
            casual_fast_path_reply("telegram", "응", false, false),
            Some("확인했습니다.")
        );
        assert_eq!(casual_fast_path_reply("telegram", "응", false, true), None);
        assert_eq!(
            casual_fast_path_reply("discord", "thanks", false, true),
            Some("Got it.")
        );
    }

    #[test]
    fn context_window_overflow_error_detector_matches_known_messages() {
        let overflow_err = anyhow::anyhow!(
            "OpenAI Codex stream error: Your input exceeds the context window of this model."
        );
        assert!(is_context_window_overflow_error(&overflow_err));

        let other_err =
            anyhow::anyhow!("OpenAI Codex API error (502 Bad Gateway): error code: 502");
        assert!(!is_context_window_overflow_error(&other_err));
    }

    #[test]
    fn memory_context_skip_rules_exclude_history_blobs() {
        assert!(should_skip_memory_context_entry(
            "telegram_123_history",
            r#"[{"role":"user"}]"#
        ));
        assert!(should_skip_memory_context_entry(
            "assistant_resp_legacy",
            "fabricated memory"
        ));
        assert!(!should_skip_memory_context_entry("telegram_123_45", "hi"));

        // Entries containing image markers must be skipped to prevent
        // auto-saved photo messages from duplicating image blocks (#2403).
        assert!(should_skip_memory_context_entry(
            "telegram_user_msg_99",
            "[IMAGE:/tmp/workspace/photo_1_2.jpg]"
        ));
        assert!(should_skip_memory_context_entry(
            "telegram_user_msg_100",
            "[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nCheck this screenshot"
        ));
        // Plain text without image markers should not be skipped.
        assert!(!should_skip_memory_context_entry(
            "telegram_user_msg_101",
            "Please describe the image"
        ));

        // Entries containing tool_result blocks must be skipped (#3402).
        assert!(should_skip_memory_context_entry(
            "telegram_user_msg_200",
            r#"[Tool results]
<tool_result name="shell">Mon Feb 20</tool_result>"#
        ));
        assert!(!should_skip_memory_context_entry(
            "telegram_user_msg_201",
            "plain text without tool results"
        ));
    }

    #[test]
    fn strip_tool_result_content_removes_blocks_and_header() {
        let input = r#"[Tool results]
<tool_result name="shell">Mon Feb 20</tool_result>
<tool_result name="http_request">{"status":200}</tool_result>"#;
        assert_eq!(strip_tool_result_content(input), "");

        let mixed = "Some context\n<tool_result name=\"shell\">ok</tool_result>\nMore text";
        let cleaned = strip_tool_result_content(mixed);
        assert!(cleaned.contains("Some context"));
        assert!(cleaned.contains("More text"));
        assert!(!cleaned.contains("tool_result"));

        assert_eq!(
            strip_tool_result_content("no tool results here"),
            "no tool results here"
        );
        assert_eq!(strip_tool_result_content(""), "");
    }

    #[test]
    fn strip_tool_summary_prefix_removes_prefix_and_preserves_content() {
        let input = "[Used tools: browser_open, shell]\nI opened the page successfully.";
        assert_eq!(
            strip_tool_summary_prefix(input),
            "I opened the page successfully."
        );
    }

    #[test]
    fn strip_tool_summary_prefix_returns_empty_when_only_prefix() {
        let input = "[Used tools: browser_open]";
        assert_eq!(strip_tool_summary_prefix(input), "");
    }

    #[test]
    fn strip_tool_summary_prefix_preserves_text_without_prefix() {
        let input = "Here is the result of the search.";
        assert_eq!(strip_tool_summary_prefix(input), input);
    }

    #[test]
    fn strip_tool_summary_prefix_handles_multiple_newlines() {
        let input = "[Used tools: shell]\n\nThe command output is 42.";
        assert_eq!(
            strip_tool_summary_prefix(input),
            "The command output is 42."
        );
    }

    #[test]
    fn sanitize_channel_response_strips_used_tools_with_leading_whitespace() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        // Issue #4478: response with leading whitespace before [Used tools: ...]
        let input = "  [Used tools: web_search_tool]\nHere is the search result.";

        let result = sanitize_channel_response(input, &tools);

        assert!(!result.contains("[Used tools:"));
        assert!(result.contains("Here is the search result."));
    }

    #[test]
    fn normalize_cached_channel_turns_merges_consecutive_user_turns() {
        let turns = vec![
            ChatMessage::user("forwarded content"),
            ChatMessage::user("summarize this"),
        ];

        let normalized = normalize_cached_channel_turns(turns);
        assert_eq!(normalized.len(), 1);
        assert_eq!(normalized[0].role, "user");
        assert!(normalized[0].content.contains("forwarded content"));
        assert!(normalized[0].content.contains("summarize this"));
    }

    #[test]
    fn normalize_cached_channel_turns_merges_consecutive_assistant_turns() {
        let turns = vec![
            ChatMessage::user("first user"),
            ChatMessage::assistant("assistant part 1"),
            ChatMessage::assistant("assistant part 2"),
            ChatMessage::user("next user"),
        ];

        let normalized = normalize_cached_channel_turns(turns);
        assert_eq!(normalized.len(), 3);
        assert_eq!(normalized[0].role, "user");
        assert_eq!(normalized[1].role, "assistant");
        assert_eq!(normalized[2].role, "user");
        assert!(normalized[1].content.contains("assistant part 1"));
        assert!(normalized[1].content.contains("assistant part 2"));
    }

    /// Verify that an orphan user turn followed by a failure-marker assistant
    /// turn normalizes correctly, so the LLM sees the failed request as closed
    /// and does not re-execute it on the next user message.
    #[test]
    fn normalize_preserves_failure_marker_after_orphan_user_turn() {
        let turns = vec![
            ChatMessage::user("download something from GitHub"),
            ChatMessage::assistant("[Task failed — not continuing this request]"),
            ChatMessage::user("what is WAL?"),
        ];

        let normalized = normalize_cached_channel_turns(turns);
        assert_eq!(normalized.len(), 3);
        assert_eq!(normalized[0].role, "user");
        assert_eq!(normalized[1].role, "assistant");
        assert!(normalized[1].content.contains("Task failed"));
        assert_eq!(normalized[2].role, "user");
        assert_eq!(normalized[2].content, "what is WAL?");
    }

    /// Same as above but for the timeout variant.
    #[test]
    fn normalize_preserves_timeout_marker_after_orphan_user_turn() {
        let turns = vec![
            ChatMessage::user("run a long task"),
            ChatMessage::assistant("[Task timed out — not continuing this request]"),
            ChatMessage::user("next question"),
        ];

        let normalized = normalize_cached_channel_turns(turns);
        assert_eq!(normalized.len(), 3);
        assert_eq!(normalized[1].role, "assistant");
        assert!(normalized[1].content.contains("Task timed out"));
        assert_eq!(normalized[2].content, "next question");
    }

    #[test]
    fn compact_sender_history_keeps_recent_truncated_messages() {
        let mut histories = HashMap::new();
        let sender = "telegram_u1".to_string();
        histories.insert(
            sender.clone(),
            (0..20)
                .map(|idx| {
                    let content = format!("msg-{idx}-{}", "x".repeat(700));
                    if idx % 2 == 0 {
                        ChatMessage::user(content)
                    } else {
                        ChatMessage::assistant(content)
                    }
                })
                .collect::<Vec<_>>(),
        );

        let ctx = ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            provider: Arc::new(DummyProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(histories)),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        };

        assert!(compact_sender_history(&ctx, &sender));

        let locked_histories = ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let kept = locked_histories
            .get(&sender)
            .expect("sender history should remain");
        assert_eq!(kept.len(), CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
        assert!(kept.iter().all(|turn| {
            let len = turn.content.chars().count();
            len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS
                || (len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS + 3
                    && turn.content.ends_with("..."))
        }));
    }

    #[test]
    fn proactive_trim_drops_oldest_turns_when_over_budget() {
        // Each message is 100 chars; 10 messages = 1000 chars total.
        let mut turns: Vec<ChatMessage> = (0..10)
            .map(|i| {
                let content = format!("m{i}-{}", "a".repeat(96));
                if i % 2 == 0 {
                    ChatMessage::user(content)
                } else {
                    ChatMessage::assistant(content)
                }
            })
            .collect();

        // Budget of 500 should drop roughly half (oldest turns).
        let dropped = proactive_trim_turns(&mut turns, 500);
        assert!(dropped > 0, "should have dropped some turns");
        assert!(turns.len() < 10, "should have fewer turns after trimming");
        // Last turn should always be preserved.
        assert!(
            turns.last().unwrap().content.starts_with("m9-"),
            "most recent turn must be preserved"
        );
        // Total chars should now be within budget.
        let total: usize = turns.iter().map(|t| t.content.chars().count()).sum();
        assert!(total <= 500, "total chars {total} should be within budget");
    }

    #[test]
    fn proactive_trim_noop_when_within_budget() {
        let mut turns = vec![
            ChatMessage::user("hello".to_string()),
            ChatMessage::assistant("hi there".to_string()),
        ];
        let dropped = proactive_trim_turns(&mut turns, 10_000);
        assert_eq!(dropped, 0);
        assert_eq!(turns.len(), 2);
    }

    #[test]
    fn proactive_trim_preserves_last_turn_even_when_over_budget() {
        let mut turns = vec![ChatMessage::user("x".repeat(2000))];
        let dropped = proactive_trim_turns(&mut turns, 100);
        assert_eq!(dropped, 0, "single turn must never be dropped");
        assert_eq!(turns.len(), 1);
    }

    #[test]
    fn append_sender_turn_stores_single_turn_per_call() {
        let sender = "telegram_u2".to_string();
        let ctx = ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            provider: Arc::new(DummyProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        };

        append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));

        let histories = ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories.get(&sender).expect("sender history should exist");
        assert_eq!(turns.len(), 1);
        assert_eq!(turns[0].role, "user");
        assert_eq!(turns[0].content, "hello");
    }

    #[test]
    fn rollback_orphan_user_turn_removes_only_latest_matching_user_turn() {
        let sender = "telegram_u3".to_string();
        let mut histories = HashMap::new();
        histories.insert(
            sender.clone(),
            vec![
                ChatMessage::user("first"),
                ChatMessage::assistant("ok"),
                ChatMessage::user("pending"),
            ],
        );
        let ctx = ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            provider: Arc::new(DummyProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(histories)),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        };

        assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));

        let locked_histories = ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = locked_histories
            .get(&sender)
            .expect("sender history should remain");
        assert_eq!(turns.len(), 2);
        assert_eq!(turns[0].content, "first");
        assert_eq!(turns[1].content, "ok");
    }

    #[test]
    fn rollback_orphan_user_turn_also_removes_from_session_store() {
        let tmp = tempfile::TempDir::new().unwrap();
        let store = Arc::new(session_store::SessionStore::new(tmp.path()).unwrap());

        let sender = "telegram_u4".to_string();

        // Pre-populate the session store with the same turns.
        store.append(&sender, &ChatMessage::user("first")).unwrap();
        store
            .append(&sender, &ChatMessage::assistant("ok"))
            .unwrap();
        store
            .append(
                &sender,
                &ChatMessage::user("[IMAGE:/tmp/photo.jpg]\n\nDescribe this"),
            )
            .unwrap();

        let mut histories = HashMap::new();
        histories.insert(
            sender.clone(),
            vec![
                ChatMessage::user("first"),
                ChatMessage::assistant("ok"),
                ChatMessage::user("[IMAGE:/tmp/photo.jpg]\n\nDescribe this"),
            ],
        );

        let ctx = ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            provider: Arc::new(DummyProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(histories)),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: Some(Arc::clone(&store)),
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        };

        assert!(rollback_orphan_user_turn(
            &ctx,
            &sender,
            "[IMAGE:/tmp/photo.jpg]\n\nDescribe this"
        ));

        // In-memory history should have 2 turns remaining.
        let locked = ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = locked.get(&sender).expect("history should remain");
        assert_eq!(turns.len(), 2);

        // Session store should also have only 2 entries.
        let persisted = store.load(&sender);
        assert_eq!(
            persisted.len(),
            2,
            "session store should also lose the rolled-back turn"
        );
        assert_eq!(persisted[0].content, "first");
        assert_eq!(persisted[1].content, "ok");
    }

    struct DummyProvider;

    #[async_trait::async_trait]
    impl Provider for DummyProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok("ok".to_string())
        }
    }

    struct FormatErrorProvider;

    #[async_trait::async_trait]
    impl Provider for FormatErrorProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok("ok".to_string())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            if messages
                .iter()
                .any(|msg| msg.content.contains("trigger format error"))
            {
                anyhow::bail!(
                    "All providers/models failed. Attempts:\nprovider=custom:https://example.invalid/v1 model=test-model attempt 1/3: non_retryable; error=Custom API error (400 Bad Request): {{\"error\":{{\"message\":\"Format Error\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"400\"}},\"request_id\":\"test-request-id\"}}"
                );
            }

            Ok("ok".to_string())
        }
    }

    #[derive(Default)]
    struct RecordingChannel {
        sent_messages: tokio::sync::Mutex<Vec<String>>,
        start_typing_calls: AtomicUsize,
        stop_typing_calls: AtomicUsize,
        reactions_added: tokio::sync::Mutex<Vec<(String, String, String)>>,
        reactions_removed: tokio::sync::Mutex<Vec<(String, String, String)>>,
    }

    #[derive(Default)]
    struct TelegramRecordingChannel {
        sent_messages: tokio::sync::Mutex<Vec<String>>,
    }

    #[derive(Default)]
    struct SlackRecordingChannel {
        sent_messages: tokio::sync::Mutex<Vec<String>>,
    }

    #[async_trait::async_trait]
    impl Channel for TelegramRecordingChannel {
        fn name(&self) -> &str {
            "telegram"
        }

        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
            self.sent_messages
                .lock()
                .await
                .push(format!("{}:{}", message.recipient, message.content));
            Ok(())
        }

        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
            Ok(())
        }

        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
            Ok(())
        }
    }

    #[async_trait::async_trait]
    impl Channel for SlackRecordingChannel {
        fn name(&self) -> &str {
            "slack"
        }

        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
            self.sent_messages
                .lock()
                .await
                .push(format!("{}:{}", message.recipient, message.content));
            Ok(())
        }

        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
            Ok(())
        }

        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
            Ok(())
        }
    }

    #[async_trait::async_trait]
    impl Channel for RecordingChannel {
        fn name(&self) -> &str {
            "test-channel"
        }

        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
            self.sent_messages
                .lock()
                .await
                .push(format!("{}:{}", message.recipient, message.content));
            Ok(())
        }

        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
            self.start_typing_calls.fetch_add(1, Ordering::SeqCst);
            Ok(())
        }

        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
            self.stop_typing_calls.fetch_add(1, Ordering::SeqCst);
            Ok(())
        }

        async fn add_reaction(
            &self,
            channel_id: &str,
            message_id: &str,
            emoji: &str,
        ) -> anyhow::Result<()> {
            self.reactions_added.lock().await.push((
                channel_id.to_string(),
                message_id.to_string(),
                emoji.to_string(),
            ));
            Ok(())
        }

        async fn remove_reaction(
            &self,
            channel_id: &str,
            message_id: &str,
            emoji: &str,
        ) -> anyhow::Result<()> {
            self.reactions_removed.lock().await.push((
                channel_id.to_string(),
                message_id.to_string(),
                emoji.to_string(),
            ));
            Ok(())
        }
    }

    struct SlowProvider {
        delay: Duration,
    }

    #[async_trait::async_trait]
    impl Provider for SlowProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            tokio::time::sleep(self.delay).await;
            Ok(format!("echo: {message}"))
        }
    }

    struct ToolCallingProvider;

    fn tool_call_payload() -> String {
        r#"<tool_call>
{"name":"mock_price","arguments":{"symbol":"BTC"}}
</tool_call>"#
            .to_string()
    }

    fn tool_call_payload_with_alias_tag() -> String {
        r#"<toolcall>
{"name":"mock_price","arguments":{"symbol":"BTC"}}
</toolcall>"#
            .to_string()
    }

    #[async_trait::async_trait]
    impl Provider for ToolCallingProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok(tool_call_payload())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            let has_tool_results = messages
                .iter()
                .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
            if has_tool_results {
                Ok("BTC is currently around $65,000 based on latest tool output.".to_string())
            } else {
                Ok(tool_call_payload())
            }
        }
    }

    struct ToolCallingAliasProvider;

    #[async_trait::async_trait]
    impl Provider for ToolCallingAliasProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok(tool_call_payload_with_alias_tag())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            let has_tool_results = messages
                .iter()
                .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
            if has_tool_results {
                Ok("BTC alias-tag flow resolved to final text output.".to_string())
            } else {
                Ok(tool_call_payload_with_alias_tag())
            }
        }
    }

    struct RawToolArtifactProvider;

    #[async_trait::async_trait]
    impl Provider for RawToolArtifactProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok("fallback".to_string())
        }

        async fn chat_with_history(
            &self,
            _messages: &[ChatMessage],
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok(r#"{"name":"mock_price","parameters":{"symbol":"BTC"}}
{"result":{"symbol":"BTC","price_usd":65000}}
BTC is currently around $65,000 based on latest tool output."#
                .to_string())
        }
    }

    struct IterativeToolProvider {
        required_tool_iterations: usize,
    }

    impl IterativeToolProvider {
        fn completed_tool_iterations(messages: &[ChatMessage]) -> usize {
            messages
                .iter()
                .filter(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
                .count()
        }
    }

    #[async_trait::async_trait]
    impl Provider for IterativeToolProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok(tool_call_payload())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            let completed_iterations = Self::completed_tool_iterations(messages);
            if completed_iterations >= self.required_tool_iterations {
                Ok(format!(
                    "Completed after {completed_iterations} tool iterations."
                ))
            } else {
                Ok(tool_call_payload())
            }
        }
    }

    #[derive(Default)]
    struct HistoryCaptureProvider {
        calls: std::sync::Mutex<Vec<Vec<(String, String)>>>,
    }

    #[async_trait::async_trait]
    impl Provider for HistoryCaptureProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok("fallback".to_string())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            let snapshot = messages
                .iter()
                .map(|m| (m.role.clone(), m.content.clone()))
                .collect::<Vec<_>>();
            let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
            calls.push(snapshot);
            Ok(format!("response-{}", calls.len()))
        }
    }

    struct DelayedHistoryCaptureProvider {
        delay: Duration,
        calls: std::sync::Mutex<Vec<Vec<(String, String)>>>,
    }

    #[async_trait::async_trait]
    impl Provider for DelayedHistoryCaptureProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok("fallback".to_string())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            let snapshot = messages
                .iter()
                .map(|m| (m.role.clone(), m.content.clone()))
                .collect::<Vec<_>>();
            let call_index = {
                let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
                calls.push(snapshot);
                calls.len()
            };
            tokio::time::sleep(self.delay).await;
            Ok(format!("response-{call_index}"))
        }
    }

    struct MockPriceTool;

    #[derive(Default)]
    struct ModelCaptureProvider {
        call_count: AtomicUsize,
        models: std::sync::Mutex<Vec<String>>,
    }

    #[async_trait::async_trait]
    impl Provider for ModelCaptureProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            Ok("fallback".to_string())
        }

        async fn chat_with_history(
            &self,
            _messages: &[ChatMessage],
            model: &str,
            _temperature: f64,
        ) -> anyhow::Result<String> {
            self.call_count.fetch_add(1, Ordering::SeqCst);
            self.models
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(model.to_string());
            Ok("ok".to_string())
        }
    }

    #[async_trait::async_trait]
    impl Tool for MockPriceTool {
        fn name(&self) -> &str {
            "mock_price"
        }

        fn description(&self) -> &str {
            "Return a mocked BTC price"
        }

        fn parameters_schema(&self) -> serde_json::Value {
            serde_json::json!({
                "type": "object",
                "properties": {
                    "symbol": { "type": "string" }
                },
                "required": ["symbol"]
            })
        }

        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
            let symbol = args.get("symbol").and_then(serde_json::Value::as_str);
            if symbol != Some("BTC") {
                return Ok(ToolResult {
                    success: false,
                    output: String::new(),
                    error: Some("unexpected symbol".to_string()),
                });
            }

            Ok(ToolResult {
                success: true,
                output: r#"{"symbol":"BTC","price_usd":65000}"#.to_string(),
                error: None,
            })
        }
    }

    #[tokio::test]
    async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(ToolCallingProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-42".to_string(),
                content: "What is the BTC price now?".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(!sent_messages.is_empty());
        let reply = sent_messages.last().unwrap();
        assert!(reply.starts_with("chat-42:"));
        assert!(reply.contains("BTC is currently around"));
        assert!(!reply.contains("\"tool_calls\""));
        assert!(!reply.contains("mock_price"));
    }

    #[tokio::test]
    async fn process_channel_message_telegram_does_not_persist_tool_summary_prefix() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(ToolCallingProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx.clone(),
            traits::ChannelMessage {
                id: "msg-telegram-tool-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-telegram".to_string(),
                content: "What is the BTC price now?".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(!sent_messages.is_empty());
        let reply = sent_messages.last().unwrap();
        assert!(reply.contains("BTC is currently around"));

        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .get("telegram_chat-telegram_alice")
            .expect("telegram history should be stored");
        let assistant_turn = turns
            .iter()
            .rev()
            .find(|turn| turn.role == "assistant")
            .expect("assistant turn should be present");
        assert!(
            !assistant_turn.content.contains("[Used tools:"),
            "telegram history should not persist tool-summary prefix"
        );
    }

    #[tokio::test]
    async fn process_channel_message_strips_unexecuted_tool_json_artifacts_from_reply() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(RawToolArtifactProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-raw-json".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-raw".to_string(),
                content: "What is the BTC price now?".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 3,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(sent_messages.len(), 1);
        assert!(sent_messages[0].starts_with("chat-raw:"));
        assert!(sent_messages[0].contains("BTC is currently around"));
        assert!(!sent_messages[0].contains("\"name\":\"mock_price\""));
        assert!(!sent_messages[0].contains("\"result\""));
    }

    #[tokio::test]
    async fn process_channel_message_executes_tool_calls_with_alias_tags() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(ToolCallingAliasProvider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-2".to_string(),
                sender: "bob".to_string(),
                reply_target: "chat-84".to_string(),
                content: "What is the BTC price now?".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(!sent_messages.is_empty());
        let reply = sent_messages.last().unwrap();
        assert!(reply.starts_with("chat-84:"));
        assert!(reply.contains("alias-tag flow resolved"));
        assert!(!reply.contains("<toolcall>"));
        assert!(!reply.contains("mock_price"));
    }

    #[tokio::test]
    async fn process_channel_message_handles_models_command_without_llm_call() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
        let fallback_provider_impl = Arc::new(ModelCaptureProvider::default());
        let fallback_provider: Arc<dyn Provider> = fallback_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
        provider_cache_seed.insert("openrouter".to_string(), fallback_provider);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&default_provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx.clone(),
            traits::ChannelMessage {
                id: "msg-cmd-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "/models openrouter".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent = channel_impl.sent_messages.lock().await;
        assert_eq!(sent.len(), 1);
        assert!(sent[0].contains("Provider switched to `openrouter`"));

        let route_key = "telegram_chat-1_alice";
        let route = runtime_ctx
            .route_overrides
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get(route_key)
            .cloned()
            .expect("route should be stored for sender");
        assert_eq!(route.provider, "openrouter");
        assert_eq!(route.model, "default-model");

        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
        assert_eq!(fallback_provider_impl.call_count.load(Ordering::SeqCst), 0);
    }

    #[tokio::test]
    async fn process_channel_message_uses_route_override_provider_and_model() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
        let routed_provider_impl = Arc::new(ModelCaptureProvider::default());
        let routed_provider: Arc<dyn Provider> = routed_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
        provider_cache_seed.insert("openrouter".to_string(), routed_provider);

        let route_key = "telegram_chat-1_alice".to_string();
        let mut route_overrides = HashMap::new();
        route_overrides.insert(
            route_key,
            ChannelRouteSelection {
                provider: "openrouter".to_string(),
                model: "route-model".to_string(),
                api_key: None,
            },
        );

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&default_provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(route_overrides)),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-routed-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "hello routed provider".to_string(),
                channel: "telegram".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
        assert_eq!(routed_provider_impl.call_count.load(Ordering::SeqCst), 1);
        assert_eq!(
            routed_provider_impl
                .models
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .as_slice(),
            &["route-model".to_string()]
        );
    }

    #[tokio::test]
    async fn process_channel_message_prefers_cached_default_provider_instance() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let startup_provider_impl = Arc::new(ModelCaptureProvider::default());
        let startup_provider: Arc<dyn Provider> = startup_provider_impl.clone();
        let reloaded_provider_impl = Arc::new(ModelCaptureProvider::default());
        let reloaded_provider: Arc<dyn Provider> = reloaded_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), reloaded_provider);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&startup_provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-default-provider-cache".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "hello cached default provider".to_string(),
                channel: "telegram".to_string(),
                timestamp: 3,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        assert_eq!(startup_provider_impl.call_count.load(Ordering::SeqCst), 0);
        assert_eq!(reloaded_provider_impl.call_count.load(Ordering::SeqCst), 1);
    }

    #[tokio::test]
    async fn process_channel_message_uses_runtime_default_model_from_store() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let provider_impl = Arc::new(ModelCaptureProvider::default());
        let provider: Arc<dyn Provider> = provider_impl.clone();
        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&provider));

        let temp = tempfile::TempDir::new().expect("temp dir");
        let config_path = temp.path().join("config.toml");

        {
            let mut store = runtime_config_store()
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            store.insert(
                config_path.clone(),
                RuntimeConfigState {
                    defaults: ChannelRuntimeDefaults {
                        default_provider: "test-provider".to_string(),
                        model: "hot-reloaded-model".to_string(),
                        temperature: 0.5,
                        api_key: None,
                        api_url: None,
                        reliability: crate::config::ReliabilityConfig::default(),
                    },
                    last_applied_stamp: None,
                },
            );
        }

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("startup-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions {
                revka_dir: Some(temp.path().to_path_buf()),
                ..providers::ProviderRuntimeOptions::default()
            },
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-runtime-store-model".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "hello runtime defaults".to_string(),
                channel: "telegram".to_string(),
                timestamp: 4,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        {
            let mut cleanup_store = runtime_config_store()
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            cleanup_store.remove(&config_path);
        }

        assert_eq!(provider_impl.call_count.load(Ordering::SeqCst), 1);
        assert_eq!(
            provider_impl
                .models
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .as_slice(),
            &["hot-reloaded-model".to_string()]
        );
    }

    #[tokio::test]
    async fn process_channel_message_respects_configured_max_tool_iterations_above_default() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(IterativeToolProvider {
                required_tool_iterations: 11,
            }),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 12,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig {
                loop_detection_enabled: false,
                ..crate::config::PacingConfig::default()
            },
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-iter-success".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-iter-success".to_string(),
                content: "Loop until done".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(!sent_messages.is_empty());
        let reply = sent_messages.last().unwrap();
        assert!(reply.starts_with("chat-iter-success:"));
        assert!(reply.contains("Completed after 11 tool iterations."));
        assert!(!reply.contains("⚠️ Error:"));
    }

    #[tokio::test]
    async fn process_channel_message_reports_configured_max_tool_iterations_limit() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(IterativeToolProvider {
                required_tool_iterations: 20,
            }),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 3,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig {
                loop_detection_enabled: false,
                ..crate::config::PacingConfig::default()
            },
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-iter-fail".to_string(),
                sender: "bob".to_string(),
                reply_target: "chat-iter-fail".to_string(),
                content: "Loop forever".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(!sent_messages.is_empty());
        let reply = sent_messages.last().unwrap();
        assert!(reply.starts_with("chat-iter-fail:"));
        // After Phase 9, the agent attempts a graceful summary instead of erroring.
        // The mock provider returns a tool call payload as text, which the agent
        // returns as its "summary". The key invariant: the loop terminates and
        // produces a response (not hanging forever).
        assert!(
            reply.contains("⚠️ Error: Agent exceeded maximum tool iterations (3)")
                || reply.len() > "chat-iter-fail:".len(),
            "Expected either an error message or a graceful summary response"
        );
    }

    struct NoopMemory;

    #[async_trait::async_trait]
    impl Memory for NoopMemory {
        fn name(&self) -> &str {
            "noop"
        }

        async fn store(
            &self,
            _key: &str,
            _content: &str,
            _category: crate::memory::MemoryCategory,
            _session_id: Option<&str>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn recall(
            &self,
            _query: &str,
            _limit: usize,
            _session_id: Option<&str>,
            _since: Option<&str>,
            _until: Option<&str>,
        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
            Ok(Vec::new())
        }

        async fn get(&self, _key: &str) -> anyhow::Result<Option<crate::memory::MemoryEntry>> {
            Ok(None)
        }

        async fn list(
            &self,
            _category: Option<&crate::memory::MemoryCategory>,
            _session_id: Option<&str>,
        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
            Ok(Vec::new())
        }

        async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
            Ok(false)
        }

        async fn count(&self) -> anyhow::Result<usize> {
            Ok(0)
        }

        async fn health_check(&self) -> bool {
            true
        }
    }

    struct RecallMemory;

    #[async_trait::async_trait]
    impl Memory for RecallMemory {
        fn name(&self) -> &str {
            "recall-memory"
        }

        async fn store(
            &self,
            _key: &str,
            _content: &str,
            _category: crate::memory::MemoryCategory,
            _session_id: Option<&str>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn recall(
            &self,
            _query: &str,
            _limit: usize,
            _session_id: Option<&str>,
            _since: Option<&str>,
            _until: Option<&str>,
        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
            Ok(vec![crate::memory::MemoryEntry {
                id: "entry-1".to_string(),
                key: "memory_key_1".to_string(),
                content: "Age is 45".to_string(),
                category: crate::memory::MemoryCategory::Conversation,
                timestamp: "2026-02-20T00:00:00Z".to_string(),
                session_id: None,
                score: Some(0.9),
                namespace: "default".into(),
                importance: None,
                superseded_by: None,
            }])
        }

        async fn get(&self, _key: &str) -> anyhow::Result<Option<crate::memory::MemoryEntry>> {
            Ok(None)
        }

        async fn list(
            &self,
            _category: Option<&crate::memory::MemoryCategory>,
            _session_id: Option<&str>,
        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
            Ok(Vec::new())
        }

        async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
            Ok(false)
        }

        async fn count(&self) -> anyhow::Result<usize> {
            Ok(1)
        }

        async fn health_check(&self) -> bool {
            true
        }
    }

    #[tokio::test]
    async fn message_dispatch_processes_messages_in_parallel() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(SlowProvider {
                delay: Duration::from_millis(250),
            }),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
        tx.send(traits::ChannelMessage {
            id: "1".to_string(),
            sender: "alice".to_string(),
            reply_target: "alice".to_string(),
            content: "hello".to_string(),
            channel: "test-channel".to_string(),
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
        })
        .await
        .unwrap();
        tx.send(traits::ChannelMessage {
            id: "2".to_string(),
            sender: "bob".to_string(),
            reply_target: "bob".to_string(),
            content: "world".to_string(),
            channel: "test-channel".to_string(),
            timestamp: 2,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
        })
        .await
        .unwrap();
        drop(tx);

        let started = Instant::now();
        run_message_dispatch_loop(rx, runtime_ctx, 2).await;
        let elapsed = started.elapsed();

        assert!(
            elapsed < Duration::from_millis(430),
            "expected parallel dispatch (<430ms), got {:?}",
            elapsed
        );

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(sent_messages.len(), 2);
    }

    #[tokio::test]
    async fn message_dispatch_interrupts_in_flight_telegram_request_and_preserves_context() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let provider_impl = Arc::new(DelayedHistoryCaptureProvider {
            delay: Duration::from_millis(250),
            calls: std::sync::Mutex::new(Vec::new()),
        });

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: provider_impl.clone(),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: true,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
        let send_task = tokio::spawn(async move {
            tx.send(traits::ChannelMessage {
                id: "msg-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "forwarded content".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(40)).await;
            tx.send(traits::ChannelMessage {
                id: "msg-2".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "summarize this".to_string(),
                channel: "telegram".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
        send_task.await.unwrap();

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(sent_messages.len(), 1);
        assert!(sent_messages[0].starts_with("chat-1:"));
        assert!(sent_messages[0].contains("response-2"));
        drop(sent_messages);

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 2);
        let second_call = &calls[1];
        assert!(
            second_call
                .iter()
                .any(|(role, content)| { role == "user" && content.contains("forwarded content") })
        );
        assert!(
            second_call
                .iter()
                .any(|(role, content)| { role == "user" && content.contains("summarize this") })
        );
        assert!(
            !second_call.iter().any(|(role, _)| role == "assistant"),
            "cancelled turn should not persist an assistant response"
        );
    }

    #[tokio::test]
    async fn message_dispatch_interrupts_in_flight_slack_request_and_preserves_context() {
        let channel_impl = Arc::new(SlackRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let provider_impl = Arc::new(DelayedHistoryCaptureProvider {
            delay: Duration::from_millis(250),
            calls: std::sync::Mutex::new(Vec::new()),
        });

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: provider_impl.clone(),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: true,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            query_classification: crate::config::QueryClassificationConfig::default(),
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
        let send_task = tokio::spawn(async move {
            tx.send(traits::ChannelMessage {
                id: "msg-1".to_string(),
                sender: "U123".to_string(),
                reply_target: "C123".to_string(),
                content: "first question".to_string(),
                channel: "slack".to_string(),
                timestamp: 1,
                thread_ts: Some("1741234567.100001".to_string()),
                interruption_scope_id: Some("1741234567.100001".to_string()),
                attachments: vec![],
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(40)).await;
            tx.send(traits::ChannelMessage {
                id: "msg-2".to_string(),
                sender: "U123".to_string(),
                reply_target: "C123".to_string(),
                content: "second question".to_string(),
                channel: "slack".to_string(),
                timestamp: 2,
                thread_ts: Some("1741234567.100001".to_string()),
                interruption_scope_id: Some("1741234567.100001".to_string()),
                attachments: vec![],
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
        send_task.await.unwrap();

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(sent_messages.len(), 1);
        assert!(sent_messages[0].starts_with("C123:"));
        assert!(sent_messages[0].contains("response-2"));
        drop(sent_messages);

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 2);
        let second_call = &calls[1];
        assert!(
            second_call
                .iter()
                .any(|(role, content)| { role == "user" && content.contains("first question") })
        );
        assert!(
            second_call
                .iter()
                .any(|(role, content)| { role == "user" && content.contains("second question") })
        );
        assert!(
            !second_call.iter().any(|(role, _)| role == "assistant"),
            "cancelled turn should not persist an assistant response"
        );
    }

    #[tokio::test]
    async fn message_dispatch_interrupt_scope_is_same_sender_same_chat() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(SlowProvider {
                delay: Duration::from_millis(180),
            }),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: true,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
        let send_task = tokio::spawn(async move {
            tx.send(traits::ChannelMessage {
                id: "msg-a".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "first chat".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(30)).await;
            tx.send(traits::ChannelMessage {
                id: "msg-b".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-2".to_string(),
                content: "second chat".to_string(),
                channel: "telegram".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
        send_task.await.unwrap();

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(sent_messages.len(), 2);
        assert!(sent_messages.iter().any(|msg| msg.starts_with("chat-1:")));
        assert!(sent_messages.iter().any(|msg| msg.starts_with("chat-2:")));
    }

    #[tokio::test]
    async fn process_channel_message_cancels_scoped_typing_task() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(SlowProvider {
                delay: Duration::from_millis(20),
            }),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "typing-msg".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-typing".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let starts = channel_impl.start_typing_calls.load(Ordering::SeqCst);
        let stops = channel_impl.stop_typing_calls.load(Ordering::SeqCst);
        assert_eq!(starts, 1, "start_typing should be called once");
        assert_eq!(stops, 1, "stop_typing should be called once");
    }

    #[tokio::test]
    async fn process_channel_message_adds_and_swaps_reactions() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(SlowProvider {
                delay: Duration::from_millis(5),
            }),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "react-msg".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-react".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let added = channel_impl.reactions_added.lock().await;
        assert!(
            added.len() >= 2,
            "expected at least 2 reactions added (\u{1F440} then \u{2705}), got {}",
            added.len()
        );
        assert_eq!(added[0].2, "\u{1F440}", "first reaction should be eyes");
        assert_eq!(
            added.last().unwrap().2,
            "\u{2705}",
            "last reaction should be checkmark"
        );

        let removed = channel_impl.reactions_removed.lock().await;
        assert_eq!(removed.len(), 1, "eyes reaction should be removed once");
        assert_eq!(removed[0].2, "\u{1F440}");
    }

    #[test]
    fn prompt_contains_all_sections() {
        let ws = make_workspace();
        let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
        let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None);

        // Section headers
        assert!(prompt.contains("## Tools"), "missing Tools section");
        assert!(prompt.contains("## Safety"), "missing Safety section");
        assert!(prompt.contains("## Workspace"), "missing Workspace section");
        assert!(
            prompt.contains("## Project Context"),
            "missing Project Context"
        );
        assert!(
            prompt.contains("## Current Date & Time"),
            "missing Date/Time"
        );
        assert!(prompt.contains("## Runtime"), "missing Runtime section");
    }

    #[test]
    fn prompt_injects_tools() {
        let ws = make_workspace();
        let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
        let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);

        assert!(prompt.contains("**shell**"));
        assert!(prompt.contains("Run commands"));
        assert!(prompt.contains("**file_read**"));
    }

    #[test]
    fn prompt_includes_single_tool_protocol_block_after_append() {
        let ws = make_workspace();
        let tools = vec![("shell", "Run commands")];
        let mut prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);

        assert!(
            !prompt.contains("## Tool Use Protocol"),
            "build_system_prompt should not emit protocol block directly"
        );

        prompt.push_str(&build_tool_instructions(&[], None));

        assert_eq!(
            prompt.matches("## Tool Use Protocol").count(),
            1,
            "protocol block should appear exactly once in the final prompt"
        );
    }

    #[test]
    fn prompt_injects_safety() {
        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        assert!(prompt.contains("Do not exfiltrate private data"));
        assert!(prompt.contains("Respect the runtime autonomy policy"));
        assert!(prompt.contains("Prefer `trash` over `rm`"));
    }

    #[test]
    fn prompt_injects_workspace_files() {
        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
        assert!(prompt.contains("Be helpful"), "missing SOUL content");
        assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md");
        assert!(prompt.contains("Name: Revka"), "missing IDENTITY content");
        assert!(prompt.contains("### USER.md"), "missing USER.md");
        assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md");
        assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md");
        // HEARTBEAT.md is intentionally excluded from channel prompts — it's only
        // relevant to the heartbeat worker and causes LLMs to emit spurious
        // "HEARTBEAT_OK" acknowledgments in channel conversations.
        assert!(
            !prompt.contains("### HEARTBEAT.md"),
            "HEARTBEAT.md should not be in channel prompt"
        );
        assert!(prompt.contains("### MEMORY.md"), "missing MEMORY.md");
        assert!(prompt.contains("User likes Rust"), "missing MEMORY content");
    }

    #[test]
    fn prompt_missing_file_markers() {
        let tmp = TempDir::new().unwrap();
        // Empty workspace — no files at all
        let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None);

        assert!(prompt.contains("[File not found: SOUL.md]"));
        assert!(prompt.contains("[File not found: AGENTS.md]"));
        assert!(prompt.contains("[File not found: IDENTITY.md]"));
    }

    #[test]
    fn prompt_never_injects_bootstrap_md() {
        // BOOTSTRAP.md was deleted from PERSONALITY_FILES per audit row 3.
        // Even if a user creates one manually in their workspace, the loader
        // must NOT pick it up — the personality block should be silent on it.
        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
        assert!(
            !prompt.contains("BOOTSTRAP.md"),
            "BOOTSTRAP.md should never appear (audit row 3 — deleted)"
        );

        // Even with a BOOTSTRAP.md present on disk, it stays out.
        std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap();
        let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None);
        assert!(
            !prompt2.contains("BOOTSTRAP.md"),
            "user-created BOOTSTRAP.md must not be loaded (audit row 3)"
        );
        assert!(
            !prompt2.contains("First run"),
            "BOOTSTRAP.md content must not bleed into the prompt"
        );
    }

    #[test]
    fn prompt_no_daily_memory_injection() {
        let ws = make_workspace();
        let memory_dir = ws.path().join("memory");
        std::fs::create_dir_all(&memory_dir).unwrap();
        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
        std::fs::write(
            memory_dir.join(format!("{today}.md")),
            "# Daily\nSome note.",
        )
        .unwrap();

        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        // Daily notes should NOT be in the system prompt (on-demand via tools)
        assert!(
            !prompt.contains("Daily Notes"),
            "daily notes should not be auto-injected"
        );
        assert!(
            !prompt.contains("Some note"),
            "daily content should not be in prompt"
        );
    }

    #[test]
    fn prompt_runtime_metadata() {
        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None);

        assert!(prompt.contains("Model: claude-sonnet-4"));
        assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS)));
        assert!(prompt.contains("Host:"));
    }

    #[test]
    fn prompt_skills_include_instructions_and_tools() {
        let ws = make_workspace();
        let skills = vec![crate::skills::Skill {
            name: "code-review".into(),
            description: "Review code for bugs".into(),
            version: "1.0.0".into(),
            author: None,
            tags: vec![],
            tools: vec![crate::skills::SkillTool {
                name: "lint".into(),
                description: "Run static checks".into(),
                kind: "shell".into(),
                command: "cargo clippy".into(),
                args: HashMap::new(),
            }],
            prompts: vec!["Always run cargo test before final response.".into()],
            location: None,
        }];

        let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);

        assert!(prompt.contains("<available_skills>"), "missing skills XML");
        assert!(prompt.contains("<name>code-review</name>"));
        assert!(prompt.contains("<description>Review code for bugs</description>"));
        assert!(prompt.contains("SKILL.md</location>"));
        assert!(prompt.contains("<instructions>"));
        assert!(
            prompt.contains(
                "<instruction>Always run cargo test before final response.</instruction>"
            )
        );
        // Registered tools (shell kind) appear under <callable_tools> with prefixed names
        assert!(prompt.contains("<callable_tools"));
        assert!(prompt.contains("<name>code-review.lint</name>"));
        assert!(!prompt.contains("loaded on demand"));
    }

    #[test]
    fn prompt_skills_compact_mode_omits_instructions_but_keeps_tools() {
        let ws = make_workspace();
        let skills = vec![crate::skills::Skill {
            name: "code-review".into(),
            description: "Review code for bugs".into(),
            version: "1.0.0".into(),
            author: None,
            tags: vec![],
            tools: vec![crate::skills::SkillTool {
                name: "lint".into(),
                description: "Run static checks".into(),
                kind: "shell".into(),
                command: "cargo clippy".into(),
                args: HashMap::new(),
            }],
            prompts: vec!["Always run cargo test before final response.".into()],
            location: None,
        }];

        let prompt = build_system_prompt_with_mode(
            ws.path(),
            "model",
            &[],
            &skills,
            None,
            None,
            false,
            crate::config::SkillsPromptInjectionMode::Compact,
            AutonomyLevel::default(),
        );

        assert!(prompt.contains("<available_skills>"), "missing skills XML");
        assert!(prompt.contains("<name>code-review</name>"));
        assert!(prompt.contains("<location>skills/code-review/SKILL.md</location>"));
        assert!(prompt.contains("loaded on demand"));
        assert!(!prompt.contains("<instructions>"));
        assert!(
            !prompt.contains(
                "<instruction>Always run cargo test before final response.</instruction>"
            )
        );
        // Compact mode should still include tools so the LLM knows about them.
        // Registered tools (shell kind) appear under <callable_tools> with prefixed names.
        assert!(prompt.contains("<callable_tools"));
        assert!(prompt.contains("<name>code-review.lint</name>"));
    }

    #[test]
    fn prompt_skills_escape_reserved_xml_chars() {
        let ws = make_workspace();
        let skills = vec![crate::skills::Skill {
            name: "code<review>&".into(),
            description: "Review \"unsafe\" and 'risky' bits".into(),
            version: "1.0.0".into(),
            author: None,
            tags: vec![],
            tools: vec![crate::skills::SkillTool {
                name: "run\"linter\"".into(),
                description: "Run <lint> & report".into(),
                kind: "shell&exec".into(),
                command: "cargo clippy".into(),
                args: HashMap::new(),
            }],
            prompts: vec!["Use <tool_call> and & keep output \"safe\"".into()],
            location: None,
        }];

        let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);

        assert!(prompt.contains("<name>code&lt;review&gt;&amp;</name>"));
        assert!(prompt.contains(
            "<description>Review &quot;unsafe&quot; and &apos;risky&apos; bits</description>"
        ));
        assert!(prompt.contains("<name>run&quot;linter&quot;</name>"));
        assert!(prompt.contains("<description>Run &lt;lint&gt; &amp; report</description>"));
        assert!(prompt.contains("<kind>shell&amp;exec</kind>"));
        assert!(prompt.contains(
            "<instruction>Use &lt;tool_call&gt; and &amp; keep output &quot;safe&quot;</instruction>"
        ));
    }

    #[test]
    fn prompt_truncation() {
        let ws = make_workspace();
        // Write a file larger than BOOTSTRAP_MAX_CHARS
        let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000);
        std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap();

        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        assert!(
            prompt.contains("truncated at"),
            "large files should be truncated"
        );
        assert!(
            !prompt.contains(&big_content),
            "full content should not appear"
        );
    }

    #[test]
    fn prompt_empty_files_skipped() {
        let ws = make_workspace();
        std::fs::write(ws.path().join("TOOLS.md"), "").unwrap();

        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        // Empty file should not produce a header
        assert!(
            !prompt.contains("### TOOLS.md"),
            "empty files should be skipped"
        );
    }

    #[test]
    fn channel_log_truncation_is_utf8_safe_for_multibyte_text() {
        let msg = "Hello from Revka 🌍. Current status is healthy, and café-style UTF-8 text stays safe in logs.";

        // Reproduces the production crash path where channel logs truncate at 80 chars.
        let result = std::panic::catch_unwind(|| crate::util::truncate_with_ellipsis(msg, 80));
        assert!(
            result.is_ok(),
            "truncate_with_ellipsis should never panic on UTF-8"
        );

        let truncated = result.unwrap();
        assert!(!truncated.is_empty());
        assert!(truncated.is_char_boundary(truncated.len()));
    }

    #[test]
    fn prompt_contains_channel_capabilities() {
        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        assert!(
            prompt.contains("## Channel Capabilities"),
            "missing Channel Capabilities section"
        );
        assert!(
            prompt.contains("running as a messaging bot"),
            "missing channel context"
        );
        assert!(
            prompt.contains("NEVER repeat, describe, or echo credentials"),
            "missing security instruction"
        );
    }

    #[test]
    fn full_autonomy_prompt_executes_allowed_tools_without_extra_approval() {
        let ws = make_workspace();
        let config = crate::config::AutonomyConfig {
            level: crate::security::AutonomyLevel::Full,
            ..crate::config::AutonomyConfig::default()
        };
        let prompt = assemble_channel_system_prompt(
            ws.path(),
            "model",
            &[],
            &[],
            None,
            None,
            Some(&config),
            false,
            crate::config::SkillsPromptInjectionMode::Full,
            false,
            0,
        );

        assert!(
            prompt.contains("execute it directly instead of asking the user for extra approval"),
            "full autonomy should instruct direct execution for allowed tools"
        );
        assert!(
            prompt.contains("Never pretend you are waiting for a human approval"),
            "full autonomy should not simulate interactive approval flows"
        );
    }

    #[test]
    fn readonly_prompt_explains_policy_blocks_without_fake_approval() {
        let ws = make_workspace();
        let config = crate::config::AutonomyConfig {
            level: crate::security::AutonomyLevel::ReadOnly,
            ..crate::config::AutonomyConfig::default()
        };
        let prompt = assemble_channel_system_prompt(
            ws.path(),
            "model",
            &[],
            &[],
            None,
            None,
            Some(&config),
            false,
            crate::config::SkillsPromptInjectionMode::Full,
            false,
            0,
        );

        assert!(
            prompt.contains("this runtime is read-only for side effects"),
            "read-only prompt should expose the runtime restriction"
        );
        assert!(
            prompt.contains("instead of simulating an approval flow"),
            "read-only prompt should explain restrictions instead of faking approval"
        );
    }

    #[test]
    fn prompt_workspace_path() {
        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
    }

    #[test]
    fn full_autonomy_omits_approval_instructions() {
        let ws = make_workspace();
        let prompt = build_system_prompt_with_mode(
            ws.path(),
            "model",
            &[],
            &[],
            None,
            None,
            false,
            crate::config::SkillsPromptInjectionMode::Full,
            AutonomyLevel::Full,
        );

        assert!(
            !prompt.contains("without asking"),
            "full autonomy prompt must not tell the model to ask before acting"
        );
        assert!(
            !prompt.contains("ask before acting externally"),
            "full autonomy prompt must not contain ask-before-acting instruction"
        );
        // Core safety rules should still be present
        assert!(
            prompt.contains("Do not exfiltrate private data"),
            "data exfiltration guard must remain"
        );
        assert!(
            prompt.contains("Prefer `trash` over `rm`"),
            "trash-over-rm hint must remain"
        );
    }

    #[test]
    fn supervised_autonomy_includes_approval_instructions() {
        let ws = make_workspace();
        let prompt = build_system_prompt_with_mode(
            ws.path(),
            "model",
            &[],
            &[],
            None,
            None,
            false,
            crate::config::SkillsPromptInjectionMode::Full,
            AutonomyLevel::Supervised,
        );

        assert!(
            prompt.contains("without asking"),
            "supervised prompt must include ask-before-acting instruction"
        );
        assert!(
            prompt.contains("ask before acting externally"),
            "supervised prompt must include ask-before-acting instruction"
        );
    }

    #[test]
    fn channel_notify_observer_truncates_utf8_arguments_safely() {
        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
        let observer = ChannelNotifyObserver {
            inner: Arc::new(NoopObserver),
            tx,
            tools_used: AtomicBool::new(false),
        };

        let payload = (0..300)
            .map(|n| serde_json::json!({ "content": format!("{}置tail", "a".repeat(n)) }))
            .map(|v| v.to_string())
            .find(|raw| raw.len() > 120 && !raw.is_char_boundary(120))
            .expect("should produce non-char-boundary data at byte index 120");

        observer.record_event(
            &crate::observability::traits::ObserverEvent::ToolCallStart {
                tool: "file_write".to_string(),
                arguments: Some(payload),
            },
        );

        let emitted = rx.try_recv().expect("observer should emit notify message");
        assert!(emitted.contains("`file_write`"));
        assert!(emitted.is_char_boundary(emitted.len()));
    }

    #[test]
    fn conversation_memory_key_uses_message_id() {
        let msg = traits::ChannelMessage {
            id: "msg_abc123".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "hello".into(),
            channel: "slack".into(),
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
        };

        assert_eq!(conversation_memory_key(&msg), "slack_U123_msg_abc123");
    }

    #[test]
    fn followup_thread_id_prefers_thread_ts() {
        let msg = traits::ChannelMessage {
            id: "slack_C123_1741234567.123456".into(),
            sender: "U123".into(),
            reply_target: "C123".into(),
            content: "hello".into(),
            channel: "slack".into(),
            timestamp: 1,
            thread_ts: Some("1741234567.123456".into()),
            interruption_scope_id: None,
            attachments: vec![],
        };

        assert_eq!(
            followup_thread_id(&msg).as_deref(),
            Some("1741234567.123456")
        );
    }

    #[test]
    fn followup_thread_id_falls_back_to_message_id() {
        let msg = traits::ChannelMessage {
            id: "msg_abc123".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "hello".into(),
            channel: "cli".into(),
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
        };

        assert_eq!(followup_thread_id(&msg).as_deref(), Some("msg_abc123"));
    }

    #[test]
    fn conversation_memory_key_is_unique_per_message() {
        let msg1 = traits::ChannelMessage {
            id: "msg_1".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "first".into(),
            channel: "slack".into(),
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
        };
        let msg2 = traits::ChannelMessage {
            id: "msg_2".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "second".into(),
            channel: "slack".into(),
            timestamp: 2,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
        };

        assert_ne!(
            conversation_memory_key(&msg1),
            conversation_memory_key(&msg2)
        );
    }

    #[tokio::test]
    async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let provider_impl = Arc::new(HistoryCaptureProvider::default());

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: provider_impl.clone(),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx.clone(),
            traits::ChannelMessage {
                id: "msg-a".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-b".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "follow up".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0].len(), 2);
        assert_eq!(calls[0][0].0, "system");
        assert_eq!(calls[0][1].0, "user");
        assert_eq!(calls[1].len(), 4);
        assert_eq!(calls[1][0].0, "system");
        assert_eq!(calls[1][1].0, "user");
        assert_eq!(calls[1][2].0, "assistant");
        assert_eq!(calls[1][3].0, "user");
        assert!(calls[1][1].1.contains("hello"));
        assert!(calls[1][2].1.contains("response-1"));
        assert!(calls[1][3].1.contains("follow up"));
    }

    #[tokio::test]
    async fn process_channel_message_refreshes_available_skills_after_new_session() {
        let workspace = make_workspace();
        let mut config = Config::default();
        config.workspace_dir = workspace.path().to_path_buf();
        config.skills.open_skills_enabled = false;

        let initial_skills = crate::skills::load_skills_with_config(workspace.path(), &config);
        assert!(initial_skills.is_empty());

        let initial_system_prompt = build_system_prompt_with_mode(
            workspace.path(),
            "test-model",
            &[],
            &initial_skills,
            Some(&config.identity),
            None,
            false,
            config.skills.prompt_injection_mode,
            AutonomyLevel::default(),
        );
        assert!(
            !initial_system_prompt.contains("refresh-test"),
            "initial prompt should not contain the new skill before it exists"
        );

        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let provider_impl = Arc::new(HistoryCaptureProvider::default());
        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: provider_impl.clone(),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new(initial_system_prompt),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(config.workspace_dir.clone()),
            prompt_config: Arc::new(config.clone()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx.clone(),
            traits::ChannelMessage {
                id: "msg-before-new".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-refresh".to_string(),
                content: "please inspect this workspace before reset".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let skill_dir = workspace.path().join("skills").join("refresh-test");
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: refresh-test\ndescription: Refresh the available skills section\n---\n# Refresh Test\nExpose this skill after /new.\n",
        )
        .unwrap();
        let refreshed_skills = crate::skills::load_skills_with_config(workspace.path(), &config);
        assert_eq!(refreshed_skills.len(), 1);
        assert_eq!(refreshed_skills[0].name, "refresh-test");
        assert!(
            refreshed_new_session_system_prompt(runtime_ctx.as_ref())
                .contains("<name>refresh-test</name>"),
            "fresh-session prompt should pick up skills added after startup"
        );

        process_channel_message(
            runtime_ctx.clone(),
            traits::ChannelMessage {
                id: "msg-new-session".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-refresh".to_string(),
                content: "/new".to_string(),
                channel: "telegram".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        {
            let histories = runtime_ctx
                .conversation_histories
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            assert!(
                !histories.contains_key("telegram_chat-refresh_alice"),
                "/new should clear the cached sender history before the next message"
            );
        }

        {
            let pending_new_sessions = runtime_ctx
                .pending_new_sessions
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            assert!(
                pending_new_sessions.contains("telegram_chat-refresh_alice"),
                "/new should mark the sender for a fresh next-message prompt rebuild"
            );
        }

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-after-new".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-refresh".to_string(),
                content: "hello again".to_string(),
                channel: "telegram".to_string(),
                timestamp: 3,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        {
            let calls = provider_impl
                .calls
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            assert_eq!(calls.len(), 2);
            assert_eq!(calls[0][0].0, "system");
            assert_eq!(calls[1][0].0, "system");
            assert!(
                !calls[0][0].1.contains("<name>refresh-test</name>"),
                "pre-/new prompt should not advertise a skill that did not exist yet"
            );
            assert!(
                calls[1][0].1.contains("<available_skills>"),
                "post-/new prompt should contain the refreshed skills block"
            );
            assert!(
                calls[1][0].1.contains("<name>refresh-test</name>"),
                "post-/new prompt should include skills discovered after the reset"
            );
        }

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(
            sent_messages.iter().any(|message| {
                message.contains("Conversation history cleared. Starting fresh.")
            })
        );
    }

    #[tokio::test]
    async fn process_channel_message_enriches_current_turn_without_persisting_context() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let provider_impl = Arc::new(HistoryCaptureProvider::default());
        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: provider_impl.clone(),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(RecallMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx.clone(),
            traits::ChannelMessage {
                id: "msg-ctx-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-ctx".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].len(), 2);
        // Memory context is injected into the system prompt, not the user message.
        assert_eq!(calls[0][0].0, "system");
        assert!(calls[0][0].1.contains("[Memory context]"));
        assert!(calls[0][0].1.contains("Age is 45"));
        assert_eq!(calls[0][1].0, "user");
        assert_eq!(calls[0][1].1, "hello");

        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .get("test-channel_chat-ctx_alice")
            .expect("history should be stored for sender");
        assert_eq!(turns[0].role, "user");
        assert_eq!(turns[0].content, "hello");
        assert!(!turns[0].content.contains("[Memory context]"));
    }

    #[tokio::test]
    async fn process_channel_message_telegram_keeps_system_instruction_at_top_only() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let provider_impl = Arc::new(HistoryCaptureProvider::default());
        let mut histories = HashMap::new();
        histories.insert(
            "telegram_chat-telegram_alice".to_string(),
            vec![
                ChatMessage::assistant("stale assistant"),
                ChatMessage::user("earlier user question"),
                ChatMessage::assistant("earlier assistant reply"),
            ],
        );

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: provider_impl.clone(),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(histories)),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx.clone(),
            traits::ChannelMessage {
                id: "tg-msg-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-telegram".to_string(),
                content: "explain the earlier context".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].len(), 4);

        let roles = calls[0]
            .iter()
            .map(|(role, _)| role.as_str())
            .collect::<Vec<_>>();
        assert_eq!(roles, vec!["system", "user", "assistant", "user"]);
        assert!(
            calls[0][0].1.contains("When responding on Telegram:"),
            "telegram channel instructions should be embedded into the system prompt"
        );
        assert!(
            calls[0][0].1.contains("For media attachments use markers:"),
            "telegram media marker guidance should live in the system prompt"
        );
        assert!(!calls[0].iter().skip(1).any(|(role, _)| role == "system"));
    }

    #[test]
    fn extract_tool_context_summary_collects_alias_and_native_tool_calls() {
        let history = vec![
            ChatMessage::system("sys"),
            ChatMessage::assistant(
                r#"<toolcall>
{"name":"shell","arguments":{"command":"date"}}
</toolcall>"#,
            ),
            ChatMessage::assistant(
                r#"{"content":null,"tool_calls":[{"id":"1","name":"web_search","arguments":"{}"}]}"#,
            ),
        ];

        let summary = extract_tool_context_summary(&history, 1);
        assert_eq!(summary, "[Used tools: shell, web_search]");
    }

    #[test]
    fn extract_tool_context_summary_collects_prompt_mode_tool_result_names() {
        let history = vec![
            ChatMessage::system("sys"),
            ChatMessage::assistant("Using markdown tool call fence"),
            ChatMessage::user(
                r#"[Tool results]
<tool_result name="http_request">
{"status":200}
</tool_result>
<tool_result name="shell">
Mon Feb 20
</tool_result>"#,
            ),
        ];

        let summary = extract_tool_context_summary(&history, 1);
        assert_eq!(summary, "[Used tools: http_request, shell]");
    }

    #[test]
    fn extract_tool_context_summary_respects_start_index() {
        let history = vec![
            ChatMessage::assistant(
                r#"<tool_call>
{"name":"stale_tool","arguments":{}}
</tool_call>"#,
            ),
            ChatMessage::assistant(
                r#"<tool_call>
{"name":"fresh_tool","arguments":{}}
</tool_call>"#,
            ),
        ];

        let summary = extract_tool_context_summary(&history, 1);
        assert_eq!(summary, "[Used tools: fresh_tool]");
    }

    #[test]
    fn strip_isolated_tool_json_artifacts_removes_tool_calls_and_results() {
        let mut known_tools = HashSet::new();
        known_tools.insert("schedule".to_string());

        let input = r#"{"name":"schedule","parameters":{"action":"create","message":"test"}}
{"name":"schedule","parameters":{"action":"cancel","task_id":"test"}}
Let me create the reminder properly:
{"name":"schedule","parameters":{"action":"create","message":"Go to sleep"}}
{"result":{"task_id":"abc","status":"scheduled"}}
Done reminder set for 1:38 AM."#;

        let result = strip_isolated_tool_json_artifacts(input, &known_tools);
        let normalized = result
            .lines()
            .filter(|line| !line.trim().is_empty())
            .collect::<Vec<_>>()
            .join("\n");
        assert_eq!(
            normalized,
            "Let me create the reminder properly:\nDone reminder set for 1:38 AM."
        );
    }

    #[test]
    fn strip_isolated_tool_json_artifacts_preserves_non_tool_json() {
        let mut known_tools = HashSet::new();
        known_tools.insert("shell".to_string());

        let input = r#"{"name":"profile","parameters":{"timezone":"UTC"}}
This is an example JSON object for profile settings."#;

        let result = strip_isolated_tool_json_artifacts(input, &known_tools);
        assert_eq!(result, input);
    }

    // ── AIEOS Identity Tests (Issue #168) ─────────────────────────

    #[test]
    fn aieos_identity_from_file() {
        use crate::config::IdentityConfig;
        use tempfile::TempDir;

        let tmp = TempDir::new().unwrap();
        let identity_path = tmp.path().join("aieos_identity.json");

        // Write AIEOS identity file
        let aieos_json = r#"{
            "identity": {
                "names": {"first": "Nova", "nickname": "Nov"},
                "bio": "A helpful AI assistant.",
                "origin": "Silicon Valley"
            },
            "psychology": {
                "mbti": "INTJ",
                "moral_compass": ["Be helpful", "Do no harm"]
            },
            "linguistics": {
                "style": "concise",
                "formality": "casual"
            }
        }"#;
        std::fs::write(&identity_path, aieos_json).unwrap();

        // Create identity config pointing to the file
        let config = IdentityConfig {
            format: "aieos".into(),
            aieos_path: Some("aieos_identity.json".into()),
            aieos_inline: None,
        };

        let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None);

        // Should contain AIEOS sections
        assert!(prompt.contains("## Identity"));
        assert!(prompt.contains("**Name:** Nova"));
        assert!(prompt.contains("**Nickname:** Nov"));
        assert!(prompt.contains("**Bio:** A helpful AI assistant."));
        assert!(prompt.contains("**Origin:** Silicon Valley"));

        assert!(prompt.contains("## Personality"));
        assert!(prompt.contains("**MBTI:** INTJ"));
        assert!(prompt.contains("**Moral Compass:**"));
        assert!(prompt.contains("- Be helpful"));

        assert!(prompt.contains("## Communication Style"));
        assert!(prompt.contains("**Style:** concise"));
        assert!(prompt.contains("**Formality Level:** casual"));

        // Augments-not-replaces semantics (audit row 6 / refactor 842b7bc):
        // AIEOS provides structured identity; workspace personality files
        // still load alongside it. Channel mode emits per-file
        // [File not found: …] markers for any PERSONALITY_FILES entry that
        // doesn't exist on disk so the LLM doesn't blindly file_read them.
        // The test workspace contains only the AIEOS JSON, so the missing
        // markers are expected here — they're a positive signal of correct
        // wiring, not noise.
        assert!(
            prompt.contains("[File not found: SOUL.md]"),
            "channel mode should emit a missing-file marker when SOUL.md \
             is absent, even with AIEOS configured (augments-not-replaces)"
        );
    }

    #[test]
    fn aieos_identity_from_inline() {
        use crate::config::IdentityConfig;

        let config = IdentityConfig {
            format: "aieos".into(),
            aieos_path: None,
            aieos_inline: Some(r#"{"identity":{"names":{"first":"Claw"}}}"#.into()),
        };

        let prompt = build_system_prompt(
            std::env::temp_dir().as_path(),
            "model",
            &[],
            &[],
            Some(&config),
            None,
        );

        assert!(prompt.contains("**Name:** Claw"));
        assert!(prompt.contains("## Identity"));
    }

    #[test]
    fn aieos_fallback_to_openclaw_on_parse_error() {
        use crate::config::IdentityConfig;

        let config = IdentityConfig {
            format: "aieos".into(),
            aieos_path: Some("nonexistent.json".into()),
            aieos_inline: None,
        };

        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);

        // Should fall back to OpenClaw format when AIEOS file is not found
        // (Error is logged to stderr with filename, not included in prompt)
        assert!(prompt.contains("### SOUL.md"));
    }

    #[test]
    fn aieos_empty_uses_openclaw() {
        use crate::config::IdentityConfig;

        // Format is "aieos" but neither path nor inline is set
        let config = IdentityConfig {
            format: "aieos".into(),
            aieos_path: None,
            aieos_inline: None,
        };

        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);

        // Should use OpenClaw format (not configured for AIEOS)
        assert!(prompt.contains("### SOUL.md"));
        assert!(prompt.contains("Be helpful"));
    }

    #[test]
    fn openclaw_format_uses_bootstrap_files() {
        use crate::config::IdentityConfig;

        let config = IdentityConfig {
            format: "openclaw".into(),
            aieos_path: Some("identity.json".into()),
            aieos_inline: None,
        };

        let ws = make_workspace();
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);

        // Should use OpenClaw format even if aieos_path is set
        assert!(prompt.contains("### SOUL.md"));
        assert!(prompt.contains("Be helpful"));
        assert!(!prompt.contains("## Identity"));
    }

    #[test]
    fn none_identity_config_uses_openclaw() {
        let ws = make_workspace();
        // Pass None for identity config
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);

        // Should use OpenClaw format
        assert!(prompt.contains("### SOUL.md"));
        assert!(prompt.contains("Be helpful"));
    }

    #[test]
    fn classify_health_ok_true() {
        let state = classify_health_result(&Ok(true));
        assert_eq!(state, ChannelHealthState::Healthy);
    }

    #[test]
    fn classify_health_ok_false() {
        let state = classify_health_result(&Ok(false));
        assert_eq!(state, ChannelHealthState::Unhealthy);
    }

    #[tokio::test]
    async fn classify_health_timeout() {
        let result = tokio::time::timeout(Duration::from_millis(1), async {
            tokio::time::sleep(Duration::from_millis(20)).await;
            true
        })
        .await;
        let state = classify_health_result(&result);
        assert_eq!(state, ChannelHealthState::Timeout);
    }

    #[test]
    fn collect_configured_channels_includes_mattermost_when_configured() {
        let mut config = Config::default();
        config.channels_config.mattermost = Some(crate::config::schema::MattermostConfig {
            url: "https://mattermost.example.com".to_string(),
            bot_token: "test-token".to_string(),
            channel_id: Some("channel-1".to_string()),
            allowed_users: vec![],
            thread_replies: Some(true),
            mention_only: Some(false),
            interrupt_on_new_message: false,
            proxy_url: None,
        });

        let channels = collect_configured_channels(&config, "test");

        assert!(
            channels
                .iter()
                .any(|entry| entry.display_name == "Mattermost")
        );
        assert!(
            channels
                .iter()
                .any(|entry| entry.channel.name() == "mattermost")
        );
    }

    struct AlwaysFailChannel {
        name: &'static str,
        calls: Arc<AtomicUsize>,
    }

    struct BlockUntilClosedChannel {
        name: String,
        calls: Arc<AtomicUsize>,
    }

    #[async_trait::async_trait]
    impl Channel for AlwaysFailChannel {
        fn name(&self) -> &str {
            self.name
        }

        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
            Ok(())
        }

        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
        ) -> anyhow::Result<()> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            anyhow::bail!("listen boom")
        }
    }

    #[async_trait::async_trait]
    impl Channel for BlockUntilClosedChannel {
        fn name(&self) -> &str {
            &self.name
        }

        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
            Ok(())
        }

        async fn listen(
            &self,
            tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
        ) -> anyhow::Result<()> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            tx.closed().await;
            Ok(())
        }
    }

    #[tokio::test]
    async fn supervised_listener_marks_error_and_restarts_on_failures() {
        let calls = Arc::new(AtomicUsize::new(0));
        let channel: Arc<dyn Channel> = Arc::new(AlwaysFailChannel {
            name: "test-supervised-fail",
            calls: Arc::clone(&calls),
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1);
        let handle = spawn_supervised_listener(channel, tx, 1, 1);

        tokio::time::sleep(Duration::from_millis(80)).await;
        drop(rx);
        handle.abort();
        let _ = handle.await;

        let snapshot = crate::health::snapshot_json();
        let component = &snapshot["components"]["channel:test-supervised-fail"];
        assert_eq!(component["status"], "error");
        assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1);
        assert!(
            component["last_error"]
                .as_str()
                .unwrap_or("")
                .contains("listen boom")
        );
        assert!(calls.load(Ordering::SeqCst) >= 1);
    }

    #[tokio::test]
    async fn supervised_listener_refreshes_health_while_running() {
        let calls = Arc::new(AtomicUsize::new(0));
        let channel_name = format!("test-supervised-heartbeat-{}", uuid::Uuid::new_v4());
        let component_name = format!("channel:{channel_name}");
        let channel: Arc<dyn Channel> = Arc::new(BlockUntilClosedChannel {
            name: channel_name,
            calls: Arc::clone(&calls),
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1);
        let handle = spawn_supervised_listener_with_health_interval(
            channel,
            tx,
            1,
            1,
            Duration::from_millis(20),
        );

        tokio::time::sleep(Duration::from_millis(35)).await;
        let first_last_ok =
            crate::health::snapshot_json()["components"][&component_name]["last_ok"]
                .as_str()
                .unwrap_or("")
                .to_string();
        assert!(!first_last_ok.is_empty());

        tokio::time::sleep(Duration::from_millis(70)).await;
        let second_last_ok =
            crate::health::snapshot_json()["components"][&component_name]["last_ok"]
                .as_str()
                .unwrap_or("")
                .to_string();
        let first = chrono::DateTime::parse_from_rfc3339(&first_last_ok)
            .expect("last_ok should be valid RFC3339");
        let second = chrono::DateTime::parse_from_rfc3339(&second_last_ok)
            .expect("last_ok should be valid RFC3339");
        assert!(second > first, "expected periodic health heartbeat refresh");

        drop(rx);
        let join = tokio::time::timeout(Duration::from_secs(1), handle).await;
        assert!(join.is_ok(), "listener should stop after channel shutdown");
        assert!(calls.load(Ordering::SeqCst) >= 1);
    }

    #[test]
    fn maybe_restart_daemon_systemd_args_regression() {
        assert_eq!(
            SYSTEMD_STATUS_ARGS,
            ["--user", "is-active", "revka.service"]
        );
        assert_eq!(SYSTEMD_RESTART_ARGS, ["--user", "restart", "revka.service"]);
    }

    #[test]
    fn maybe_restart_daemon_openrc_args_regression() {
        assert_eq!(OPENRC_STATUS_ARGS, ["revka", "status"]);
        assert_eq!(OPENRC_RESTART_ARGS, ["revka", "restart"]);
    }

    #[test]
    fn normalize_merges_consecutive_user_turns() {
        let turns = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
        let result = normalize_cached_channel_turns(turns);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].role, "user");
        assert_eq!(result[0].content, "hello\n\nworld");
    }

    #[test]
    fn normalize_preserves_strict_alternation() {
        let turns = vec![
            ChatMessage::user("hello"),
            ChatMessage::assistant("hi"),
            ChatMessage::user("bye"),
        ];
        let result = normalize_cached_channel_turns(turns);
        assert_eq!(result.len(), 3);
        assert_eq!(result[0].content, "hello");
        assert_eq!(result[1].content, "hi");
        assert_eq!(result[2].content, "bye");
    }

    #[test]
    fn normalize_merges_multiple_consecutive_user_turns() {
        let turns = vec![
            ChatMessage::user("a"),
            ChatMessage::user("b"),
            ChatMessage::user("c"),
        ];
        let result = normalize_cached_channel_turns(turns);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].role, "user");
        assert_eq!(result[0].content, "a\n\nb\n\nc");
    }

    #[test]
    fn normalize_empty_input() {
        let result = normalize_cached_channel_turns(vec![]);
        assert!(result.is_empty());
    }

    // ── E2E: photo [IMAGE:] marker rejected by non-vision provider ───

    /// End-to-end test: a photo attachment message (containing `[IMAGE:]`
    /// marker) sent through `process_channel_message` with a non-vision
    /// provider must produce a `"⚠️ Error: …does not support vision"` reply
    /// on the recording channel — no real Telegram or LLM API required.
    #[tokio::test]
    async fn e2e_photo_attachment_rejected_by_non_vision_provider() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        // DummyProvider has default capabilities (vision: false).
        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(DummyProvider),
            default_provider: Arc::new("dummy".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        // Simulate a photo attachment message with [IMAGE:] marker.
        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-photo-1".to_string(),
                sender: "revka_user".to_string(),
                reply_target: "chat-photo".to_string(),
                content: "[IMAGE:/tmp/workspace/photo_99_1.jpg]\n\nWhat is this?".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent = channel_impl.sent_messages.lock().await;
        assert_eq!(sent.len(), 1, "expected exactly one reply message");
        assert!(
            sent[0].contains("does not support vision"),
            "reply must mention vision capability error, got: {}",
            sent[0]
        );
        assert!(
            sent[0].contains("⚠️ Error"),
            "reply must start with error prefix, got: {}",
            sent[0]
        );
    }

    #[tokio::test]
    async fn e2e_failed_vision_turn_does_not_poison_follow_up_text_turn() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(DummyProvider),
            default_provider: Arc::new("dummy".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            Arc::clone(&runtime_ctx),
            traits::ChannelMessage {
                id: "msg-photo-1".to_string(),
                sender: "revka_user".to_string(),
                reply_target: "chat-photo".to_string(),
                content: "[IMAGE:/tmp/workspace/photo_99_1.jpg]\n\nWhat is this?".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        process_channel_message(
            Arc::clone(&runtime_ctx),
            traits::ChannelMessage {
                id: "msg-text-2".to_string(),
                sender: "revka_user".to_string(),
                reply_target: "chat-photo".to_string(),
                content: "What is WAL?".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent = channel_impl.sent_messages.lock().await;
        assert_eq!(sent.len(), 2, "expected one error and one successful reply");
        assert!(
            sent[0].contains("does not support vision"),
            "first reply must mention vision capability error, got: {}",
            sent[0]
        );
        assert!(
            sent[1].ends_with(":ok"),
            "second reply should succeed for text-only turn, got: {}",
            sent[1]
        );
        drop(sent);

        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .get("test-channel_chat-photo_revka_user")
            .expect("history should exist for sender");
        assert_eq!(turns.len(), 2);
        assert_eq!(turns[0].role, "user");
        assert_eq!(turns[0].content, "What is WAL?");
        assert_eq!(turns[1].role, "assistant");
        assert_eq!(turns[1].content, "ok");
        assert!(
            turns.iter().all(|turn| !turn.content.contains("[IMAGE:")),
            "failed vision turn must not persist image marker content"
        );
    }

    #[tokio::test]
    async fn e2e_failed_non_retryable_turn_does_not_poison_follow_up_text_turn() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(FormatErrorProvider),
            default_provider: Arc::new("dummy".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 50000,
            context_token_budget: 128_000,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(std::time::Duration::ZERO)),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
        });

        process_channel_message(
            Arc::clone(&runtime_ctx),
            traits::ChannelMessage {
                id: "msg-bad-1".to_string(),
                sender: "revka_user".to_string(),
                reply_target: "chat-format".to_string(),
                content: "trigger format error".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        process_channel_message(
            Arc::clone(&runtime_ctx),
            traits::ChannelMessage {
                id: "msg-text-2".to_string(),
                sender: "revka_user".to_string(),
                reply_target: "chat-format".to_string(),
                content: "What is WAL?".to_string(),
                channel: "test-channel".to_string(),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        let sent = channel_impl.sent_messages.lock().await;
        assert_eq!(sent.len(), 2, "expected one error and one successful reply");
        assert!(
            sent[0].contains("Format Error"),
            "first reply must mention the request format error, got: {}",
            sent[0]
        );
        assert!(
            sent[1].ends_with(":ok"),
            "second reply should succeed for follow-up text, got: {}",
            sent[1]
        );
        drop(sent);

        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .get("test-channel_chat-format_revka_user")
            .expect("history should exist for sender");
        assert_eq!(turns.len(), 2);
        assert_eq!(turns[0].role, "user");
        assert_eq!(turns[0].content, "What is WAL?");
        assert_eq!(turns[1].role, "assistant");
        assert_eq!(turns[1].content, "ok");
        assert!(
            turns
                .iter()
                .all(|turn| turn.content != "trigger format error"),
            "failed non-retryable turn must not persist in history"
        );
    }

    #[test]
    fn build_channel_by_id_unknown_channel_returns_error() {
        let config = Config::default();
        match build_channel_by_id(&config, "nonexistent") {
            Err(e) => {
                let err_msg = e.to_string();
                assert!(
                    err_msg.contains("Unknown channel"),
                    "expected 'Unknown channel' in error, got: {err_msg}"
                );
            }
            Ok(_) => panic!("should fail for unknown channel"),
        }
    }

    // ── Query classification in channel message processing ─────────

    #[tokio::test]
    async fn process_channel_message_applies_query_classification_route() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
        let vision_provider_impl = Arc::new(ModelCaptureProvider::default());
        let vision_provider: Arc<dyn Provider> = vision_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
        provider_cache_seed.insert("vision-provider".to_string(), vision_provider);

        let classification_config = crate::config::QueryClassificationConfig {
            enabled: true,
            rules: vec![crate::config::schema::ClassificationRule {
                hint: "vision".into(),
                keywords: vec!["analyze-image".into()],
                ..Default::default()
            }],
        };

        let model_routes = vec![crate::config::ModelRouteConfig {
            hint: "vision".into(),
            provider: "vision-provider".into(),
            model: "gpt-4-vision".into(),
            api_key: None,
        }];

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&default_provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(model_routes),
            query_classification: classification_config,
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-qc-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "please analyze-image from the dataset".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        // Vision provider should have been called instead of the default.
        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
        assert_eq!(vision_provider_impl.call_count.load(Ordering::SeqCst), 1);
        assert_eq!(
            vision_provider_impl
                .models
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .as_slice(),
            &["gpt-4-vision".to_string()]
        );
    }

    #[tokio::test]
    async fn process_channel_message_classification_disabled_uses_default_route() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
        let vision_provider_impl = Arc::new(ModelCaptureProvider::default());
        let vision_provider: Arc<dyn Provider> = vision_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
        provider_cache_seed.insert("vision-provider".to_string(), vision_provider);

        // Classification is disabled — matching keyword should NOT trigger reroute.
        let classification_config = crate::config::QueryClassificationConfig {
            enabled: false,
            rules: vec![crate::config::schema::ClassificationRule {
                hint: "vision".into(),
                keywords: vec!["analyze-image".into()],
                ..Default::default()
            }],
        };

        let model_routes = vec![crate::config::ModelRouteConfig {
            hint: "vision".into(),
            provider: "vision-provider".into(),
            model: "gpt-4-vision".into(),
            api_key: None,
        }];

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&default_provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(model_routes),
            query_classification: classification_config,
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-qc-disabled".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "please analyze-image from the dataset".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        // Default provider should be used since classification is disabled.
        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 1);
        assert_eq!(vision_provider_impl.call_count.load(Ordering::SeqCst), 0);
    }

    #[tokio::test]
    async fn process_channel_message_classification_no_match_uses_default_route() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
        let vision_provider_impl = Arc::new(ModelCaptureProvider::default());
        let vision_provider: Arc<dyn Provider> = vision_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
        provider_cache_seed.insert("vision-provider".to_string(), vision_provider);

        // Classification enabled with a rule that won't match the message.
        let classification_config = crate::config::QueryClassificationConfig {
            enabled: true,
            rules: vec![crate::config::schema::ClassificationRule {
                hint: "vision".into(),
                keywords: vec!["analyze-image".into()],
                ..Default::default()
            }],
        };

        let model_routes = vec![crate::config::ModelRouteConfig {
            hint: "vision".into(),
            provider: "vision-provider".into(),
            model: "gpt-4-vision".into(),
            api_key: None,
        }];

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&default_provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(model_routes),
            query_classification: classification_config,
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-qc-nomatch".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "just a regular text message".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        // Default provider should be used since no classification rule matched.
        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 1);
        assert_eq!(vision_provider_impl.call_count.load(Ordering::SeqCst), 0);
    }

    #[tokio::test]
    async fn process_channel_message_classification_priority_selects_highest() {
        let channel_impl = Arc::new(TelegramRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
        let fast_provider_impl = Arc::new(ModelCaptureProvider::default());
        let fast_provider: Arc<dyn Provider> = fast_provider_impl.clone();
        let code_provider_impl = Arc::new(ModelCaptureProvider::default());
        let code_provider: Arc<dyn Provider> = code_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
        provider_cache_seed.insert("fast-provider".to_string(), fast_provider);
        provider_cache_seed.insert("code-provider".to_string(), code_provider);

        // Both rules match "code" keyword, but "code" rule has higher priority.
        let classification_config = crate::config::QueryClassificationConfig {
            enabled: true,
            rules: vec![
                crate::config::schema::ClassificationRule {
                    hint: "fast".into(),
                    keywords: vec!["code".into()],
                    priority: 1,
                    ..Default::default()
                },
                crate::config::schema::ClassificationRule {
                    hint: "code".into(),
                    keywords: vec!["code".into()],
                    priority: 10,
                    ..Default::default()
                },
            ],
        };

        let model_routes = vec![
            crate::config::ModelRouteConfig {
                hint: "fast".into(),
                provider: "fast-provider".into(),
                model: "fast-model".into(),
                api_key: None,
            },
            crate::config::ModelRouteConfig {
                hint: "code".into(),
                provider: "code-provider".into(),
                model: "code-model".into(),
                api_key: None,
            },
        ];

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::clone(&default_provider),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(model_routes),
            query_classification: classification_config,
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        process_channel_message(
            runtime_ctx,
            traits::ChannelMessage {
                id: "msg-qc-prio".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "write some code for me".to_string(),
                channel: "telegram".to_string(),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
            },
            CancellationToken::new(),
        )
        .await;

        // Higher-priority "code" rule (priority=10) should win over "fast" (priority=1).
        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
        assert_eq!(fast_provider_impl.call_count.load(Ordering::SeqCst), 0);
        assert_eq!(code_provider_impl.call_count.load(Ordering::SeqCst), 1);
        assert_eq!(
            code_provider_impl
                .models
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .as_slice(),
            &["code-model".to_string()]
        );
    }

    #[test]
    fn build_channel_by_id_unconfigured_telegram_returns_error() {
        let config = Config::default();
        match build_channel_by_id(&config, "telegram") {
            Err(e) => {
                let err_msg = e.to_string();
                assert!(
                    err_msg.contains("not configured"),
                    "expected 'not configured' in error, got: {err_msg}"
                );
            }
            Ok(_) => panic!("should fail when telegram is not configured"),
        }
    }

    #[test]
    fn build_channel_by_id_configured_telegram_succeeds() {
        let mut config = Config::default();
        config.channels_config.telegram = Some(crate::config::schema::TelegramConfig {
            bot_token: "test-token".to_string(),
            allowed_users: vec![],
            stream_mode: crate::config::StreamMode::Off,
            draft_update_interval_ms: 1000,
            interrupt_on_new_message: false,
            mention_only: false,
            ack_reactions: None,
            proxy_url: None,
            notification_chat_id: None,
        });
        match build_channel_by_id(&config, "telegram") {
            Ok(channel) => assert_eq!(channel.name(), "telegram"),
            Err(e) => panic!("should succeed when telegram is configured: {e}"),
        }
    }

    // ── is_stop_command tests ─────────────────────────────────────────────

    #[test]
    fn is_stop_command_matches_bare_slash_stop() {
        assert!(is_stop_command("/stop"));
    }

    #[test]
    fn is_stop_command_matches_with_leading_trailing_whitespace() {
        assert!(is_stop_command("  /stop  "));
    }

    #[test]
    fn is_stop_command_is_case_insensitive() {
        assert!(is_stop_command("/STOP"));
        assert!(is_stop_command("/Stop"));
    }

    #[test]
    fn is_stop_command_matches_with_bot_suffix() {
        assert!(is_stop_command("/stop@revka_bot"));
    }

    #[test]
    fn is_stop_command_rejects_other_slash_commands() {
        assert!(!is_stop_command("/new"));
        assert!(!is_stop_command("/model gpt-4"));
        assert!(!is_stop_command("/models"));
    }

    #[test]
    fn is_stop_command_rejects_plain_text() {
        assert!(!is_stop_command("stop"));
        assert!(!is_stop_command("please stop"));
        assert!(!is_stop_command(""));
    }

    #[test]
    fn is_stop_command_rejects_stop_as_substring() {
        assert!(!is_stop_command("/stopwatch"));
        assert!(!is_stop_command("/stop-all"));
    }

    #[test]
    fn interrupt_on_new_message_enabled_for_mattermost_when_true() {
        let cfg = InterruptOnNewMessageConfig {
            telegram: false,
            slack: false,
            discord: false,
            mattermost: true,
            matrix: false,
        };
        assert!(cfg.enabled_for_channel("mattermost"));
    }

    #[test]
    fn interrupt_on_new_message_disabled_for_mattermost_by_default() {
        let cfg = InterruptOnNewMessageConfig {
            telegram: false,
            slack: false,
            discord: false,
            mattermost: false,
            matrix: false,
        };
        assert!(!cfg.enabled_for_channel("mattermost"));
    }

    #[test]
    fn interrupt_on_new_message_enabled_for_discord() {
        let cfg = InterruptOnNewMessageConfig {
            telegram: false,
            slack: false,
            discord: true,
            mattermost: false,
            matrix: false,
        };
        assert!(cfg.enabled_for_channel("discord"));
    }

    #[test]
    fn interrupt_on_new_message_disabled_for_discord_by_default() {
        let cfg = InterruptOnNewMessageConfig {
            telegram: false,
            slack: false,
            discord: false,
            mattermost: false,
            matrix: false,
        };
        assert!(!cfg.enabled_for_channel("discord"));
    }

    // ── interruption_scope_key tests ──────────────────────────────────────

    #[test]
    fn interruption_scope_key_without_scope_id_is_three_component() {
        let msg = traits::ChannelMessage {
            id: "1".into(),
            sender: "alice".into(),
            reply_target: "room".into(),
            content: "hi".into(),
            channel: "matrix".into(),
            timestamp: 0,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
        };
        assert_eq!(interruption_scope_key(&msg), "matrix_room_alice");
    }

    #[test]
    fn interruption_scope_key_with_scope_id_is_four_component() {
        let msg = traits::ChannelMessage {
            id: "1".into(),
            sender: "alice".into(),
            reply_target: "room".into(),
            content: "hi".into(),
            channel: "matrix".into(),
            timestamp: 0,
            thread_ts: Some("$thread1".into()),
            interruption_scope_id: Some("$thread1".into()),
            attachments: vec![],
        };
        assert_eq!(interruption_scope_key(&msg), "matrix_room_alice_$thread1");
    }

    #[test]
    fn interruption_scope_key_thread_ts_alone_does_not_affect_key() {
        // thread_ts used for reply anchoring should not bleed into scope key
        let msg = traits::ChannelMessage {
            id: "1".into(),
            sender: "alice".into(),
            reply_target: "C123".into(),
            content: "hi".into(),
            channel: "slack".into(),
            timestamp: 0,
            thread_ts: Some("1234567890.000100".into()), // Slack top-level fallback
            interruption_scope_id: None,                 // but NOT a thread reply
            attachments: vec![],
        };
        assert_eq!(interruption_scope_key(&msg), "slack_C123_alice");
    }

    #[tokio::test]
    async fn message_dispatch_different_threads_do_not_cancel_each_other() {
        let channel_impl = Arc::new(SlackRecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();

        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            provider: Arc::new(SlowProvider {
                delay: Duration::from_millis(150),
            }),
            default_provider: Arc::new("test-provider".to_string()),
            memory: Arc::new(NoopMemory),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: 0.0,
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
            provider_cache: Arc::new(Mutex::new(HashMap::new())),
            route_overrides: Arc::new(Mutex::new(HashMap::new())),
            api_key: None,
            api_url: None,
            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(crate::config::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: true,
                discord: false,
                mattermost: false,
                matrix: false,
            },
            multimodal: crate::config::MultimodalConfig::default(),
            media_pipeline: crate::config::MediaPipelineConfig::default(),
            transcription_config: crate::config::TranscriptionConfig::default(),
            hooks: None,
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            model_routes: Arc::new(Vec::new()),
            query_classification: crate::config::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &crate::config::AutonomyConfig::default(),
            )),
            activated_tools: None,
            mcp_registry: None,
            cost_tracking: None,
            pacing: crate::config::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            audit_logger: None,
            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
        let send_task = tokio::spawn(async move {
            // Two messages from same sender but in different Slack threads —
            // they must NOT cancel each other.
            tx.send(traits::ChannelMessage {
                id: "1741234567.100001".to_string(),
                sender: "alice".to_string(),
                reply_target: "C123".to_string(),
                content: "thread-a question".to_string(),
                channel: "slack".to_string(),
                timestamp: 1,
                thread_ts: Some("1741234567.100001".to_string()),
                interruption_scope_id: Some("1741234567.100001".to_string()),
                attachments: vec![],
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(30)).await;
            tx.send(traits::ChannelMessage {
                id: "1741234567.200002".to_string(),
                sender: "alice".to_string(),
                reply_target: "C123".to_string(),
                content: "thread-b question".to_string(),
                channel: "slack".to_string(),
                timestamp: 2,
                thread_ts: Some("1741234567.200002".to_string()),
                interruption_scope_id: Some("1741234567.200002".to_string()),
                attachments: vec![],
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
        send_task.await.unwrap();

        // Both tasks should have completed — different threads, no cancellation.
        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(
            sent_messages.len(),
            2,
            "both Slack thread messages should complete, got: {sent_messages:?}"
        );
    }

    #[test]
    fn sanitize_channel_response_redacts_detected_credentials() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let leaked = "Temporary key: AKIAABCDEFGHIJKLMNOP"; // gitleaks:allow

        let result = sanitize_channel_response(leaked, &tools);

        assert!(!result.contains("AKIAABCDEFGHIJKLMNOP")); // gitleaks:allow
        assert!(result.contains("[REDACTED"));
    }

    #[test]
    fn sanitize_channel_response_passes_clean_text() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let clean_text = "This is a normal message with no credentials.";

        let result = sanitize_channel_response(clean_text, &tools);

        assert_eq!(result, clean_text);
    }

    // ── Tests for #4827: tool context preservation ──────────────

    #[test]
    fn extract_current_turn_tool_messages_returns_intermediate_messages() {
        let history = vec![
            ChatMessage::system("sys"),
            ChatMessage::user("older msg"),
            ChatMessage::assistant("older reply"),
            ChatMessage::user("block the iPad"),
            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
            ChatMessage::tool("ok"),
            ChatMessage::assistant("Done, iPad is blocked."),
        ];

        let tool_msgs = extract_current_turn_tool_messages(&history);
        assert_eq!(tool_msgs.len(), 2);
        assert_eq!(tool_msgs[0].role, "assistant");
        assert!(tool_msgs[0].content.contains("tool_call"));
        assert_eq!(tool_msgs[1].role, "tool");
    }

    #[test]
    fn extract_current_turn_tool_messages_empty_when_no_tools() {
        let history = vec![
            ChatMessage::user("hello"),
            ChatMessage::assistant("Hi there!"),
        ];

        let tool_msgs = extract_current_turn_tool_messages(&history);
        assert!(tool_msgs.is_empty());
    }

    #[test]
    fn extract_current_turn_tool_messages_multiple_tool_rounds() {
        let history = vec![
            ChatMessage::user("do two things"),
            ChatMessage::assistant("{\"tool_call\": \"read_skill\"}"),
            ChatMessage::tool("skill content"),
            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
            ChatMessage::tool("shell output"),
            ChatMessage::assistant("All done."),
        ];

        let tool_msgs = extract_current_turn_tool_messages(&history);
        assert_eq!(tool_msgs.len(), 4);
    }

    #[test]
    fn is_tool_call_content_detects_tool_calls() {
        assert!(is_tool_call_content("{\"tool_call\": \"shell\"}"));
        assert!(is_tool_call_content("<tool_call>shell</tool_call>"));
        assert!(is_tool_call_content(
            "{\"name\": \"read_file\", \"args\": {}}"
        ));
        assert!(!is_tool_call_content("The iPad has been blocked."));
        assert!(!is_tool_call_content(""));
    }

    #[test]
    fn normalize_cached_channel_turns_passes_through_tool_messages() {
        let turns = vec![
            ChatMessage::user("block the iPad"),
            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
            ChatMessage::tool("ok"),
            ChatMessage::assistant("iPad blocked."),
            ChatMessage::user("next question"),
        ];

        let normalized = normalize_cached_channel_turns(turns);
        // user, assistant(tool_call), tool, assistant(final), user
        assert_eq!(normalized.len(), 5);
        assert_eq!(normalized[2].role, "tool");
    }

    #[test]
    fn default_keep_tool_context_turns_is_two() {
        let config = crate::config::schema::AgentConfig::default();
        assert_eq!(config.keep_tool_context_turns, 2);
    }
}
