//! Channel subsystem for messaging platform integrations.

#[cfg(feature = "channel-acp-server")]
pub mod acp_embedded;
#[cfg(feature = "channel-acp-server")]
pub mod acp_server;
pub mod media_pipeline;
#[cfg(feature = "channel-mqtt")]
pub mod mqtt;

// Channel types imported directly from source crates (no shim files)
#[cfg(feature = "channel-amqp")]
pub use crate::amqp::AmqpChannel;
#[cfg(feature = "channel-bluesky")]
pub use crate::bluesky::BlueskyChannel;
#[cfg(feature = "channel-clawdtalk")]
pub use crate::clawdtalk::ClawdTalkChannel;
#[cfg(feature = "channel-dingtalk")]
pub use crate::dingtalk::DingTalkChannel;
#[cfg(feature = "channel-discord")]
pub use crate::discord::DiscordChannel;
#[cfg(feature = "channel-email")]
pub use crate::email_channel::EmailChannel;
#[cfg(feature = "channel-filesystem")]
pub use crate::filesystem::FilesystemChannel;
#[cfg(feature = "channel-git")]
pub use crate::git::GitChannel;
#[cfg(feature = "channel-email")]
pub use crate::gmail_push::GmailPushChannel;
#[cfg(feature = "channel-imessage")]
pub use crate::imessage::IMessageChannel;
#[cfg(feature = "channel-irc")]
pub use crate::irc::IrcChannel;
#[cfg(feature = "channel-lark")]
pub use crate::lark::LarkChannel;
#[cfg(feature = "channel-line")]
pub use crate::line::LineChannel;
#[cfg(feature = "channel-linq")]
pub use crate::linq::LinqChannel;
#[cfg(feature = "channel-mattermost")]
pub use crate::mattermost::MattermostChannel;
#[cfg(feature = "channel-mochat")]
pub use crate::mochat::MochatChannel;
#[cfg(feature = "channel-nextcloud")]
pub use crate::nextcloud_talk::NextcloudTalkChannel;
#[cfg(feature = "channel-nostr")]
pub use crate::nostr::NostrChannel;
#[cfg(feature = "channel-notion")]
pub use crate::notion::NotionChannel;
#[cfg(feature = "channel-qq")]
pub use crate::qq::QQChannel;
#[cfg(feature = "channel-reddit")]
pub use crate::reddit::RedditChannel;
#[cfg(feature = "channel-signal")]
pub use crate::signal::SignalChannel;
#[cfg(feature = "channel-slack")]
pub use crate::slack::SlackChannel;
pub use crate::transcription;
pub use crate::tts::{TtsManager, TtsProvider};
#[cfg(feature = "channel-twitch")]
pub use crate::twitch::TwitchChannel;
#[cfg(feature = "channel-twitter")]
pub use crate::twitter::TwitterChannel;
#[cfg(feature = "channel-voice-call")]
pub use crate::voice_call::VoiceCallChannel;
#[cfg(feature = "voice-wake")]
pub use crate::voice_wake::VoiceWakeChannel;
#[cfg(feature = "channel-wati")]
pub use crate::wati::WatiChannel;
#[cfg(feature = "channel-webhook")]
pub use crate::webhook::WebhookChannel;
#[cfg(feature = "channel-wechat")]
pub use crate::wechat::WeChatChannel;
#[cfg(feature = "channel-wecom")]
pub use crate::wecom::WeComChannel;
#[cfg(feature = "channel-wecom-ws")]
pub use crate::wecom_ws::WeComWsChannel;
#[cfg(feature = "channel-wecom-ws")]
use crate::wecom_ws::WeComWsRuntimePolicy;
#[cfg(feature = "channel-whatsapp-cloud")]
pub use crate::whatsapp::WhatsAppChannel;
pub use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
// Local channel types (in misc, not zeroclaw-channels)
pub use crate::cli::CliChannel;
pub use crate::link_enricher;
#[cfg(feature = "channel-matrix")]
pub use crate::matrix::MatrixChannel;
#[cfg(feature = "channel-telegram")]
pub use crate::telegram::TelegramChannel;
#[cfg(feature = "whatsapp-web")]
pub use crate::whatsapp_web::WhatsAppWebChannel;
pub use zeroclaw_infra::debounce::MessageDebouncer;
pub use zeroclaw_infra::session_backend::SessionBackend;
pub use zeroclaw_infra::session_sqlite::SqliteSessionBackend;
pub use zeroclaw_infra::stall_watchdog::StallWatchdog;

use anyhow::{Context, Result};
use parking_lot::RwLock;
use portable_atomic::{AtomicU64, Ordering};
use pulldown_cmark::{Event, Options as MarkdownOptions, Parser as MarkdownParser, Tag};

use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
use tokio_util::sync::CancellationToken;
use url::Url;

use zeroclaw_api::memory_traits::MemoryStrategy;
use zeroclaw_api::session_keys::sanitize_session_key;
use zeroclaw_config::scattered_types::{ThinkingConfig, ThinkingLevel};
use zeroclaw_config::schema::Config;
#[cfg(test)]
use zeroclaw_memory::MEMORY_CONTEXT_OPEN;
use zeroclaw_memory::{self, Memory};
use zeroclaw_providers::reliable::{scope_provider_fallback, take_last_provider_fallback};
use zeroclaw_providers::{self, ChatMessage, ModelProvider, ProviderDispatch};
use zeroclaw_runtime::agent::loop_::{
    LoopKnobs, ResolvedAgentExecution, ResolvedIo, ResolvedModelAccess, ResolvedRuntimeKnobs,
    ToolLoop, append_pinned_mcp_section, apply_text_tool_prompt_policy,
    build_tool_instructions_for_names, is_model_switch_requested, run_tool_call_loop,
    scope_session_key, scope_thread_id, scrub_credentials,
};
use zeroclaw_runtime::approval::ApprovalManager;
use zeroclaw_runtime::observability::traits::{ObserverEvent, ObserverMetric};
use zeroclaw_runtime::observability::{self, Observer};
use zeroclaw_runtime::platform;
use zeroclaw_runtime::security::{AutonomyLevel, SecurityPolicy};
use zeroclaw_runtime::tools::{self, Tool};
use zeroclaw_runtime::util::truncate_with_ellipsis;

type CronChannelRegistry = Arc<HashMap<String, Arc<dyn Channel>>>;

/// Live channel registry consulted by `deliver_announcement` so cron sends reuse the
/// authenticated channel instance (Matrix E2EE can't tolerate per-send session restore).
/// Replaced wholesale by each `start_channels` call.
static CRON_CHANNEL_REGISTRY: std::sync::RwLock<Option<CronChannelRegistry>> =
    std::sync::RwLock::new(None);

/// 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::Sender<String>,
    tools_used: AtomicBool,
}

const NOTIFY_DETAIL_MAX_CHARS: usize = 4096;

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!(": {}", truncate_with_ellipsis(p, NOTIFY_DETAIL_MAX_CHARS))
                        } else if let Some(u) = v.get("url").and_then(|c| c.as_str()) {
                            format!(": {}", truncate_with_ellipsis(u, NOTIFY_DETAIL_MAX_CHARS))
                        } 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.try_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.
/// Bounded by `MAX_CONVERSATION_SENDERS` — oldest-accessed senders are evicted.
type ConversationHistoryMap = Arc<Mutex<lru::LruCache<String, Vec<ChatMessage>>>>;
/// Senders that requested `/new` or `/clear` and must force a fresh prompt on their next message.
type PendingNewSessionSet = Arc<Mutex<HashSet<String>>>;
/// Maximum conversation senders kept in memory (LRU eviction beyond this).
const MAX_CONVERSATION_SENDERS: usize = 1000;
/// 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;
const CURRENT_DATE_HEADING: &str = "## Current Date\n\n";
const LEGACY_CURRENT_DATE_TIME_HEADING: &str = "## Current Date & Time\n\n";
const WHATSAPP_OBSERVED_GROUP_MESSAGE_LABEL: &str = "Observed WhatsApp group message";
const WHATSAPP_CURRENT_GROUP_MESSAGE_LABEL: &str = "Current WhatsApp group message";

// System prompt functions live in `zeroclaw_runtime::agent::system_prompt`.
#[allow(unused_imports)]
pub use zeroclaw_runtime::agent::system_prompt::{
    BOOTSTRAP_MAX_CHARS, build_system_prompt, build_system_prompt_with_mode,
    build_system_prompt_with_mode_and_autonomy,
};

const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
const MIN_CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 30;
#[cfg(test)]
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_MIN_IN_FLIGHT_MESSAGES: usize = 8;
const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
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 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).
/// Guardrail for hook-modified outbound channel content.
const CHANNEL_HOOK_MAX_OUTBOUND_CHARS: usize = 20_000;

type ProviderCacheMap = Arc<Mutex<HashMap<String, Arc<dyn ModelProvider>>>>;
type RouteSelectionMap = Arc<Mutex<HashMap<String, ChannelRouteSelection>>>;
type ThinkingOverrideMap = Arc<Mutex<HashMap<String, ThinkingLevel>>>;
/// Session-only model overrides scoped above the per-sender [`RouteSelectionMap`].
/// Keyed by a `scope_override_key` (prefixed `user::`/`agent::`), so both
/// scopes share one in-memory map. Never persisted — lost on restart by design.
type ScopedRouteMap = Arc<Mutex<HashMap<String, ChannelRouteSelection>>>;

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

#[cfg(test)]
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 {
    model_provider: String,
    model: String,
    /// Route-specific API key override. When set, this credential is passed
    /// directly to the requested provider instead of the alias entry's key.
    api_key: Option<String>,
}

/// Selectable scope for a session-only `/model` override. The absence of any
/// stored entry is the implicit "default" (config) tier, so it is not a variant.
/// Precedence at resolution time is `User > Agent` (above the per-sender
/// route override and the config default).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OverrideScope {
    /// All chats for the invoking user under this bot alias (drops thread).
    User,
    /// The whole agent, everywhere (drops the sender).
    Agent,
}

fn channel_runtime_cli_string(key: &str) -> String {
    zeroclaw_runtime::i18n::get_required_cli_string(key)
}

fn channel_runtime_cli_string_with_args(key: &str, args: &[(&str, &str)]) -> String {
    zeroclaw_runtime::i18n::get_required_cli_string_with_args(key, args)
}

fn channel_runtime_scope_label(scope: OverrideScope) -> String {
    match scope {
        OverrideScope::User => channel_runtime_cli_string("channel-runtime-scope-user"),
        OverrideScope::Agent => channel_runtime_cli_string("channel-runtime-scope-agent"),
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum ChannelRuntimeCommand {
    ShowProviders,
    SetProvider(String),
    ShowModel,
    SetModel(String),
    /// `/model --user|--agent <ref>` — set the model at an explicit scope.
    SetModelScoped(OverrideScope, String),
    ShowConfig,
    NewSession,
    SetThinking(Option<ThinkingLevel>),
    InvalidThinking(String),
}

// ModelCacheState / ModelCacheEntry are defined in zeroclaw-config::schema
// as the single source of truth for the on-disk cache contract.

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

#[derive(Debug, Clone)]
struct ChannelRuntimeDefaultsSnapshot {
    config: Arc<Config>,
    defaults: ChannelRuntimeDefaults,
    hot: bool,
    generation: u64,
}

#[derive(Debug, Clone)]
struct ChannelRuntimeOverride {
    config: Arc<Config>,
    defaults: ChannelRuntimeDefaults,
    generation: u64,
}

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

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

#[derive(Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
struct InterruptOnNewMessageConfig {
    telegram: bool,
    slack: bool,
    discord: bool,
    mattermost: bool,
    matrix: bool,
    whatsapp: 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,
            "whatsapp" => self.whatsapp,
            _ => false,
        }
    }
}

fn interrupt_on_new_message_config(
    channels: &zeroclaw_config::schema::ChannelsConfig,
) -> InterruptOnNewMessageConfig {
    InterruptOnNewMessageConfig {
        telegram: channels
            .telegram
            .get("default")
            .is_some_and(|tg| tg.interrupt_on_new_message),
        slack: channels
            .slack
            .get("default")
            .is_some_and(|sl| sl.interrupt_on_new_message),
        discord: channels
            .discord
            .get("default")
            .is_some_and(|dc| dc.interrupt_on_new_message),
        mattermost: channels
            .mattermost
            .get("default")
            .is_some_and(|mm| mm.interrupt_on_new_message),
        matrix: channels
            .matrix
            .get("default")
            .is_some_and(|mx| mx.interrupt_on_new_message),
        whatsapp: channels
            .whatsapp
            .get("default")
            .is_some_and(|wa| wa.interrupt_on_new_message),
    }
}

#[derive(Clone)]
struct ChannelCostTrackingState {
    tracker: Arc<zeroclaw_runtime::cost::CostTracker>,
    model_provider_pricing: Arc<zeroclaw_runtime::agent::cost::ModelProviderPricing>,
    agent_alias: Arc<String>,
}

#[derive(Clone)]
struct ChannelRuntimeContext {
    channels_by_name: Arc<HashMap<String, Arc<dyn Channel>>>,
    model_provider: Arc<dyn ModelProvider>,
    model_provider_ref: Arc<String>,
    /// Alias of the agent that owns this runtime context. Stamped onto
    /// every per-message tracing span so descendant events inherit the
    /// attribution without each call site re-passing it.
    agent_alias: Arc<String>,
    /// Resolved aliased-agent config for the agent owning this
    /// runtime context. Per-channel agent dispatch (one agent per
    /// channel.`<type>`.`<alias>`) is a follow-up.
    agent_cfg: Arc<zeroclaw_config::schema::AliasedAgentConfig>,
    prompt_config: Arc<zeroclaw_config::schema::Config>,
    memory: Arc<dyn Memory>,
    memory_strategy: Arc<dyn MemoryStrategy>,
    tools_registry: Arc<Vec<Box<dyn Tool>>>,
    observer: Arc<dyn Observer>,
    system_prompt: Arc<String>,
    model: Arc<String>,
    temperature: Option<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,
    thinking_overrides: ThinkingOverrideMap,
    /// Session-only `/model` overrides scoped by user/agent (see
    /// [`ScopedRouteMap`]). Consulted above `route_overrides` in
    /// [`get_route_selection`]; never persisted.
    scope_overrides: ScopedRouteMap,
    reliability: Arc<zeroclaw_config::schema::ReliabilityConfig>,
    provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
    workspace_dir: Arc<PathBuf>,
    message_timeout_secs: u64,
    interrupt_on_new_message: InterruptOnNewMessageConfig,
    multimodal: zeroclaw_config::schema::MultimodalConfig,
    media_pipeline: zeroclaw_config::schema::MediaPipelineConfig,
    transcription_config: zeroclaw_config::schema::TranscriptionConfig,
    /// Resolved per-agent transcription provider alias (`<type>.<alias>`)
    /// for the runtime-active agent that owns this channel context.
    /// Empty when the agent has no transcription_provider set; downstream
    /// `TranscriptionManager.transcribe` calls then fail loud.
    agent_transcription_provider: String,
    hooks: Option<Arc<zeroclaw_runtime::hooks::HookRunner>>,
    non_cli_excluded_tools: Arc<Vec<String>>,
    autonomy_level: AutonomyLevel,
    tool_call_dedup_exempt: Arc<Vec<String>>,
    model_routes: Arc<Vec<zeroclaw_config::schema::ModelRouteConfig>>,
    query_classification: zeroclaw_config::schema::QueryClassificationConfig,
    ack_reactions: bool,
    show_tool_calls: bool,
    session_store: Option<Arc<dyn zeroclaw_infra::session_backend::SessionBackend>>,
    /// 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<zeroclaw_runtime::tools::ActivatedToolSet>>>,
    cost_tracking: Option<ChannelCostTrackingState>,
    pacing: zeroclaw_config::schema::PacingConfig,
    max_tool_result_chars: usize,
    context_token_budget: usize,
    debouncer: Arc<zeroclaw_infra::debounce::MessageDebouncer>,
    /// HMAC receipt generator. `Some` when `[agent.resolved.tool_receipts] enabled = true`.
    /// Threaded into `run_tool_call_loop` so `tool_execution::execute_one_tool`
    /// can sign each result.
    receipt_generator: Option<zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator>,
    /// Mirror of `[agent.resolved.tool_receipts] show_in_response`. When true,
    /// `process_channel_message` renders the per-turn collector as a trailing
    /// `Tool receipts:` block sent after the main reply.
    show_receipts_in_response: bool,
    last_applied_config_stamp: Arc<Mutex<Option<ConfigFileStamp>>>,
    runtime_defaults_override: Arc<Mutex<Option<Arc<ChannelRuntimeOverride>>>>,
    /// Per-conversation-history-key locks that serialize persistence mutations
    /// (append / remove_last / delete_session) for the same sender without
    /// serializing the full message-processing loop.
    persist_locks: Arc<std::sync::Mutex<HashMap<String, Arc<std::sync::Mutex<()>>>>>,
    sop_engine: Option<Arc<std::sync::Mutex<zeroclaw_runtime::sop::SopEngine>>>,
    sop_audit: Option<Arc<zeroclaw_runtime::sop::SopAuditLogger>>,
}

/// Acquire the per-conversation-history-key persistence lock so that
/// append/remove_last/delete_session operations for the same sender are
/// serialized without blocking the full message-processing loop
fn acquire_persist_lock(ctx: &ChannelRuntimeContext, key: &str) -> Arc<std::sync::Mutex<()>> {
    let mut map = ctx.persist_locks.lock().unwrap_or_else(|e| e.into_inner());
    map.entry(key.to_string())
        .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
        .clone()
}

#[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: &zeroclaw_api::channel::ChannelMessage) -> String {
    // Include thread_ts for per-topic memory isolation in forum groups
    let raw = match &msg.thread_ts {
        Some(tid) => format!("{}_{}_{}_{}", msg.channel, tid, msg.sender, msg.id),
        None => format!("{}_{}_{}", msg.channel, msg.sender, msg.id),
    };
    sanitize_session_key(&raw)
}

/// The channel prefix used in session/route keys: the channel type plus the
/// zeroclaw alias when present, so two bots on the same platform (e.g.
/// `discord.clamps` + `discord.glados`) never share a keyspace.
fn channel_scope(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
    match &msg.channel_alias {
        Some(alias) => format!("{}.{}", msg.channel, alias),
        None => msg.channel.clone(),
    }
}

pub fn conversation_history_key(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
    let channel_scope = channel_scope(msg);
    let thread_scope = match msg.thread_ts.as_deref() {
        // Matrix thread_ts is a delivery anchor, not a topic boundary: root
        // and follow-ups must share one sender+room session.
        Some(_) if is_matrix_channel_name(&msg.channel) => None,
        other => other,
    };
    let raw = match (msg.conversation_scope, thread_scope) {
        (zeroclaw_api::channel::ChannelConversationScope::ReplyTarget, _) => {
            format!("{channel_scope}_{}", msg.reply_target)
        }
        (zeroclaw_api::channel::ChannelConversationScope::Sender, Some(tid)) => {
            format!("{channel_scope}_{}_{tid}_{}", msg.reply_target, msg.sender)
        }
        (zeroclaw_api::channel::ChannelConversationScope::Sender, None) => {
            format!("{channel_scope}_{}_{}", msg.reply_target, msg.sender)
        }
    };
    sanitize_session_key(&raw)
}

fn scope_override_key(
    scope: OverrideScope,
    msg: &zeroclaw_api::channel::ChannelMessage,
    agent_alias: &str,
) -> String {
    let raw = match scope {
        OverrideScope::User => format!("user::{}::{}", channel_scope(msg), msg.sender),
        OverrideScope::Agent => format!("agent::{agent_alias}"),
    };
    sanitize_session_key(&raw)
}

fn followup_thread_id(msg: &zeroclaw_api::channel::ChannelMessage) -> Option<String> {
    if is_matrix_channel_name(&msg.channel) {
        msg.thread_ts.clone()
    } else {
        msg.thread_ts.clone().or_else(|| Some(msg.id.clone()))
    }
}

fn interruption_scope_key(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
    match (msg.conversation_scope, msg.interruption_scope_id.as_deref()) {
        (zeroclaw_api::channel::ChannelConversationScope::ReplyTarget, Some(scope)) => {
            sanitize_session_key(&format!("{}_{}", channel_scope(msg), scope))
        }
        (zeroclaw_api::channel::ChannelConversationScope::ReplyTarget, None) => {
            sanitize_session_key(&format!("{}_{}", channel_scope(msg), msg.reply_target))
        }
        (zeroclaw_api::channel::ChannelConversationScope::Sender, Some(scope)) => format!(
            "{}_{}_{}_{}",
            msg.channel, msg.reply_target, msg.sender, scope
        ),
        (zeroclaw_api::channel::ChannelConversationScope::Sender, 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")
}

pub(crate) 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..];
        }
    }

    fn tool_structure_runs_to_end(inner: &str) -> bool {
        let mut rest = inner.trim_start();
        while rest.starts_with('<') {
            match rest.find('>') {
                Some(gt) => rest = rest[gt + 1..].trim_start(),
                None => return true,
            }
        }
        let tail = rest.trim();
        if tail.is_empty() {
            return true;
        }
        !looks_like_prose(tail)
    }

    // Heuristic: does `text` read like resumed natural-language prose (as opposed
    // to a cut-off parameter value)? True on an internal sentence boundary
    // (". " / "! " / "? " + a letter) or a multi-word string that ends like a
    // sentence. Deliberately lenient so ambiguous tails are kept, not dropped.
    fn looks_like_prose(text: &str) -> bool {
        let bytes = text.as_bytes();
        for i in 0..bytes.len().saturating_sub(1) {
            if matches!(bytes[i], b'.' | b'!' | b'?')
                && matches!(bytes[i + 1], b' ' | b'\n' | b'\t')
                && text[i + 1..]
                    .trim_start()
                    .chars()
                    .next()
                    .is_some_and(|c| c.is_alphabetic())
            {
                return true;
            }
        }
        let trimmed = text.trim_end();
        let ends_like_sentence = trimmed
            .chars()
            .last()
            .is_some_and(|c| matches!(c, '.' | '!' | '?'))
            && trimmed
                .chars()
                .rev()
                .nth(1)
                .is_some_and(|c| c.is_alphabetic());
        ends_like_sentence && text.trim().contains(' ')
    }

    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;
        }

        let inner = after_open.trim_start();
        let inner_lower = inner.to_ascii_lowercase();
        let looks_like_tool_structure = inner_lower.starts_with("<invoke")
            || inner_lower.starts_with("<parameter")
            || inner_lower.starts_with("<tool")
            || inner_lower.starts_with("<function")
            || inner.starts_with('{')
            || inner.starts_with('[');
        if looks_like_tool_structure && tool_structure_runs_to_end(inner) {
            remaining = "";
            break;
        }

        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\
             - 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\
             - Local marker paths may be workspace-relative or absolute, but they must resolve inside the configured workspace directory.\n\
             - Copy paths from inbound messages or file tools exactly into markers. Do not add, remove, or rewrite path components.\n\
             - Remote media is also accepted via http:// or https:// URLs in the same marker form.\n\
             - Keep normal text outside markers and never wrap markers in code fences.\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",
        ),
        "discord" => Some(
            "When responding on Discord:\n\
             - Use Markdown formatting (bold, italic, code blocks)\n\
             - Be concise and direct\n\
             - For media attachments use markers: [IMAGE:<absolute-path>], [DOCUMENT:<absolute-path>], [VIDEO:<absolute-path>], [AUDIO:<absolute-path>], or [VOICE:<absolute-path>]\n\
             - Paths inside markers MUST be absolute (starting with /) and live inside the configured workspace directory. Never use relative paths.\n\
             - Remote media is also accepted via http:// or https:// URLs in the same marker form.\n\
             - For a rich embed, emit [EMBED:{...}] where {...} is a Discord embed JSON object (keys: title, description, url, color, timestamp, footer{text,icon_url}, image, thumbnail, author{name,url,icon_url}, fields[{name,value,inline}]). Any image/thumbnail/icon/url MUST be an http(s) URL; local paths are not embeddable. Keep the JSON on one line.\n\
             - To offer interactive buttons or a menu, emit one marker [COMPONENTS:{\"rows\":[[<component>, ...], ...]}] on a single line (up to 5 rows; a row holds up to 5 buttons OR exactly one select). Action button: {\"label\":\"Approve\",\"style\":\"primary|secondary|success|danger\",\"prompt\":\"<text run as a new turn when clicked>\"}; link button: {\"label\":\"Docs\",\"url\":\"https://...\"}; select: {\"select\":\"placeholder\",\"options\":[{\"label\":\"A\",\"value\":\"a\",\"prompt\":\"<run when chosen>\"}, ...]}. A button may instead carry a modal (a popup form) in place of prompt/url: {\"label\":\"Report\",\"style\":\"danger\",\"prompt\":\"<run on submit>\",\"modal\":{\"title\":\"Report\",\"fields\":[{\"id\":\"reason\",\"label\":\"Reason\",\"style\":\"short|paragraph\",\"required\":true,\"placeholder\":\"...\",\"min\":1,\"max\":500}]}} — clicking opens the form and the typed field values are appended to that button's prompt when submitted. Every action button and select option needs a prompt describing what should happen when it is clicked.\n\
             - Keep normal text outside markers and never wrap markers in code fences.\n",
        ),
        "whatsapp" | "whatsapp-web" => Some(
            "When responding on WhatsApp Web:\n\
             - Be concise and direct\n\
             - For media attachments use markers: [IMAGE:<path>], [DOCUMENT:<path>], [VIDEO:<path>], [AUDIO:<path>], or [VOICE:<path>]\n\
             - To send a native location pin, use marker: [LOCATION:<latitude>,<longitude>,<name>,<address>] where name and address are optional. Double-quote the name if it contains commas; the trailing address may contain commas without quoting.\n\
             - Marker paths must refer to local files inside the configured workspace directory. Absolute paths and workspace-relative paths are accepted when they stay inside that workspace.\n\
             - Do not use http://, https://, data:, file:, or any other URL scheme in WhatsApp Web media markers.\n\
             - Keep normal text outside markers and never wrap markers in code fences.\n",
        ),
        "lark" | "feishu" => Some(
            "When responding on Lark/Feishu:\n\
             - Be concise and direct\n\
             - Use Markdown formatting for readable answers\n\
             - If a tool can answer the task, use your tools instead of stopping at a plain chat reply\n\
             - Use tool results silently: answer with the outcome and do not narrate internal tool execution bookkeeping\n\
             - For media attachments use markers: [IMAGE:<path>], [DOCUMENT:<path>], [VIDEO:<path>], [AUDIO:<path>], or [VOICE:<path>]\n\
             - Marker paths must refer to local files inside the configured workspace directory. Absolute paths and workspace-relative paths are accepted when they stay inside that workspace.\n\
             - Do not use http://, https://, data:, file:, or any other URL scheme in Lark/Feishu media markers.\n\
             - Keep normal text outside markers and never wrap markers, tool output, or protocol markup in code fences.\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\
             - When a question needs current, real-time, or external information \
               (prices, news, weather, web pages, lookups, etc.), use your tools — \
               e.g. web_search_tool and web_fetch — to obtain it before answering; \
               never guess or answer from memory alone when a tool can verify it.\n\
             - Present the final answer to the latest user message directly from the \
               tool results, without narrating 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",
        ),
        "wechat" => Some(
            "When responding on WeChat:\n\
             - Be concise and direct\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 absolute local paths when sending generated files whenever possible.\n",
        ),
        "wecom_ws" => Some(
            "When responding on WeCom AI Bot WebSocket:\n\
             - Be concise and direct\n\
             - Use Markdown text; the channel sends progressive draft updates when enabled\n\
             - Do not use local attachment markers; outbound image payloads are not supported yet.\n",
        ),
        _ => None,
    }
}

fn build_channel_system_prompt_for_message(
    base_prompt: &str,
    msg: &zeroclaw_api::channel::ChannelMessage,
    target_channel: Option<&Arc<dyn Channel>>,
) -> String {
    let bot_mention = target_channel.and_then(|c| c.self_addressed_mention());
    build_channel_system_prompt(base_prompt, &msg.channel, bot_mention.as_deref())
}

/// Build the cached system-prompt prefix for a channel session.
///
/// **Byte-stability contract:** given identical `base_prompt`, `channel_name`,
/// and `bot_mention` arguments, this function MUST return byte-identical
/// output across consecutive calls — even across a second boundary, across
/// sender/reply_target/message_id changes, and across per-turn memory
/// recall. Provider-side prompt caching keys on this prefix, so any
/// per-turn data here invalidates the cache for every turn.
///
/// The volatile per-turn data (datetime, reply_target, sender, message_id,
/// cron_add delivery hint, and bot_mention for the current turn only)
/// lives in [`build_channel_turn_context_preamble`] and is prepended to
/// the outgoing user turn by the caller.
fn build_channel_system_prompt(
    base_prompt: &str,
    channel_name: &str,
    bot_mention: Option<&str>,
) -> String {
    let mut prompt = base_prompt.to_string();

    // Date refresh stays in the system prompt: the heading is date-only
    // (no seconds), so within a single day the rendered value is stable and
    // cache hits; it only changes once per day at midnight. Acceptable for
    // a 99%+ intra-session cache-hit rate.
    refresh_channel_prompt_date_section(&mut prompt);

    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 let Some(mention) = bot_mention {
        // Self-addressed mention handling is byte-stable: the mention
        // string is fixed per channel (set once at channel boot), so the
        // block content does not vary across turns.
        let block = format!(
            "\n\nYour addressable handle on this channel: {mention}. \
             When you see this exact string anywhere in an inbound message, \
             it refers to YOU, not another agent or user. This same format \
             is also what you should emit when you need to tag yourself or \
             address peers in outbound replies on this channel."
        );
        prompt.push_str(&block);
    }

    // Calibration note: static behavioral instruction that benefits from
    // the higher weight of the system prompt. Lifted out of the deleted
    // per-turn Channel context block so it survives the relocation.
    prompt.push_str(
        "\n\nCalibration note: agents in this system currently err on the side \
         of silence when a response would be appropriate, which users find \
         frustrating. Skew toward replying. Memory is supplementary context \
         that informs how you respond, not a gate on whether you respond.",
    );

    prompt
}

fn build_channel_system_prompt_for_message_with_signal(
    base_prompt: &str,
    msg: &zeroclaw_api::channel::ChannelMessage,
    target_channel: Option<&Arc<dyn Channel>>,
    native_tool_specs_present: bool,
) -> String {
    let prompt = build_channel_system_prompt_for_message(base_prompt, msg, target_channel);
    let want = if native_tool_specs_present {
        ::zeroclaw_runtime::agent::system_prompt::NATIVE_TOOLS_TASK_FRAMING
    } else {
        ::zeroclaw_runtime::agent::system_prompt::NO_TOOLS_TASK_FRAMING
    };
    if prompt.contains(::zeroclaw_runtime::agent::system_prompt::NATIVE_TOOLS_TASK_FRAMING) {
        prompt.replace(
            ::zeroclaw_runtime::agent::system_prompt::NATIVE_TOOLS_TASK_FRAMING,
            want,
        )
    } else if prompt.contains(::zeroclaw_runtime::agent::system_prompt::NO_TOOLS_TASK_FRAMING) {
        prompt.replace(
            ::zeroclaw_runtime::agent::system_prompt::NO_TOOLS_TASK_FRAMING,
            want,
        )
    } else {
        // Anchor absent (custom system_prompt_prefix or unusual config);
        // no-op. Preserves byte-stability for non-default startup prompts.
        prompt
    }
}

fn current_date_section() -> String {
    let now = chrono::Local::now();
    format!(
        "{CURRENT_DATE_HEADING}{} ({})",
        now.format("%Y-%m-%d"),
        now.format("%:z")
    )
}

fn refresh_channel_prompt_date_section(prompt: &mut String) {
    let runtime_start = prompt
        .find("\n## Runtime")
        .map(|i| i + 1)
        .unwrap_or(prompt.len());

    if let Some((start, heading_len)) = find_latest_date_heading_before(prompt, runtime_start) {
        let content_start = start + heading_len;
        let section_end = prompt[content_start..]
            .find("\n## ")
            .map(|i| content_start + i)
            .unwrap_or(prompt.len());
        prompt.replace_range(start..section_end, &current_date_section());
    }
}

fn find_latest_date_heading_before(prompt: &str, before: usize) -> Option<(usize, usize)> {
    let prefix = &prompt[..before];
    [CURRENT_DATE_HEADING, LEGACY_CURRENT_DATE_TIME_HEADING]
        .iter()
        .filter_map(|heading| prefix.rfind(heading).map(|start| (start, heading.len())))
        .max_by_key(|(start, _)| *start)
}

/// Build the volatile per-turn context that the model needs but the cached
/// system prompt must NOT contain. The caller prepends the returned string
/// to the current outgoing user turn; the cached conversation history copy
/// stays clean.
///
/// **Trust-boundary contract:** the caller MUST prepend this preamble to the
/// current outgoing user turn whenever `reply_target` is non-empty, without
/// inspecting user-controlled content. A user message that happens to start
/// with `[turn-context]` is not treated as proof that this preamble is
/// already present — the runtime preamble is authoritative, not
/// user-suppressible. (An earlier draft used a `starts_with("[turn-context]")`
/// guard on the outgoing user turn that let a malicious sender suppress the
/// `reply_target` / `sender` / delivery hint; this helper removes that
/// regression.)
///
/// Carries: channel/reply_target/sender/message_id, the wall-clock datetime,
/// the `cron_add` delivery hint (with the webhook `delivery.thread_id`
/// contract preserved), and (if set) the bot_mention handle.
fn build_channel_turn_context_preamble(
    msg: &zeroclaw_api::channel::ChannelMessage,
    target_channel: Option<&Arc<dyn Channel>>,
) -> String {
    if msg.reply_target.is_empty() {
        // CLI-style path: no channel recipient, no need to inject channel
        // context. Mirrors the CLI shape where no preamble is added.
        return String::new();
    }

    let now = chrono::Local::now();
    let channel_name = msg.channel.as_str();
    let reply_target = msg.reply_target.as_str();
    let sender = msg.sender.as_str();
    let message_id = msg.id.as_str();

    // Webhook contract: downstream services expect the *sender* as the
    // recipient and the thread/conversation identifier in `thread_id`.
    // Reusing `reply_target` as `to` for webhook would strip the thread
    // context and the receiver would discard the reply.
    let delivery_hint = if channel_name.eq_ignore_ascii_case("webhook") {
        format!(
            "delivery={{\"mode\":\"announce\",\"channel\":\"{channel_name}\",\
             \"to\":\"{sender}\",\"thread_id\":\"{reply_target}\"}}"
        )
    } else {
        format!(
            "delivery={{\"mode\":\"announce\",\"channel\":\"{channel_name}\",\
             \"to\":\"{reply_target}\"}}"
        )
    };

    let mut preamble = format!(
        "[turn-context] time={time} date={date} tz={tz} \
         channel={channel} reply_target={reply_target} sender={sender} \
         message_id={message_id}. The sender field is the platform-specific \
         user ID of the person who sent this message. Use it to distinguish \
         between different users. The message_id field identifies this \
         incoming message; pass it as the `message_id` argument when calling \
         the `reaction` tool. When scheduling delayed messages or reminders \
         via cron_add for this conversation, use {delivery_hint} so the \
         message reaches the user.\n\n",
        time = now.format("%H:%M:%S"),
        date = now.format("%Y-%m-%d"),
        tz = now.format("%Z"),
        channel = channel_name,
        reply_target = reply_target,
        sender = sender,
        message_id = message_id,
        delivery_hint = delivery_hint,
    );

    if let Some(channel) = target_channel
        && let Some(mention) = channel.self_addressed_mention()
    {
        preamble.push_str(&format!(
            "Your addressable handle on this channel: {mention}. \
             When you see this exact string anywhere in an inbound message, \
             it refers to YOU, not another agent or user. This same format \
             is also what you should emit when you need to tag yourself or \
             address peers in outbound replies on this channel.\n\n"
        ));
    }

    preamble
}

fn compose_outgoing_user_turn_with_context(preamble: &str, raw_user_content: &str) -> String {
    let mut parts: Vec<&str> = Vec::with_capacity(2);
    if !preamble.is_empty() {
        parts.push(preamble);
    }
    parts.push(raw_user_content);
    parts.join("\n\n")
}

fn timestamp_channel_user_content(content: &str) -> String {
    let now = chrono::Local::now();
    format!("[{}] {}", now.format("%Y-%m-%d %H:%M:%S %Z"), content)
}

fn format_whatsapp_group_history_turn(label: &str, sender: &str, content: &str) -> String {
    let sender = sender.trim();
    if sender.is_empty() {
        format!("[{label}]\n{content}")
    } else {
        format!("[{label} from {sender}]\n{content}")
    }
}

fn attributed_whatsapp_group_user_turn(
    msg: &zeroclaw_api::channel::ChannelMessage,
    label: &str,
    content: &str,
) -> String {
    if msg.channel == "whatsapp" && is_group_reply_target(&msg.reply_target) {
        format_whatsapp_group_history_turn(label, &msg.sender, content)
    } else {
        content.to_string()
    }
}

fn timestamped_channel_user_history_content(
    msg: &zeroclaw_api::channel::ChannelMessage,
    label: &str,
) -> String {
    let timestamped_content = timestamp_channel_user_content(&msg.content);
    attributed_whatsapp_group_user_turn(msg, label, &timestamped_content)
}

/// Collapse only heavy inline `data:` image payloads in historical turns while
/// preserving re-loadable `[IMAGE:<path>]` file references, so a later turn can
/// re-inflate from disk without re-sending megabytes of base64 every request.
/// File-path and placeholder markers pass through untouched.
fn collapse_inline_image_payloads(turns: &mut [ChatMessage]) {
    if turns.len() <= 1 {
        return;
    }
    let last_idx = turns.len() - 1;
    for turn in &mut turns[..last_idx] {
        if turn.role != "user" || !turn.content.contains("[IMAGE:data:") {
            continue;
        }
        let (_, refs) = zeroclaw_providers::multimodal::parse_image_markers(&turn.content);
        if refs.iter().any(|r| r.starts_with("data:")) {
            turn.content = strip_inline_data_image_markers(&turn.content);
        }
    }
}

fn strip_inline_data_image_markers(content: &str) -> String {
    let mut out = String::with_capacity(content.len());
    let mut cursor = 0usize;
    while let Some(rel) = content[cursor..].find("[IMAGE:data:") {
        let start = cursor + rel;
        out.push_str(&content[cursor..start]);
        match content[start..].find(']') {
            Some(rel_end) => {
                out.push_str("[Image attachment omitted from history]");
                cursor = start + rel_end + 1;
            }
            None => {
                out.push_str(&content[start..]);
                cursor = content.len();
                break;
            }
        }
    }
    if cursor < content.len() {
        out.push_str(&content[cursor..]);
    }
    out.trim().to_string()
}

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.  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()
                    && !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>")
            .expect("TOOL_RESULT_RE regex must compile")
    });

    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()
}

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"
            | "wecom_ws"
            | "whatsapp"
            | "whatsapp-web"
            | "whatsapp_web"
    )
}

fn should_bypass_reply_intent_precheck(
    msg: &zeroclaw_api::channel::ChannelMessage,
    direct_message: bool,
) -> bool {
    msg.explicitly_addressed || direct_message
}

fn is_matrix_channel_name(channel_name: &str) -> bool {
    channel_name == "matrix" || channel_name.starts_with("matrix:")
}

fn parse_thinking_command_arg(raw: Option<&str>) -> Result<Option<ThinkingLevel>, String> {
    let Some(raw) = raw else {
        return Ok(None);
    };
    let token = raw.trim();
    if token.is_empty() {
        return Ok(None);
    }
    match token.to_ascii_lowercase().as_str() {
        "reset" | "default" | "auto" => Ok(None),
        "on" | "true" | "1" | "enable" | "enabled" | "yes" => Ok(Some(ThinkingLevel::High)),
        "off" | "false" | "0" | "disable" | "disabled" | "no" => Ok(Some(ThinkingLevel::Off)),
        _ => ThinkingLevel::from_str_insensitive(token)
            .map(Some)
            .ok_or_else(|| token.to_string()),
    }
}

struct ChannelThinkingResolution {
    effective_content: String,
    level: ThinkingLevel,
    params: zeroclaw_runtime::agent::thinking::ThinkingParams,
    effective_temperature: Option<f64>,
}

fn resolve_channel_thinking(
    content: &str,
    session_override: Option<ThinkingLevel>,
    config: &ThinkingConfig,
    base_temperature: Option<f64>,
) -> ChannelThinkingResolution {
    let (directive, effective_content) =
        match zeroclaw_runtime::agent::thinking::parse_thinking_directive(content) {
            Some((level, remaining)) => (Some(level), remaining),
            None => (None, content.to_string()),
        };
    let level = zeroclaw_runtime::agent::thinking::resolve_thinking_level(
        directive,
        session_override,
        config,
    );
    let params = zeroclaw_runtime::agent::thinking::apply_thinking_level_with_config(level, config);
    let effective_temperature = base_temperature.map(|temperature| {
        zeroclaw_runtime::agent::thinking::clamp_temperature(
            temperature + params.temperature_adjustment,
        )
    });

    ChannelThinkingResolution {
        effective_content,
        level,
        params,
        effective_temperature,
    }
}

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` and bare `/clear` are available on every channel — no model-switch gate.
        "/new" => Some(ChannelRuntimeCommand::NewSession),
        "/clear" => {
            if parts.next().is_none() {
                Some(ChannelRuntimeCommand::NewSession)
            } else {
                None
            }
        }
        "/thinking" => {
            let arg = parts.next();
            if parts.next().is_some() {
                Some(ChannelRuntimeCommand::InvalidThinking(
                    "too many arguments".to_string(),
                ))
            } else {
                match parse_thinking_command_arg(arg) {
                    Ok(level) => Some(ChannelRuntimeCommand::SetThinking(level)),
                    Err(raw) => Some(ChannelRuntimeCommand::InvalidThinking(raw)),
                }
            }
        }
        // Model/model_provider switching is channel-gated.
        "/models" if supports_runtime_model_switch(channel_name) => {
            if let Some(model_provider) = parts.next() {
                Some(ChannelRuntimeCommand::SetProvider(
                    model_provider.trim().to_string(),
                ))
            } else {
                Some(ChannelRuntimeCommand::ShowProviders)
            }
        }
        "/model" if supports_runtime_model_switch(channel_name) => {
            let rest: Vec<&str> = parts.collect();
            // An optional leading `--user|--agent` flag selects the override
            // scope; without it, bare `/model <ref>` keeps its existing
            // per-sender behavior.
            let (scope, model_tokens) = match rest.first() {
                Some(&"--user") => (Some(OverrideScope::User), &rest[1..]),
                Some(&"--agent") => (Some(OverrideScope::Agent), &rest[1..]),
                // A mistyped `--flag` is a typo, not a model id — don't silently
                // set a model literally named "--foo". Show the help/ladder.
                Some(t) if t.starts_with("--") => return Some(ChannelRuntimeCommand::ShowModel),
                _ => (None, &rest[..]),
            };
            let model = model_tokens.join(" ").trim().to_string();
            match (scope, model.is_empty()) {
                // `/model` or `/model --scope` (no ref): show current + scopes.
                (_, true) => Some(ChannelRuntimeCommand::ShowModel),
                (None, false) => Some(ChannelRuntimeCommand::SetModel(model)),
                (Some(scope), false) => Some(ChannelRuntimeCommand::SetModelScoped(scope, model)),
            }
        }
        "/config" if supports_runtime_model_switch(channel_name) => {
            Some(ChannelRuntimeCommand::ShowConfig)
        }
        _ => None,
    }
}

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

    zeroclaw_providers::list_model_providers()
        .into_iter()
        .find(|model_provider| model_provider.name.eq_ignore_ascii_case(candidate))
        .map(|model_provider| model_provider.name.to_string())
}

/// Outcome of resolving a `/models <arg>` request to a configured,
/// alias-backed provider ref. The bare family path must never construct a
/// provider that ignores the configured `[providers.models.<family>.<alias>]`
/// key/URI — every accepted route resolves to a real alias entry.
#[cfg_attr(test, derive(Debug))]
enum ModelsCommandResolution {
    /// A dotted `<family>.<alias>` ref backed by a configured entry.
    Resolved(String),
    /// The family is valid but has more than one configured alias; the user
    /// must qualify which one. Carries the canonical family and its aliases.
    Ambiguous {
        family: String,
        aliases: Vec<String>,
    },
    /// The family is valid but has no configured alias entry, so there is no
    /// credentialed provider to switch to.
    NoAlias(String),
    /// The argument names no known provider family.
    Unknown,
}

fn resolve_models_command(
    config: &zeroclaw_config::schema::Config,
    raw: &str,
) -> ModelsCommandResolution {
    let candidate = raw.trim();
    if let Some((family, alias)) = candidate.split_once('.') {
        return match config.providers.models.find(family, alias) {
            Some(_) => ModelsCommandResolution::Resolved(format!("{family}.{alias}")),
            None => ModelsCommandResolution::NoAlias(candidate.to_string()),
        };
    }

    let Some(family) = canonical_model_provider_name(candidate) else {
        return ModelsCommandResolution::Unknown;
    };

    let mut aliases: Vec<String> = config
        .providers
        .models
        .aliases_of(&family)
        .map(ToString::to_string)
        .collect();
    aliases.sort();
    match aliases.len() {
        0 => ModelsCommandResolution::NoAlias(family),
        1 => ModelsCommandResolution::Resolved(format!("{family}.{}", aliases[0])),
        _ => ModelsCommandResolution::Ambiguous { family, aliases },
    }
}

fn resolve_provider_ref_for_runtime_switch(config: &Config, raw: &str) -> anyhow::Result<String> {
    match resolve_models_command(config, raw) {
        ModelsCommandResolution::Resolved(provider_ref) => Ok(provider_ref),
        ModelsCommandResolution::Ambiguous { family, aliases } => {
            let list = aliases
                .iter()
                .map(|alias| format!("{family}.{alias}"))
                .collect::<Vec<_>>()
                .join(", ");
            anyhow::bail!(
                "model_provider `{family}` has multiple configured aliases; use one of: {list}"
            )
        }
        ModelsCommandResolution::NoAlias(ref_or_family) => {
            anyhow::bail!(
                "model_provider `{ref_or_family}` does not resolve to a configured provider"
            )
        }
        ModelsCommandResolution::Unknown => {
            anyhow::bail!("unknown model_provider `{raw}`")
        }
    }
}

fn resolved_runtime_model_provider_ref(
    config: &Config,
    agent_alias: &str,
) -> anyhow::Result<String> {
    let agent = config
        .agents
        .get(agent_alias)
        .with_context(|| format!("agents.{agent_alias} is not configured"))?;
    let configured = agent.model_provider.trim();
    if configured.is_empty() {
        anyhow::bail!(
            "agents.{agent_alias}.model_provider is empty; runtime reload requires a dotted `<type>.<alias>` provider reference"
        );
    }
    let (model_provider, _) = model_provider_entry_for_ref(config, configured)?;
    Ok(model_provider)
}

fn model_provider_entry_for_ref<'a>(
    config: &'a Config,
    model_provider: &str,
) -> anyhow::Result<(String, &'a zeroclaw_config::schema::ModelProviderConfig)> {
    let trimmed = model_provider.trim();
    if trimmed.is_empty() {
        anyhow::bail!("model_provider reference must not be empty");
    }

    let Some((provider_type, provider_alias)) = trimmed.split_once('.') else {
        anyhow::bail!("model_provider `{trimmed}` must use `<type>.<alias>` form");
    };
    let Some(entry) = config.providers.models.find(provider_type, provider_alias) else {
        anyhow::bail!("model_provider `{trimmed}` does not resolve to a configured provider");
    };
    Ok((trimmed.to_string(), entry))
}

/// Resolve runtime defaults from `config` against a specific dotted
/// `model_provider` reference (`"<type>.<alias>"`) — the per-agent
/// resolution path.
fn runtime_defaults_from_config(
    config: &Config,
    model_provider: &str,
) -> anyhow::Result<ChannelRuntimeDefaults> {
    let (default_model_provider, entry) = model_provider_entry_for_ref(config, model_provider)?;
    let model = entry
        .model
        .as_deref()
        .map(str::trim)
        .filter(|model| !model.is_empty())
        .map(ToString::to_string)
        .ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "model_provider": model_provider,
                        "reason": "no_model_configured",
                    })),
                "orchestrator: model_provider has no resolvable model"
            );
            anyhow::Error::msg(format!(
                "no model configured: model_provider '{model_provider}' does not resolve to a \
                 ModelProviderConfig with a `model` field, and providers.models has no \
                 fallback entry."
            ))
        })?;
    Ok(ChannelRuntimeDefaults {
        default_model_provider,
        model,
        temperature: entry.temperature,
        api_key: entry.api_key.clone(),
        api_url: entry.uri.clone(),
        reliability: config.reliability.clone(),
    })
}

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

fn runtime_defaults_snapshot(ctx: &ChannelRuntimeContext) -> ChannelRuntimeDefaultsSnapshot {
    if let Some(runtime_override) = ctx
        .runtime_defaults_override
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .clone()
    {
        return ChannelRuntimeDefaultsSnapshot {
            config: Arc::clone(&runtime_override.config),
            defaults: runtime_override.defaults.clone(),
            hot: true,
            generation: runtime_override.generation,
        };
    }

    ChannelRuntimeDefaultsSnapshot {
        config: Arc::clone(&ctx.prompt_config),
        defaults: ChannelRuntimeDefaults {
            default_model_provider: ctx.model_provider_ref.as_str().to_string(),
            model: ctx.model.as_str().to_string(),
            temperature: ctx.temperature,
            api_key: None,
            api_url: None,
            reliability: (*ctx.reliability).clone(),
        },
        hot: false,
        generation: 0,
    }
}

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(),
    })
}

async fn load_runtime_config_and_defaults(
    path: &Path,
    agent_alias: &str,
) -> Result<(Config, ChannelRuntimeDefaults)> {
    let contents = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("Failed to read {}", path.display()))?;
    let mut parsed: Config = zeroclaw_config::migration::migrate_to_current(&contents)
        .with_context(|| format!("Failed to migrate {}", path.display()))?;
    parsed.config_path = path.to_path_buf();

    if let Some(zeroclaw_dir) = path.parent() {
        let store =
            zeroclaw_runtime::security::SecretStore::new(zeroclaw_dir, parsed.secrets.encrypt);
        parsed.decrypt_secrets(&store)?;
    }
    let applied = zeroclaw_config::env_overrides::apply_env_overrides(&mut parsed)?;
    parsed.env_overridden_paths = applied.paths;
    parsed.pre_override_snapshots = applied.snapshots;

    let model_provider = resolved_runtime_model_provider_ref(&parsed, agent_alias)?;
    let defaults = runtime_defaults_from_config(&parsed, &model_provider)?;
    Ok((parsed, defaults))
}

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 last = ctx
            .last_applied_config_stamp
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        if *last == Some(stamp) {
            return Ok(());
        }
    }

    let (next_config, next_defaults) =
        load_runtime_config_and_defaults(&config_path, ctx.agent_alias.as_str()).await?;
    let next_config = Arc::new(next_config);
    let next_options = zeroclaw_providers::options_for_provider_ref(
        next_config.as_ref(),
        &next_defaults.default_model_provider,
        &ctx.provider_runtime_options,
    );
    let model_provider_instance = zeroclaw_providers::create_resilient_model_provider_from_ref(
        next_config.as_ref(),
        &next_defaults.default_model_provider,
        next_defaults.api_key.as_deref(),
        next_defaults.api_url.as_deref(),
        &next_defaults.reliability,
        &next_options,
    )?;
    let model_provider_instance: Arc<dyn ModelProvider> = Arc::from(model_provider_instance);

    if let Err(err) = ProviderDispatch::from_ref(&*model_provider_instance)
        .warmup()
        .await
    {
        if zeroclaw_providers::reliable::is_non_retryable(&err) {
            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"model_provider": next_defaults.default_model_provider, "model": next_defaults.model, "err": err.to_string()})), "Rejecting config reload: model not available (non-retryable)");
            return Ok(());
        }
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(
                    ::serde_json::json!({"model_provider": next_defaults.default_model_provider, "err": err.to_string()})
                ),
            "ModelProvider warmup failed after config reload (retryable, applying anyway)"
        );
    }

    {
        let mut override_guard = ctx
            .runtime_defaults_override
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let next_generation = override_guard.as_ref().map_or(1, |runtime_override| {
            runtime_override.generation.saturating_add(1)
        });
        let next_override = Arc::new(ChannelRuntimeOverride {
            config: Arc::clone(&next_config),
            defaults: next_defaults.clone(),
            generation: next_generation,
        });
        let cache_key =
            provider_cache_key(&next_defaults.default_model_provider, None, next_generation);

        let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
        cache.clear();
        cache.insert(cache_key, Arc::clone(&model_provider_instance));
        *override_guard = Some(next_override);
    }

    *ctx.last_applied_config_stamp
        .lock()
        .unwrap_or_else(|e| e.into_inner()) = Some(stamp);

    ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config_path.display().to_string(), "model_provider": next_defaults.default_model_provider, "model": next_defaults.model, "temperature": next_defaults.temperature, "agent_model_provider": next_defaults.default_model_provider})), "Applied updated channel runtime config from disk");

    Ok(())
}

fn default_route_selection_from_snapshot(
    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
) -> ChannelRouteSelection {
    let defaults = defaults_snapshot.defaults.clone();
    ChannelRouteSelection {
        model_provider: defaults.default_model_provider,
        model: defaults.model,
        api_key: None,
    }
}

/// First scope override that matches `msg`, in precedence order
/// `User > Agent`. Session-only — never consults disk.
fn scope_override_lookup(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
) -> Option<ChannelRouteSelection> {
    let overrides = ctx
        .scope_overrides
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    // Hot path: nearly all deployments never set a scoped override, so avoid
    // building (and sanitizing) the per-scope keys on every message.
    if overrides.is_empty() {
        return None;
    }
    [OverrideScope::User, OverrideScope::Agent]
        .into_iter()
        .find_map(|scope| {
            overrides
                .get(&scope_override_key(scope, msg, ctx.agent_alias.as_str()))
                .cloned()
        })
}

fn get_route_selection(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
    sender_key: &str,
    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
) -> ChannelRouteSelection {
    // Precedence (most specific wins): user > agent scope override,
    // then the per-sender route override, then the config default.
    scope_override_lookup(ctx, msg).unwrap_or_else(|| {
        ctx.route_overrides
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get(sender_key)
            .cloned()
            .unwrap_or_else(|| default_route_selection_from_snapshot(defaults_snapshot))
    })
}

fn set_route_selection(
    ctx: &ChannelRuntimeContext,
    sender_key: &str,
    next: ChannelRouteSelection,
    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
) {
    let default_route = default_route_selection_from_snapshot(defaults_snapshot);
    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 apply_model_ref(
    sel: &mut ChannelRouteSelection,
    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
    model: &str,
) {
    if let Some(route) = model_routes
        .iter()
        .find(|r| r.model.eq_ignore_ascii_case(model) || r.hint.eq_ignore_ascii_case(model))
    {
        sel.model_provider = route.model_provider.clone();
        sel.model = route.model.clone();
        sel.api_key = route.api_key.clone();
    } else {
        sel.model = model.to_string();
    }
}

fn shadow_note(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
    sender_key: &str,
    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
    wrote: &ChannelRouteSelection,
) -> String {
    let effective = get_route_selection(ctx, msg, sender_key, defaults_snapshot);
    if effective.model == wrote.model && effective.model_provider == wrote.model_provider {
        String::new()
    } else {
        format!(
            "\n{}",
            channel_runtime_cli_string_with_args(
                "channel-runtime-shadow-note",
                &[
                    ("model", effective.model.as_str()),
                    ("provider", effective.model_provider.as_str()),
                ],
            )
        )
    }
}

/// Write (or clear) a session-only scope override. Returns `false` without
/// Write (or clear) a session-only scope override. Setting a value equal to the
/// config default clears the override (mirrors [`set_route_selection`]).
fn set_scope_override(
    ctx: &ChannelRuntimeContext,
    scope: OverrideScope,
    msg: &zeroclaw_api::channel::ChannelMessage,
    next: ChannelRouteSelection,
    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
) {
    let key = scope_override_key(scope, msg, ctx.agent_alias.as_str());
    let default_route = default_route_selection_from_snapshot(defaults_snapshot);
    let mut overrides = ctx
        .scope_overrides
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    if next == default_route {
        overrides.remove(&key);
    } else {
        overrides.insert(key, next);
    }
}

/// Per-sender authorization for `/model --agent <model>`. Resolves live
/// from `Config::peer_groups` via `Config::channel_agent_scope_admins`;
/// no cache, no per-channel duplicate sender list (consistent with
/// `AGENTS.md` SINGLE SOURCE OF TRUTH). Default deny
/// (`RequireExplicit`); operators who want the prior behavior opt in
/// by marking one or more peer groups `admin_for_agent_scope = true`.
///
/// **Effective-on-restart semantics:** this gate reads
/// `ctx.prompt_config`, an `Arc<Config>` snapshot captured when the
/// runtime context was built. A `peer_groups` edit in `config.toml`
/// therefore takes effect on context rebuild / daemon restart, not on
/// the next command — same lifetime as the other `prompt_config`-backed
/// orchestrator helpers. (The `channel_external_peers` sibling reads a
/// live `RwLock` for inbound dispatch because the gateway constructs
/// fresh `peer_resolver` closures per alias; the orchestrator's runtime
/// context is built once at startup and uses the snapshot path.)
///
/// Matching routes through `crate::allowlist::is_user_allowed` so the
/// gate honors the same wildcard (`["*"]` admits anyone) and per-channel
/// peer-identity semantics every inbound channel uses, instead of a raw
/// `==` that ignores wildcard, case, and the leading `@` Telegram strips
/// before comparison. Both the configured peer list and the incoming
/// sender are normalized through [`normalize_peer_username`] (strip a
/// leading `@`, ASCII-lowercase) so an operator who writes
/// `external_peers = ["@user_1"]` is matched by an inbound `user_1`
/// sender — matching what every channel's inbound path does before
/// calling `is_user_allowed`.
fn is_agent_scope_authorized(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
) -> bool {
    let channel_type = msg.channel.as_str();
    let channel_alias = msg.channel_alias.as_deref().unwrap_or(msg.channel.as_str());
    let agent_alias = ctx.agent_alias.as_str();
    let admins: Vec<String> = ctx
        .prompt_config
        .channel_agent_scope_admins(channel_type, channel_alias, agent_alias)
        .into_iter()
        .map(|p| normalize_peer_username(&p))
        .collect();
    let sender = normalize_peer_username(msg.sender.as_str());
    crate::allowlist::is_user_allowed(&admins, &sender, crate::allowlist::Match::Sensitive)
}

/// Canonical peer-username form used by the agent-scope gate. Inbound
/// channels (Telegram: `Self::normalize_identity`; IRC: `Match::CaseInsensitive`;
/// Matrix: same) already collapse the inbound sender into a stripped /
/// case-folded identity before calling `allowlist::is_user_allowed`. The
/// gate must apply the same shape to the configured `external_peers`
/// list so an operator's `"@user_1"` / `"user_1"` / `"@Alice"` entries
/// all match the same channel-normalized sender identity.
///
/// Kept local to this module so any future per-channel nuance (E.164
/// phone, email domain) can be plumbed explicitly through
/// `allowlist::is_user_allowed_by` rather than overloading this helper.
fn normalize_peer_username(raw: &str) -> String {
    raw.trim_start_matches('@').to_ascii_lowercase()
}

fn clear_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) {
    ctx.conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .pop(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)
        && 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 = zeroclaw_runtime::skills::skills_to_prompt_with_mode(
        &zeroclaw_runtime::skills::load_skills_for_agent(
            ctx.workspace_dir.as_ref(),
            ctx.prompt_config.as_ref(),
            ctx.agent_alias.as_ref(),
        ),
        ctx.workspace_dir.as_ref(),
        ctx.prompt_config
            .effective_skills_prompt_mode(ctx.agent_alias.as_str()),
    );
    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
}

/// Number of most-recent turns whose tool-result payloads are kept at full size
/// when proactively trimming. The active exchange stays intact; only older
/// tool results are shrunk to a bounded extract.
fn append_sender_turn(ctx: &ChannelRuntimeContext, sender_key: &str, turn: ChatMessage) {
    // Serialize per-sender persistence to prevent interleaving across concurrent
    // workers that share the same conversation_history_key
    let persist_lock = acquire_persist_lock(ctx, sender_key);
    let _lock = persist_lock.lock().unwrap_or_else(|e| e.into_inner());

    // Persist to JSONL before adding to in-memory history.
    if let Some(ref store) = ctx.session_store
        && let Err(e) = store.append(sender_key, &turn)
    {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
            "Failed to persist session turn"
        );
    }

    // 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.agent_cfg.resolved.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.get_or_insert_mut(sender_key.to_string(), Vec::new);
    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()
}

fn rollback_orphan_user_turn(
    ctx: &ChannelRuntimeContext,
    sender_key: &str,
    expected_content: &str,
) -> bool {
    // Serialize per-sender persistence to prevent interleaving across concurrent
    // workers that share the same conversation_history_key
    let persist_lock = acquire_persist_lock(ctx, sender_key);
    let _lock = persist_lock.lock().unwrap_or_else(|e| e.into_inner());

    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.pop(sender_key);
    }

    // Also remove the orphan turn from the persisted JSONL session store so
    // it doesn't resurface after a daemon restart
    if let Some(ref store) = ctx.session_store
        && let Err(e) = store.remove_last(sender_key)
    {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
            "Failed to rollback session store entry"
        );
    }

    true
}

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

    zeroclaw_providers::reliable::is_non_retryable(error)
}

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(
    data_dir: &Path,
    agent_workspace_dir: &Path,
    provider_name: &str,
) -> Vec<String> {
    // Canonicalize undotted names so lookup matches the cache key.
    let canonical = if provider_name.contains('.') {
        provider_name.to_string()
    } else {
        format!("{provider_name}.default")
    };

    // Check the shared cache location first (written by `zeroclaw models refresh`),
    // then fall back to the agent workspace for backward compatibility.
    let shared_path = data_dir.join("state").join(MODEL_CACHE_FILE);
    let agent_path = agent_workspace_dir.join("state").join(MODEL_CACHE_FILE);

    for cache_path in [&shared_path, &agent_path] {
        let Ok(raw) = std::fs::read_to_string(cache_path) else {
            continue;
        };
        let Ok(state) = serde_json::from_str::<zeroclaw_config::schema::ModelCacheState>(&raw)
        else {
            continue;
        };
        if let Some(entry) = state
            .entries
            .into_iter()
            .find(|e| e.model_provider == canonical)
        {
            return entry
                .models
                .into_iter()
                .take(MODEL_CACHE_PREVIEW_LIMIT)
                .collect();
        }
    }
    Vec::new()
}

/// Build a cache key that includes the runtime-defaults generation, the
/// model_provider name, and, when a route-specific API key is supplied, a hash
/// of that key. Generation `0` is the immutable startup config, so its key shape
/// stays unchanged; hot-reload generations get isolated cache entries.
fn provider_cache_key(provider_name: &str, route_api_key: Option<&str>, generation: u64) -> String {
    let base = 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(),
    };
    if generation == 0 {
        base
    } else {
        format!("g{generation}:{base}")
    }
}

fn provider_credentials_for_ref(
    config: &zeroclaw_config::schema::Config,
    provider_ref: &str,
) -> (Option<String>, Option<String>) {
    let Some((type_key, alias_key)) = provider_ref.trim().split_once('.') else {
        return (None, None);
    };
    config
        .providers
        .models
        .find(type_key, alias_key)
        .map_or((None, None), |entry| {
            (entry.api_key.clone(), entry.uri.clone())
        })
}

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

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

    let config = Arc::clone(&defaults_snapshot.config);
    let defaults = defaults_snapshot.defaults.clone();

    // Only return the pre-built startup default model_provider while the
    // current runtime defaults still match startup and there is no
    // route-specific credential override. Once config reload changes defaults,
    // the cache/store path above owns the live default provider.
    if route_api_key.is_none()
        && provider_name == defaults.default_model_provider.as_str()
        && provider_name == ctx.model_provider_ref.as_str()
        && !defaults_snapshot.hot
    {
        return Ok(Arc::clone(&ctx.model_provider));
    }
    let (entry_api_key, entry_api_url) =
        provider_credentials_for_ref(config.as_ref(), provider_name);
    let effective_api_key = route_api_key.map(ToString::to_string).or(entry_api_key);

    let model_provider = create_resilient_model_provider_nonblocking(
        config,
        provider_name,
        effective_api_key,
        entry_api_url,
        defaults.reliability,
        ctx.provider_runtime_options.clone(),
    )
    .await?;
    let model_provider: Arc<dyn ModelProvider> = Arc::from(model_provider);

    if let Err(err) = ProviderDispatch::from_ref(&*model_provider).warmup().await {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(
                    ::serde_json::json!({"model_provider": provider_name, "err": err.to_string()})
                ),
            "ModelProvider warmup failed"
        );
    }

    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(&model_provider));
    Ok(Arc::clone(cached))
}

async fn create_resilient_model_provider_nonblocking(
    config: Arc<zeroclaw_config::schema::Config>,
    provider_name: &str,
    api_key: Option<String>,
    api_url: Option<String>,
    reliability: zeroclaw_config::schema::ReliabilityConfig,
    provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn ModelProvider>> {
    let provider_name = provider_name.to_string();
    tokio::task::spawn_blocking(move || {
        let options = zeroclaw_providers::options_for_provider_ref(
            &config,
            &provider_name,
            &provider_runtime_options,
        );
        zeroclaw_providers::create_resilient_model_provider_from_ref(
            &config,
            &provider_name,
            api_key.as_deref(),
            api_url.as_deref(),
            &reliability,
            &options,
        )
    })
    .await
    .context("failed to join model_provider initialization task")?
}

fn build_models_help_response(
    current: &ChannelRouteSelection,
    data_dir: &Path,
    agent_workspace_dir: &Path,
    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
) -> String {
    let mut response = String::new();
    response.push_str(&channel_runtime_cli_string_with_args(
        "channel-runtime-current-model-status",
        &[
            ("provider", current.model_provider.as_str()),
            ("model", current.model.as_str()),
        ],
    ));
    response.push('\n');
    response.push_str(&channel_runtime_cli_string(
        "channel-runtime-model-switch-hint",
    ));
    response.push('\n');

    if !model_routes.is_empty() {
        response.push('\n');
        response.push_str(&channel_runtime_cli_string(
            "channel-runtime-configured-routes-header",
        ));
        response.push('\n');
        for route in model_routes {
            let _ = writeln!(
                response,
                "  `{}` → {} ({})",
                route.hint, route.model, route.model_provider
            );
        }
    }

    let cached_models =
        load_cached_model_preview(data_dir, agent_workspace_dir, &current.model_provider);
    if cached_models.is_empty() {
        response.push('\n');
        response.push_str(&channel_runtime_cli_string_with_args(
            "channel-runtime-no-cached-models",
            &[("provider", current.model_provider.as_str())],
        ));
        response.push('\n');
    } else {
        response.push('\n');
        response.push_str(&channel_runtime_cli_string_with_args(
            "channel-runtime-cached-model-ids-header",
            &[("count", &cached_models.len().to_string())],
        ));
        response.push('\n');
        for model in cached_models {
            let _ = writeln!(response, "- `{model}`");
        }
    }

    response
}

fn build_providers_help_response(current: &ChannelRouteSelection) -> String {
    let mut response = String::new();
    response.push_str(&channel_runtime_cli_string_with_args(
        "channel-runtime-current-model-status",
        &[
            ("provider", current.model_provider.as_str()),
            ("model", current.model.as_str()),
        ],
    ));
    response.push('\n');
    response.push_str(&channel_runtime_cli_string(
        "channel-runtime-provider-switch-hint",
    ));
    response.push('\n');
    response.push_str(&channel_runtime_cli_string(
        "channel-runtime-model-switch-hint",
    ));
    response.push_str("\n\n");
    response.push_str(&channel_runtime_cli_string(
        "channel-runtime-available-providers-header",
    ));
    response.push('\n');
    for model_provider in zeroclaw_providers::list_model_providers() {
        let _ = writeln!(response, "- {}", model_provider.name);
    }
    response
}

/// Build a plain-text `/config` response for non-Slack channels.
fn build_config_text_response(
    current: &ChannelRouteSelection,
    _workspace_dir: &Path,
    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
) -> String {
    let mut resp = String::new();
    resp.push_str(&channel_runtime_cli_string_with_args(
        "channel-runtime-current-model-status",
        &[
            ("provider", current.model_provider.as_str()),
            ("model", current.model.as_str()),
        ],
    ));
    resp.push('\n');
    resp.push('\n');
    resp.push_str(&channel_runtime_cli_string(
        "channel-runtime-available-providers-header",
    ));
    resp.push('\n');
    for p in zeroclaw_providers::list_model_providers() {
        let _ = writeln!(resp, "- `{}`", p.name);
    }
    if !model_routes.is_empty() {
        resp.push('\n');
        resp.push_str(&channel_runtime_cli_string(
            "channel-runtime-configured-routes-header",
        ));
        resp.push('\n');
        for route in model_routes {
            let _ = writeln!(
                resp,
                "  `{}` -> {} ({})",
                route.hint, route.model, route.model_provider
            );
        }
    }
    resp.push('\n');
    resp.push_str(&channel_runtime_cli_string(
        "channel-runtime-config-switch-hints",
    ));
    resp
}

/// Build a Slack Block Kit JSON payload for the `/config` interactive UI.
fn build_config_block_kit(
    current: &ChannelRouteSelection,
    data_dir: &Path,
    agent_workspace_dir: &Path,
    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
) -> String {
    let provider_options: Vec<serde_json::Value> = zeroclaw_providers::list_model_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(data_dir, agent_workspace_dir, &current.model_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.model_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": "zeroclaw_config_provider",
        "placeholder": {
            "type": "plain_text",
            "text": channel_runtime_cli_string("channel-runtime-config-select-provider-placeholder")
        },
        "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": "zeroclaw_config_model",
        "placeholder": {
            "type": "plain_text",
            "text": channel_runtime_cli_string("channel-runtime-config-select-model-placeholder")
        },
        "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": channel_runtime_cli_string_with_args(
                    "channel-runtime-config-block-title",
                    &[
                        ("provider", current.model_provider.as_str()),
                        ("model", current.model.as_str()),
                    ],
                )
            }
        },
        {
            "type": "section",
            "block_id": "config_provider_block",
            "text": {
                "type": "mrkdwn",
                "text": channel_runtime_cli_string("channel-runtime-config-provider-label")
            },
            "accessory": provider_select
        },
        {
            "type": "section",
            "block_id": "config_model_block",
            "text": {
                "type": "mrkdwn",
                "text": channel_runtime_cli_string("channel-runtime-config-model-label")
            },
            "accessory": model_select
        }
    ]);

    blocks.to_string()
}

/// Render the per-scope override ladder appended to `/model` (no args), so a
/// user can see what is set at each tier and the resolution precedence.
fn build_scope_override_summary(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
) -> String {
    let fmt_sel =
        |sel: &ChannelRouteSelection| format!("`{}` / `{}`", sel.model_provider, sel.model);
    let (user, agent) = {
        let overrides = ctx
            .scope_overrides
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let scope_line = |scope: OverrideScope| -> String {
            overrides
                .get(&scope_override_key(scope, msg, ctx.agent_alias.as_str()))
                .map(&fmt_sel)
                .unwrap_or_else(|| "—".to_string())
        };
        (
            scope_line(OverrideScope::User),
            scope_line(OverrideScope::Agent),
        )
    };
    let sender_key = conversation_history_key(msg);
    let session = ctx
        .route_overrides
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .get(&sender_key)
        .map(fmt_sel)
        .unwrap_or_else(|| "—".to_string());
    let default = default_route_selection_from_snapshot(defaults_snapshot);
    let default = fmt_sel(&default);
    format!(
        "\n\n{}",
        channel_runtime_cli_string_with_args(
            "channel-runtime-scope-overrides-summary",
            &[
                ("user", user.as_str()),
                ("agent", agent.as_str()),
                ("session", session.as_str()),
                ("default", default.as_str()),
            ],
        )
    )
}

async fn handle_runtime_command_if_needed(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::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 defaults_snapshot = runtime_defaults_snapshot(ctx);
    let mut current = get_route_selection(ctx, msg, &sender_key, &defaults_snapshot);

    let response = match command {
        ChannelRuntimeCommand::ShowProviders => build_providers_help_response(&current),
        ChannelRuntimeCommand::SetProvider(raw_model_provider) => {
            match resolve_models_command(defaults_snapshot.config.as_ref(), &raw_model_provider) {
                ModelsCommandResolution::Resolved(provider_ref) => {
                    match get_or_create_provider(ctx, &provider_ref, None, &defaults_snapshot).await
                    {
                        Ok(_) => {
                            if provider_ref != current.model_provider {
                                current.model_provider = provider_ref.clone();
                                set_route_selection(
                                    ctx,
                                    &sender_key,
                                    current.clone(),
                                    &defaults_snapshot,
                                );
                            }

                            channel_runtime_cli_string_with_args(
                                "channel-runtime-set-provider-switched",
                                &[
                                    ("provider", provider_ref.as_str()),
                                    ("model", current.model.as_str()),
                                ],
                            )
                        }
                        Err(err) => {
                            let safe_err = zeroclaw_providers::sanitize_api_error(&err.to_string());
                            channel_runtime_cli_string_with_args(
                                "channel-runtime-set-provider-init-failed",
                                &[
                                    ("provider", provider_ref.as_str()),
                                    ("error", safe_err.as_str()),
                                ],
                            )
                        }
                    }
                }
                ModelsCommandResolution::Ambiguous { family, aliases } => {
                    let list = aliases
                        .iter()
                        .map(|a| format!("`{family}.{a}`"))
                        .collect::<Vec<_>>()
                        .join(", ");
                    channel_runtime_cli_string_with_args(
                        "channel-runtime-provider-ambiguous",
                        &[("family", family.as_str()), ("list", list.as_str())],
                    )
                }
                ModelsCommandResolution::NoAlias(ref_or_family) => {
                    channel_runtime_cli_string_with_args(
                        "channel-runtime-provider-no-alias",
                        &[("provider", ref_or_family.as_str())],
                    )
                }
                ModelsCommandResolution::Unknown => channel_runtime_cli_string_with_args(
                    "channel-runtime-provider-unknown",
                    &[("provider", raw_model_provider.as_str())],
                ),
            }
        }
        ChannelRuntimeCommand::ShowModel => {
            let mut resp = build_models_help_response(
                &current,
                ctx.prompt_config.data_dir.as_path(),
                ctx.workspace_dir.as_path(),
                &ctx.model_routes,
            );
            resp.push_str(&build_scope_override_summary(ctx, msg, &defaults_snapshot));
            resp
        }
        ChannelRuntimeCommand::SetModelScoped(scope, raw_model) => {
            let model = raw_model.trim().trim_matches('`').to_string();
            if model.is_empty() {
                channel_runtime_cli_string("channel-runtime-scoped-model-empty")
            } else if scope == OverrideScope::Agent && !is_agent_scope_authorized(ctx, msg) {
                // Per-sender authorization gate for the `--agent` scope only.
                // `/model --user` is unaffected.
                let channel_alias = msg.channel_alias.as_deref().unwrap_or(msg.channel.as_str());
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({
                            "sender": msg.sender.as_str(),
                            "agent": ctx.agent_alias.as_str(),
                            "channel": msg.channel.as_str(),
                            "channel_alias": channel_alias,
                            "model_requested": model.as_str(),
                            "command": "/model --agent",
                        })),
                    "agent-scope /model override rejected"
                );
                zeroclaw_runtime::i18n::get_required_cli_string_with_args(
                    "channel-runtime-agent-scope-rejected",
                    &[
                        ("sender", msg.sender.as_str()),
                        ("agent", ctx.agent_alias.as_str()),
                        ("model", model.as_str()),
                    ],
                )
            } else {
                // Resolve provider+model the same way bare `/model` does, then
                // write it at the requested scope instead of the per-sender route.
                let mut next = current.clone();
                apply_model_ref(&mut next, &ctx.model_routes, &model);
                set_scope_override(ctx, scope, msg, next.clone(), &defaults_snapshot);
                if scope == OverrideScope::Agent {
                    let channel_alias =
                        msg.channel_alias.as_deref().unwrap_or(msg.channel.as_str());
                    ::zeroclaw_log::record!(
                        INFO,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Approve)
                            .with_outcome(::zeroclaw_log::EventOutcome::Success)
                            .with_attrs(::serde_json::json!({
                                "sender": msg.sender.as_str(),
                                "agent": ctx.agent_alias.as_str(),
                                "channel": msg.channel.as_str(),
                                "channel_alias": channel_alias,
                                "model_provider": next.model_provider.as_str(),
                                "model": next.model.as_str(),
                                "command": "/model --agent",
                            })),
                        "agent-scope /model override accepted"
                    );
                }
                let scope_label = channel_runtime_scope_label(scope);
                let mut resp = channel_runtime_cli_string_with_args(
                    "channel-runtime-scoped-model-switched",
                    &[
                        ("model", next.model.as_str()),
                        ("provider", next.model_provider.as_str()),
                        ("scope", scope_label.as_str()),
                    ],
                );
                resp.push_str(&shadow_note(
                    ctx,
                    msg,
                    &sender_key,
                    &defaults_snapshot,
                    &next,
                ));
                resp
            }
        }
        ChannelRuntimeCommand::SetModel(raw_model) => {
            let model = raw_model.trim().trim_matches('`').to_string();
            if model.is_empty() {
                channel_runtime_cli_string("channel-runtime-model-empty")
            } else {
                apply_model_ref(&mut current, &ctx.model_routes, &model);
                set_route_selection(ctx, &sender_key, current.clone(), &defaults_snapshot);

                let mut resp = channel_runtime_cli_string_with_args(
                    "channel-runtime-model-switched",
                    &[
                        ("model", current.model.as_str()),
                        ("provider", current.model_provider.as_str()),
                    ],
                );
                resp.push_str(&shadow_note(
                    ctx,
                    msg,
                    &sender_key,
                    &defaults_snapshot,
                    &current,
                ));
                resp
            }
        }
        ChannelRuntimeCommand::ShowConfig => {
            if msg.channel == "slack" {
                let blocks_json = build_config_block_kit(
                    &current,
                    ctx.prompt_config.data_dir.as_path(),
                    ctx.workspace_dir.as_path(),
                    &ctx.model_routes,
                );
                // Use a magic prefix so SlackChannel::send() can detect Block Kit JSON.
                format!("__ZEROCLAW_BLOCK_KIT__{blocks_json}")
            } else {
                build_config_text_response(&current, ctx.workspace_dir.as_path(), &ctx.model_routes)
            }
        }
        ChannelRuntimeCommand::NewSession => {
            // Serialize per-sender persistence to prevent interleaving
            let persist_lock = acquire_persist_lock(ctx, &sender_key);
            let _lock = persist_lock.lock().unwrap_or_else(|e| e.into_inner());
            clear_sender_history(ctx, &sender_key);
            ctx.thinking_overrides
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .remove(&sender_key);
            if let Some(ref store) = ctx.session_store
                && let Err(e) = store.delete_session(&sender_key)
            {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(
                            ::serde_json::json!({"error": format!("{}", e), "sender_key": sender_key})
                        ),
                    "Failed to delete persisted session for"
                );
            }
            mark_sender_for_new_session(ctx, &sender_key);
            channel_runtime_cli_string("channel-runtime-new-session")
        }
        ChannelRuntimeCommand::SetThinking(level) => match level {
            Some(level) => {
                ctx.thinking_overrides
                    .lock()
                    .unwrap_or_else(|e| e.into_inner())
                    .insert(sender_key.clone(), level);
                channel_runtime_cli_string_with_args(
                    "channel-runtime-thinking-set",
                    &[("level", level.as_str())],
                )
            }
            None => {
                let removed = ctx
                    .thinking_overrides
                    .lock()
                    .unwrap_or_else(|e| e.into_inner())
                    .remove(&sender_key)
                    .is_some();
                let default = ctx.agent_cfg.resolved.thinking.default_level.as_str();
                if removed {
                    channel_runtime_cli_string_with_args(
                        "channel-runtime-thinking-cleared",
                        &[("default", default)],
                    )
                } else {
                    channel_runtime_cli_string_with_args(
                        "channel-runtime-thinking-default",
                        &[("default", default)],
                    )
                }
            }
        },
        ChannelRuntimeCommand::InvalidThinking(raw) => channel_runtime_cli_string_with_args(
            "channel-runtime-thinking-invalid",
            &[("raw", raw.as_str())],
        ),
    };

    if let Err(err) = channel
        .send(&{
            let mut sm = SendMessage::new(response, &msg.reply_target)
                .in_thread(msg.thread_ts.clone())
                .in_reply_to(Some(msg.id.clone()));
            if let Some(ref subj) = msg.subject {
                let reply_subject = if subj.to_lowercase().starts_with("re:") {
                    subj.clone()
                } else {
                    format!("Re: {}", subj)
                };
                sm = sm.subject(reply_subject);
            }
            sm
        })
        .await
    {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            &format!(
                "Failed to send runtime command response on {}: {err}",
                channel.name()
            )
        );
    }

    true
}

fn is_group_reply_target(reply_target: &str) -> bool {
    reply_target.contains("@g.us") || reply_target.starts_with("group:")
}

fn sender_memory_session_ids(
    msg: &zeroclaw_api::channel::ChannelMessage,
    history_key: &str,
) -> Vec<String> {
    // Match the sanitized form persisted by memory backend migrations.
    let sanitized_sender = sanitize_session_key(&msg.sender);
    if is_group_reply_target(&msg.reply_target) {
        vec![sanitized_sender]
    } else {
        vec![history_key.to_string(), sanitized_sender]
    }
}

#[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)
                        && 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)
            && 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(", "))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NoReplyKind {
    /// "Got it, no action needed" — informational, social, or
    /// non-addressed messages. Reaction: 👍.
    Informational,
    /// "I will not do this" — safety / policy refusals (prompt injection,
    /// blocked tool, disallowed request). Reaction: 🚫.
    Refused,
    /// "I tried but couldn't fulfil" — external failures, missing
    /// resources, timeouts where the assistant gave up. Reaction: ⚠️.
    Failed,
}

impl NoReplyKind {
    fn emoji(self) -> &'static str {
        match self {
            NoReplyKind::Informational => "👍",
            NoReplyKind::Refused => "🚫",
            NoReplyKind::Failed => "⚠️",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum AssistantChannelOutcome {
    Reply(String),
    NoReply {
        kind: NoReplyKind,
        reason: Option<String>,
    },
}

impl AssistantChannelOutcome {
    fn history_marker(&self) -> String {
        match self {
            Self::Reply(text) => text.clone(),
            Self::NoReply {
                reason: Some(reason),
                ..
            } if !reason.trim().is_empty() => {
                format!("[No reply sent: {}]", reason.trim())
            }
            Self::NoReply { .. } => "[No reply sent]".to_string(),
        }
    }
}

async fn classify_channel_reply_intent(
    model_provider: &dyn ModelProvider,
    system_prompt: &str,
    history: &[ChatMessage],
    model: &str,
    temperature: Option<f64>,
) -> anyhow::Result<AssistantChannelOutcome> {
    let mut convo = String::from(
        "Decide whether the assistant should send any visible reply to the latest inbound \
         channel message, and if not, which kind of non-reply it is.\n\nReturn exactly one of:\n\
         - `REPLY`\n\
         - `NO_REPLY[INFO]: <short reason>`   (informational/social, no action needed)\n\
         - `NO_REPLY[REFUSE]: <short reason>` (refused for safety, policy, or prompt injection)\n\
         - `NO_REPLY[FAIL]: <short reason>`   (tried but couldn't fulfil — bad URL, missing file, timeout)\n\
         - `NO_REPLY: <short reason>`         (legacy form; treated as INFO)\n\n\
         Rules:\n\
         - Any call to action from the user MUST be actioned — return `REPLY`. A call to action \
         is a question, request, command, or ask: a message that requires the assistant to do \
         or say something. Being merely named, addressed, or referenced is NOT a call to action \
         on its own (e.g. \"stand by\", \"hold on\", \"thanks bot\" — those are not asks). \
         There is no exception when a real ask is present: memory or prior history showing a \
         similar earlier exchange is NOT grounds to skip the response — the user asked now and \
         is owed a reply now.\n\
         - For everything that is not a call to action, default to `REPLY`. Only emit \
         `NO_REPLY[*]` when one of the categories below clearly applies; when in doubt, `REPLY`.\n\
         - `NO_REPLY[INFO]` is reserved for messages plainly not for the assistant: chatter \
         between other humans in a group channel, system broadcasts, or content the embedded \
         system prompt explicitly tells the assistant to ignore.\n\
         - Output exactly one of the tokens above; emit no other text. The `<short reason>` \
         describes the inbound message — it MUST NOT restate or paraphrase these classifier \
         instructions.\n\nConversation:\n",
    );

    for msg in history.iter().filter(|m| m.role != "system") {
        let role = match msg.role.as_str() {
            "assistant" => "assistant",
            _ => "user",
        };
        // Strip media markers — auxiliary classifier does not need image
        // content, and forwarding `[IMAGE:/local/path]` would reach the
        // provider as a malformed `image_url.url` and trigger 400 errors.
        let safe_content = zeroclaw_providers::multimodal::strip_media_markers(&msg.content);
        let _ = writeln!(convo, "[{role}] {safe_content}");
    }

    let response = ProviderDispatch::from_ref(model_provider)
        .chat_with_system(Some(system_prompt), &convo, model, temperature)
        .await?;
    Ok(parse_reply_intent(&response))
}

/// Parse the classifier's raw output into an `AssistantChannelOutcome`. Pure
/// helper extracted so the LLM-call wrapper has no parsing logic and the
/// kinded `NO_REPLY[...]` forms can be unit-tested without a model_provider.
fn parse_reply_intent(response: &str) -> AssistantChannelOutcome {
    let trimmed = response.trim();
    if trimmed.is_empty() {
        return AssistantChannelOutcome::NoReply {
            kind: NoReplyKind::Informational,
            reason: None,
        };
    }
    if trimmed.eq_ignore_ascii_case("REPLY") {
        return AssistantChannelOutcome::Reply(String::new());
    }

    for (tag, kind) in &[
        ("NO_REPLY[INFO]:", NoReplyKind::Informational),
        ("NO_REPLY[REFUSE]:", NoReplyKind::Refused),
        ("NO_REPLY[FAIL]:", NoReplyKind::Failed),
    ] {
        if let Some(reason) = trimmed.strip_prefix(tag) {
            return outcome_for_no_reply(reason.trim(), *kind);
        }
    }

    if let Some(reason) = trimmed.strip_prefix("NO_REPLY:") {
        return outcome_for_no_reply(reason.trim(), NoReplyKind::Informational);
    }
    if trimmed.eq_ignore_ascii_case("NO_REPLY") {
        return AssistantChannelOutcome::NoReply {
            kind: NoReplyKind::Informational,
            reason: None,
        };
    }

    AssistantChannelOutcome::Reply(String::new())
}

async fn resolve_classifier_route(
    ctx: &ChannelRuntimeContext,
    provider_ref: &zeroclaw_config::providers::ModelProviderRef,
    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
) -> Option<(Arc<dyn ModelProvider>, String, Option<f64>)> {
    let provider_str = provider_ref.as_str().trim();
    if provider_str.is_empty() {
        return None;
    }

    let (type_key, alias_key) = match provider_str.split_once('.') {
        Some(parts) => parts,
        None => {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({"provider": provider_str})),
                "classifier_provider must be dotted `<type>.<alias>`; falling back to main agent"
            );
            return None;
        }
    };

    let model_cfg = match defaults_snapshot
        .config
        .providers
        .models
        .find(type_key, alias_key)
    {
        Some(cfg) => cfg,
        None => {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({"provider": provider_str})),
                "classifier_provider references an unknown [providers.models.<type>.<alias>] entry; falling back to main agent"
            );
            return None;
        }
    };

    let model = model_cfg.model.clone().unwrap_or_default();
    let temperature = model_cfg.temperature;
    if model.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({"provider": provider_str})),
            "classifier_provider points to a [providers.models] entry without a `model` field; falling back to main agent"
        );
        return None;
    }

    let provider = match get_or_create_provider(
        ctx,
        provider_str,
        model_cfg.api_key.as_deref(),
        defaults_snapshot,
    )
    .await
    {
        Ok(p) => p,
        Err(e) => {
            let safe_err = zeroclaw_providers::sanitize_api_error(&e.to_string());
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({"provider": provider_str, "error": safe_err})),
                "Failed to initialize classifier_provider; falling back to main agent provider"
            );
            return None;
        }
    };

    ::zeroclaw_log::record!(
        INFO,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_attrs(::serde_json::json!({"provider": provider_str, "model": model.as_str()})),
        "classifier_provider override active"
    );

    Some((provider, model, temperature))
}

fn outcome_for_no_reply(reason: &str, kind: NoReplyKind) -> AssistantChannelOutcome {
    if matches!(kind, NoReplyKind::Informational) && looks_like_meta_instruction_echo(reason) {
        return AssistantChannelOutcome::Reply(String::new());
    }
    AssistantChannelOutcome::NoReply {
        kind,
        reason: (!reason.is_empty()).then(|| reason.to_string()),
    }
}

fn looks_like_meta_instruction_echo(reason: &str) -> bool {
    if reason.is_empty() {
        return false;
    }
    let lower = reason.to_ascii_lowercase();
    const MARKERS: &[&str] = &[
        "classification task",
        "only classify",
        "must not answer",
        "not answering the user",
        "do not answer the user",
        "do not reply to the user",
        "classifier instruction",
    ];
    MARKERS.iter().any(|m| lower.contains(m))
}

/// Strip `<think>...</think>` blocks from streaming draft text so reasoning
/// tokens are never shown to the user in partial updates.
fn strip_think_tags_inline(s: &str) -> String {
    let mut result = String::with_capacity(s.len());
    let mut rest = s;
    loop {
        if let Some(start) = rest.find("<think>") {
            result.push_str(&rest[..start]);
            if let Some(end) = rest[start..].find("</think>") {
                rest = &rest[start + end + "</think>".len()..];
            } else {
                // Unclosed tag: drop the tail to avoid leaking partial reasoning.
                break;
            }
        } else {
            result.push_str(rest);
            break;
        }
    }
    result.trim().to_string()
}

fn starts_with_visible_tool_call_tag_example(response: &str) -> bool {
    let lower = response.trim_start().to_ascii_lowercase();
    let starts_with_tool_tag = lower.starts_with("<tool_call")
        || lower.starts_with("<toolcall")
        || lower.starts_with("<tool-call")
        || lower.starts_with("<invoke");

    starts_with_tool_tag && zeroclaw_tool_call_parser::looks_like_tool_protocol_example(response)
}

fn should_suppress_top_level_tool_protocol_response(
    response: &str,
    known_tool_names: &HashSet<String>,
) -> bool {
    if zeroclaw_tool_call_parser::looks_like_tool_protocol_example(response) {
        return false;
    }

    if zeroclaw_tool_call_parser::looks_like_malformed_tool_protocol_envelope_for_known_tools(
        response,
        known_tool_names,
    ) {
        return true;
    }

    if let Some(kind) = zeroclaw_tool_call_parser::classify_tool_protocol_envelope(response) {
        return matches!(
            kind,
            zeroclaw_tool_call_parser::ToolProtocolEnvelopeKind::TaggedToolCall
        ) || (!known_tool_names.is_empty()
            && (matches!(
                kind,
                zeroclaw_tool_call_parser::ToolProtocolEnvelopeKind::ToolResult
            ) || zeroclaw_tool_call_parser::tool_protocol_envelope_mentions_known_tool(
                response,
                known_tool_names,
            )));
    }

    // If the broad envelope detector still matches after classification failed,
    // this is malformed internal protocol JSON rather than ordinary content.
    zeroclaw_tool_call_parser::looks_like_tool_protocol_envelope(response)
}

#[cfg(test)]
fn sanitize_channel_response(response: &str, tools: &[Box<dyn Tool>]) -> String {
    sanitize_channel_response_with_leak_detection(
        response,
        tools,
        &zeroclaw_config::schema::LeakDetectionConfig::default(),
    )
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutboundContentFormat {
    Markdown,
    PlainText,
}

fn outbound_content_format_for_channel(channel: &str) -> OutboundContentFormat {
    let channel_type = channel
        .split_once('.')
        .map_or(channel, |(channel_type, _)| channel_type);
    if channel_type.eq_ignore_ascii_case("irc") || channel_type.eq_ignore_ascii_case("twitch") {
        OutboundContentFormat::PlainText
    } else {
        OutboundContentFormat::Markdown
    }
}

#[cfg(test)]
fn sanitize_channel_response_with_leak_detection(
    response: &str,
    tools: &[Box<dyn Tool>],
    leak_detection: &zeroclaw_config::schema::LeakDetectionConfig,
) -> String {
    sanitize_channel_response_for_format_with_leak_detection(
        response,
        tools,
        leak_detection,
        OutboundContentFormat::Markdown,
    )
}

fn sanitize_channel_response_for_format_with_leak_detection(
    response: &str,
    tools: &[Box<dyn Tool>],
    leak_detection: &zeroclaw_config::schema::LeakDetectionConfig,
    content_format: OutboundContentFormat,
) -> 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. Trim first to handle leading/trailing whitespace.
    let trimmed_response = response.trim();
    let trimmed_response = strip_think_tags_inline(trimmed_response).trim().to_string();
    let trimmed_response = trimmed_response.as_str();
    // Final channel guardrail: reuse the parser classifier so channel cleanup
    // cannot drift from runtime tool-protocol detection.
    if should_suppress_top_level_tool_protocol_response(trimmed_response, &known_tool_names) {
        return String::new();
    }
    let stripped_summary = strip_tool_summary_prefix(trimmed_response);
    let stripped_xml = if starts_with_visible_tool_call_tag_example(&stripped_summary) {
        stripped_summary
    } else {
        strip_tool_call_tags(&stripped_summary)
    };
    let stripped_results = strip_tool_result_content(&stripped_xml);
    let stripped_fenced_json =
        strip_fenced_tool_protocol_artifacts(&stripped_results, &known_tool_names);
    let stripped_json =
        strip_isolated_tool_json_artifacts(&stripped_fenced_json, &known_tool_names);
    // Strip leading narration lines that announce tool usage
    let sanitized = strip_tool_narration(&stripped_json);

    redact_channel_outbound_leaks(&sanitized, leak_detection, content_format)
}

fn redact_channel_outbound_leaks(
    content: &str,
    leak_detection: &zeroclaw_config::schema::LeakDetectionConfig,
    content_format: OutboundContentFormat,
) -> String {
    if !leak_detection.enabled {
        return content.to_string();
    }
    // Scan for credential leaks before returning to caller. Format-specific
    // outbound layers identify parsed destinations that must remain intact and
    // pass only byte ranges to the format-agnostic detector.
    let protected_spans = channel_outbound_protected_spans(content, content_format);
    match zeroclaw_runtime::security::LeakDetector::with_config(leak_detection)
        .scan_with_protected_spans(content, &protected_spans)
    {
        zeroclaw_runtime::security::LeakResult::Clean => content.to_string(),
        zeroclaw_runtime::security::LeakResult::Detected { patterns, redacted } => {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({"patterns": patterns})),
                "output guardrail: credential leak detected in outbound channel response"
            );
            redacted
        }
    }
}

fn channel_outbound_protected_spans(
    content: &str,
    content_format: OutboundContentFormat,
) -> Vec<Range<usize>> {
    let mut spans = Vec::new();
    // A file URI is a file reference even when punctuation inside it looks
    // like query syntax; protect it for every outbound text format.
    if content
        .as_bytes()
        .windows(b"file:".len())
        .any(|window| window.eq_ignore_ascii_case(b"file:"))
    {
        collect_raw_file_uri_spans(content, &mut spans);
    }
    match content_format {
        OutboundContentFormat::Markdown => {
            if content.contains("](")
                || content.contains("]:")
                || (content.contains('<') && content.contains("://"))
            {
                collect_markdown_link_destination_spans(content, &mut spans);
            }
        }
        OutboundContentFormat::PlainText => {}
    }
    spans
}

fn collect_markdown_link_destination_spans(content: &str, spans: &mut Vec<Range<usize>>) {
    let parser = MarkdownParser::new_ext(content, MarkdownOptions::empty());

    for (_, link_def) in parser.reference_definitions().iter() {
        if let Some(span) =
            parsed_destination_span(content, link_def.span.clone(), link_def.dest.as_ref())
        {
            spans.push(span);
        }
    }

    for (event, range) in parser.into_offset_iter() {
        if let Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) = event
            && let Some(span) = parsed_destination_span(content, range, dest_url.as_ref())
        {
            spans.push(span);
        }
    }
}

fn parsed_destination_span(
    content: &str,
    source_range: Range<usize>,
    parsed_destination: &str,
) -> Option<Range<usize>> {
    if parsed_destination.is_empty() {
        return None;
    }
    let source = content.get(source_range.clone())?;
    let search_start = destination_search_start(source);
    decoded_destination_span(source, search_start, parsed_destination)
        .map(|span| source_range.start + span.start..source_range.start + span.end)
}

fn destination_search_start(source: &str) -> usize {
    source
        .find("](")
        .map(|idx| idx + 2)
        .or_else(|| source.find("]:").map(|idx| idx + 2))
        .unwrap_or(0)
}

fn decoded_destination_span(
    source: &str,
    search_start: usize,
    parsed_destination: &str,
) -> Option<Range<usize>> {
    for (offset, _) in source[search_start..].char_indices() {
        let start = search_start + offset;
        if let Some(end) = decoded_match_end(&source[start..], parsed_destination) {
            return Some(start..start + end);
        }
    }
    None
}

fn decoded_match_end(raw: &str, parsed: &str) -> Option<usize> {
    let mut raw_idx = 0;

    for expected in parsed.chars() {
        let ch = raw[raw_idx..].chars().next()?;
        let (decoded, end) = if ch == '\\' {
            let escaped_idx = raw_idx + ch.len_utf8();
            let next_ch = raw[escaped_idx..].chars().next()?;
            if !next_ch.is_ascii_punctuation() {
                return None;
            }
            (next_ch, escaped_idx + next_ch.len_utf8())
        } else if ch == '&' {
            decode_markdown_entity(raw, raw_idx)?
        } else {
            (ch, raw_idx + ch.len_utf8())
        };

        if decoded != expected {
            return None;
        }
        raw_idx = end;
    }

    Some(raw_idx)
}

fn decode_markdown_entity(raw: &str, amp_idx: usize) -> Option<(char, usize)> {
    let entity_end = raw[amp_idx..].find(';')? + amp_idx + 1;
    let entity = &raw[amp_idx + 1..entity_end - 1];
    let decoded = match entity {
        "amp" | "AMP" => '&',
        "lt" | "LT" => '<',
        "gt" | "GT" => '>',
        "quot" | "QUOT" => '"',
        "apos" | "APOS" => '\'',
        "colon" | "COLON" => ':',
        "sol" | "SOL" => '/',
        _ if entity.starts_with("#x") || entity.starts_with("#X") => {
            let value = u32::from_str_radix(&entity[2..], 16).ok()?;
            char::from_u32(value)?
        }
        _ if entity.starts_with('#') => {
            let value = entity[1..].parse::<u32>().ok()?;
            char::from_u32(value)?
        }
        _ => return None,
    };
    Some((decoded, entity_end))
}

fn collect_raw_file_uri_spans(content: &str, spans: &mut Vec<Range<usize>>) {
    let mut token_start = None;

    for (idx, ch) in content.char_indices() {
        if ch.is_whitespace() {
            if let Some(start) = token_start.take() {
                collect_file_uri_token_span(content, start, idx, spans);
            }
        } else {
            token_start.get_or_insert(idx);
        }
    }

    if let Some(start) = token_start {
        collect_file_uri_token_span(content, start, content.len(), spans);
    }
}

fn collect_file_uri_token_span(
    content: &str,
    token_start: usize,
    token_end: usize,
    spans: &mut Vec<Range<usize>>,
) {
    let token = &content[token_start..token_end];
    let trimmed_start = token
        .char_indices()
        .find(|(_, ch)| !matches!(ch, '<' | '(' | '[' | '{' | '"' | '\''))
        .map_or(token.len(), |(idx, _)| idx);
    let trimmed_end = token
        .char_indices()
        .rev()
        .find(|(_, ch)| {
            !matches!(
                ch,
                '>' | ')' | ']' | '}' | '"' | '\'' | '.' | ',' | ';' | ':'
            )
        })
        .map_or(trimmed_start, |(idx, ch)| idx + ch.len_utf8());

    if trimmed_start >= trimmed_end {
        return;
    }

    let trimmed = &token[trimmed_start..trimmed_end];
    let Some(scheme_offset) = trimmed
        .as_bytes()
        .windows(b"file:".len())
        .position(|window| window.eq_ignore_ascii_case(b"file:"))
    else {
        return;
    };
    let uri_start = trimmed_start + scheme_offset;
    let candidate = &token[uri_start..trimmed_end];

    if Url::parse(candidate).is_ok_and(|url| url.scheme().eq_ignore_ascii_case("file")) {
        spans.push(token_start + uri_start..token_start + trimmed_end);
    }
}

/// Shown when the agent turn completes but no visible text remains after sanitization.
const EMPTY_CHANNEL_REPLY_FALLBACK: &str =
    "I couldn't produce a visible reply for that message. Please try again.";

/// Ensure channel outbound text is never empty so users don't see typing with no message.
fn ensure_nonempty_channel_reply(
    delivered_response: String,
    outbound_response: &str,
    channel: &str,
    reply_target: &str,
) -> String {
    if !delivered_response.trim().is_empty() {
        return delivered_response;
    }
    ::zeroclaw_log::record!(
        WARN,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
            .with_attrs(::serde_json::json!({
                "channel": channel,
                "reply_target": reply_target,
                "outbound_len": outbound_response.len(),
            })),
        "channel_reply_empty; substituting fallback"
    );
    EMPTY_CHANNEL_REPLY_FALLBACK.to_string()
}

/// 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 let Some(kind) =
        zeroclaw_tool_call_parser::classify_tool_protocol_envelope(&value.to_string())
    {
        if known_tool_names.is_empty() {
            return None;
        }

        if matches!(
            kind,
            zeroclaw_tool_call_parser::ToolProtocolEnvelopeKind::ToolResult
        ) {
            return Some((String::new(), true));
        }

        if !zeroclaw_tool_call_parser::tool_protocol_envelope_mentions_known_tool(
            &value.to_string(),
            known_tool_names,
        ) {
            return None;
        }

        let content = safe_protocol_envelope_content(value);
        return Some((content, true));
    }

    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())
        && !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 safe_protocol_envelope_content(value: &serde_json::Value) -> String {
    let content = value
        .get("content")
        .and_then(|value| value.as_str())
        .unwrap_or("")
        .trim();

    if content.is_empty()
        || zeroclaw_tool_call_parser::looks_like_tool_protocol_envelope(content)
        || zeroclaw_tool_call_parser::looks_like_malformed_tool_protocol_envelope(content)
    {
        return String::new();
    }

    content.to_string()
}

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 is_inside_markdown_code_fence(message: &str, index: usize) -> bool {
    // This intentionally uses a lightweight fence parity check. The sanitizer only
    // needs to avoid re-processing JSON in ordinary triple-backtick fences that
    // `strip_fenced_tool_protocol_artifacts` already handles; it is not a full
    // Markdown parser for inline code spans or longer fence runs.
    let mut in_fence = false;
    let mut cursor = 0usize;
    while let Some(rel_pos) = message[cursor..index].find("```") {
        in_fence = !in_fence;
        cursor += rel_pos + 3;
    }
    in_fence
}

fn isolated_malformed_tool_protocol_segment_end(
    message: &str,
    start: usize,
    known_tool_names: &HashSet<String>,
) -> Option<usize> {
    let line_start = message[..start].rfind('\n').map_or(0, |idx| idx + 1);
    if !message[line_start..start].trim().is_empty() {
        return None;
    }

    let mut end = start;
    // Malformed JSON has no serde byte offset. Scan forward from an isolated
    // JSON candidate start, but stop before ordinary prose resumes.
    for line in message[start..].split_inclusive('\n') {
        let trimmed = line.trim();
        if end > start
            && !trimmed.is_empty()
            && !trimmed.starts_with(['{', '[', ']', '}'])
            && !trimmed.starts_with('"')
        {
            break;
        }
        end += line.len();
        let candidate = &message[start..end];
        if zeroclaw_tool_call_parser::looks_like_malformed_tool_protocol_envelope_for_known_tools(
            candidate,
            known_tool_names,
        ) {
            return Some(end);
        }
    }

    None
}

fn is_tool_protocol_fence_language(language: &str) -> bool {
    let lower = language.trim().to_ascii_lowercase();
    lower == "tool_call"
        || lower == "toolcall"
        || lower == "tool-call"
        || lower == "invoke"
        || lower
            .strip_prefix("tool")
            .is_some_and(|rest| rest.starts_with(char::is_whitespace) && !rest.trim().is_empty())
}

fn strip_fenced_tool_protocol_artifacts(
    message: &str,
    known_tool_names: &HashSet<String>,
) -> String {
    if zeroclaw_tool_call_parser::looks_like_tool_protocol_example(message) {
        return message.to_string();
    }

    let mut cleaned = String::with_capacity(message.len());
    let mut cursor = 0usize;

    while let Some(rel_open) = message[cursor..].find("```") {
        let open_start = cursor + rel_open;
        let language_start = open_start + 3;
        let Some(line_end_rel) = message[language_start..].find('\n') else {
            break;
        };
        let line_end = language_start + line_end_rel;
        let language = message[language_start..line_end]
            .trim()
            .trim_end_matches('\r');
        let body_start = line_end + 1;
        let Some(close_rel) = message[body_start..].find("```") else {
            break;
        };
        let close_start = body_start + close_rel;
        let close_end = close_start + 3;

        let fence_block = &message[open_start..close_end];
        let should_strip = if language.eq_ignore_ascii_case("json") {
            should_suppress_top_level_tool_protocol_response(
                message[body_start..close_start].trim(),
                known_tool_names,
            )
        } else {
            is_tool_protocol_fence_language(language)
                && zeroclaw_tool_call_parser::contains_tool_protocol_tag_call(fence_block)
        };

        if should_strip {
            cleaned.push_str(&message[cursor..open_start]);
            cursor = close_end;
            continue;
        }

        cleaned.push_str(&message[cursor..close_end]);
        cursor = close_end;
    }

    cleaned.push_str(&message[cursor..]);
    cleaned
}

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]);
        if is_inside_markdown_code_fence(message, start) {
            let Some(ch) = message[start..].chars().next() else {
                break;
            };
            cleaned.push(ch);
            cursor = start + ch.len_utf8();
            continue;
        }

        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)
                    && 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;
                }
            }
        }

        if let Some(end) =
            isolated_malformed_tool_protocol_segment_end(message, start, known_tool_names)
        {
            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>,
    alias: Option<String>,
    tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
    initial_backoff_secs: u64,
    max_backoff_secs: u64,
    cancel: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<()> {
    spawn_supervised_listener_with_health_interval(
        ch,
        alias,
        tx,
        initial_backoff_secs,
        max_backoff_secs,
        Duration::from_secs(CHANNEL_HEALTH_HEARTBEAT_SECS),
        cancel,
    )
}

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

    let composite = match alias.as_deref() {
        Some(a) if !a.is_empty() => format!("{}.{}", ch.name(), a),
        _ => ch.name().to_string(),
    };
    let span = zeroclaw_log::attribution_span!(&*ch);
    zeroclaw_spawn::spawn!(
        async move {
            let component = format!("channel:{composite}");
            let mut backoff = initial_backoff_secs.max(1);
            let max_backoff = max_backoff_secs.max(backoff);

            loop {
                zeroclaw_runtime::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! {
                            () = cancel.cancelled() => return,
                            _ = health.tick() => {
                                zeroclaw_runtime::health::mark_component_ok(&component);
                            }
                            result = &mut listen_future => break result,
                        }
                    }
                };

                match result {
                    Ok(()) => {
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Note
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                            &format!("Channel {} exited unexpectedly; restarting", ch.name())
                        );
                        zeroclaw_runtime::health::mark_component_error(
                            &component,
                            "listener exited unexpectedly",
                        );
                        backoff = initial_backoff_secs.max(1);
                    }
                    Err(e) => {
                        if is_non_retryable_channel_listener_error(ch.name(), &e) {
                            ::zeroclaw_log::record!(
                                ERROR,
                                ::zeroclaw_log::Event::new(
                                    module_path!(),
                                    ::zeroclaw_log::Action::Reject
                                )
                                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                                "channel listener hit non-retryable error; waiting for config change or shutdown"
                            );
                            zeroclaw_runtime::health::mark_component_error(&component, e.to_string());
                            tokio::select! {
                                () = cancel.cancelled() => return,
                                () = std::future::pending::<()>() => unreachable!(),
                            }
                        }
                        ::zeroclaw_log::record!(
                            ERROR,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Fail
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                            "channel listener error; restarting"
                        );
                        zeroclaw_runtime::health::mark_component_error(&component, e.to_string());
                    }
                }

                zeroclaw_runtime::health::bump_component_restart(&component);
                tokio::select! {
                    () = cancel.cancelled() => return,
                    () = tokio::time::sleep(Duration::from_secs(backoff)) => {}
                }
                backoff = backoff.saturating_mul(2).min(max_backoff);
            }
        }
        .instrument(span)
    )
}

fn is_non_retryable_channel_listener_error(channel_name: &str, error: &anyhow::Error) -> bool {
    match channel_name {
        name if name == "discord" || name.starts_with("discord-") => {
            #[cfg(feature = "channel-discord")]
            if error
                .downcast_ref::<crate::discord::DiscordListenerFatalError>()
                .is_some()
            {
                return true;
            }
            zeroclaw_providers::reliable::is_non_retryable(error)
        }
        _ => false,
    }
}

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

fn max_in_flight_messages_for_config(
    channel_count: usize,
    config: &zeroclaw_config::schema::ChannelsConfig,
) -> usize {
    compute_max_in_flight_messages(channel_count, config.max_concurrent_per_channel)
}

fn log_worker_join_result(result: Result<(), tokio::task::JoinError>) {
    if let Err(error) = result {
        ::zeroclaw_log::record!(
            ERROR,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                .with_attrs(::serde_json::json!({"error": format!("{}", error)})),
            "Channel message worker crashed"
        );
    }
}

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);
    zeroclaw_spawn::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 {
                        ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"error": format!("{}", e)})), "failed to start typing");
                    }
                }
            }
        }

        if let Err(e) = channel.stop_typing(&recipient).await {
            ::zeroclaw_log::record!(
                DEBUG,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                "failed to stop typing"
            );
        }
    })
}

async fn process_channel_message(
    ctx: Arc<ChannelRuntimeContext>,
    msg: zeroclaw_api::channel::ChannelMessage,
    cancellation_token: CancellationToken,
) {
    if cancellation_token.is_cancelled() {
        return;
    }

    let channel_composite = match &msg.channel_alias {
        Some(alias) => format!("{}.{}", msg.channel, alias),
        None => msg.channel.clone(),
    };
    let agent_alias = Arc::clone(&ctx.agent_alias);
    let sender = msg.sender.clone();
    let message_id = msg.id.clone();
    let composite_for_body = channel_composite.clone();
    zeroclaw_log::scope!(
        category: "channel",
        agent_alias: agent_alias.as_str(),
        channel: channel_composite.as_str(),
        sender: sender.as_str(),
        message_id: message_id.as_str(),
        => async move {
            process_channel_message_body(ctx, msg, cancellation_token, composite_for_body).await;
        }
    )
    .await;
}

fn resolve_channel_ack_reactions(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
) -> bool {
    let Some(ref alias) = msg.channel_alias else {
        return ctx.ack_reactions;
    };
    match msg.channel.as_str() {
        "lark" | "feishu" => ctx
            .prompt_config
            .channels
            .lark
            .get(alias)
            .and_then(|c| c.ack_reactions)
            .unwrap_or(ctx.ack_reactions),
        "telegram" => ctx
            .prompt_config
            .channels
            .telegram
            .get(alias)
            .and_then(|c| c.ack_reactions)
            .unwrap_or(ctx.ack_reactions),
        "matrix" => ctx
            .prompt_config
            .channels
            .matrix
            .get(alias)
            .and_then(|c| c.ack_reactions)
            .unwrap_or(ctx.ack_reactions),
        _ => ctx.ack_reactions,
    }
}

async fn reconcile_early_ack(
    ctx: &ChannelRuntimeContext,
    msg: &ChannelMessage,
    target_channel: Option<&Arc<dyn Channel>>,
    early_ack_task: Option<tokio::task::JoinHandle<()>>,
    done_emoji: Option<&str>,
) {
    if !resolve_channel_ack_reactions(ctx, msg) {
        return;
    }
    let Some(channel) = target_channel else {
        return;
    };
    // Wait for the spawned 👀 add to land first; otherwise a fast early-return
    // path could remove before the add runs and strand the ack.
    if let Some(task) = early_ack_task {
        let _ = task.await;
    }
    let _ = channel
        .remove_reaction(&msg.reply_target, &msg.id, "\u{1F440}")
        .await;
    if let Some(emoji) = done_emoji {
        let _ = channel
            .add_reaction(&msg.reply_target, &msg.id, emoji)
            .await;
    }
}

fn stamp_session_routing_context(
    ctx: &ChannelRuntimeContext,
    msg: &ChannelMessage,
    history_key: &str,
) {
    let Some(ref store) = ctx.session_store else {
        return;
    };

    let channel_id = msg
        .channel_alias
        .as_deref()
        .map(|alias| format!("{}.{alias}", msg.channel));
    let room_id = msg
        .thread_ts
        .as_deref()
        .filter(|s| !s.is_empty())
        .or_else(|| {
            let target = msg.reply_target.trim();
            if target.is_empty() {
                None
            } else {
                Some(target)
            }
        });
    let context = zeroclaw_infra::session_backend::SessionContext {
        channel_id: channel_id.as_deref(),
        room_id,
        sender_id: Some(msg.sender.as_str()).filter(|s| !s.is_empty()),
    };
    if let Err(e) = store.set_session_context(history_key, context) {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({"history_key": history_key, "e": e.to_string()})),
            "Failed to stamp session routing context"
        );
    }
}

fn record_passive_context(ctx: &ChannelRuntimeContext, msg: &ChannelMessage, history_key: &str) {
    let timestamped_content =
        timestamped_channel_user_history_content(msg, WHATSAPP_OBSERVED_GROUP_MESSAGE_LABEL);
    append_sender_turn(ctx, history_key, ChatMessage::user(&timestamped_content));
    ::zeroclaw_log::record!(
        INFO,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
            ::serde_json::json!({
                "message_id": msg.id,
                "history_key": history_key,
            })
        ),
        "recorded passive channel context"
    );
}

async fn process_channel_message_body(
    ctx: Arc<ChannelRuntimeContext>,
    msg: zeroclaw_api::channel::ChannelMessage,
    cancellation_token: CancellationToken,
    channel_composite: String,
) {
    ::zeroclaw_log::record!(
        INFO,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Inbound).with_attrs(
            ::serde_json::json!({
                "sender": msg.sender,
                "message_id": msg.id,
                "reply_target": msg.reply_target,
                "thread_ts": msg.thread_ts,
                "content": msg.content,
                "attachments_count": msg.attachments.len(),
                "passive_context": msg.passive_context,
            })
        ),
        "channel inbound message"
    );

    // ── Hook: on_message_received (modifying) ────────────
    let mut msg = if let Some(hooks) = &ctx.hooks {
        match hooks.run_on_message_received(msg).await {
            zeroclaw_runtime::hooks::HookResult::Cancel(reason) => {
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"reason": reason.to_string()})),
                    "incoming message dropped by hook"
                );
                return;
            }
            zeroclaw_runtime::hooks::HookResult::Continue(modified) => modified,
        }
    } else {
        msg
    };

    let target_channel = find_channel_for_message(&ctx.channels_by_name, &msg).cloned();

    if let Some(channel) = target_channel.as_ref() {
        if channel.drop_self_messages(&msg) {
            ::zeroclaw_log::record!(
                DEBUG,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
                "dropping self-authored inbound message (self-loop guard, sdk layer)"
            );
            return;
        }
        if zeroclaw_runtime::peers::should_drop_self_loop(
            &msg.sender,
            channel.self_handle().as_deref(),
        ) {
            ::zeroclaw_log::record!(
                DEBUG,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
                "dropping self-authored inbound message (self-loop guard, agent-loop fallback)"
            );
            return;
        }
    }

    if ctx.sop_engine.is_some() || ctx.sop_audit.is_some() {
        let topic = match &msg.channel_alias {
            Some(alias) if !alias.is_empty() => format!("{}/{}", msg.channel, alias),
            _ => msg.channel.clone(),
        };
        zeroclaw_runtime::sop::dispatch::SopIngress::new(
            ctx.sop_engine.as_ref(),
            ctx.sop_audit.as_deref(),
        )
        .dispatch(
            zeroclaw_runtime::sop::types::SopTriggerSource::Channel,
            Some(&topic),
            Some(&msg.content),
            None,
            None,
        )
        .await;
    }

    let history_key = conversation_history_key(&msg);
    stamp_session_routing_context(ctx.as_ref(), &msg, &history_key);
    if msg.passive_context {
        record_passive_context(ctx.as_ref(), &msg, &history_key);
        return;
    }

    // The early ack is spawned (fire-and-forget) so it lands before the
    // enrichment/model pipeline without blocking it. The join handle is kept so
    // any early-return reconciliation can await the add before removing the 👀,
    // making the swap deterministic instead of racing the spawned add.
    let early_ack_task: Option<tokio::task::JoinHandle<()>> =
        if resolve_channel_ack_reactions(&ctx, &msg)
            && let Some(channel) = target_channel.clone()
        {
            let reply_target = msg.reply_target.clone();
            let message_id = msg.id.clone();
            let message_id_label = message_id.clone();
            let agent_alias = Arc::clone(&ctx.agent_alias);
            let sender = msg.sender.clone();
            let channel_label = channel.name().to_string();
            let span = ::zeroclaw_log::attribution_span!(&*channel);
            Some(zeroclaw_spawn::spawn!(
            ::zeroclaw_log::scope!(
                category: "channel",
                agent_alias: agent_alias.as_str(),
                channel: channel_label.as_str(),
                sender: sender.as_str(),
                message_id: message_id_label.as_str(),
                => async move {
                    if let Err(e) = channel
                        .add_reaction(&reply_target, &message_id, "\u{1F440}")
                        .await
                    {
                        ::zeroclaw_log::record!(
                            DEBUG,
                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                            "Failed to add ack reaction"
                        );
                    }
                }
            )
            .instrument(span)
        ))
        } else {
            None
        };

    let thinking_override = ctx
        .thinking_overrides
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .get(&history_key)
        .copied();
    let thinking = resolve_channel_thinking(
        &msg.content,
        thinking_override,
        &ctx.agent_cfg.resolved.thinking,
        runtime_defaults_snapshot(ctx.as_ref()).defaults.temperature,
    );
    if thinking.effective_content != msg.content {
        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_attrs(::serde_json::json!({"thinking_level": thinking.level})),
            "Thinking directive parsed from channel message"
        );
        msg.content = thinking.effective_content.clone();
    }

    // ── Media pipeline: enrich inbound message with media annotations ──
    if ctx.media_pipeline.enabled && !msg.attachments.is_empty() {
        let vision =
            ctx.model_provider.supports_vision() || ctx.multimodal.vision_model_provider.is_some();
        // Build from legacy config; if that fails (e.g. no legacy api_key
        // but typed providers are configured), fall back to an empty shell
        // so with_typed_providers() can still populate the registry.
        let transcription_manager = {
            let base = crate::transcription::TranscriptionManager::new(&ctx.transcription_config)
                .unwrap_or_else(|_| crate::transcription::TranscriptionManager::empty());
            let m = base
                .with_typed_providers(&ctx.prompt_config.providers.transcription)
                .with_agent_transcription_provider(ctx.agent_transcription_provider.clone());
            if m.available_providers().is_empty() {
                None
            } else {
                Some(m)
            }
        };
        let pipeline = media_pipeline::MediaPipeline::new(
            &ctx.media_pipeline,
            transcription_manager.as_ref(),
            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 {
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
                "Link enricher: prepended URL summaries to message"
            );
            msg.content = enriched;
        }
    }

    if let Err(err) = maybe_apply_runtime_config_update(ctx.as_ref()).await {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
            "Failed to apply runtime config update"
        );
    }
    if handle_runtime_command_if_needed(ctx.as_ref(), &msg, target_channel.as_ref()).await {
        reconcile_early_ack(
            ctx.as_ref(),
            &msg,
            target_channel.as_ref(),
            early_ack_task,
            Some("\u{2705}"),
        )
        .await;
        return;
    }

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

    if let Some(hint) =
        zeroclaw_runtime::agent::classifier::classify(&ctx.query_classification, &msg.content)
        && let Some(matched_route) = ctx
            .model_routes
            .iter()
            .find(|r| r.hint.eq_ignore_ascii_case(&hint))
    {
        ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"hint": hint.as_str(), "model_provider": matched_route.model_provider.as_str(), "model": matched_route.model.as_str()})), "Channel message classified — overriding route");
        route = ChannelRouteSelection {
            model_provider: matched_route.model_provider.clone(),
            model: matched_route.model.clone(),
            api_key: matched_route.api_key.clone(),
        };
    }

    let mut active_model_provider = match get_or_create_provider(
        ctx.as_ref(),
        &route.model_provider,
        route.api_key.as_deref(),
        &runtime_defaults,
    )
    .await
    {
        Ok(model_provider) => model_provider,
        Err(err) => {
            let safe_err = zeroclaw_providers::sanitize_api_error(&err.to_string());
            let message = channel_runtime_cli_string_with_args(
                "channel-runtime-provider-turn-init-failed",
                &[
                    ("provider", route.model_provider.as_str()),
                    ("error", safe_err.as_str()),
                ],
            );
            if let Some(channel) = target_channel.as_ref() {
                let _ = channel.send(&SendMessage::reply_to(&msg, message)).await;
            }
            reconcile_early_ack(
                ctx.as_ref(),
                &msg,
                target_channel.as_ref(),
                early_ack_task,
                Some("\u{26A0}\u{FE0F}"),
            )
            .await;
            return;
        }
    };
    let history_user_content = msg.content.clone();
    // Autosave must not persist heavy/private inline `data:` image bytes into
    // durable memory. Strip them here (path/markers are preserved) before the
    // store; the channel-history cache still keeps the re-loadable markers via
    // collapse_inline_image_payloads downstream.
    let autosave_content = strip_inline_data_image_markers(&history_user_content);
    if ctx.auto_save_memory
        && autosave_content.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
        && !zeroclaw_memory::should_skip_autosave_content(&autosave_content)
    {
        let autosave_key = conversation_memory_key(&msg);
        let _ = ctx
            .memory
            .store(
                &autosave_key,
                &autosave_content,
                zeroclaw_memory::MemoryCategory::Conversation,
                Some(&history_key),
            )
            .await;
    }

    ::zeroclaw_log::record!(
        INFO,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_attrs(::serde_json::json!({"message_id": msg.id})),
        "processing inbound message"
    );
    let started_at = Instant::now();

    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.
        // Serialize per-sender persistence to prevent interleaving
        let persist_lock = acquire_persist_lock(ctx.as_ref(), &history_key);
        let _lock = persist_lock.lock().unwrap_or_else(|e| e.into_inner());
        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())
            .peek(&history_key)
            .is_some_and(|turns| !turns.is_empty())
    };

    // Preserve the dated user turn verbatim before the LLM call so interrupted
    // requests keep the same temporal context as CLI turns. History stores the
    // full content for every marker type so a later turn can re-load it.
    let timestamped_content =
        timestamped_channel_user_history_content(&msg, WHATSAPP_CURRENT_GROUP_MESSAGE_LABEL);
    append_sender_turn(
        ctx.as_ref(),
        &history_key,
        ChatMessage::user(&timestamped_content),
    );

    // Build history from per-sender conversation cache.
    let prior_turns_raw = if force_fresh_session {
        vec![ChatMessage::user(&timestamped_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.
    for turn in &mut prior_turns {
        if turn.role == "assistant" && turn.content.starts_with("[Used tools:") {
            turn.content = strip_tool_summary_prefix(&turn.content);
        }
    }

    // Collapse only heavy inline `data:` image payloads in older cached turns.
    // Re-loadable `[IMAGE:<path>]` references survive so a later turn can
    // re-inflate from disk inline base64 is dropped to keep history
    // within the context budget
    collapse_inline_image_payloads(&mut prior_turns);

    let is_group_chat = is_group_reply_target(&msg.reply_target);
    let mut memory_sessions: Vec<Option<String>> = sender_memory_session_ids(&msg, &history_key)
        .into_iter()
        .map(Some)
        .collect();
    if is_group_chat {
        memory_sessions.push(Some(history_key.clone()));
    }

    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 per_turn_excluded_tools: &[String] =
        if msg.channel == "cli" || ctx.autonomy_level == AutonomyLevel::Full {
            &[]
        } else {
            ctx.non_cli_excluded_tools.as_ref()
        };
    let per_turn_native_tool_specs_present =
        ::zeroclaw_runtime::agent::loop_::native_tool_specs_present_for_turn(
            active_model_provider.as_ref(),
            ctx.tools_registry.as_ref(),
            per_turn_excluded_tools,
            ctx.activated_tools.as_ref(),
        )
        .unwrap_or(false);
    let mut system_prompt = build_channel_system_prompt_for_message_with_signal(
        &base_system_prompt,
        &msg,
        target_channel.as_ref(),
        per_turn_native_tool_specs_present,
    );
    if send_message_to_peer_tool_available(ctx.as_ref(), &msg)
        && let Some(current_channel_ref) = peer_prompt_channel_ref(ctx.as_ref(), &msg)
    {
        let peer_map =
            zeroclaw_runtime::tools::send_message_to_peer::render_sender_peer_map_for_channel(
                ctx.prompt_config.as_ref(),
                ctx.agent_alias.as_str(),
                &current_channel_ref,
            );
        if !peer_map.is_empty() {
            let _ = write!(system_prompt, "\n\n{peer_map}");
        }
    }
    // NOTE: memory_context is intentionally NOT appended to the system prompt
    // here — it carries per-turn data that would invalidate the provider-side
    // prompt cache The preamble below carries it into the outgoing
    // user turn instead, matching the CLI shape.
    if let Some(ref prefix) = thinking.params.system_prompt_prefix {
        system_prompt = format!("{prefix}\n\n{system_prompt}");
    }
    let mut history = vec![ChatMessage::system(system_prompt)];
    history.extend(prior_turns);

    let preamble = build_channel_turn_context_preamble(&msg, target_channel.as_ref());
    if let Some(last_turn) = history.last_mut()
        && last_turn.role == "user"
    {
        let raw_content = last_turn.content.clone();
        last_turn.content = compose_outgoing_user_turn_with_context(&preamble, &raw_content);
    }

    // ── Reply-intent precheck ────────────────────────────────────────
    let direct_message = target_channel
        .as_ref()
        .map(|c| c.is_direct_message(&msg))
        .unwrap_or(false);
    let precheck = &ctx.agent_cfg.precheck;
    let classifier_intent = ::zeroclaw_log::scope!(
        category: "channel",
        model_provider: route.model_provider.as_str(),
        model: route.model.as_str(),
        => async {
            if should_bypass_reply_intent_precheck(&msg, direct_message) {
                AssistantChannelOutcome::Reply(String::new())
            } else if !precheck.enabled {
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Skip).with_attrs(
                        ::serde_json::json!({
                            "phase": "precheck",
                            "reason": "disabled",
                        })
                    ),
                    "reply-intent precheck skipped"
                );
                AssistantChannelOutcome::Reply(String::new())
            } else {
                let (classifier_provider_arc, classifier_model_owned, classifier_temperature): (
                    Arc<dyn ModelProvider>,
                    String,
                    Option<f64>,
                ) = resolve_classifier_route(
                    ctx.as_ref(),
                    &ctx.agent_cfg.classifier_provider,
                    &runtime_defaults,
                )
                .await
                .unwrap_or_else(|| {
                    (
                        Arc::clone(&active_model_provider),
                        route.model.clone(),
                        None,
                    )
                });

                let started = Instant::now();
                let precheck_future = classify_channel_reply_intent(
                    classifier_provider_arc.as_ref(),
                    history[0].content.as_str(),
                    &history,
                    classifier_model_owned.as_str(),
                    classifier_temperature.or(runtime_defaults.defaults.temperature),
                );
                match tokio::time::timeout(Duration::from_secs(precheck.timeout_secs), precheck_future)
                    .await
                {
                    Ok(Ok(outcome)) => {
                        ::zeroclaw_log::record!(
                            INFO,
                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                                .with_duration(
                                    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
                                )
                                .with_attrs(::serde_json::json!({
                                    "classifier_model": classifier_model_owned.as_str(),
                                    "phase": "precheck",
                                })),
                            "reply-intent precheck completed"
                        );
                        outcome
                    }
                    Ok(Err(e)) => {
                        let safe_err = zeroclaw_providers::sanitize_api_error(&e.to_string());
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                                .with_duration(
                                    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
                                )
                                .with_attrs(::serde_json::json!({
                                    "classifier_model": classifier_model_owned.as_str(),
                                    "error": safe_err,
                                    "phase": "precheck",
                                })),
                            "reply-intent precheck failed open"
                        );
                        AssistantChannelOutcome::Reply(String::new())
                    }
                    Err(_) => {
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                                .with_duration(
                                    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
                                )
                                .with_attrs(::serde_json::json!({
                                    "classifier_model": classifier_model_owned.as_str(),
                                    "phase": "precheck",
                                    "timeout_secs": precheck.timeout_secs,
                                })),
                            "reply-intent precheck timed out; failing open"
                        );
                        AssistantChannelOutcome::Reply(String::new())
                    }
                }
            }
        }
    )
    .await;

    let is_acp_channel = target_channel
        .as_ref()
        .map(|c| {
            matches!(
                ::zeroclaw_api::attribution::Attributable::role(c.as_ref()),
                ::zeroclaw_api::attribution::Role::Channel(
                    ::zeroclaw_api::attribution::ChannelKind::AcpChannel
                )
            )
        })
        .unwrap_or(false);
    let reply_intent = if is_acp_channel
        && let AssistantChannelOutcome::NoReply {
            ref kind,
            ref reason,
        } = classifier_intent
    {
        ::zeroclaw_log::record!(
            DEBUG,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
                ::serde_json::json!({
                    "kind": format!("{kind:?}"),
                    "reason": reason.as_deref().unwrap_or(""),
                })
            ),
            "ACP channel: classifier voted no_reply, overriding to reply (ACP must always respond)"
        );
        AssistantChannelOutcome::Reply(String::new())
    } else {
        classifier_intent
    };

    if let AssistantChannelOutcome::NoReply { kind, reason } = reply_intent {
        let history_response = AssistantChannelOutcome::NoReply {
            kind,
            reason: reason.clone(),
        }
        .history_marker();
        append_sender_turn(
            ctx.as_ref(),
            &history_key,
            ChatMessage::assistant(&history_response),
        );
        reconcile_early_ack(
            ctx.as_ref(),
            &msg,
            target_channel.as_ref(),
            early_ack_task,
            None,
        )
        .await;
        if resolve_channel_ack_reactions(&ctx, &msg)
            && let Some(channel) = target_channel.as_ref()
        {
            let emoji = kind.emoji();
            if let Err(e) = channel
                .add_reaction(&msg.reply_target, &msg.id, emoji)
                .await
            {
                ::zeroclaw_log::record!(
                    DEBUG,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
                    &format!(
                        "Failed to add {emoji} no-reply reaction on {}: {e}",
                        channel.name()
                    )
                );
            }
        }
        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Skip)
                .with_duration(u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),)
                .with_attrs(::serde_json::json!({
                    "model_provider": route.model_provider,
                    "model": route.model,
                    "sender": msg.sender,
                    "phase": "precheck",
                    "kind": format!("{kind:?}"),
                    "reason": reason.as_deref().unwrap_or("no reason provided"),
                })),
            "channel_message_no_reply"
        );
        return;
    }

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

    ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"has_target_channel": target_channel.is_some(), "use_draft_streaming": 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::<zeroclaw_runtime::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) => {
                    ::zeroclaw_log::record!(
                        DEBUG,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                        &format!("Failed to send draft on {}", channel.name())
                    );
                    None
                }
            }
        } else {
            None
        }
    } else {
        None
    };

    // 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();
            Some(zeroclaw_spawn::spawn!(async move {
                use zeroclaw_runtime::agent::loop_::StreamDelta;
                let mut accumulated = String::new();
                while let Some(event) = rx.recv().await {
                    match event {
                        StreamDelta::Status(text) => {
                            let visible = strip_think_tags_inline(&text);
                            if let Err(e) = channel
                                .update_draft_progress(&reply_target, &draft_id, &visible)
                                .await
                            {
                                ::zeroclaw_log::record!(
                                    DEBUG,
                                    ::zeroclaw_log::Event::new(
                                        module_path!(),
                                        ::zeroclaw_log::Action::Note
                                    )
                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                                    "Draft progress update failed"
                                );
                            }
                        }
                        StreamDelta::Text(text) => {
                            accumulated.push_str(&text);
                            let visible = strip_think_tags_inline(&accumulated);
                            if let Err(e) = channel
                                .update_draft(&reply_target, &draft_id, &visible)
                                .await
                            {
                                ::zeroclaw_log::record!(
                                    DEBUG,
                                    ::zeroclaw_log::Event::new(
                                        module_path!(),
                                        ::zeroclaw_log::Action::Note
                                    )
                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                                    "Draft update failed"
                                );
                            }
                        }
                    }
                }
            }))
        } else {
            None
        }
    } else {
        None
    };

    // 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
    // Bounded so a slow downstream channel cannot grow this queue
    // without bound. See `ChannelNotifyObserver::record_event` for the
    // drop-on-full contract.
    let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel::<String>(128);
    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);
    // Tool-call notifications go out as SEPARATE messages below, which is right
    // for chat channels (Discord/Telegram threads) but wrong for partial-draft
    // channels like the git forge, where every message is a PERMANENT comment on
    // a third-party issue/PR: each tool call became its own comment (issue spam),
    // duplicating the progress the draft stream already folds into the single
    // edited comment. Partial-draft channels drain-and-drop here; their draft
    // stream remains the (single-message) tool-activity surface.
    let notify_task = if msg.channel == "cli" || !ctx.show_tool_calls || is_partial_draft {
        Some(zeroclaw_spawn::spawn!(async move {
            while notify_rx.recv().await.is_some() {}
        }))
    } else {
        Some(zeroclaw_spawn::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 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| {
        zeroclaw_runtime::agent::loop_::ToolLoopCostTrackingContext::new(
            state.tracker,
            state.model_provider_pricing,
        )
        .with_agent_alias(state.agent_alias.as_str())
    });
    let llm_call_start = Instant::now();
    #[allow(clippy::cast_possible_truncation)]
    let elapsed_before_llm_ms = started_at.elapsed().as_millis() as u64;
    ::zeroclaw_log::record!(
        INFO,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_attrs(::serde_json::json!({"elapsed_before_llm_ms": elapsed_before_llm_ms})),
        "starting LLM call"
    );
    // Fresh per-turn routing handle, scoped into TURN_ROUTING for the duration of
    // the tool-call loop below. Allocating per turn (rather than clearing a shared
    // handle) keeps concurrent same-agent turns from reading each other's routes.
    let turn_routing: tools::TurnRoutingHandle =
        std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));

    let tool_receipts_collector: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
        std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
    let receipt_scope = ctx.receipt_generator.as_ref().map(|generator| {
        zeroclaw_runtime::agent::tool_receipts::ReceiptScope {
            generator: generator.clone(),
            collector: std::sync::Arc::clone(&tool_receipts_collector),
        }
    });
    let loop_knobs = LoopKnobs::default();
    let turn_id = uuid::Uuid::new_v4().to_string();
    // Bracket the channel turn so lifecycle events
    // reach observers (and, via the broadcast hook, /api/events and
    // /api/events/history) for channel-originated turns — mirroring the CLI
    // `run` and `Agent::turn_streamed` entry points. The drop-safe guard opens
    // exactly once before the model-switch retry loop and closes on every exit.
    // A successful switch updates the closing attribution without creating a
    // second lifecycle start for the same logical turn.
    let turn_observer = Arc::clone(&ctx.observer);
    let mut turn_guard = zeroclaw_runtime::observability::AgentTurnGuard::start(
        turn_observer.as_ref(),
        route.model_provider.clone(),
        route.model.clone(),
        Some(msg.channel.to_string()),
        Some(ctx.agent_alias.to_string()),
        Some(turn_id.clone()),
    );
    let (llm_result, fallback_info) = scope_provider_fallback(async {
        let llm_result = loop {
            let thread_scope_id = msg
                .interruption_scope_id
                .clone()
                .or_else(|| msg.thread_ts.clone())
                .or_else(|| Some(msg.id.clone()));
            let excluded_tools: &[String] =
                if msg.channel == "cli" || ctx.autonomy_level == AutonomyLevel::Full {
                    &[]
                } else {
                    ctx.non_cli_excluded_tools.as_ref()
                };
            let tool_loop = Box::pin(run_tool_call_loop(ToolLoop {
                exec: ResolvedAgentExecution::resolve(
                    ResolvedModelAccess {
                        model_provider: active_model_provider.as_ref(),
                        provider_name: route.model_provider.as_str(),
                        model: route.model.as_str(),
                        temperature: thinking.effective_temperature,
                    },
                    ResolvedIo {
                        tools_registry: ctx.tools_registry.as_ref(),
                        observer: notify_observer.as_ref() as &dyn Observer,
                        silent: true,
                        approval: Some(&*ctx.approval_manager),
                        multimodal_config: &ctx.multimodal,
                        // Full config for the vision route to resolve the
                        // configured `vision_model_provider`'s alias options - the
                        // same canonical `prompt_config` snapshot this path already
                        // uses for provider construction.
                        config: Some(ctx.prompt_config.as_ref()),
                        hooks: ctx.hooks.as_deref(),
                        activated_tools: ctx.activated_tools.as_ref(),
                        model_switch_callback: None,
                        receipt_generator: ctx.receipt_generator.as_ref(),
                    },
                    ResolvedRuntimeKnobs {
                        max_tool_iterations: ctx.max_tool_iterations,
                        excluded_tools,
                        dedup_exempt_tools: ctx.tool_call_dedup_exempt.as_ref(),
                        pacing: &ctx.pacing,
                        strict_tool_parsing: ctx.agent_cfg.resolved.strict_tool_parsing,
                        parallel_tools: ctx.agent_cfg.resolved.parallel_tools,
                        max_tool_result_chars: ctx.max_tool_result_chars,
                        context_token_budget: ctx.context_token_budget,
                        knobs: &loop_knobs,
                    },
                ),
                history: &mut history,
                channel_name: msg.channel.as_str(),
                channel_reply_target: Some(msg.reply_target.as_str()),
                cancellation_token: Some(cancellation_token.clone()),
                on_delta: delta_tx.clone(),
                shared_budget: None,
                channel: target_channel.as_deref(),
                // Collector is meaningful only when the generator is active.
                // Pass None when receipts are disabled so the call site
                // reflects that coupling explicitly.
                collected_receipts: ctx
                    .receipt_generator
                    .as_ref()
                    .map(|_| tool_receipts_collector.as_ref()),
                event_tx: None,
                steering: None,
                new_messages_out: None,
                image_cache: None,
                // Channel-orchestrator dispatch; source/transport/trust stay
                // placeholders, not yet stamped at the edge.
                memory: Some(zeroclaw_runtime::agent::memory_inject::TurnMemory {
                    handle: ctx.memory.as_ref(),
                    query: msg.content.clone(),
                    sessions: memory_sessions.clone(),
                    suppress: false,
                    // The relevance floor stays the context's resolved copy;
                    // the rerank stage settings thread from the live config.
                    cfg: zeroclaw_runtime::agent::memory_inject::MemoryInjectConfig {
                        min_relevance_score: ctx.min_relevance_score,
                        ..zeroclaw_runtime::agent::memory_inject::MemoryInjectConfig::from_memory_config(
                            &ctx.prompt_config.memory,
                            zeroclaw_runtime::agent::memory_inject::DEFAULT_RECALL_LIMIT,
                        )
                    },
                }),
                ingress: zeroclaw_api::ingress::IngressContext::channel(),
                agent_alias: Some(ctx.agent_alias.as_str()),
                parent_agent_alias: None,
                turn_id: &turn_id,
                // Live channel-daemon SOP path: re-assemble a nested step's
                // agent when it delegates to a different agent, so the step runs
                // with that agent's own gated tools/policy/MCP scope rather than
                // this turn's.
                sop_reassembly: Some(zeroclaw_runtime::agent::loop_::SopStepReassembly {
                    config: ctx.prompt_config.as_ref(),
                }),
            }));
            // Scope this turn's routing handle so concurrent same-agent turns,
            // which share one SendViaTool, never read each other's routes.
            let tool_loop =
                tools::TURN_ROUTING.scope(Some(std::sync::Arc::clone(&turn_routing)), tool_loop);
            let tool_loop = zeroclaw_api::NATIVE_THINKING_OVERRIDE
                .scope(thinking.params.native_thinking, tool_loop);
            let tool_loop = zeroclaw_runtime::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT
                .scope(receipt_scope.clone(), tool_loop);
            let tool_loop = zeroclaw_runtime::agent::loop_::TOOL_LOOP_COST_TRACKING_CONTEXT
                .scope(cost_tracking_context.clone(), tool_loop);
            let tool_loop = scope_session_key(Some(history_key.clone()), tool_loop);
            let tool_loop = scope_thread_id(thread_scope_id, tool_loop);
            let timed_tool_loop =
                tokio::time::timeout(Duration::from_secs(timeout_budget_secs), tool_loop);

            let loop_result = tokio::select! {
                () = cancellation_token.cancelled() => LlmExecutionResult::Cancelled,
                result = timed_tool_loop => LlmExecutionResult::Completed(result),
            };

            if let LlmExecutionResult::Completed(Ok(Err(ref e))) = loop_result
                && let Some((new_model_provider, new_model)) = is_model_switch_requested(e)
            {
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
                    &format!(
                        "Model switch requested, switching from {} {} to {} {}",
                        route.model_provider, route.model, new_model_provider, new_model
                    )
                );

                let resolved_model_provider = match resolve_provider_ref_for_runtime_switch(
                    runtime_defaults.config.as_ref(),
                    &new_model_provider,
                ) {
                    Ok(provider_ref) => provider_ref,
                    Err(err) => {
                        ::zeroclaw_log::record!(
                            ERROR,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Fail
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({"err": err.to_string()})),
                            "Failed to resolve model_provider after model switch"
                        );
                        break loop_result;
                    }
                };

                let resolved_api_key = ctx
                    .model_routes
                    .iter()
                    .find(|r| {
                        r.model_provider.eq_ignore_ascii_case(&new_model_provider)
                            && (r.model.eq_ignore_ascii_case(&new_model)
                                || r.hint.eq_ignore_ascii_case(&new_model))
                    })
                    .and_then(|r| r.api_key.clone());

                match get_or_create_provider(
                    ctx.as_ref(),
                    &resolved_model_provider,
                    resolved_api_key.as_deref(),
                    &runtime_defaults,
                )
                .await
                {
                    Ok(new_prov) => {
                        // Commit state only after the provider was built
                        // successfully, so a failure leaves the turn on the
                        // original provider/model pair instead of a
                        // half-switched state.
                        active_model_provider = new_prov;
                        route.model_provider = resolved_model_provider;
                        route.model = new_model;
                        route.api_key = resolved_api_key;
                        // Persist the route override so subsequent messages
                        // from this sender continue using the switched model.
                        set_route_selection(
                            ctx.as_ref(),
                            &history_key,
                            ChannelRouteSelection {
                                model_provider: route.model_provider.clone(),
                                model: route.model.clone(),
                                api_key: route.api_key.clone(),
                            },
                            &runtime_defaults,
                        );

                        continue;
                    }
                    Err(err) => {
                        ::zeroclaw_log::record!(
                            ERROR,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Fail
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({"err": err.to_string()})),
                            "Failed to create model_provider after model switch"
                        );
                        // Fall through with the original error
                    }
                }
            }

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

    // Attribute the closing event to the final route and attach aggregate
    // usage. Explicit completion records the normal duration; the guard's
    // `Drop` path supplies the same matched end on panic or early unwind.
    let turn_tokens_used = cost_tracking_context.as_ref().and_then(|ctx| {
        let usage = ctx.snapshot_turn_usage();
        (usage.input_tokens > 0 || usage.output_tokens > 0).then_some(
            zeroclaw_api::observability_traits::TurnTokenUsage {
                input_tokens: usage.input_tokens,
                output_tokens: usage.output_tokens,
            },
        )
    });
    turn_guard.set_model_route(route.model_provider.clone(), route.model.clone());
    turn_guard.set_usage(turn_tokens_used, None);
    turn_guard.finish();

    // Drop all senders so updater tasks can exit (rx.recv() returns None).
    ::zeroclaw_log::record!(
        DEBUG,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
        "Post-loop: dropping delta_tx and awaiting draft updater"
    );
    drop(delta_tx);
    if let Some(handle) = draft_updater {
        let _ = handle.await;
    }
    ::zeroclaw_log::record!(
        DEBUG,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
        "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;
    ::zeroclaw_log::record!(
        INFO,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_attrs(::serde_json::json!({"llm_call_ms": llm_call_ms, "total_ms": total_ms})),
        "LLM call completed"
    );

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

    let reaction_done_emoji = match &llm_result {
        LlmExecutionResult::Completed(Ok(Ok(_))) => "\u{2705}", // ✅
        _ => "\u{26A0}\u{FE0F}",                                // ⚠️
    };

    match llm_result {
        LlmExecutionResult::Cancelled => {
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
                "Cancelled in-flight channel request due to newer message"
            );
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Cancel)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_duration(
                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
                    )
                    .with_attrs(::serde_json::json!({
                        "model_provider": route.model_provider,
                        "model": route.model,
                        "sender": msg.sender,
                        "reason": "cancelled due to newer inbound message",
                    })),
                "channel_message_cancelled"
            );
            if let (Some(channel), Some(draft_id)) =
                (target_channel.as_ref(), draft_message_id.as_deref())
                && let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await
            {
                ::zeroclaw_log::record!(
                    DEBUG,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
                    &format!("Failed to cancel draft on {}", 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
                {
                    zeroclaw_runtime::hooks::HookResult::Cancel(reason) => {
                        ::zeroclaw_log::record!(
                            INFO,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Note
                            )
                            .with_attrs(::serde_json::json!({"reason": reason.to_string()})),
                            "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;
                    }
                    zeroclaw_runtime::hooks::HookResult::Continue((
                        hook_channel,
                        hook_recipient,
                        mut modified_content,
                    )) => {
                        if hook_channel != msg.channel || hook_recipient != msg.reply_target {
                            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"from_channel": channel_composite, "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 {
                            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"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 {
                            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"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_for_format_with_leak_detection(
                &outbound_response,
                ctx.tools_registry.as_ref(),
                &ctx.prompt_config.security.leak_detection,
                outbound_content_format_for_channel(&msg.channel),
            );
            let mut delivered_response =
                if sanitized_response.is_empty() && !outbound_response.trim().is_empty() {
                    channel_runtime_cli_string("channel-runtime-malformed-tool-output")
                } else {
                    sanitized_response
                };
            delivered_response = ensure_nonempty_channel_reply(
                delivered_response,
                &outbound_response,
                &msg.channel,
                &msg.reply_target,
            );

            // Append a footer when the response was served by a different model_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 {
                    delivered_response.push_str("\n\n---\n");
                    delivered_response.push_str(&channel_runtime_cli_string_with_args(
                        "channel-runtime-fallback-footer",
                        &[
                            ("requested", fb.requested_provider.as_str()),
                            ("actual", fb.actual_provider.as_str()),
                            ("model", fb.actual_model.as_str()),
                        ],
                    ));
                }
            }

            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Outbound)
                    .with_outcome(::zeroclaw_log::EventOutcome::Success)
                    .with_duration(
                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
                    )
                    .with_attrs(::serde_json::json!({
                        "model_provider": route.model_provider,
                        "model": route.model,
                        "sender": msg.sender,
                        "response": scrub_credentials(&delivered_response),
                    })),
                "channel_message_outbound"
            );

            // 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.
            let keep_tool_turns = ctx.agent_cfg.resolved.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),
            );

            // Fire-and-forget LLM-driven memory consolidation. Passes the
            // agent's resolved temperature through unchanged — `None`
            // means the provider sends no `temperature` field (necessary
            // for models that reject it, e.g. claude-opus-4-7).
            if ctx.auto_save_memory && msg.content.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS {
                let memory_strategy = Arc::clone(&ctx.memory_strategy);
                let model_provider = Arc::clone(&ctx.model_provider);
                let model = ctx.model.to_string();
                let temperature = ctx.temperature;
                let user_msg = msg.content.clone();
                let assistant_resp = delivered_response.clone();
                zeroclaw_spawn::spawn!(async move {
                    if let Err(e) = memory_strategy
                        .consolidate_turn(
                            &user_msg,
                            &assistant_resp,
                            model_provider.as_ref(),
                            &model,
                            temperature,
                        )
                        .await
                    {
                        ::zeroclaw_log::record!(
                            DEBUG,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Note
                            )
                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                            "Memory consolidation skipped"
                        );
                    }
                });
            }

            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Outbound)
                    .with_outcome(::zeroclaw_log::EventOutcome::Success)
                    .with_duration(
                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
                    )
                    .with_attrs(::serde_json::json!({
                        "sender": msg.sender,
                        "message_id": msg.id,
                        "reply_target": msg.reply_target,
                        "thread_ts": msg.thread_ts,
                        "content": delivered_response,
                    })),
                "reply delivered"
            );
            let receipts_block = if ctx.show_receipts_in_response {
                let receipts = tool_receipts_collector
                    .lock()
                    .unwrap_or_else(|e| e.into_inner());
                zeroclaw_runtime::agent::tool_receipts::render_receipts_block(&receipts)
            } else {
                None
            };

            // Read the last routing instruction set by `send_via` this turn from
            // the per-turn handle scoped into TURN_ROUTING around the loop above.
            let turn_route = turn_routing
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .last()
                .cloned();

            // Resolve the delivery channel and modality from the routing entry.
            // `None` entry → default delivery (originating channel, no modality override).
            let (
                delivery_channel,
                delivery_recipient,
                suppress_voice_override,
                force_voice_override,
            ) = if let Some(ref route) = turn_route {
                let ch: Option<Arc<dyn Channel>> = match route.channel.as_deref() {
                    None | Some("") => target_channel.clone(),
                    Some(key) => ctx.channels_by_name.get(key).map(Arc::clone),
                };
                let recipient = route
                    .recipient
                    .clone()
                    .unwrap_or_else(|| msg.reply_target.clone());
                let suppress = match route.modality {
                    zeroclaw_config::multi_agent::OutputModality::Text => Some(true),
                    zeroclaw_config::multi_agent::OutputModality::Voice => Some(false),
                    zeroclaw_config::multi_agent::OutputModality::Mirror => None,
                };
                let force_voice = matches!(
                    route.modality,
                    zeroclaw_config::multi_agent::OutputModality::Voice
                );
                (ch, recipient, suppress, force_voice)
            } else {
                (
                    target_channel.clone(),
                    msg.reply_target.clone(),
                    None,
                    false,
                )
            };

            if let Some(channel) = delivery_channel.as_ref() {
                let is_redirect = turn_route
                    .as_ref()
                    .and_then(|r| r.channel.as_deref())
                    .is_some();
                // Whether the agent's reply reached a channel — gates the
                // `fire_message_sent` observer hook below.
                let reply_delivered = if is_redirect {
                    // Routing redirects to a different channel: cancel any in-progress
                    // draft on the originating channel before delivering elsewhere.
                    if let (Some(orig_ch), Some(draft_id)) =
                        (target_channel.as_ref(), draft_message_id.as_deref())
                    {
                        let _ = orig_ch.cancel_draft(&msg.reply_target, draft_id).await;
                    }
                    let suppress = suppress_voice_override.unwrap_or(false);
                    let mut send_msg = SendMessage::new(&delivered_response, &delivery_recipient)
                        .in_thread(msg.thread_ts.clone());
                    if suppress {
                        send_msg = send_msg.suppress_voice();
                    } else if force_voice_override {
                        send_msg = send_msg.force_voice();
                    }
                    channel.send(&send_msg).await.is_ok()
                } else if let Some(ref draft_id) = draft_message_id {
                    // Same channel with draft. For force-voice routing: cancel the
                    // draft placeholder and deliver via send() so force_voice
                    // reaches the channel's voice path (finalize_draft has no
                    // force_voice concept).
                    if force_voice_override {
                        let _ = channel.cancel_draft(&delivery_recipient, draft_id).await;
                        channel
                            .send(
                                &SendMessage::new(&delivered_response, &delivery_recipient)
                                    .force_voice()
                                    .in_thread(msg.thread_ts.clone()),
                            )
                            .await
                            .is_ok()
                    } else {
                        let suppress = suppress_voice_override.unwrap_or(false);
                        match channel
                            .finalize_draft(
                                &delivery_recipient,
                                draft_id,
                                &delivered_response,
                                suppress,
                            )
                            .await
                        {
                            Ok(()) => true,
                            Err(e) => {
                                ::zeroclaw_log::record!(
                                    WARN,
                                    ::zeroclaw_log::Event::new(
                                        module_path!(),
                                        ::zeroclaw_log::Action::Note
                                    )
                                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                                    "Failed to finalize draft; sending as new message"
                                );
                                let mut fallback = SendMessage::reply_to(&msg, &delivered_response);
                                if suppress {
                                    fallback = fallback.suppress_voice();
                                }
                                channel.send(&fallback).await.is_ok()
                            }
                        }
                    }
                } else {
                    // No draft — plain send.
                    let suppress = suppress_voice_override.unwrap_or(false);
                    let mut send_msg = SendMessage::reply_to(&msg, &delivered_response)
                        .with_cancellation(cancellation_token.clone());
                    if suppress {
                        send_msg = send_msg.suppress_voice();
                    } else if force_voice_override {
                        send_msg = send_msg.force_voice();
                    }
                    match channel.send(&send_msg).await {
                        Ok(()) => true,
                        Err(e) => {
                            ::zeroclaw_log::record!(
                                ERROR,
                                ::zeroclaw_log::Event::new(
                                    module_path!(),
                                    ::zeroclaw_log::Action::Fail
                                )
                                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                                "failed to reply"
                            );
                            false
                        }
                    }
                };
                if reply_delivered && let Some(hooks) = ctx.hooks.as_ref() {
                    hooks
                        .fire_message_sent(&msg.channel, &msg.reply_target, &delivered_response)
                        .await;
                }
                // Send tool receipts as a separate message in the same thread.
                // The block is the operator-facing audit surface for the feature,
                // so a dropped send must leave a log signal rather than silently
                // disappear.
                if let Some(ref block) = receipts_block
                    && let Err(e) = channel
                        .send(
                            &SendMessage::new(block, &delivery_recipient)
                                .in_thread(msg.thread_ts.clone()),
                        )
                        .await
                {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                        "failed to send tool receipts block"
                    );
                }
            }
        }
        LlmExecutionResult::Completed(Ok(Err(e))) => {
            if zeroclaw_runtime::agent::loop_::is_tool_loop_cancelled(&e)
                || cancellation_token.is_cancelled()
            {
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"sender": msg.sender})),
                    "Cancelled in-flight channel request due to newer message"
                );
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Cancel)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_duration(
                            u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
                        )
                        .with_attrs(::serde_json::json!({
                            "model_provider": route.model_provider,
                            "model": route.model,
                            "sender": msg.sender,
                            "reason": "cancelled during tool-call loop",
                        })),
                    "channel_message_cancelled"
                );
                if let (Some(channel), Some(draft_id)) =
                    (target_channel.as_ref(), draft_message_id.as_deref())
                    && let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await
                {
                    ::zeroclaw_log::record!(
                        DEBUG,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
                        &format!("Failed to cancel draft on {}", 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
                );
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_duration(
                            u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
                        )
                        .with_attrs(::serde_json::json!({
                            "model_provider": route.model_provider,
                            "model": route.model,
                            "sender": msg.sender,
                            "reason": "context window exceeded",
                            "history_compacted": compacted,
                        })),
                    "channel_message_error"
                );
                if let Some(channel) = target_channel.as_ref() {
                    if let Some(draft_id) = draft_message_id.as_deref() {
                        let _ = channel.cancel_draft(&msg.reply_target, draft_id).await;
                    }
                    let _ = channel
                        .send(
                            &SendMessage::new(error_text, &msg.reply_target)
                                .suppress_voice()
                                .in_thread(msg.thread_ts.clone()),
                        )
                        .await;
                }
            } else {
                eprintln!(
                    "  ❌ LLM error after {}ms: {e}",
                    started_at.elapsed().as_millis()
                );

                // Evict cached model_provider on auth errors so the next request
                // re-creates it with fresh OAuth credentials.
                if zeroclaw_providers::reliable::is_auth_error(&e) {
                    let cache_key = provider_cache_key(
                        &route.model_provider,
                        route.api_key.as_deref(),
                        runtime_defaults.generation,
                    );
                    let mut cache = ctx.provider_cache.lock().unwrap_or_else(|p| p.into_inner());
                    if cache.remove(&cache_key).is_some() {
                        ::zeroclaw_log::record!(
                            INFO,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Note
                            )
                            .with_attrs(
                                ::serde_json::json!({"model_provider": route.model_provider})
                            ),
                            "Evicted cached model_provider after auth error; next request will re-create with fresh credentials"
                        );
                    }
                }
                let safe_error = zeroclaw_providers::sanitize_api_error(&e.to_string());
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_duration(
                            u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
                        )
                        .with_attrs(::serde_json::json!({
                            "model_provider": route.model_provider,
                            "model": route.model,
                            "sender": msg.sender,
                            "error": safe_error,
                        })),
                    "channel_message_error"
                );
                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, &timestamped_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() {
                    let user_msg = zeroclaw_providers::reliable::transient_error_hint(&e)
                        .map(str::to_string)
                        .unwrap_or_else(|| format!("⚠️ Error: {safe_error}"));
                    // Cancel any in-progress draft (don't finalize it with the
                    // error text, which would trigger TTS on the error message)
                    // then deliver the error as a plain suppressed send.
                    if let Some(ref draft_id) = draft_message_id {
                        let _ = channel.cancel_draft(&msg.reply_target, draft_id).await;
                    }
                    let _ = channel
                        .send(
                            &SendMessage::new(user_msg, &msg.reply_target)
                                .suppress_voice()
                                .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
            );
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Timeout)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_duration(
                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
                    )
                    .with_attrs(::serde_json::json!({
                        "model_provider": route.model_provider,
                        "model": route.model,
                        "sender": msg.sender,
                        "reason": timeout_msg,
                    })),
                "channel_message_timeout"
            );
            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() {
                // Localized error text (master) delivered with suppress_voice
                // (RFCerror-path fix): cancel the draft, then send as
                // text so a timeout notice is never read aloud on a voice peer.
                let error_text = zeroclaw_runtime::i18n::get_required_cli_string(
                    "channel-runtime-request-timeout",
                );
                if let Some(draft_id) = draft_message_id.as_deref() {
                    let _ = channel.cancel_draft(&msg.reply_target, draft_id).await;
                }
                let _ = channel
                    .send(
                        &SendMessage::new(error_text, &msg.reply_target)
                            .suppress_voice()
                            .in_thread(msg.thread_ts.clone()),
                    )
                    .await;
            }
        }
    }

    // Swap 👀 → ✅ (or ⚠️ on error) to signal processing is complete. Await the
    // spawned ack add first so the remove can never race ahead of it.
    if resolve_channel_ack_reactions(&ctx, &msg)
        && let Some(channel) = target_channel.as_ref()
    {
        if let Some(task) = early_ack_task {
            let _ = task.await;
        }
        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: zeroclaw_api::channel::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);

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

    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 && let Some(previous) = previous {
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"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();
}

#[derive(Clone)]
struct AgentRouter {
    by_agent: Arc<HashMap<String, Arc<ChannelRuntimeContext>>>,
    owner_by_channel_key: Arc<HashMap<String, String>>,
    single_ctx: Option<Arc<ChannelRuntimeContext>>,
    sop_engine: Option<Arc<std::sync::Mutex<zeroclaw_runtime::sop::SopEngine>>>,
    sop_audit: Option<Arc<zeroclaw_runtime::sop::SopAuditLogger>>,
}

impl AgentRouter {
    #[cfg(test)]
    fn single(ctx: Arc<ChannelRuntimeContext>) -> Self {
        Self {
            by_agent: Arc::new(HashMap::new()),
            owner_by_channel_key: Arc::new(HashMap::new()),
            single_ctx: Some(ctx),
            sop_engine: None,
            sop_audit: None,
        }
    }

    fn multi(
        by_agent: HashMap<String, Arc<ChannelRuntimeContext>>,
        owner_by_channel_key: HashMap<String, String>,
        sop_engine: Option<Arc<std::sync::Mutex<zeroclaw_runtime::sop::SopEngine>>>,
        sop_audit: Option<Arc<zeroclaw_runtime::sop::SopAuditLogger>>,
    ) -> Self {
        Self {
            by_agent: Arc::new(by_agent),
            owner_by_channel_key: Arc::new(owner_by_channel_key),
            single_ctx: None,
            sop_engine,
            sop_audit,
        }
    }

    fn resolve(
        &self,
        msg: &zeroclaw_api::channel::ChannelMessage,
    ) -> Option<Arc<ChannelRuntimeContext>> {
        if let Some(ctx) = &self.single_ctx {
            return Some(Arc::clone(ctx));
        }
        if let Some(alias) = msg.channel_alias.as_deref().filter(|s| !s.is_empty()) {
            let composite = format!("{}.{alias}", msg.channel);
            // An explicit alias identifies a distinct configured channel. It
            // must not fall back to another alias's bare platform owner.
            return self
                .owner_by_channel_key
                .get(&composite)
                .and_then(|agent| self.by_agent.get(agent))
                .cloned();
        }
        if let Some(agent) = self.owner_by_channel_key.get(&msg.channel)
            && let Some(ctx) = self.by_agent.get(agent)
        {
            return Some(Arc::clone(ctx));
        }
        None
    }
}

/// Split an inbound gate reference into its run part and revision. A reference
/// may be revision-qualified (`<run_id>#<rev>`); a bare reference means
/// revision 0 (the ORIGINAL presentation) — NOT "whatever is current" — so a
/// click on a superseded prompt can never resolve a newer draft it wasn't
/// looking at. A malformed suffix leaves the whole string as the run part.
fn parse_gate_reference(reference: &str) -> (String, u32) {
    match reference.rsplit_once('#') {
        Some((run_part, rev_part)) if !run_part.is_empty() => match rev_part.parse::<u32>() {
            Ok(rev) => (run_part.to_string(), rev),
            Err(_) => (reference.to_string(), 0),
        },
        _ => (reference.to_string(), 0),
    }
}

fn channel_key_for_message(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
    match msg.channel_alias.as_deref() {
        Some(alias) => format!("{}.{alias}", msg.channel),
        None => msg.channel.clone(),
    }
}

fn unique_channel_handles(
    channels_by_name: &HashMap<String, Arc<dyn Channel>>,
) -> Vec<Arc<dyn Channel>> {
    let mut unique = Vec::new();
    for channel in channels_by_name.values() {
        if !unique.iter().any(|existing| Arc::ptr_eq(existing, channel)) {
            unique.push(Arc::clone(channel));
        }
    }
    unique
}

async fn finalize_gate_prompts(channels: &[Arc<dyn Channel>], reference: &str, outcome: &str) {
    for channel in channels {
        if let Err(e) = channel.finalize_gate_prompt(reference, outcome).await {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({
                        "reference": reference,
                        "channel": channel.name(),
                        "error": e.to_string(),
                    })),
                "gate-prompt finalize failed (decision unaffected)"
            );
        }
    }
}

fn text_gate_reply_matches_approval_route(
    engine: &zeroclaw_runtime::sop::SopEngine,
    run_id: &str,
    channel_route_keys: &[String],
    reply_target: &str,
) -> bool {
    let Some(policy_name) = engine.current_step_policy_name(run_id) else {
        return false;
    };
    let broker = engine.approval_broker();
    broker
        .reply_routes(engine.approval_config(), &policy_name)
        .iter()
        .any(|route| {
            let Some((route_channel_key, route_recipient)) =
                zeroclaw_runtime::sop::approval::channel_route::parse_approval_route(route)
            else {
                return false;
            };
            channel_route_keys
                .iter()
                .any(|channel_key| channel_key == route_channel_key)
                && route_recipient == reply_target
        })
}

/// Resolve a SOP gate answered from a chat channel. Two answer forms converge
/// here, per the channel-agnostic gate-prompt seam:
///
/// - a component click: the channel's OWN interaction producer stamps the
///   internal `sop.gate:<choice>:<reference>` marker (unforgeable from message
///   text, same guarantee as the git producer's SOP-event marker);
/// - a plain `<choice> <reference>` text reply (the fallback prompt tells the
///   operator to send exactly this) — consumed ONLY when the reference matches a
///   run actually parked on a human AND the run's current policy can deliver its
///   approval prompt to this same channel route. Ordinary conversation and
///   unauthorised channel traffic never get swallowed.
///
/// Returns `true` when the message was consumed as a gate answer.
async fn dispatch_channel_sop_gate(
    router: &AgentRouter,
    msg: &zeroclaw_api::channel::ChannelMessage,
    config: &zeroclaw_config::schema::Config,
    gate_prompt_channels: &[Arc<dyn Channel>],
    gate_channel_route_keys: &[String],
) -> bool {
    const MARKER_PREFIX: &str = "sop.gate:";
    #[derive(Clone, Copy, PartialEq, Eq)]
    enum Form {
        Marker,
        Text,
    }
    let (form, choice, reference) = if let Some(rest) = msg
        .internal_sop_event
        .as_deref()
        .and_then(|s| s.strip_prefix(MARKER_PREFIX))
    {
        match rest.split_once(':') {
            // Any known gate-choice token is a valid marker; unknown tokens are
            // dropped, never coerced (the enum is the single vocabulary).
            Some((c, r))
                if !r.is_empty() && zeroclaw_api::channel::GateChoiceKind::from_id(c).is_some() =>
            {
                (Form::Marker, c.to_ascii_lowercase(), r.to_string())
            }
            _ => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"marker": rest})),
                    "dropping malformed or unknown channel SOP-gate marker"
                );
                return true;
            }
        }
    } else if msg.internal_sop_event.is_none() {
        // Text form: exactly two tokens, and the first must be a text-free
        // choice. Edit/Revise stay marker-only (they carry a text payload a
        // two-token reply cannot); approve/deny remain universally answerable.
        let mut words = msg.content.split_whitespace();
        match (words.next(), words.next(), words.next()) {
            (Some(c), Some(r), None)
                if zeroclaw_api::channel::GateChoiceKind::from_id(c)
                    .is_some_and(|k| !k.collects_text()) =>
            {
                (Form::Text, c.to_ascii_lowercase(), r.to_string())
            }
            _ => return false,
        }
    } else {
        return false;
    };

    let Some(engine) = router.sop_engine.as_ref() else {
        // A marker message exists only to answer a gate — consume it either way.
        return matches!(form, Form::Marker);
    };

    let (ref_run, ref_rev) = parse_gate_reference(&reference);
    let channel_key = channel_key_for_message(msg);
    let mut channel_route_keys = gate_channel_route_keys.to_vec();
    if !channel_route_keys
        .iter()
        .any(|route_key| route_key == &channel_key)
    {
        channel_route_keys.push(channel_key.clone());
    }

    // Resolve against runs actually parked on a human. Both marker and plain text
    // replies must carry the full run id minted in the prompt. For the TEXT form
    // a non-match means "not a gate answer" — fall through to the agent; a marker
    // non-match is consumed (stale buttons after the run ended). A matched run
    // whose CURRENT revision differs from the reference's is superseded only
    // after that replacement park is durable. While persistence retries, the
    // prior prompt stays visible and is not finalized as stale. Text replies
    // must first prove they came through a policy route that can present fallback
    // instructions.
    let resolved = {
        let Ok(guard) = engine.lock() else {
            return matches!(form, Form::Marker);
        };
        let mut candidates = guard.active_runs().values().filter(|r| {
            matches!(
                r.status,
                zeroclaw_runtime::sop::types::SopRunStatus::WaitingApproval
                    | zeroclaw_runtime::sop::types::SopRunStatus::PausedCheckpoint
            )
        });
        let matched: Vec<(String, u32, bool, bool)> = candidates
            .by_ref()
            .filter(|r| r.run_id == ref_run)
            .map(|r| {
                let text_admissible = matches!(form, Form::Marker)
                    || text_gate_reply_matches_approval_route(
                        &guard,
                        &r.run_id,
                        &channel_route_keys,
                        &msg.reply_target,
                    );
                let superseded = guard.is_gate_reference_superseded(&r.run_id, ref_rev);
                (r.run_id.clone(), r.revision, text_admissible, superseded)
            })
            .collect();
        match matched.as_slice() {
            [one] => Some(one.clone()),
            _ => None,
        }
    };
    if let Some((run_id, _, false, _)) = &resolved {
        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
                ::serde_json::json!({
                    "run_id": run_id,
                    "reference": reference,
                    "channel": channel_key,
                    "reply_target": msg.reply_target.as_str(),
                })
            ),
            "channel SOP-gate text reply did not match a gate approval route"
        );
        return false;
    }
    if let Some((run_id, current_rev, _, true)) = &resolved {
        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
                ::serde_json::json!({
                    "run_id": run_id,
                    "reference": reference,
                    "current_revision": current_rev,
                    "channel": msg.channel.as_str(),
                })
            ),
            "channel SOP-gate answer targeted a superseded prompt revision"
        );
        finalize_gate_prompts(
            gate_prompt_channels,
            &reference,
            "\u{1f501} This prompt was superseded by a newer draft \u{2014} \
             answer the latest prompt instead.",
        )
        .await;
        // Consumed for both forms: it named a real parked gate, just an old
        // presentation of it — never a message for the agent.
        return true;
    }
    let resolved_run_id = resolved.map(|(run_id, _, _, _)| run_id);
    let Some(run_id) = resolved_run_id else {
        return match form {
            Form::Marker => {
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({
                            "reference": reference,
                            "channel": msg.channel.as_str(),
                        })),
                    "channel SOP-gate click did not match a parked run (stale or finished)"
                );
                // Name the state correctly on the prompt itself: this gate's
                // approval window has passed.
                finalize_gate_prompts(
                    gate_prompt_channels,
                    &reference,
                    "\u{23f0} The approval window for this gate has passed \
                     (the run already resolved or finished).",
                )
                .await;
                true
            }
            Form::Text => false,
        };
    };

    use zeroclaw_api::channel::GateChoiceKind;
    use zeroclaw_runtime::sop::approval::ApprovalDecision;
    // `choice` already passed `GateChoiceKind::from_id` at parse time; this
    // match is exhaustive over the enum, so a new choice is a compile error
    // here (not a silent fall-through to Deny).
    let decision = match GateChoiceKind::from_id(&choice) {
        Some(GateChoiceKind::Approve) => ApprovalDecision::Approve,
        Some(GateChoiceKind::Deny) | None => ApprovalDecision::Deny {
            reason: Some(format!("denied by {} via {channel_key}", msg.sender)),
        },
        // Edit / Revise carry their text in the marker message's content (the
        // connector puts the modal's typed field there). Empty text cannot
        // amend or steer anything — consume without resolving (the connector's
        // required-field modal makes this unreachable in practice).
        Some(kind @ (GateChoiceKind::Edit | GateChoiceKind::Revise)) => {
            let text = msg.content.trim().to_string();
            if text.is_empty() {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({
                            "run_id": run_id,
                            "choice": choice,
                        })),
                    "channel SOP-gate edit/revise arrived without text; ignored"
                );
                return true;
            }
            if kind == GateChoiceKind::Edit {
                ApprovalDecision::Amend { text }
            } else {
                ApprovalDecision::Revise { guidance: text }
            }
        }
    };
    let is_edit = matches!(decision, ApprovalDecision::Amend { .. });
    let principal = zeroclaw_runtime::sop::approval::ApprovalPrincipal::channel(
        channel_key.clone(),
        Some(msg.sender.clone()),
    );
    let outcome = match engine.lock() {
        Ok(mut guard) => guard.resolve_via_broker(&run_id, decision, principal),
        Err(_) => return true,
    };
    match outcome {
        Ok(outcome) => {
            zeroclaw_runtime::sop::drive_resumed_broker_action(
                config,
                Arc::clone(engine),
                router.sop_audit.clone(),
                &outcome,
            );
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({
                        "run_id": run_id,
                        "choice": choice,
                        "sender": msg.sender,
                        "channel": channel_key,
                        "outcome": outcome.label(),
                    })),
                "channel SOP-gate answer resolved"
            );
            // Finalize the prompt (strip buttons, show the decision in place)
            // ONLY on terminal outcomes. Non-terminal ones — pending quorum, a
            // failed slot re-acquire — leave the buttons alive so the decision
            // can be retried or CHANGED while the run is still parked.
            use zeroclaw_runtime::sop::approval::{BrokerOutcome, ResolveOutcome};
            let final_text = match &outcome {
                BrokerOutcome::Resolved(ResolveOutcome::Resumed(_)) if is_edit => Some(format!(
                    "\u{2705} Approved with edits by <@{}> \u{2014} run resumed with the \
                     amended text.",
                    msg.sender
                )),
                BrokerOutcome::Resolved(ResolveOutcome::Resumed(_)) => Some(format!(
                    "\u{2705} Approved by <@{}> \u{2014} run resumed.",
                    msg.sender
                )),
                BrokerOutcome::Resolved(ResolveOutcome::Denied) => Some(format!(
                    "\u{1f6ab} Denied by <@{}> \u{2014} run cancelled.",
                    msg.sender
                )),
                BrokerOutcome::Resolved(ResolveOutcome::Revised) => Some(format!(
                    "\u{1f501} Revision requested by <@{}> \u{2014} a new draft prompt is \
                     on its way.",
                    msg.sender
                )),
                BrokerOutcome::Resolved(ResolveOutcome::AlreadyResolved) => Some(
                    "\u{23f0} The approval window for this gate has passed \
                     (already resolved)."
                        .to_string(),
                ),
                _ => None,
            };
            // Finalize by the prompt's CANONICAL reference (revision-qualified
            // when > 0): the prompt registry is keyed by what was sent.
            let finalize_reference = if ref_rev == 0 {
                run_id.clone()
            } else {
                format!("{run_id}#{ref_rev}")
            };
            if let Some(text) = final_text {
                finalize_gate_prompts(gate_prompt_channels, &finalize_reference, &text).await;
            }
        }
        Err(e) => {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "run_id": run_id,
                        "error": e.to_string(),
                    })),
                "channel SOP-gate resolution failed"
            );
        }
    }
    true
}

async fn dispatch_channel_sop_event(
    router: &AgentRouter,
    msg: &zeroclaw_api::channel::ChannelMessage,
) -> bool {
    let Some(topic) = msg
        .internal_sop_event
        .as_deref()
        .filter(|s| !s.trim().is_empty())
    else {
        return false;
    };

    let target_sop = channel_sop_target(msg);
    zeroclaw_runtime::sop::dispatch::SopIngress::new(
        router.sop_engine.as_ref(),
        router.sop_audit.as_deref(),
    )
    .dispatch(
        zeroclaw_runtime::sop::types::SopTriggerSource::Channel,
        Some(topic),
        Some(&msg.content),
        target_sop.as_deref(),
        None,
    )
    .await;
    true
}

fn channel_sop_target(msg: &zeroclaw_api::channel::ChannelMessage) -> Option<String> {
    serde_json::from_str::<serde_json::Value>(&msg.content)
        .ok()
        .and_then(|payload| {
            payload
                .get("sop")
                .and_then(serde_json::Value::as_str)
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(ToString::to_string)
        })
}

/// Resolve effective debounce window: a per-channel override with a positive
/// value wins, otherwise falls back to the global default from `ChannelsConfig`.
/// A per-channel value of `0` is treated as unset (falls back to global).
fn resolve_effective_debounce_window(
    global_ms: u64,
    channel: &str,
    channel_alias: Option<&str>,
    telegram_configs: &std::collections::HashMap<String, zeroclaw_config::schema::TelegramConfig>,
) -> std::time::Duration {
    let per_channel_ms = if channel == "telegram" {
        channel_alias
            .and_then(|alias| telegram_configs.get(alias))
            .and_then(|cfg| cfg.debounce_ms)
            .filter(|ms| *ms > 0)
    } else {
        None
    };
    std::time::Duration::from_millis(per_channel_ms.unwrap_or(global_ms))
}

async fn run_message_dispatch_loop(
    mut rx: tokio::sync::mpsc::Receiver<zeroclaw_api::channel::ChannelMessage>,
    router: AgentRouter,
    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 {
        // Gate answers (button-click markers / `approve <ref>` text replies)
        // resolve a PARKED run and must never start one, so they are consumed
        // BEFORE agent ownership lookup. A configured approval route may be
        // intentionally unowned by an agent; it can present gate prompts but
        // must never receive ordinary agent traffic. All live contexts share
        // this global channel registry and prompt config.
        let gate_ctx = router
            .single_ctx
            .as_ref()
            .cloned()
            .or_else(|| router.by_agent.values().next().cloned());
        if let Some(gate_ctx) = gate_ctx {
            let gate_channel = find_channel_for_message(&gate_ctx.channels_by_name, &msg).cloned();
            let gate_channel_route_keys = gate_channel
                .as_ref()
                .map(|target| {
                    let mut keys: Vec<String> = gate_ctx
                        .channels_by_name
                        .iter()
                        .filter(|&(_key, channel)| Arc::ptr_eq(channel, target))
                        .map(|(key, _channel)| key.clone())
                        .collect();
                    let inbound_key = channel_key_for_message(&msg);
                    if !keys.iter().any(|key| key == &inbound_key) {
                        keys.push(inbound_key);
                    }
                    keys.sort();
                    keys.dedup();
                    keys
                })
                .unwrap_or_else(|| vec![channel_key_for_message(&msg)]);
            let gate_prompt_channels = unique_channel_handles(&gate_ctx.channels_by_name);
            if dispatch_channel_sop_gate(
                &router,
                &msg,
                gate_ctx.prompt_config.as_ref(),
                &gate_prompt_channels,
                &gate_channel_route_keys,
            )
            .await
            {
                continue;
            }
        }

        let Some(ctx) = router.resolve(&msg) else {
            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"channel_alias": msg.channel_alias, "sender": msg.sender})), "dropping inbound message: no agent owns this channel");
            continue;
        };

        // Gate answers were already considered against the global approval
        // channel registry above. The remaining path only dispatches events and
        // ordinary messages to an agent-owned runtime.
        if dispatch_channel_sop_event(&router, &msg).await {
            continue;
        }
        // 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();
                zeroclaw_runtime::i18n::get_required_cli_string("channel-runtime-stop-sent")
            } else {
                zeroclaw_runtime::i18n::get_required_cli_string("channel-runtime-stop-no-task")
            };
            let channel = find_channel_for_message(&ctx.channels_by_name, &msg).cloned();
            if let Some(channel) = channel {
                let reply_target = msg.reply_target.clone();
                let thread_ts = msg.thread_ts.clone();
                zeroclaw_spawn::spawn!(async move {
                    let _ = channel
                        .send(&SendMessage::new(reply, &reply_target).in_thread(thread_ts))
                        .await;
                });
            } else {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "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" {
            let debounce_key = conversation_history_key(&msg);

            // Resolve effective debounce window: per-channel override wins,
            // otherwise falls back to the global default from ChannelsConfig.
            // A per-channel value of 0 is treated as unset (falls back to global).
            let debounce_window = resolve_effective_debounce_window(
                ctx.prompt_config.channels.debounce_ms,
                &msg.channel,
                msg.channel_alias.as_deref(),
                &ctx.prompt_config.channels.telegram,
            );

            match ctx
                .debouncer
                .debounce_with_window(&debounce_key, &msg.content, debounce_window)
                .await
            {
                zeroclaw_infra::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;
                        ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"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;
                }
                zeroclaw_infra::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);
    }
}

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

/// Trim-only identity normalizer for channels whose native id has no
/// `@`-style prefix to strip (WeChat openid, LINE user id).
fn normalize_trim_identity(value: &str) -> String {
    value.trim().to_string()
}

/// Per-channel-type identity normalizer. The operator-bind op is otherwise
/// identical across the pairing-capable channels; the only variance is how a
/// raw identity is canonicalized before it is stored in the allowlist.
pub type ChannelIdentityNormalizer = fn(&str) -> String;

/// Resolve the identity normalizer for a pairing-capable channel type, or
/// `None` for a type with no operator-bind surface. `None` is the closed-set
/// gate: only `telegram` / `wechat` / `line` can be bound this way.
#[must_use]
pub fn channel_identity_normalizer(channel_type: &str) -> Option<ChannelIdentityNormalizer> {
    match channel_type {
        "telegram" => Some(normalize_telegram_identity),
        "wechat" | "line" => Some(normalize_trim_identity),
        _ => None,
    }
}

/// Whether a `[channels.<type>.<alias>]` section exists. Rust has no
/// reflection over the typed channel maps, so this stays an explicit per-type
/// match; only this arm grows when a new pairing channel lands.
#[must_use]
pub fn channel_alias_configured(config: &Config, channel_type: &str, alias: &str) -> bool {
    match channel_type {
        "telegram" => config.channels.telegram.contains_key(alias),
        "wechat" => config.channels.wechat.contains_key(alias),
        "line" => config.channels.line.contains_key(alias),
        _ => false,
    }
}

/// Add `identity` to the peer group bound to `<type>.<alias>` in-place.
///
/// Returns `Ok(true)` when the identity was newly added, `Ok(false)` when it
/// was already present. Pure config mutation — no disk write, no daemon
/// restart — so it is the single core shared by the CLI
/// (`bind_telegram_identity`) and the gateway bind endpoint. The `channel`
/// field is the dotted `<type>.<alias>` ref so authorization stays scoped to
/// the bound alias; a bare type would broaden the peer across every alias of
/// that type.
pub fn bind_channel_identity_into(
    config: &mut Config,
    channel_type: &str,
    alias: &str,
    identity: &str,
) -> Result<bool> {
    use zeroclaw_config::multi_agent::{PeerGroupConfig, PeerUsername};
    use zeroclaw_config::providers::ChannelRef;

    let Some(normalize) = channel_identity_normalizer(channel_type) else {
        anyhow::bail!(
            "Channel type `{channel_type}` does not support identity binding \
             (supported: telegram, wechat, line)."
        );
    };

    let normalized = normalize(identity);
    if normalized.is_empty() {
        anyhow::bail!("{channel_type} identity cannot be empty");
    }

    // The alias must name an existing `[channels.<type>.<alias>]` section.
    // Binding into a phantom alias would mint a peer group the runtime never
    // reads (it resolves authorization per the alias the channel actually
    // runs under), so fail loudly instead of silently authorizing nobody.
    if !channel_alias_configured(config, channel_type, alias) {
        anyhow::bail!(
            "{channel_type} channel alias `{alias}` is not configured. Run \
             `zeroclaw config set channels.{channel_type}.{alias}.bot_token <token>` \
             (see docs/book/src/channels/overview.md for the full field list)."
        );
    }

    let group_name = format!("{channel_type}_{alias}");
    let channel_ref = format!("{channel_type}.{alias}");
    let group = config
        .peer_groups
        .entry(group_name)
        .or_insert_with(|| PeerGroupConfig {
            channel: ChannelRef::new(channel_ref),
            ..PeerGroupConfig::default()
        });

    if group
        .external_peers
        .iter()
        .any(|p| normalize(p.as_str()) == normalized)
    {
        return Ok(false);
    }

    group.external_peers.push(PeerUsername::new(normalized));
    Ok(true)
}

/// Telegram-specific thin wrapper over [`bind_channel_identity_into`], kept
/// for the CLI entry point and its unit tests.
fn bind_telegram_identity_into(config: &mut Config, identity: &str, alias: &str) -> Result<bool> {
    bind_channel_identity_into(config, "telegram", alias, identity)
}

pub async fn bind_telegram_identity(config: &Config, identity: &str, alias: &str) -> Result<()> {
    let normalized = normalize_telegram_identity(identity);
    let mut updated = config.clone();

    if !bind_telegram_identity_into(&mut updated, identity, alias)? {
        println!("✅ Telegram identity already bound to telegram.{alias}: {normalized}");
        return Ok(());
    }

    updated.save().await?;
    println!("✅ Bound Telegram identity {normalized} to telegram.{alias}");
    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 `zeroclaw 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 `zeroclaw service stop && zeroclaw 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.zeroclaw.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.zeroclaw.daemon") {
            return Ok(false);
        }

        let _ = Command::new("launchctl")
            .args(["stop", "com.zeroclaw.daemon"])
            .output();
        let start_output = Command::new("launchctl")
            .args(["start", "com.zeroclaw.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/zeroclaw");
        if openrc_init_script.exists()
            && 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("zeroclaw.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)
}

#[cfg(any(
    test,
    feature = "channel-discord",
    feature = "channel-lark",
    feature = "channel-matrix",
    feature = "channel-slack",
    feature = "channel-telegram",
    feature = "channel-wechat",
    feature = "whatsapp-web",
))]
fn one_shot_channel_workspace_dir(config: &Config, channel_type: &str, alias: &str) -> PathBuf {
    config.channel_workspace_dir(&format!("{channel_type}.{alias}"))
}

/// Returned by [`build_channel_by_id`] when no arm claims `channel_id`.
///
/// One-off callers match on this sentinel instead of the error text so the
/// builder stays the single source of truth for which ids it resolves; a family
/// added or removed there changes the fallback automatically.
#[derive(Debug)]
struct UnknownChannelId(String);

impl std::fmt::Display for UnknownChannelId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let Self(channel_id) = self;
        write!(
            f,
            "Unknown channel '{channel_id}'. Supported: telegram, discord, slack, mattermost, \
            signal, matrix, whatsapp, qq, lark, feishu, dingtalk, wecom, wecom_ws, nextcloud_talk, \
            wati, linq, email, gmail_push, git, irc, twitter, mochat, imessage, line, voice-call"
        )
    }
}

impl std::error::Error for UnknownChannelId {}

/// Build a single channel instance by config section name (e.g. "telegram").
fn build_channel_by_id(
    config_arc: &Arc<RwLock<Config>>,
    channel_id: &str,
) -> Result<Arc<dyn Channel>> {
    #[allow(unused_variables)]
    let config = config_arc.read();
    match channel_id {
        #[cfg(feature = "channel-telegram")]
        "telegram" => {
            let tg = config
                .channels
                .telegram
                .get("default")
                .context("Telegram channel is not configured")?;
            let ack = tg.ack_reactions.unwrap_or(config.channels.ack_reactions);
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("telegram", &alias))
            };
            let workspace_dir = one_shot_channel_workspace_dir(&config, "telegram", &alias);
            let voice_peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_voice_peers("telegram", &alias))
            };
            Ok(Arc::new(
                TelegramChannel::new(
                    tg.bot_token.clone(),
                    alias.clone(),
                    peer_resolver,
                    tg.mention_only,
                )
                .with_voice_peer_resolver(voice_peer_resolver)
                .with_persistence(config_arc.clone())
                .with_api_base(tg.api_base_url.clone())
                .with_ack_reactions(ack)
                .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
                .with_transcription(config.transcription.clone())
                .with_tts(&config)
                .with_workspace_dir(workspace_dir)
                .with_approval_timeout_secs(tg.approval_timeout_secs),
            ))
        }
        #[cfg(not(feature = "channel-telegram"))]
        "telegram" => {
            anyhow::bail!("Telegram channel requires the `channel-telegram` feature");
        }
        #[cfg(feature = "channel-discord")]
        "discord" => {
            let dc = config
                .channels
                .discord
                .get("default")
                .context("Discord channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("discord", &alias))
            };
            let workspace_dir = one_shot_channel_workspace_dir(&config, "discord", &alias);
            Ok(Arc::new(
                DiscordChannel::new(
                    dc.bot_token.clone(),
                    dc.guild_ids.clone(),
                    alias,
                    peer_resolver,
                    dc.listen_to_bots,
                    dc.mention_only,
                )
                .with_channel_ids(dc.channel_ids.clone())
                .with_workspace_dir(workspace_dir)
                .with_streaming(
                    dc.stream_mode,
                    dc.draft_update_interval_ms,
                    dc.multi_message_delay_ms,
                )
                .with_transcription(config.transcription.clone())
                .with_stall_timeout(dc.stall_timeout_secs)
                .with_approval_timeout_secs(dc.approval_timeout_secs)
                .with_intents_mask(dc.intents_mask)
                .with_reaction_notifications(dc.reaction_notifications),
            ))
        }
        #[cfg(not(feature = "channel-discord"))]
        "discord" => {
            anyhow::bail!("Discord channel requires the `channel-discord` feature");
        }
        #[cfg(feature = "channel-slack")]
        "slack" => {
            let sl = config
                .channels
                .slack
                .get("default")
                .context("Slack channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("slack", &alias))
            };
            let workspace_dir = one_shot_channel_workspace_dir(&config, "slack", &alias);
            let bot_token = sl.resolved_bot_token().with_context(|| {
                format!(
                    "Slack channel '{alias}': bot_token is not set. Provide it in config \
                     (channels.slack.{alias}.bot_token) or via the \
                     ZEROCLAW_SLACK_BOT_TOKEN / SLACK_BOT_TOKEN environment variable."
                )
            })?;
            Ok(Arc::new(
                SlackChannel::new(
                    bot_token,
                    sl.resolved_app_token(),
                    sl.channel_ids.clone(),
                    alias,
                    peer_resolver,
                )
                .with_workspace_dir(workspace_dir)
                .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())
                .with_approval_timeout_secs(sl.approval_timeout_secs),
            ))
        }
        #[cfg(not(feature = "channel-slack"))]
        "slack" => {
            anyhow::bail!("Slack channel requires the `channel-slack` feature");
        }
        #[cfg(feature = "channel-mattermost")]
        "mattermost" => {
            let mm = config
                .channels
                .mattermost
                .get("default")
                .context("Mattermost channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("mattermost", &alias))
            };
            Ok(Arc::new(
                MattermostChannel::new(
                    mm.url.clone(),
                    mm.bot_token.clone(),
                    mm.login_id.clone(),
                    mm.password.clone(),
                    mm.channel_ids.clone(),
                    alias,
                    peer_resolver,
                    mm.thread_replies.unwrap_or(true),
                    mm.mention_only.unwrap_or(false),
                )
                .with_team_ids(mm.team_ids.clone())
                .with_discover_dms(mm.discover_dms.unwrap_or(true))
                .with_listen_mode(mm.listen_mode),
            ))
        }
        #[cfg(not(feature = "channel-mattermost"))]
        "mattermost" => {
            anyhow::bail!("Mattermost channel requires the `channel-mattermost` feature");
        }
        #[cfg(feature = "channel-signal")]
        "signal" => {
            let sg = config
                .channels
                .signal
                .get("default")
                .context("Signal channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("signal", &alias))
            };
            Ok(Arc::new(
                SignalChannel::new(
                    sg.http_url.clone(),
                    sg.account.clone(),
                    sg.group_ids.clone(),
                    sg.dm_only,
                    alias,
                    peer_resolver,
                    sg.ignore_attachments,
                    sg.ignore_stories,
                )
                .with_approval_timeout_secs(sg.approval_timeout_secs),
            ))
        }
        #[cfg(not(feature = "channel-signal"))]
        "signal" => {
            anyhow::bail!("Signal channel requires the `channel-signal` feature");
        }
        "matrix" => {
            #[cfg(feature = "channel-matrix")]
            {
                let mx = config
                    .channels
                    .matrix
                    .get("default")
                    .context("Matrix channel is not configured")?;
                let alias = "default".to_string();
                let state_dir = matrix_state_dir(&config.config_path, &alias);
                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                    let cfg_arc = config_arc.clone();
                    let alias = alias.clone();
                    Arc::new(move || cfg_arc.read().channel_external_peers("matrix", &alias))
                };
                let ack = mx.ack_reactions.unwrap_or(config.channels.ack_reactions);
                let workspace_dir = one_shot_channel_workspace_dir(&config, "matrix", &alias);
                Ok(Arc::new(
                    MatrixChannel::new(mx.clone(), alias, peer_resolver, state_dir)?
                        .with_transcription(config.transcription.clone())
                        .with_workspace_dir(workspace_dir)
                        .with_ack_reactions(ack),
                ))
            }
            #[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
                    .whatsapp
                    .get("default")
                    .context("WhatsApp channel is not configured")?;
                if !wa.is_web_config() {
                    anyhow::bail!(
                        "WhatsApp channel send requires Web mode (set session_path, pair_phone, or mode = personal)"
                    );
                }
                let alias = "default".to_string();
                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                    let cfg_arc = config_arc.clone();
                    let alias = alias.clone();
                    Arc::new(move || cfg_arc.read().channel_external_peers("whatsapp", &alias))
                };
                let allowed_groups_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                    let cfg_arc = config_arc.clone();
                    let alias = alias.clone();
                    Arc::new(move || {
                        cfg_arc
                            .read()
                            .channels
                            .whatsapp
                            .get(&alias)
                            .map(|wa| wa.allowed_groups.clone())
                            .unwrap_or_default()
                    })
                };
                let workspace_dir = one_shot_channel_workspace_dir(&config, "whatsapp", &alias);
                Ok(Arc::new(
                    WhatsAppWebChannel::new(wa, alias, peer_resolver, allowed_groups_resolver)
                        .with_persistence(config_arc.clone())
                        .with_workspace_dir(workspace_dir),
                ))
            }
            #[cfg(not(feature = "whatsapp-web"))]
            {
                anyhow::bail!("WhatsApp channel requires the `whatsapp-web` feature");
            }
        }
        #[cfg(feature = "channel-qq")]
        "qq" => {
            let qq = config
                .channels
                .qq
                .get("default")
                .context("QQ channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("qq", &alias))
            };
            Ok(Arc::new(QQChannel::new(
                qq.app_id.clone(),
                qq.app_secret.clone(),
                alias,
                peer_resolver,
            )))
        }
        #[cfg(not(feature = "channel-qq"))]
        "qq" => {
            anyhow::bail!("QQ channel requires the `channel-qq` feature");
        }
        "lark" => {
            #[cfg(feature = "channel-lark")]
            {
                let lk = config
                    .channels
                    .lark
                    .get("default")
                    .context("Lark channel is not configured")?;
                let alias = "default".to_string();
                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                    let cfg_arc = config_arc.clone();
                    let alias = alias.clone();
                    Arc::new(move || cfg_arc.read().channel_external_peers("lark", &alias))
                };
                Ok(Arc::new(
                    LarkChannel::from_config(lk, alias, peer_resolver)
                        .with_workspace_dir(one_shot_channel_workspace_dir(
                            &config, "lark", "default",
                        ))
                        .with_approval_timeout_secs(lk.approval_timeout_secs)
                        .with_per_user_session(lk.per_user_session)
                        .with_ack_reactions(
                            lk.ack_reactions.unwrap_or(config.channels.ack_reactions),
                        )
                        .with_streaming(lk.stream_mode, lk.draft_update_interval_ms),
                ))
            }
            #[cfg(not(feature = "channel-lark"))]
            {
                anyhow::bail!("Lark channel requires the `channel-lark` feature");
            }
        }
        #[cfg(feature = "channel-dingtalk")]
        "dingtalk" => {
            let dt = config
                .channels
                .dingtalk
                .get("default")
                .context("DingTalk channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("dingtalk", &alias))
            };
            Ok(Arc::new(
                DingTalkChannel::new(
                    dt.client_id.clone(),
                    dt.client_secret.clone(),
                    alias,
                    peer_resolver,
                )
                .with_proxy_url(dt.proxy_url.clone()),
            ))
        }
        #[cfg(not(feature = "channel-dingtalk"))]
        "dingtalk" => {
            anyhow::bail!("DingTalk channel requires the `channel-dingtalk` feature");
        }
        #[cfg(feature = "channel-wecom")]
        "wecom" => {
            let wc = config
                .channels
                .wecom
                .get("default")
                .context("WeCom channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("wecom", &alias))
            };
            Ok(Arc::new(WeComChannel::new(
                wc.webhook_key.clone(),
                alias,
                peer_resolver,
            )))
        }
        #[cfg(not(feature = "channel-wecom"))]
        "wecom" => {
            anyhow::bail!("WeCom channel requires the `channel-wecom` feature");
        }
        #[cfg(feature = "channel-wecom-ws")]
        channel_id
            if channel_id == "wecom_ws"
                || channel_id == "wecom-ws"
                || channel_id.starts_with("wecom_ws.")
                || channel_id.starts_with("wecom-ws.") =>
        {
            let alias = channel_id
                .split_once('.')
                .map(|(_, alias)| alias)
                .unwrap_or("default")
                .to_string();
            let wc =
                config.channels.wecom_ws.get(&alias).with_context(|| {
                    format!("WeCom WebSocket channel '{alias}' is not configured")
                })?;
            let policy_resolver: Arc<dyn Fn() -> WeComWsRuntimePolicy + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                let snapshot = wc.clone();
                Arc::new(move || {
                    let config = cfg_arc.read();
                    let mut external_peers = config.channel_external_peers("wecom-ws", &alias);
                    external_peers.extend(config.channel_external_peers("wecom_ws", &alias));

                    if let Some(wc_ws) = config.channels.wecom_ws.get(&alias) {
                        WeComWsRuntimePolicy::from_config(wc_ws, external_peers)
                    } else {
                        WeComWsRuntimePolicy::from_config(&snapshot, external_peers)
                    }
                })
            };
            Ok(Arc::new(WeComWsChannel::new_with_alias(
                wc,
                alias.clone(),
                policy_resolver,
                &config.channel_workspace_dir(&format!("wecom_ws.{alias}")),
            )?))
        }
        #[cfg(not(feature = "channel-wecom-ws"))]
        channel_id
            if channel_id == "wecom_ws"
                || channel_id == "wecom-ws"
                || channel_id.starts_with("wecom_ws.")
                || channel_id.starts_with("wecom-ws.") =>
        {
            anyhow::bail!("WeCom WebSocket channel requires the `channel-wecom-ws` feature");
        }
        #[cfg(feature = "channel-wechat")]
        "wechat" => {
            let wc = config
                .channels
                .wechat
                .get("default")
                .context("WeChat channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("wechat", &alias))
            };
            let workspace_dir = one_shot_channel_workspace_dir(&config, "wechat", &alias);
            Ok(Arc::new(
                WeChatChannel::new(
                    alias,
                    peer_resolver,
                    wc.api_base_url.clone(),
                    wc.cdn_base_url.clone(),
                    Some(WeChatChannel::resolve_state_dir(wc.state_dir.as_deref())),
                )?
                .with_persistence(config_arc.clone())
                .with_workspace_dir(workspace_dir),
            ))
        }
        #[cfg(not(feature = "channel-wechat"))]
        "wechat" => {
            anyhow::bail!("WeChat channel requires the `channel-wechat` feature");
        }
        #[cfg(feature = "channel-nextcloud")]
        "nextcloud_talk" | "nextcloud-talk" => {
            let nc = config
                .channels
                .nextcloud_talk
                .get("default")
                .context("Nextcloud Talk channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || {
                    cfg_arc
                        .read()
                        .channel_external_peers("nextcloud_talk", &alias)
                })
            };
            Ok(Arc::new(
                NextcloudTalkChannel::new_with_proxy(
                    nc.base_url.clone(),
                    nc.resolve_bot_secret().unwrap_or_else(|e| {
                        ::zeroclaw_log::record!(
                            WARN,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Note
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure),
                            &e.to_string()
                        );
                        None
                    }),
                    nc.bot_name.clone().unwrap_or_default(),
                    alias,
                    peer_resolver,
                    nc.proxy_url.clone(),
                )
                .with_streaming(nc.stream_mode, nc.draft_update_interval_ms),
            ))
        }
        #[cfg(not(feature = "channel-nextcloud"))]
        "nextcloud_talk" | "nextcloud-talk" => {
            anyhow::bail!("Nextcloud Talk channel requires the `channel-nextcloud` feature");
        }
        #[cfg(feature = "channel-wati")]
        "wati" => {
            let wati_cfg = config
                .channels
                .wati
                .get("default")
                .context("WATI channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("wati", &alias))
            };
            Ok(Arc::new(WatiChannel::new_with_proxy(
                wati_cfg.api_token.clone(),
                wati_cfg.api_url.clone(),
                wati_cfg.tenant_id.clone(),
                alias,
                peer_resolver,
                wati_cfg.proxy_url.clone(),
            )))
        }
        #[cfg(not(feature = "channel-wati"))]
        "wati" => {
            anyhow::bail!("WATI channel requires the `channel-wati` feature");
        }
        #[cfg(feature = "channel-linq")]
        "linq" => {
            let lq = config
                .channels
                .linq
                .get("default")
                .context("Linq channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("linq", &alias))
            };
            Ok(Arc::new(LinqChannel::new(
                lq.api_token.clone(),
                lq.from_phone.clone(),
                alias,
                peer_resolver,
            )))
        }
        #[cfg(feature = "channel-linq")]
        x if x.starts_with("linq.") => {
            let alias = x.strip_prefix("linq.").context("invalid linq channel id")?;
            let lq = config
                .channels
                .linq
                .get(alias)
                .with_context(|| format!("Linq alias '{alias}' not configured"))?;
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.to_string();
                Arc::new(move || cfg_arc.read().channel_external_peers("linq", &alias))
            };
            Ok(Arc::new(LinqChannel::new(
                lq.api_token.clone(),
                lq.from_phone.clone(),
                alias.to_string(),
                peer_resolver,
            )))
        }
        #[cfg(not(feature = "channel-linq"))]
        x if x.starts_with("linq") => {
            anyhow::bail!("Linq channel requires the `channel-linq` feature");
        }
        #[cfg(feature = "channel-email")]
        "email" => {
            let em = config
                .channels
                .email
                .get("default")
                .context("Email channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("email", &alias))
            };
            Ok(Arc::new(EmailChannel::new(
                em.clone(),
                alias,
                peer_resolver,
            )))
        }
        #[cfg(not(feature = "channel-email"))]
        "email" => {
            anyhow::bail!("Email channel requires the `channel-email` feature");
        }
        #[cfg(feature = "channel-email")]
        "gmail_push" | "gmail-push" => {
            let gp = config
                .channels
                .gmail_push
                .get("default")
                .context("Gmail Push channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("gmail_push", &alias))
            };
            Ok(Arc::new(GmailPushChannel::new(
                gp.clone(),
                alias,
                peer_resolver,
            )))
        }
        #[cfg(not(feature = "channel-email"))]
        "gmail_push" | "gmail-push" => {
            anyhow::bail!("Gmail Push channel requires the `channel-email` feature");
        }
        #[cfg(feature = "channel-irc")]
        "irc" => {
            let irc_cfg = config
                .channels
                .irc
                .get("default")
                .context("IRC channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("irc", &alias))
            };
            Ok(Arc::new(IrcChannel::new(crate::irc::IrcChannelConfig {
                server: irc_cfg.server.clone(),
                port: irc_cfg.port,
                nickname: irc_cfg.nickname.clone(),
                username: irc_cfg.username.clone(),
                channels: irc_cfg.channels.clone(),
                alias,
                peer_resolver,
                server_password: irc_cfg.server_password.clone(),
                nickserv_password: irc_cfg.nickserv_password.clone(),
                sasl_password: irc_cfg.sasl_password.clone(),
                verify_tls: irc_cfg.verify_tls.unwrap_or(true),
                mention_only: irc_cfg.mention_only,
            })))
        }
        #[cfg(not(feature = "channel-irc"))]
        "irc" => {
            anyhow::bail!("IRC channel requires the `channel-irc` feature");
        }
        #[cfg(feature = "channel-twitch")]
        "twitch" => {
            let tw_cfg = config
                .channels
                .twitch
                .get("default")
                .context("Twitch channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("twitch", &alias))
            };
            Ok(Arc::new(TwitchChannel::new(
                tw_cfg.bot_username.clone(),
                tw_cfg.oauth_token.clone(),
                tw_cfg.channels.clone(),
                tw_cfg.mention_only,
                alias,
                peer_resolver,
            )))
        }
        #[cfg(not(feature = "channel-twitch"))]
        "twitch" => {
            anyhow::bail!("Twitch channel requires the `channel-twitch` feature");
        }
        #[cfg(feature = "channel-twitter")]
        "twitter" => {
            let tw = config
                .channels
                .twitter
                .get("default")
                .context("X/Twitter channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("twitter", &alias))
            };
            Ok(Arc::new(TwitterChannel::new(
                tw.bearer_token.clone(),
                alias,
                peer_resolver,
            )))
        }
        #[cfg(not(feature = "channel-twitter"))]
        "twitter" => {
            anyhow::bail!("X/Twitter channel requires the `channel-twitter` feature");
        }
        #[cfg(feature = "channel-git")]
        "git" => {
            let g = config
                .channels
                .git
                .get("default")
                .context("Git channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("git", &alias))
            };
            Ok(Arc::new(GitChannel::new(g.clone(), alias, peer_resolver)?))
        }
        #[cfg(not(feature = "channel-git"))]
        "git" => {
            anyhow::bail!("Git channel requires the `channel-git` feature");
        }
        #[cfg(feature = "channel-mochat")]
        "mochat" => {
            let mc = config
                .channels
                .mochat
                .get("default")
                .context("Mochat channel is not configured")?;
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("mochat", &alias))
            };
            Ok(Arc::new(MochatChannel::new(
                mc.api_url.clone(),
                mc.api_token.clone(),
                alias,
                peer_resolver,
                mc.poll_interval_secs,
            )))
        }
        #[cfg(not(feature = "channel-mochat"))]
        "mochat" => {
            anyhow::bail!("Mochat channel requires the `channel-mochat` feature");
        }
        #[cfg(feature = "channel-imessage")]
        "imessage" => {
            if !config.channels.imessage.contains_key("default") {
                anyhow::bail!("iMessage channel is not configured");
            }
            let alias = "default".to_string();
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("imessage", &alias))
            };
            Ok(Arc::new(IMessageChannel::new(alias, peer_resolver)))
        }
        #[cfg(not(feature = "channel-imessage"))]
        "imessage" => {
            anyhow::bail!("iMessage channel requires the `channel-imessage` feature");
        }
        "line" => {
            #[cfg(feature = "channel-line")]
            {
                let ln = config
                    .channels
                    .line
                    .get("default")
                    .context("LINE channel is not configured")?;
                let alias = "default".to_string();
                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                    let cfg_arc = config_arc.clone();
                    let alias = alias.clone();
                    Arc::new(move || cfg_arc.read().channel_external_peers("line", &alias))
                };
                let sender_name_resolver: Arc<dyn Fn() -> Option<String> + Send + Sync> = {
                    let cfg_arc = config_arc.clone();
                    let alias = alias.clone();
                    Arc::new(move || {
                        cfg_arc
                            .read()
                            .channels
                            .line
                            .get(&alias)
                            .and_then(|ln| ln.sender_name.clone())
                            .filter(|s| !s.is_empty())
                    })
                };
                Ok(Arc::new(
                    LineChannel::from_config(ln, alias, peer_resolver, sender_name_resolver)
                        .with_persistence(config_arc.clone()),
                ))
            }
            #[cfg(not(feature = "channel-line"))]
            {
                anyhow::bail!("LINE channel requires the `channel-line` feature");
            }
        }
        "voice-call" => {
            #[cfg(feature = "channel-voice-call")]
            {
                let (alias, vc) = config
                    .channels
                    .voice_call
                    .iter()
                    .next()
                    .context("Voice Call channel is not configured")?;
                Ok(Arc::new(VoiceCallChannel::new(alias.clone(), vc.clone())))
            }
            #[cfg(not(feature = "channel-voice-call"))]
            {
                anyhow::bail!("Voice Call channel requires the `channel-voice-call` feature");
            }
        }
        other => Err(anyhow::Error::new(UnknownChannelId(other.to_string()))),
    }
}

/// Send a one-off message to a configured channel.
pub async fn send_channel_message(
    config: &Config,
    channel_id: &str,
    recipient: &str,
    message: &str,
) -> Result<()> {
    // Wrap into the canonical shared handle for the builder; this is a
    // one-shot path so the snapshot is dropped immediately after send.
    let config_arc = Arc::new(RwLock::new(config.clone()));
    // The builder gets first refusal so families it already resolves natively
    // (notably `linq.<alias>`) keep their established route and their own
    // configuration errors. Only a dotted id the builder does not claim at all
    // falls through to the announcement dispatcher, which resolves any
    // `<type>.<alias>` it supports.
    let channel = match build_channel_by_id(&config_arc, channel_id) {
        Ok(channel) => channel,
        Err(err)
            if channel_id.contains('.') && err.downcast_ref::<UnknownChannelId>().is_some() =>
        {
            deliver_announcement(config, channel_id, recipient, None, message)
                .await
                .with_context(|| format!("Failed to send message via {channel_id}"))?;
            println!("Message sent via {channel_id}.");
            return Ok(());
        }
        Err(err) => return Err(err),
    };
    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,
    alias: Option<String>,
    channel: Arc<dyn Channel>,
}

/// Compose the registry key for a channel given its `name()` and configured alias.
/// Aliased channels live at `<name>.<alias>`; un-aliased singletons keep the bare name.
pub(crate) fn composite_channel_key(name: &str, alias: Option<&str>) -> String {
    match alias.filter(|s| !s.is_empty()) {
        Some(alias) => format!("{name}.{alias}"),
        None => name.to_string(),
    }
}

fn configured_channel_map(configured: &[ConfiguredChannel]) -> HashMap<String, Arc<dyn Channel>> {
    let mut map: HashMap<String, Arc<dyn Channel>> = HashMap::new();
    let mut name_counts: HashMap<&str, usize> = HashMap::new();
    for cc in configured {
        *name_counts.entry(cc.channel.name()).or_insert(0) += 1;
    }
    for cc in configured {
        let name = cc.channel.name();
        let composite = composite_channel_key(name, cc.alias.as_deref());
        map.insert(composite, Arc::clone(&cc.channel));
        if name_counts.get(name).copied().unwrap_or(0) == 1 {
            map.entry(name.to_string())
                .or_insert_with(|| Arc::clone(&cc.channel));
        }
    }
    map
}

fn find_channel_for_message<'a>(
    channels: &'a HashMap<String, Arc<dyn Channel>>,
    msg: &zeroclaw_api::channel::ChannelMessage,
) -> Option<&'a Arc<dyn Channel>> {
    if let Some(alias) = msg.channel_alias.as_deref().filter(|s| !s.is_empty()) {
        let composite = format!("{}.{alias}", msg.channel);
        if let Some(ch) = channels.get(&composite) {
            return Some(ch);
        }
    }
    if let Some(ch) = channels.get(&msg.channel) {
        return Some(ch);
    }
    msg.channel
        .split_once(':')
        .and_then(|(base, _)| channels.get(base))
}

fn send_message_to_peer_tool_available(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
) -> bool {
    let excluded_for_turn = msg.channel != "cli" && ctx.autonomy_level != AutonomyLevel::Full;
    if excluded_for_turn
        && ctx
            .non_cli_excluded_tools
            .iter()
            .any(|tool_name| tool_name == "send_message_to_peer")
    {
        return false;
    }

    ctx.tools_registry
        .iter()
        .any(|tool| tool.name() == "send_message_to_peer")
}

fn peer_prompt_channel_ref(
    ctx: &ChannelRuntimeContext,
    msg: &zeroclaw_api::channel::ChannelMessage,
) -> Option<String> {
    let composite = composite_channel_key(&msg.channel, msg.channel_alias.as_deref());
    if msg
        .channel_alias
        .as_deref()
        .is_some_and(|alias| !alias.is_empty())
    {
        return Some(composite);
    }

    let Some(agent) = ctx.prompt_config.agents.get(ctx.agent_alias.as_str()) else {
        return Some(composite);
    };

    if agent.channels.iter().any(|channel| channel == &composite) {
        return Some(composite);
    }

    let matches: Vec<&str> = agent
        .channels
        .iter()
        .map(|channel| channel.as_str())
        .filter(|channel| channel_ref_matches_message_channel(channel, &msg.channel))
        .collect();
    if matches.len() == 1 {
        Some(matches[0].to_string())
    } else {
        None
    }
}

fn channel_ref_matches_message_channel(channel_ref: &str, message_channel: &str) -> bool {
    if channel_ref == message_channel {
        return true;
    }

    let message_base = message_channel
        .split_once(':')
        .map(|(base, _)| base)
        .unwrap_or(message_channel);
    channel_ref == message_base
        || channel_ref
            .split_once('.')
            .is_some_and(|(channel_type, _)| channel_type == message_base)
}

/// Active `<type>.<alias>` channel references from enabled agents and SOP
/// approval routes.
///
/// When no agent declares channel bindings, collection falls back to legacy
/// behavior and accepts all enabled channels.
struct ActiveChannelAliases {
    /// `<type>.<alias>` declared by ENABLED agents. Drives `contains` in
    /// explicit-binding mode: only enabled owners' bindings count.
    enabled_bindings: HashSet<String>,
    /// Bindings declared by all agents, including disabled owners. Their
    /// presence prevents legacy fallback from activating disabled channels.
    all_known_bindings: HashSet<String>,
    /// `<type>.<alias>` named by an approval request or escalation route.
    /// These channels are live to deliver and receive SOP gate replies, but
    /// they remain absent from the agent ownership map for ordinary traffic.
    approval_route_bindings: HashSet<String>,
}

impl ActiveChannelAliases {
    /// Returns true when `channel_ref` is agent-bound, named by an approval
    /// route, or when no explicit agent bindings exist and legacy "accept all
    /// enabled channels" mode applies.
    fn contains(&self, channel_ref: &str) -> bool {
        self.all_known_bindings.is_empty()
            || self.enabled_bindings.contains(channel_ref)
            || self.approval_route_bindings.contains(channel_ref)
    }

    /// True when bindings exist somewhere in the config but every owner is
    /// `enabled = false`.
    fn disabled_owners_exist(&self) -> bool {
        !self.all_known_bindings.is_empty() && self.enabled_bindings.is_empty()
    }

    /// Computes the canonical channel-binding view used by collection and
    /// startup checks. Disabled owners never activate channels, while an
    /// explicit SOP approval route keeps its delivery channel live without
    /// assigning it to an agent.
    fn compute(config: &Config) -> Self {
        let configured_channel_aliases = config.channels_by_alias();
        let approval_route_bindings = config
            .sop
            .approval
            .policies
            .values()
            .flat_map(|policy| {
                [
                    policy.request_route.as_deref(),
                    policy.escalation_route.as_deref(),
                ]
            })
            .filter_map(|route| {
                route.and_then(zeroclaw_runtime::sop::approval::channel_route::parse_approval_route)
            })
            .flat_map(|(channel_key, _)| {
                if channel_key.contains('.') {
                    return vec![channel_key.to_string()];
                }

                let enabled_aliases: Vec<_> = configured_channel_aliases
                    .iter()
                    .filter(|channel| channel.enabled && channel.channel_type == channel_key)
                    .collect();
                match enabled_aliases.as_slice() {
                    [channel] => vec![format!("{}.{}", channel.channel_type, channel.alias)],
                    _ => vec![channel_key.to_string()],
                }
            })
            .collect();

        Self {
            enabled_bindings: config
                .agents
                .values()
                .filter(|a| a.enabled)
                .flat_map(|a| a.channels.iter().map(|c| c.as_str().to_string()))
                .collect(),
            all_known_bindings: config
                .agents
                .values()
                .flat_map(|a| a.channels.iter().map(|c| c.as_str().to_string()))
                .collect(),
            approval_route_bindings,
        }
    }
}

pub fn build_channel_map(
    config: &Config,
) -> HashMap<String, Arc<dyn zeroclaw_api::channel::Channel>> {
    let config_arc = Arc::new(RwLock::new(config.clone()));
    let configured = collect_configured_channels(&config_arc, "", &[], None, None);
    configured_channel_map(&configured)
}

pub fn register_channels_for_tools(
    config: &Config,
    ask_user_handle: &Option<tools::PerToolChannelHandle>,
    channel_room_handle: &Option<tools::PerToolChannelHandle>,
    reaction_handle: &Option<tools::PerToolChannelHandle>,
    poll_handle: &Option<tools::PerToolChannelHandle>,
    escalate_handle: &Option<tools::PerToolChannelHandle>,
) -> Vec<String> {
    let config_arc = Arc::new(RwLock::new(config.clone()));
    let configured = collect_configured_channels(&config_arc, "", &[], None, None);

    let handles = [
        ask_user_handle.as_ref(),
        channel_room_handle.as_ref(),
        reaction_handle.as_ref(),
        poll_handle.as_ref(),
        escalate_handle.as_ref(),
    ];

    let map = configured_channel_map(&configured);
    for (key, channel) in &map {
        for handle in handles.iter().flatten() {
            handle.write().insert(key.clone(), Arc::clone(channel));
        }
    }
    let mut names: Vec<String> = map.keys().cloned().collect();
    names.sort();
    names
}

/// Per-alias Matrix state directory. Each `[channels.matrix.<alias>]` block
/// must own its own session/crypto store so two bots under one daemon don't
/// restore each other's `session.json` and run as the wrong account. The
/// alias component is what keeps them distinct.
#[cfg(feature = "channel-matrix")]
fn matrix_state_dir(config_path: &std::path::Path, alias: &str) -> std::path::PathBuf {
    config_path
        .parent()
        .map(|p| p.join("state").join("matrix").join(alias))
        .unwrap_or_else(|| std::path::PathBuf::from(".zeroclaw/state/matrix").join(alias))
}

fn collect_configured_channels(
    config_arc: &Arc<RwLock<Config>>,
    matrix_skip_context: &str,
    tool_specs: &[(String, String)],
    sop_engine: Option<Arc<std::sync::Mutex<zeroclaw_runtime::sop::SopEngine>>>,
    sop_audit: Option<Arc<zeroclaw_runtime::sop::SopAuditLogger>>,
) -> Vec<ConfiguredChannel> {
    let _ = matrix_skip_context;
    let _ = tool_specs;
    #[cfg(not(feature = "channel-amqp"))]
    let _ = (&sop_engine, &sop_audit);
    #[allow(unused_mut)]
    let mut channels = Vec::new();

    // Shadow `config` with a read guard so the existing body keeps
    // working via `Deref<Target = Config>`. Resolver closures that
    // outlive the function capture `config_arc.clone()`.
    let config = config_arc.read();

    let active_channel_aliases = ActiveChannelAliases::compute(&config);

    if active_channel_aliases.disabled_owners_exist() {
        let skipped: Vec<&String> = active_channel_aliases.all_known_bindings.iter().collect();
        ::zeroclaw_log::record!(
            INFO,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({
                    "skipped_bindings": skipped.len(),
                    "bindings": skipped,
                })),
            "channel binding(s) skipped: all owning agent(s) are disabled (#8013)"
        );
    }

    #[cfg(feature = "channel-telegram")]
    for (alias, tg) in &config.channels.telegram {
        if !active_channel_aliases.contains(&format!("telegram.{alias}")) {
            continue;
        }
        if !tg.enabled {
            continue;
        }
        let ack = tg.ack_reactions.unwrap_or(config.channels.ack_reactions);
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("telegram", &alias))
        };
        let voice_peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_voice_peers("telegram", &alias))
        };
        let channel_key = format!("telegram.{alias}");
        let agent_transcription_provider = config
            .agents
            .values()
            .filter(|a| a.enabled && a.channels.iter().any(|c| c.as_str() == channel_key))
            .find_map(|a| {
                let s = a.transcription_provider.as_str();
                if s.is_empty() {
                    None
                } else {
                    Some(s.to_string())
                }
            })
            .unwrap_or_default();
        channels.push(ConfiguredChannel {
            display_name: "Telegram",
            alias: Some(alias.clone()),
            channel: crate::paced_channel::PacedChannel::wrap(
                Arc::new(
                    TelegramChannel::new(
                        tg.bot_token.clone(),
                        alias.clone(),
                        peer_resolver,
                        tg.mention_only,
                    )
                    .with_voice_peer_resolver(voice_peer_resolver)
                    .with_persistence(config_arc.clone())
                    .with_api_base(tg.api_base_url.clone())
                    .with_ack_reactions(ack)
                    .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
                    .with_transcription(config.transcription.clone())
                    .with_agent_transcription_provider(agent_transcription_provider.clone())
                    .with_typed_transcription_providers(
                        &config.providers.transcription,
                        &agent_transcription_provider,
                    )
                    .with_tts(&config)
                    .with_workspace_dir(config.channel_workspace_dir(&format!("telegram.{alias}")))
                    .with_proxy_url(tg.proxy_url.clone())
                    .with_tool_command_specs(tool_specs.to_vec())
                    .with_approval_timeout_secs(tg.approval_timeout_secs),
                ),
                tg,
            ),
        });
    }

    #[cfg(not(feature = "channel-telegram"))]
    if !config.channels.telegram.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Telegram channel is configured but this build was compiled without \
             `channel-telegram`; skipping Telegram."
        );
    }

    #[cfg(feature = "channel-discord")]
    for (alias, dc) in &config.channels.discord {
        if !active_channel_aliases.contains(&format!("discord.{alias}")) {
            continue;
        }
        if !dc.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("discord", &alias))
        };
        let mut discord_ch = DiscordChannel::new(
            dc.bot_token.clone(),
            dc.guild_ids.clone(),
            alias.clone(),
            peer_resolver,
            dc.listen_to_bots,
            dc.mention_only,
        )
        .with_channel_ids(dc.channel_ids.clone())
        .with_workspace_dir(config.channel_workspace_dir(&format!("discord.{alias}")))
        .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_stall_timeout(dc.stall_timeout_secs)
        .with_approval_timeout_secs(dc.approval_timeout_secs)
        .with_slash_commands(dc.slash_commands)
        .with_slash_command_scope(dc.slash_command_scope)
        .with_intents_mask(dc.intents_mask)
        .with_reaction_notifications(dc.reaction_notifications);
        if dc.slash_commands {
            let cfg_arc_for_slash = config_arc.clone();
            let channel_ref = format!("discord.{alias}");
            discord_ch = discord_ch.with_slash_command_resolver(std::sync::Arc::new(move || {
                let config = { cfg_arc_for_slash.read().clone() };
                let Some(agent_alias) = config
                    .agent_for_channel(&channel_ref)
                    .map(ToString::to_string)
                else {
                    return Vec::new();
                };
                let workspace = config.agent_workspace_dir(&agent_alias);
                let skills = zeroclaw_runtime::skills::load_skills_for_agent(
                    &workspace,
                    &config,
                    &agent_alias,
                );
                crate::discord::discord_slash_specs_from_skills(&skills)
            }));
        }
        if dc.archive {
            match zeroclaw_memory::SqliteMemory::new_named("sqlite", &config.data_dir, "discord") {
                Ok(mem) => {
                    discord_ch = discord_ch.with_archive_memory(std::sync::Arc::new(mem));
                }
                Err(e) => {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                        "discord: archive enabled but failed to open discord.db"
                    );
                }
            }
        }
        channels.push(ConfiguredChannel {
            display_name: "Discord",
            alias: Some(alias.clone()),
            channel: crate::paced_channel::PacedChannel::wrap(Arc::new(discord_ch), dc),
        });
    }

    #[cfg(not(feature = "channel-discord"))]
    if !config.channels.discord.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Discord channel is configured but this build was compiled without \
             `channel-discord`; skipping Discord."
        );
    }

    #[cfg(feature = "channel-slack")]
    for (alias, sl) in &config.channels.slack {
        if !active_channel_aliases.contains(&format!("slack.{alias}")) {
            continue;
        }
        if !sl.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("slack", &alias))
        };
        let Some(bot_token) = sl.resolved_bot_token() else {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({ "alias": alias.clone() })),
                "Slack channel skipped: bot_token not set in config or via \
                 ZEROCLAW_SLACK_BOT_TOKEN / SLACK_BOT_TOKEN env"
            );
            continue;
        };
        channels.push(ConfiguredChannel {
            display_name: "Slack",
            alias: Some(alias.clone()),
            channel: crate::paced_channel::PacedChannel::wrap(
                Arc::new(
                    SlackChannel::new(
                        bot_token,
                        sl.resolved_app_token(),
                        sl.channel_ids.clone(),
                        alias.clone(),
                        peer_resolver,
                    )
                    .with_thread_replies(sl.thread_replies.unwrap_or(true))
                    .with_group_reply_policy(sl.mention_only, Vec::new())
                    .with_strict_mention_in_thread(sl.strict_mention_in_thread)
                    .with_workspace_dir(config.channel_workspace_dir(&format!("slack.{alias}")))
                    .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_timeout_secs(sl.approval_timeout_secs),
                ),
                sl,
            ),
        });
    }

    #[cfg(not(feature = "channel-slack"))]
    if !config.channels.slack.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Slack channel is configured but this build was compiled without \
             `channel-slack`; skipping Slack."
        );
    }

    #[cfg(feature = "channel-mattermost")]
    for (alias, mm) in &config.channels.mattermost {
        if !active_channel_aliases.contains(&format!("mattermost.{alias}")) {
            continue;
        }
        if !mm.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("mattermost", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "Mattermost",
            alias: Some(alias.clone()),
            channel: crate::paced_channel::PacedChannel::wrap(
                Arc::new(
                    MattermostChannel::new(
                        mm.url.clone(),
                        mm.bot_token.clone(),
                        mm.login_id.clone(),
                        mm.password.clone(),
                        mm.channel_ids.clone(),
                        alias.clone(),
                        peer_resolver,
                        mm.thread_replies.unwrap_or(true),
                        mm.mention_only.unwrap_or(false),
                    )
                    .with_team_ids(mm.team_ids.clone())
                    .with_discover_dms(mm.discover_dms.unwrap_or(true))
                    .with_proxy_url(mm.proxy_url.clone())
                    .with_transcription(config.transcription.clone())
                    .with_listen_mode(mm.listen_mode),
                ),
                mm,
            ),
        });
    }

    #[cfg(not(feature = "channel-mattermost"))]
    if !config.channels.mattermost.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Mattermost channel is configured but this build was compiled without \
             `channel-mattermost`; skipping Mattermost."
        );
    }

    #[cfg(feature = "channel-imessage")]
    for (alias, im) in &config.channels.imessage {
        if !active_channel_aliases.contains(&format!("imessage.{alias}")) {
            continue;
        }
        if !im.enabled {
            continue;
        }
        let _ = im;
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("imessage", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "iMessage",
            alias: Some(alias.clone()),
            channel: crate::paced_channel::PacedChannel::wrap(
                Arc::new(IMessageChannel::new(alias.clone(), peer_resolver)),
                im,
            ),
        });
    }

    #[cfg(not(feature = "channel-imessage"))]
    if !config.channels.imessage.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "iMessage channel is configured but this build was compiled without \
             `channel-imessage`; skipping iMessage."
        );
    }

    #[cfg(feature = "channel-matrix")]
    for (alias, mx) in &config.channels.matrix {
        if !active_channel_aliases.contains(&format!("matrix.{alias}")) {
            continue;
        }
        if !mx.enabled {
            continue;
        }
        let state_dir = matrix_state_dir(&config.config_path, alias);
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("matrix", &alias))
        };
        let ack = mx.ack_reactions.unwrap_or(config.channels.ack_reactions);
        match MatrixChannel::new(mx.clone(), alias.clone(), peer_resolver, state_dir) {
            Ok(channel) => {
                let channel = channel
                    .with_transcription(config.transcription.clone())
                    .with_workspace_dir(config.channel_workspace_dir(&format!("matrix.{alias}")))
                    .with_ack_reactions(ack);
                channels.push(ConfiguredChannel {
                    display_name: "Matrix",
                    alias: Some(alias.clone()),
                    channel: crate::paced_channel::PacedChannel::wrap(Arc::new(channel), mx),
                });
            }
            Err(e) => {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                    "Matrix channel construction failed"
                );
            }
        }
    }

    #[cfg(not(feature = "channel-matrix"))]
    if !config.channels.matrix.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            &format!(
                "Matrix channel is configured but this build was compiled without `channel-matrix`; skipping Matrix {}.",
                matrix_skip_context
            )
        );
    }

    #[cfg(feature = "channel-signal")]
    for (alias, sig) in &config.channels.signal {
        if !active_channel_aliases.contains(&format!("signal.{alias}")) {
            continue;
        }
        if !sig.enabled {
            continue;
        }
        if !sig.has_required_credentials() {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                &format!(
                    "Signal channel '{alias}' is enabled but missing required fields \
                     (channels.signal.{alias}.http_url, channels.signal.{alias}.account); \
                     skipping Signal to avoid a connect-fail crashloop."
                )
            );
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("signal", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "Signal",
            alias: Some(alias.clone()),
            channel: crate::paced_channel::PacedChannel::wrap(
                Arc::new(
                    SignalChannel::new(
                        sig.http_url.clone(),
                        sig.account.clone(),
                        sig.group_ids.clone(),
                        sig.dm_only,
                        alias.clone(),
                        peer_resolver,
                        sig.ignore_attachments,
                        sig.ignore_stories,
                    )
                    .with_proxy_url(sig.proxy_url.clone())
                    .with_approval_timeout_secs(sig.approval_timeout_secs),
                ),
                sig,
            ),
        });
    }

    #[cfg(not(feature = "channel-signal"))]
    if !config.channels.signal.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Signal channel is configured but this build was compiled without \
             `channel-signal`; skipping Signal."
        );
    }

    #[cfg(any(feature = "channel-whatsapp-cloud", feature = "whatsapp-web"))]
    for (alias, wa) in &config.channels.whatsapp {
        if !active_channel_aliases.contains(&format!("whatsapp.{alias}")) {
            continue;
        }
        if !wa.enabled {
            continue;
        }
        if wa.is_ambiguous_config() {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                "WhatsApp config has both phone_number_id (Cloud) and a Web selector (session_path/pair_phone/pair_code/ws_url/mode=personal) set; preferring Cloud API mode. Remove one selector to avoid ambiguity."
            );
        }
        // Runtime negotiation: detect backend type from config
        match wa.backend_type() {
            #[cfg(feature = "channel-whatsapp-cloud")]
            "cloud" => {
                // Cloud API mode: requires phone_number_id, access_token, verify_token
                if wa.is_cloud_config() {
                    let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                        let cfg_arc = config_arc.clone();
                        let alias = alias.clone();
                        Arc::new(move || cfg_arc.read().channel_external_peers("whatsapp", &alias))
                    };
                    channels.push(ConfiguredChannel {
                        display_name: "WhatsApp",
                        alias: Some(alias.clone()),
                        channel: crate::paced_channel::PacedChannel::wrap(
                            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(),
                                    alias.clone(),
                                    peer_resolver,
                                )
                                .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())
                                .with_approval_timeout_secs(wa.approval_timeout_secs),
                            ),
                            wa,
                        ),
                    });
                } else {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                        "WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)"
                    );
                }
                #[cfg(not(feature = "channel-whatsapp-cloud"))]
                {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                        "WhatsApp Cloud API backend requires 'channel-whatsapp-cloud' feature. Build/run with --features channel-whatsapp-cloud"
                    );
                }
            }
            #[cfg(not(feature = "channel-whatsapp-cloud"))]
            "cloud" => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "WhatsApp Cloud API is configured but this build was compiled without `channel-whatsapp-cloud`; skipping WhatsApp Cloud."
                );
            }
            "web" => {
                // Web mode: requires session_path
                #[cfg(feature = "whatsapp-web")]
                if wa.is_web_config() {
                    let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                        let cfg_arc = config_arc.clone();
                        let alias = alias.clone();
                        Arc::new(move || cfg_arc.read().channel_external_peers("whatsapp", &alias))
                    };
                    let workspace_dir = config.channel_workspace_dir(&format!("whatsapp.{alias}"));
                    let allowed_groups_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                        let cfg_arc = config_arc.clone();
                        let alias = alias.clone();
                        Arc::new(move || {
                            cfg_arc
                                .read()
                                .channels
                                .whatsapp
                                .get(&alias)
                                .map(|wa| wa.allowed_groups.clone())
                                .unwrap_or_default()
                        })
                    };
                    channels.push(ConfiguredChannel {
                        display_name: "WhatsApp",
                        alias: Some(alias.clone()),
                        channel: crate::paced_channel::PacedChannel::wrap(
                            Arc::new(
                                WhatsAppWebChannel::new(
                                    wa,
                                    alias.clone(),
                                    peer_resolver,
                                    allowed_groups_resolver,
                                )
                                .with_persistence(config_arc.clone())
                                .with_transcription(config.transcription.clone())
                                .with_tts(&config)
                                .with_workspace_dir(workspace_dir)
                                .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
                                .with_group_mention_patterns(wa.group_mention_patterns.clone()),
                            ),
                            wa,
                        ),
                    });
                } else {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                        "WhatsApp Web configured but session_path not set"
                    );
                }
                #[cfg(not(feature = "whatsapp-web"))]
                {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                        "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");
                }
            }
            _ => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set"
                );
            }
        }
    }

    #[cfg(feature = "channel-linq")]
    for (alias, lq) in &config.channels.linq {
        if !active_channel_aliases.contains(&format!("linq.{alias}")) {
            continue;
        }
        if !lq.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("linq", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "Linq",
            alias: Some(alias.clone()),
            channel: Arc::new(LinqChannel::new(
                lq.api_token.clone(),
                lq.from_phone.clone(),
                alias.clone(),
                peer_resolver,
            )),
        });
    }

    #[cfg(not(feature = "channel-linq"))]
    if !config.channels.linq.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Linq channel is configured but this build was compiled without \
             `channel-linq`; skipping Linq."
        );
    }

    #[cfg(feature = "channel-wati")]
    for (alias, wati_cfg) in &config.channels.wati {
        if !active_channel_aliases.contains(&format!("wati.{alias}")) {
            continue;
        }
        if !wati_cfg.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("wati", &alias))
        };
        let wati_channel = WatiChannel::new_with_proxy(
            wati_cfg.api_token.clone(),
            wati_cfg.api_url.clone(),
            wati_cfg.tenant_id.clone(),
            alias.clone(),
            peer_resolver,
            wati_cfg.proxy_url.clone(),
        )
        .with_transcription(config.transcription.clone());
        channels.push(ConfiguredChannel {
            display_name: "WATI",
            alias: Some(alias.clone()),
            channel: Arc::new(wati_channel),
        });
    }

    #[cfg(not(feature = "channel-wati"))]
    if !config.channels.wati.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "WATI channel is configured but this build was compiled without \
             `channel-wati`; skipping WATI."
        );
    }

    #[cfg(feature = "channel-nextcloud")]
    for (alias, nc) in &config.channels.nextcloud_talk {
        if !active_channel_aliases.contains(&format!("nextcloud_talk.{alias}")) {
            continue;
        }
        if !nc.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || {
                cfg_arc
                    .read()
                    .channel_external_peers("nextcloud_talk", &alias)
            })
        };
        channels.push(ConfiguredChannel {
            display_name: "Nextcloud Talk",
            alias: Some(alias.clone()),
            channel: Arc::new(NextcloudTalkChannel::new_with_proxy(
                nc.base_url.clone(),
                nc.resolve_bot_secret().unwrap_or_else(|e| {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure),
                        &e.to_string()
                    );
                    None
                }),
                nc.bot_name.clone().unwrap_or_default(),
                alias.clone(),
                peer_resolver,
                nc.proxy_url.clone(),
            )),
        });
    }

    #[cfg(not(feature = "channel-nextcloud"))]
    if !config.channels.nextcloud_talk.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Nextcloud Talk channel is configured but this build was compiled without \
             `channel-nextcloud`; skipping Nextcloud Talk."
        );
    }

    #[cfg(feature = "channel-email")]
    {
        // Construct once and share across all email channel instances.
        let auth_service = Arc::new(zeroclaw_providers::auth::AuthService::from_config(&config));

        for (alias, email_cfg) in &config.channels.email {
            if !active_channel_aliases.contains(&format!("email.{alias}")) {
                continue;
            }
            if !email_cfg.enabled {
                continue;
            }
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("email", &alias))
            };
            let mut channel = EmailChannel::new(email_cfg.clone(), alias.clone(), peer_resolver);
            if email_cfg.oauth2.is_some() {
                channel = channel.with_auth_service(auth_service.clone());
            }
            channels.push(ConfiguredChannel {
                display_name: "Email",
                alias: Some(alias.clone()),
                channel: Arc::new(channel),
            });
        }
    }

    #[cfg(feature = "channel-email")]
    for (alias, gp_cfg) in &config.channels.gmail_push {
        if !active_channel_aliases.contains(&format!("gmail_push.{alias}")) {
            continue;
        }
        if !gp_cfg.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("gmail_push", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "Gmail Push",
            alias: Some(alias.clone()),
            channel: Arc::new(GmailPushChannel::new(
                gp_cfg.clone(),
                alias.clone(),
                peer_resolver,
            )),
        });
    }

    #[cfg(not(feature = "channel-email"))]
    if !config.channels.email.is_empty() || !config.channels.gmail_push.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Email/Gmail Push channel is configured but this build was compiled without \
             `channel-email`; skipping Email and Gmail Push."
        );
    }

    #[cfg(feature = "channel-irc")]
    for (alias, irc) in &config.channels.irc {
        if !active_channel_aliases.contains(&format!("irc.{alias}")) {
            continue;
        }
        if !irc.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("irc", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "IRC",
            alias: Some(alias.clone()),
            channel: Arc::new(IrcChannel::new(crate::irc::IrcChannelConfig {
                server: irc.server.clone(),
                port: irc.port,
                nickname: irc.nickname.clone(),
                username: irc.username.clone(),
                channels: irc.channels.clone(),
                alias: alias.clone(),
                peer_resolver,
                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),
                mention_only: irc.mention_only,
            })),
        });
    }

    #[cfg(not(feature = "channel-irc"))]
    if !config.channels.irc.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "IRC channel is configured but this build was compiled without \
             `channel-irc`; skipping IRC."
        );
    }

    #[cfg(feature = "channel-amqp")]
    for (alias, amqp) in &config.channels.amqp {
        if !active_channel_aliases.contains(&format!("amqp.{alias}")) {
            continue;
        }
        if !amqp.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("amqp", &alias))
        };
        let amqp_channel = match AmqpChannel::new(crate::amqp::AmqpChannelConfig {
            amqp_url: amqp.amqp_url.clone(),
            exchange: amqp.exchange.clone(),
            routing_keys: amqp.routing_keys.clone(),
            queue: amqp.queue.clone(),
            ca_cert: amqp.ca_cert.clone(),
            client_cert: amqp.client_cert.clone(),
            client_key: amqp.client_key.clone(),
            sender_label: amqp.sender_label.clone(),
            content_template: amqp.content_template.clone(),
            thread_id_field: amqp.thread_id_field.clone(),
            durable_ack: amqp.durable_ack,
            dispatch: amqp.dispatch,
            engine: sop_engine.clone(),
            audit: sop_audit.clone(),
            alias: alias.clone(),
            peer_resolver,
        }) {
            Ok(ch) => ch,
            Err(err) => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({
                            "alias": alias,
                            "error": err.to_string(),
                        })),
                    "skipping AMQP channel: SOP dispatch without engine/audit handles"
                );
                continue;
            }
        };
        channels.push(ConfiguredChannel {
            display_name: "AMQP",
            alias: Some(alias.clone()),
            channel: Arc::new(amqp_channel),
        });
    }

    #[cfg(not(feature = "channel-amqp"))]
    if !config.channels.amqp.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "AMQP channel is configured but this build was compiled without \
             `channel-amqp`; skipping AMQP."
        );
    }

    #[cfg(feature = "channel-twitch")]
    for (alias, tw) in &config.channels.twitch {
        if !active_channel_aliases.contains(&format!("twitch.{alias}")) {
            continue;
        }
        if !tw.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("twitch", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "Twitch",
            alias: Some(alias.clone()),
            channel: Arc::new(TwitchChannel::new(
                tw.bot_username.clone(),
                tw.oauth_token.clone(),
                tw.channels.clone(),
                tw.mention_only,
                alias.clone(),
                peer_resolver,
            )),
        });
    }

    #[cfg(not(feature = "channel-twitch"))]
    if !config.channels.twitch.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Twitch channel is configured but this build was compiled without \
             `channel-twitch`; skipping Twitch."
        );
    }

    #[cfg(feature = "channel-lark")]
    for (alias, lk) in &config.channels.lark {
        if !active_channel_aliases.contains(&format!("lark.{alias}")) {
            continue;
        }
        if !lk.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("lark", &alias))
        };
        let display_name = if lk.use_feishu { "Feishu" } else { "Lark" };
        channels.push(ConfiguredChannel {
            display_name,
            alias: Some(alias.clone()),
            channel: Arc::new(
                LarkChannel::from_config(lk, alias.clone(), peer_resolver)
                    .with_workspace_dir(config.channel_workspace_dir(&format!("lark.{alias}")))
                    .with_approval_timeout_secs(lk.approval_timeout_secs)
                    .with_per_user_session(lk.per_user_session)
                    .with_ack_reactions(lk.ack_reactions.unwrap_or(config.channels.ack_reactions))
                    .with_streaming(lk.stream_mode, lk.draft_update_interval_ms)
                    .with_transcription(config.transcription.clone()),
            ),
        });
    }

    #[cfg(not(feature = "channel-lark"))]
    if !config.channels.lark.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Lark/Feishu channel is configured but this build was compiled without `channel-lark`; skipping Lark/Feishu health check."
        );
    }

    #[cfg(feature = "channel-line")]
    for (alias, ln) in &config.channels.line {
        if !active_channel_aliases.contains(&format!("line.{alias}")) {
            continue;
        }
        if !ln.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("line", &alias))
        };
        let sender_name_resolver: Arc<dyn Fn() -> Option<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || {
                cfg_arc
                    .read()
                    .channels
                    .line
                    .get(&alias)
                    .and_then(|ln| ln.sender_name.clone())
                    .filter(|s| !s.is_empty())
            })
        };
        channels.push(ConfiguredChannel {
            display_name: "LINE",
            alias: Some(alias.clone()),
            channel: Arc::new(
                LineChannel::from_config(ln, alias.clone(), peer_resolver, sender_name_resolver)
                    .with_persistence(config_arc.clone())
                    .with_transcription(config.transcription.clone()),
            ),
        });
    }

    #[cfg(not(feature = "channel-line"))]
    if !config.channels.line.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "LINE channel is configured but this build was compiled without `channel-line`; skipping LINE health check."
        );
    }

    #[cfg(feature = "channel-dingtalk")]
    for (alias, dt) in &config.channels.dingtalk {
        if !active_channel_aliases.contains(&format!("dingtalk.{alias}")) {
            continue;
        }
        if !dt.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("dingtalk", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "DingTalk",
            alias: Some(alias.clone()),
            channel: Arc::new(
                DingTalkChannel::new(
                    dt.client_id.clone(),
                    dt.client_secret.clone(),
                    alias.clone(),
                    peer_resolver,
                )
                .with_proxy_url(dt.proxy_url.clone()),
            ),
        });
    }

    #[cfg(not(feature = "channel-dingtalk"))]
    if !config.channels.dingtalk.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "DingTalk channel is configured but this build was compiled without \
             `channel-dingtalk`; skipping DingTalk."
        );
    }

    #[cfg(feature = "channel-qq")]
    for (alias, qq) in &config.channels.qq {
        if !active_channel_aliases.contains(&format!("qq.{alias}")) {
            continue;
        }
        if !qq.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("qq", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "QQ",
            alias: Some(alias.clone()),
            channel: Arc::new(
                QQChannel::new(
                    qq.app_id.clone(),
                    qq.app_secret.clone(),
                    alias.clone(),
                    peer_resolver,
                )
                .with_workspace_dir(config.channel_workspace_dir(&format!("qq.{alias}")))
                .with_proxy_url(qq.proxy_url.clone())
                .with_transcription(config.transcription.clone()),
            ),
        });
    }

    #[cfg(not(feature = "channel-qq"))]
    if !config.channels.qq.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "QQ channel is configured but this build was compiled without \
             `channel-qq`; skipping QQ."
        );
    }

    #[cfg(feature = "channel-twitter")]
    for (alias, tw) in &config.channels.twitter {
        if !active_channel_aliases.contains(&format!("twitter.{alias}")) {
            continue;
        }
        if !tw.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("twitter", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "X/Twitter",
            alias: Some(alias.clone()),
            channel: Arc::new(TwitterChannel::new(
                tw.bearer_token.clone(),
                alias.clone(),
                peer_resolver,
            )),
        });
    }

    #[cfg(not(feature = "channel-twitter"))]
    if !config.channels.twitter.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "X/Twitter channel is configured but this build was compiled without \
             `channel-twitter`; skipping X/Twitter."
        );
    }

    #[cfg(feature = "channel-git")]
    for (alias, g) in &config.channels.git {
        if !active_channel_aliases.contains(&format!("git.{alias}")) {
            continue;
        }
        if !g.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("git", &alias))
        };
        match GitChannel::new(g.clone(), alias.clone(), peer_resolver) {
            Ok(channel) => channels.push(ConfiguredChannel {
                display_name: "Git",
                alias: Some(alias.clone()),
                channel: Arc::new(channel),
            }),
            Err(e) => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({
                            "alias": alias,
                            "error": e.to_string(),
                        })),
                    "Git channel alias misconfigured; skipping"
                );
            }
        }
    }

    #[cfg(not(feature = "channel-git"))]
    if !config.channels.git.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Git channel is configured but this build was compiled without \
             `channel-git`; skipping Git."
        );
    }

    #[cfg(feature = "channel-mochat")]
    for (alias, mc) in &config.channels.mochat {
        if !active_channel_aliases.contains(&format!("mochat.{alias}")) {
            continue;
        }
        if !mc.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("mochat", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "Mochat",
            alias: Some(alias.clone()),
            channel: Arc::new(MochatChannel::new(
                mc.api_url.clone(),
                mc.api_token.clone(),
                alias.clone(),
                peer_resolver,
                mc.poll_interval_secs,
            )),
        });
    }

    #[cfg(not(feature = "channel-mochat"))]
    if !config.channels.mochat.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Mochat channel is configured but this build was compiled without \
             `channel-mochat`; skipping Mochat."
        );
    }

    #[cfg(feature = "channel-wecom")]
    for (alias, wc) in &config.channels.wecom {
        if !active_channel_aliases.contains(&format!("wecom.{alias}")) {
            continue;
        }
        if !wc.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("wecom", &alias))
        };
        channels.push(ConfiguredChannel {
            display_name: "WeCom",
            alias: Some(alias.clone()),
            channel: Arc::new(WeComChannel::new(
                wc.webhook_key.clone(),
                alias.clone(),
                peer_resolver,
            )),
        });
    }

    #[cfg(not(feature = "channel-wecom"))]
    if !config.channels.wecom.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "WeCom channel is configured but this build was compiled without \
             `channel-wecom`; skipping WeCom."
        );
    }

    #[cfg(feature = "channel-wecom-ws")]
    for (alias, wc_ws) in &config.channels.wecom_ws {
        if !active_channel_aliases.contains(&format!("wecom_ws.{alias}"))
            && !active_channel_aliases.contains(&format!("wecom-ws.{alias}"))
        {
            continue;
        }
        if !wc_ws.enabled {
            continue;
        }
        let policy_resolver: Arc<dyn Fn() -> WeComWsRuntimePolicy + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            let snapshot = wc_ws.clone();
            Arc::new(move || {
                let config = cfg_arc.read();
                let mut external_peers = config.channel_external_peers("wecom-ws", &alias);
                external_peers.extend(config.channel_external_peers("wecom_ws", &alias));

                if let Some(wc_ws) = config.channels.wecom_ws.get(&alias) {
                    WeComWsRuntimePolicy::from_config(wc_ws, external_peers)
                } else {
                    WeComWsRuntimePolicy::from_config(&snapshot, external_peers)
                }
            })
        };
        match WeComWsChannel::new_with_alias(
            wc_ws,
            alias.clone(),
            policy_resolver,
            &config.channel_workspace_dir(&format!("wecom_ws.{alias}")),
        ) {
            Ok(channel) => channels.push(ConfiguredChannel {
                display_name: "WeCom WebSocket",
                alias: Some(alias.clone()),
                channel: Arc::new(channel),
            }),
            Err(err) => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({"error": format!("{err:#}")})),
                    format!(
                        "WeCom WebSocket channel configuration is invalid; skipping WeCom WebSocket {matrix_skip_context}"
                    ),
                );
            }
        }
    }

    #[cfg(not(feature = "channel-wecom-ws"))]
    if !config.channels.wecom_ws.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            format!(
                "WeCom WebSocket channel is configured but this build was compiled without `channel-wecom-ws`; skipping WeCom WebSocket {matrix_skip_context}."
            ),
        );
    }

    #[cfg(feature = "channel-wechat")]
    for (alias, wechat) in &config.channels.wechat {
        if !active_channel_aliases.contains(&format!("wechat.{alias}")) {
            continue;
        }
        if !wechat.enabled {
            continue;
        }
        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
            let cfg_arc = config_arc.clone();
            let alias = alias.clone();
            Arc::new(move || cfg_arc.read().channel_external_peers("wechat", &alias))
        };
        match WeChatChannel::new(
            alias.clone(),
            peer_resolver,
            wechat.api_base_url.clone(),
            wechat.cdn_base_url.clone(),
            Some(WeChatChannel::resolve_state_dir(
                wechat.state_dir.as_deref(),
            )),
        ) {
            Ok(channel) => {
                channels.push(ConfiguredChannel {
                    display_name: "WeChat",
                    alias: Some(alias.clone()),
                    channel: Arc::new(
                        channel
                            .with_persistence(config_arc.clone())
                            .with_workspace_dir(
                                config.channel_workspace_dir(&format!("wechat.{alias}")),
                            ),
                    ),
                });
            }
            Err(err) => {
                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"matrix_skip_context": matrix_skip_context, "err": err.to_string()})), "WeChat channel configuration is invalid; skipping WeChat");
            }
        }
    }

    #[cfg(not(feature = "channel-wechat"))]
    for alias in config.channels.wechat.keys() {
        if active_channel_aliases.contains(&format!("wechat.{alias}")) {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(::serde_json::json!({"matrix_skip_context": matrix_skip_context})),
                "WeChat channel is configured but this build was compiled without `channel-wechat`; skipping WeChat ."
            );
        }
    }

    #[cfg(feature = "channel-clawdtalk")]
    for (alias, ct) in &config.channels.clawdtalk {
        if !active_channel_aliases.contains(&format!("clawdtalk.{alias}")) {
            continue;
        }
        if !ct.enabled {
            continue;
        }
        channels.push(ConfiguredChannel {
            display_name: "ClawdTalk",
            alias: Some(alias.clone()),
            channel: Arc::new(ClawdTalkChannel::new(alias.clone(), ct.clone())),
        });
    }

    #[cfg(not(feature = "channel-clawdtalk"))]
    if !config.channels.clawdtalk.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "ClawdTalk channel is configured but this build was compiled without \
             `channel-clawdtalk`; skipping ClawdTalk."
        );
    }

    // Notion database poller channel
    #[cfg(feature = "channel-notion")]
    if config.notion.enabled && !config.notion.database_id.trim().is_empty() {
        let notion_api_key = config.notion.api_key.trim().to_string();
        if notion_api_key.is_empty() {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                "Notion channel enabled but `notion.api_key` is unset. Set it via the schema-mirror grammar: \
                 `ZEROCLAW_notion__api_key=...`."
            );
        } else {
            channels.push(ConfiguredChannel {
                display_name: "Notion",
                alias: None,
                channel: Arc::new(NotionChannel::new(
                    "notion",
                    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,
                )),
            });
        }
    }

    #[cfg(not(feature = "channel-notion"))]
    if config.notion.enabled {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Notion channel is enabled but this build was compiled without \
             `channel-notion`; skipping Notion."
        );
    }

    #[cfg(feature = "channel-reddit")]
    for (alias, rd) in &config.channels.reddit {
        if !active_channel_aliases.contains(&format!("reddit.{alias}")) {
            continue;
        }
        if !rd.enabled {
            continue;
        }
        channels.push(ConfiguredChannel {
            display_name: "Reddit",
            alias: Some(alias.clone()),
            channel: Arc::new(RedditChannel::new(
                alias.clone(),
                rd.client_id.clone(),
                rd.client_secret.clone(),
                rd.refresh_token.clone(),
                rd.username.clone(),
                rd.subreddits.clone(),
            )),
        });
    }

    #[cfg(not(feature = "channel-reddit"))]
    if !config.channels.reddit.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Reddit channel is configured but this build was compiled without \
             `channel-reddit`; skipping Reddit."
        );
    }

    #[cfg(feature = "channel-bluesky")]
    for (alias, bs) in &config.channels.bluesky {
        if !active_channel_aliases.contains(&format!("bluesky.{alias}")) {
            continue;
        }
        if !bs.enabled {
            continue;
        }
        channels.push(ConfiguredChannel {
            display_name: "Bluesky",
            alias: Some(alias.clone()),
            channel: Arc::new(BlueskyChannel::new(
                alias.clone(),
                bs.handle.clone(),
                bs.app_password.clone(),
            )),
        });
    }

    #[cfg(not(feature = "channel-bluesky"))]
    if !config.channels.bluesky.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Bluesky channel is configured but this build was compiled without \
             `channel-bluesky`; skipping Bluesky."
        );
    }

    #[cfg(feature = "voice-wake")]
    for (alias, vw) in &config.channels.voice_wake {
        if !active_channel_aliases.contains(&format!("voice_wake.{alias}")) {
            continue;
        }
        if !vw.enabled {
            continue;
        }
        channels.push(ConfiguredChannel {
            display_name: "VoiceWake",
            alias: Some(alias.clone()),
            channel: Arc::new(VoiceWakeChannel::new(
                alias.clone(),
                vw.clone(),
                config.transcription.clone(),
            )),
        });
    }

    #[cfg(not(feature = "voice-wake"))]
    if !config.channels.voice_wake.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "VoiceWake channel is configured but this build was compiled without \
             `voice-wake`; skipping VoiceWake."
        );
    }

    #[cfg(feature = "channel-voice-call")]
    for (alias, vc) in &config.channels.voice_call {
        if !active_channel_aliases.contains(&format!("voice_call.{alias}")) {
            continue;
        }
        if !vc.enabled {
            continue;
        }
        if !vc.has_required_credentials() {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                &format!(
                    "Voice Call channel '{alias}' is enabled but missing required fields \
                     (channels.voice_call.{alias}.account_id, channels.voice_call.{alias}.auth_token, \
                     channels.voice_call.{alias}.from_number); skipping Voice Call to avoid a \
                     connect-fail crashloop."
                )
            );
            continue;
        }
        channels.push(ConfiguredChannel {
            display_name: "Voice Call",
            alias: Some(alias.clone()),
            channel: Arc::new(VoiceCallChannel::new(alias.clone(), vc.clone())),
        });
    }

    #[cfg(not(feature = "channel-voice-call"))]
    if !config.channels.voice_call.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Voice Call channel is configured but this build was compiled without \
             `channel-voice-call`; skipping Voice Call."
        );
    }

    #[cfg(feature = "channel-webhook")]
    for (alias, wh) in &config.channels.webhook {
        if !active_channel_aliases.contains(&format!("webhook.{alias}")) {
            continue;
        }
        if !wh.enabled {
            continue;
        }
        channels.push(ConfiguredChannel {
            display_name: "Webhook",
            alias: Some(alias.clone()),
            channel: crate::paced_channel::PacedChannel::wrap(
                Arc::new(WebhookChannel::new(
                    alias.clone(),
                    wh.port,
                    wh.listen_path.clone(),
                    wh.send_url.clone(),
                    wh.send_method.clone(),
                    wh.auth_header.clone(),
                    wh.secret.clone(),
                    wh.max_retries,
                    wh.retry_base_delay_ms,
                    wh.retry_max_delay_ms,
                )),
                wh,
            ),
        });
    }

    #[cfg(not(feature = "channel-webhook"))]
    if !config.channels.webhook.is_empty() {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Webhook channel is configured but this build was compiled without \
             `channel-webhook`; skipping Webhook."
        );
    }

    ::zeroclaw_log::record!(
        INFO,
        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
            .with_attrs(::serde_json::json!({
                "activated_bindings": active_channel_aliases.enabled_bindings.len(),
                "bindings": active_channel_aliases.enabled_bindings.iter().collect::<Vec<_>>(),
            })),
        "channel binding(s) activated from enabled agents"
    );

    channels
}

fn no_real_time_channels_message() -> &'static str {
    "No real-time channels configured. Run `zeroclaw quickstart` to set one up."
}

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

    #[cfg(feature = "channel-nostr")]
    {
        // Materialize the work list into owned values BEFORE any `.await`
        // so the RwLockReadGuard is dropped before the async constructor
        // runs (parking_lot guards are not Send).
        let nostr_jobs: Vec<(String, String, Vec<String>)> = {
            let config = config_arc.read();
            // Share the same gate as the Discord/shared-collector path so
            // theinvariant ("a disabled agent must not bring its
            // bound channel online") is enforced uniformly — see the
            // `ActiveChannelAliases::compute` constructor for details.
            let active = ActiveChannelAliases::compute(&config);
            config
                .channels
                .nostr
                .iter()
                .filter(|(alias, _)| active.contains(&format!("nostr.{alias}")))
                .filter(|(_, ns)| ns.enabled)
                .map(|(alias, ns)| (alias.clone(), ns.private_key.clone(), ns.relays.clone()))
                .collect()
        };
        for (alias, private_key, relays) in nostr_jobs {
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                let cfg_arc = config_arc.clone();
                let alias = alias.clone();
                Arc::new(move || cfg_arc.read().channel_external_peers("nostr", &alias))
            };
            channels.push(ConfiguredChannel {
                display_name: "Nostr",
                alias: Some(alias.clone()),
                channel: Arc::new(
                    NostrChannel::new(&private_key, relays, alias, peer_resolver).await?,
                ),
            });
        }
    }

    #[cfg(not(feature = "channel-nostr"))]
    {
        let config = config_arc.read();
        if !config.channels.nostr.is_empty() {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                "Nostr channel is configured but this build was compiled without \
                 `channel-nostr`; skipping Nostr health check."
            );
        }
    }

    if channels.is_empty() {
        println!("{}", no_real_time_channels_message());
        return Ok(());
    }

    println!("🩺 ZeroClaw 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_arc.read().channels.webhook.is_empty() {
        println!("  ℹ️  Webhook   check via `zeroclaw gateway` then GET /health");
    }

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

fn build_owner_by_channel_key(
    config: &Config,
    enabled_agents: &[String],
    collected_channel_keys: &[String],
) -> HashMap<String, String> {
    // Owner map: `<channel_type>.<alias>` (and bare `<channel_type>` for
    // backward-compat with cron callers / singleton channels) → agent_alias.
    // Built from each enabled agent's `agents.<alias>.channels` list — the
    // schema treats this as the source of truth for channel ownership.
    let mut owner_by_channel_key: HashMap<String, String> = HashMap::new();
    for alias_str in enabled_agents {
        let Some(agent_cfg) = config.agents.get(alias_str) else {
            debug_assert!(
                false,
                "enabled agent alias missing from config.agents: {}",
                alias_str
            );
            continue;
        };
        for ch in &agent_cfg.channels {
            let ch_str: &str = ch.as_ref();
            owner_by_channel_key.insert(ch_str.to_string(), alias_str.clone());
            if let Some((bare, _)) = ch_str.split_once('.') {
                owner_by_channel_key
                    .entry(bare.to_string())
                    .or_insert_with(|| alias_str.clone());
            }
        }
    }

    let any_binding_declared_anywhere = config.agents.values().any(|a| !a.channels.is_empty());

    if any_binding_declared_anywhere {
        if owner_by_channel_key.is_empty() && !collected_channel_keys.is_empty() {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                "channel bindings exist but no owning agent is enabled; \
                 affected channels will be unbound and inbound messages dropped (#8013)"
            );
        }
        return owner_by_channel_key;
    }

    // True legacy mode: no agent anywhere declares a binding. Preserve the
    // existing deterministic fallback so on-disk session hydration and the
    // pre-existing `build_owner_by_channel_key_legacy_fallback_*` tests
    // continue to work.
    if !collected_channel_keys.is_empty() {
        let fallback_owner = config
            .resolved_runtime_agent_alias()
            .filter(|alias| enabled_agents.iter().any(|enabled| enabled == *alias))
            .map(ToString::to_string)
            .or_else(|| enabled_agents.first().cloned());

        if let Some(owner_alias) = fallback_owner {
            for channel_key in collected_channel_keys {
                owner_by_channel_key.insert(channel_key.clone(), owner_alias.clone());
                if let Some((bare, _)) = channel_key.split_once('.') {
                    owner_by_channel_key
                        .entry(bare.to_string())
                        .or_insert_with(|| owner_alias.clone());
                }
            }
        }
    }

    owner_by_channel_key
}

/// The per-agent tool registry, prompt sections, and channel/deferred-MCP handles
/// `start_channels` needs from [`assemble_channel_agent_tools`].
struct ChannelAssembledTools {
    tools: Vec<Box<dyn Tool>>,
    deferred_section: String,
    pinned_section: String,
    ask_user_handle: Option<tools::PerToolChannelHandle>,
    reaction_handle: tools::PerToolChannelHandle,
    poll_handle: Option<tools::PerToolChannelHandle>,
    escalate_handle: Option<tools::PerToolChannelHandle>,
    channel_room_handle: Option<tools::PerToolChannelHandle>,
    activated_handle: Option<Arc<std::sync::Mutex<tools::ActivatedToolSet>>>,
}

/// Route a channel agent's tool registry through the one gated seam
/// (`ScopedToolRegistry::assemble`) - the same seam `run()`/`process_message()`/
/// `Agent::from_config` use. Extracted from `start_channels` so the channel path's
/// specific assembly knobs (below) are exercised directly by a unit test instead of
/// only indirectly through `start_channels`'s much larger, harder-to-isolate flow.
///
/// Replaces the channel path's former hand-rolled peripheral wiring, built-in
/// filter, MCP scoping, and skill registration - which had silently diverged from
/// every other construction path in two ways this cutover closes: MCP
/// resource/prompt capability tools and pinned MCP resources
/// (`docs/book/src/tools/mcp.md` "Pinning resources into context", a documented
/// general agent capability with no channel-specific exception) were never wired
/// into the channel path at all.
///
/// - `connect_peripherals: true` - channel-driven sessions actuate hardware,
///   mirroring the old unconditional `load_peripheral_tools` call.
/// - `runtime` - the orchestrator's REAL configured `RuntimeAdapter`, threaded
///   through skill execution. The old `register_skill_tools_with_context` call
///   defaulted to `NativeRuntime` regardless of `[platform]`.
/// - `connect_mcp: true`, `exclude_memory: false`, `caller_allowed: None` - match
///   the channel path's pre-cutover behavior exactly (no allowlist narrowing beyond
///   the agent's own policy; memory tools kept; MCP connected whenever
///   `config.mcp.enabled`).
///
/// Test coverage: the `assemble_channel_agent_tools_*` tests below drive this
/// function directly. They pin `exclude_memory: false` (memory tools survive),
/// the built-in allow/deny and runtime-threading behavior, and -- via a mock MCP
/// server granting a pinned resource -- that `connect_mcp: true` resolves MCP
/// content into a `pinned_section` kept separate from the deferred tool-search
/// listing. `connect_peripherals: true` is still only exercised as a literal
/// value: `load_peripheral_tools` reads a process-global `OnceLock` that stays
/// empty outside the real daemon binary, so peripheral-tool inclusion cannot be
/// unit-tested here and a regression flipping that knob to `false` would still
/// pass. Closing it needs a daemon-level peripheral harness; tracked as a
/// residual, not silently skipped.
async fn assemble_channel_agent_tools(
    config: &Config,
    agent_alias: &str,
    model_provider: &str,
    model: &str,
    security: &Arc<SecurityPolicy>,
    built: tools::AllToolsResult,
    skills: &[zeroclaw_runtime::skills::Skill],
    runtime: Arc<dyn platform::RuntimeAdapter>,
) -> ChannelAssembledTools {
    use zeroclaw_log::Instrument as _;

    let agent_attribution = zeroclaw_runtime::agent::AgentAttribution(agent_alias);
    let assembled = async {
        zeroclaw_log::scope!(
            model_provider: model_provider,
            model: model,
            => async {
                zeroclaw_runtime::tools::scoped::ScopedToolRegistry::assemble(
                    zeroclaw_runtime::tools::scoped::ScopedAssembly {
                        config,
                        agent_alias,
                        security,
                        built,
                        skills,
                        runtime,
                        caller_allowed: None,
                        connect_mcp: true,
                        connect_peripherals: true,
                        exclude_memory: false,
                        // Channel listeners (Telegram, Slack, ...) do not transport an
                        // ACP file attachment, so `deliver_file` is dropped here; only
                        // the ACP turn path (Agent::from_*_backchannel) opts it in.
                        acp_delivery: false,
                        // Channel startup is an execution surface (the agent actually runs),
                        // so deferral behaves as normal; the dashboard-only per-spec listing
                        // is off, matching `run`/`process_message`.
                        list_deferred_mcp_specs: false,
                        emit_assembly_logs: true,
                        // Channel tools are assembled once at daemon startup and
                        // retain their registry-backed wrappers for the listener
                        // lifetime, so there is no per-turn reconnect to avoid here.
                        // The heartbeat worker remains the only caller that supplies
                        // a pre-built registry for reuse across repeated assemblies.
                        mcp_registry: None,
                    },
                )
                .await
            }
        )
        .await
    }
    .instrument(zeroclaw_log::attribution_span!(&agent_attribution))
    .await;
    let deferred_section = assembled.deferred_section().to_string();
    let pinned_section = assembled.pinned_section().to_string();
    let zeroclaw_runtime::tools::scoped::ScopedAssembled {
        registry,
        // `assemble` threads the target's own `delegate_handle` into eager MCP
        // registration internally (mirroring `run`/`process_message`, which also
        // discard it here) - the channel path never separately needed it after
        // that internal registration completes.
        delegate_handle: _,
        ask_user_handle,
        reaction_handle,
        poll_handle,
        escalate_handle,
        channel_room_handle,
        activated_handle,
        ..
    } = assembled;
    ChannelAssembledTools {
        tools: registry.into_inner(),
        deferred_section,
        pinned_section,
        ask_user_handle,
        reaction_handle,
        poll_handle,
        escalate_handle,
        channel_room_handle,
        activated_handle,
    }
}

/// Compose a channel agent's post-assembly MCP prompt sections in the order the
/// system prompt requires: apply the strict text-tool suppression policy to ONLY
/// the deferred/tool-search section, then append the pinned MCP resource section
/// afterward. This keeps the two concerns separate so that a strict, non-native
/// target (which clears the deferred tool-search listing) still starts with its
/// granted pinned MCP resources intact. Returns whether the text-tool protocol
/// should be exposed.
///
/// Single-sourced on purpose: `start_channels` and its regression test both call
/// this exact step, so a future edit that reorders the policy/append pair (or
/// applies suppression to a combined section) fails the test instead of silently
/// dropping pinned resources.
fn compose_channel_mcp_prompt_sections(
    native_tools: bool,
    strict_tool_parsing: bool,
    tool_descs: &mut Vec<(&str, &str)>,
    deferred_section: &mut String,
    pinned_section: &str,
) -> bool {
    let expose_text_tool_protocol = apply_text_tool_prompt_policy(
        native_tools,
        strict_tool_parsing,
        tool_descs,
        deferred_section,
    );
    append_pinned_mcp_section(deferred_section, pinned_section);
    expose_text_tool_protocol
}

/// Start all configured channels and route messages to the agent
#[allow(clippy::too_many_lines)]
pub async fn start_channels(
    config: Config,
    canvas_store: Option<zeroclaw_runtime::tools::CanvasStore>,
    cancel: tokio_util::sync::CancellationToken,
    sop_engine: Option<Arc<std::sync::Mutex<zeroclaw_runtime::sop::SopEngine>>>,
    sop_audit: Option<Arc<zeroclaw_runtime::sop::SopAuditLogger>>,
) -> Result<()> {
    let config_arc = Arc::new(RwLock::new(config));
    let config: Config = config_arc.read().clone();
    let any_agent_provider_resolves = config
        .agents
        .iter()
        .filter(|(_, a)| a.enabled)
        .any(|(_, a)| runtime_defaults_from_config(&config, a.model_provider.as_str()).is_ok());
    if !any_agent_provider_resolves {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
            "Channels supervisor: no model configured. Waiting for reload \
             (complete onboarding at /onboard or set \
             [providers.models.<type>.<alias>] model = \"...\" and reload)."
        );
        cancel.cancelled().await;
        return Ok(());
    }

    zeroclaw_providers::pricing::spawn_refresher(config_arc.clone());

    let enabled_agents: Vec<String> = {
        let mut v: Vec<String> = config
            .agents
            .iter()
            .filter(|(_, a)| a.enabled)
            .map(|(alias, _)| alias.clone())
            .collect();
        if v.is_empty() {
            anyhow::bail!("start_channels requires at least one enabled [agents.<alias>] entry");
        }
        v.sort();
        v
    };

    let observer: Arc<dyn Observer> =
        Arc::from(observability::create_observer(&config.observability));
    let runtime: Arc<dyn platform::RuntimeAdapter> =
        Arc::from(platform::create_runtime(&config.runtime)?);

    // i18n is process-global; initialize once before the per-agent loop
    // touches tool descriptions.
    let i18n_locale = config
        .locale
        .as_deref()
        .filter(|s| !s.is_empty())
        .map(ToString::to_string)
        .unwrap_or_else(zeroclaw_runtime::i18n::detect_locale);
    zeroclaw_runtime::i18n::init(&i18n_locale);

    // Single session backend shared across agents — they're scoped by
    // `session_key` (which already encodes `<channel_type>.<alias>`), so
    // multiple agent ctxs reading the same backend never overlap.
    let shared_session_store: Option<Arc<dyn zeroclaw_infra::session_backend::SessionBackend>> =
        if config.channels.session_persistence {
            match zeroclaw_infra::make_session_backend(
                &config.data_dir,
                &config.channels.session_backend,
            ) {
                Ok(backend) => {
                    ::zeroclaw_log::record!(
                        INFO,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
                        &format!(
                            "📂 Session persistence enabled (backend: {})",
                            config.channels.session_backend
                        )
                    );
                    Some(backend)
                }
                Err(e) => {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                        "Session persistence disabled"
                    );
                    None
                }
            }
        } else {
            None
        };

    let mut channels_by_name_shared: Option<Arc<HashMap<String, Arc<dyn Channel>>>> = None;
    let mut collected_channel_keys: Vec<String> = Vec::new();
    let mut max_in_flight_messages: Option<usize> = None;
    let mut listener_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
    let mut rx_holder: Option<tokio::sync::mpsc::Receiver<zeroclaw_api::channel::ChannelMessage>> =
        None;

    let mut agent_ctxs: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();

    for agent_alias in &enabled_agents {
        let agent = config
            .resolved_agent_config(agent_alias)
            .with_context(|| format!("agents.{agent_alias} is not configured"))?;
        let risk_profile = config
            .risk_profile_for_agent(agent_alias)
            .with_context(|| {
                format!(
                    "agents.{agent_alias}.risk_profile does not name a configured risk_profiles entry"
                )
            })?
            .clone();

        // Resolve the agent's model provider strictly from its mandatory
        // `<type>.<alias>` reference. No fallback to a first/default provider:
        // an agent whose ref does not resolve to a configured entry with a
        // `model` is rejected here.
        let runtime_defaults = runtime_defaults_from_config(&config, agent.model_provider.as_str())
            .with_context(|| format!("agents.{agent_alias}.model_provider"))?;
        let provider_name = runtime_defaults.default_model_provider.clone();
        let model = runtime_defaults.model.clone();
        let temperature = runtime_defaults.temperature;
        let provider_api_key = runtime_defaults.api_key.clone();
        let provider_api_url = runtime_defaults.api_url.clone();
        let provider_reliability = runtime_defaults.reliability.clone();
        let provider_runtime_options =
            zeroclaw_providers::provider_runtime_options_for_agent(&config, agent_alias);
        let model_provider: Arc<dyn ModelProvider> = Arc::from(
            create_resilient_model_provider_nonblocking(
                Arc::new(config.clone()),
                &provider_name,
                provider_api_key.clone(),
                provider_api_url.clone(),
                provider_reliability.clone(),
                provider_runtime_options.clone(),
            )
            .await?,
        );

        if let Err(e) = ProviderDispatch::from_ref(&*model_provider).warmup().await {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                    .with_attrs(
                        ::serde_json::json!({"error": format!("{}", e), "agent": agent_alias})
                    ),
                "ModelProvider warmup failed (non-fatal)"
            );
        }

        let security = Arc::new(SecurityPolicy::for_agent(&config, agent_alias)?);
        let mem: Arc<dyn Memory> = zeroclaw_memory::create_memory_for_agent(
            &config,
            agent_alias,
            provider_api_key.as_deref(),
        )
        .await?;
        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)
        };

        let workspace = config.agent_workspace_dir(agent_alias);
        // Per-agent skills: install-wide workspace + open_skills set,
        // unioned with this agent's declared `skill_bundles`.
        let skills =
            zeroclaw_runtime::skills::load_skills_for_agent(&workspace, &config, agent_alias);

        let all_tools_result_ch = tools::all_tools_with_runtime(
            Arc::new(config.clone()),
            &security,
            &risk_profile,
            agent_alias,
            Arc::clone(&runtime),
            Arc::clone(&mem),
            composio_key,
            composio_entity_id,
            &config.browser,
            &config.http_request,
            &config.web_fetch,
            &workspace,
            &config.agents,
            provider_api_key.as_deref(),
            &config,
            canvas_store.clone(),
            false,
            None,
            sop_engine.clone(),
            sop_audit.clone(),
            Some(Arc::clone(&config_arc)),
        );
        // Route the per-agent tool registry through the one gated seam - see
        // `assemble_channel_agent_tools` for the knobs and why. `mut` because the
        // text-tool prompt policy below may clear `deferred_section` for a
        // non-native strict-tool-parsing target.
        let ChannelAssembledTools {
            tools: built_tools,
            mut deferred_section,
            pinned_section,
            ask_user_handle: ask_user_handle_ch,
            reaction_handle: reaction_handle_ch,
            poll_handle: poll_handle_ch,
            escalate_handle: escalate_handle_ch,
            channel_room_handle: channel_room_handle_ch,
            activated_handle: ch_activated_handle,
        } = assemble_channel_agent_tools(
            &config,
            agent_alias,
            provider_name.as_str(),
            model.as_str(),
            &security,
            all_tools_result_ch,
            &skills,
            Arc::clone(&runtime),
        )
        .await;

        let tool_specs: Vec<(String, String)> = built_tools
            .iter()
            .map(|t| (t.name().to_string(), t.description().to_string()))
            .collect();

        let tools_registry = Arc::new(built_tools);

        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.",
            ),
            (
                "memory_store",
                "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
            ),
            (
                "memory_recall",
                "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
            ),
            (
                "memory_forget",
                "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
            ),
        ];

        if matches!(
            config.effective_skills_prompt_mode(agent_alias),
            zeroclaw_config::schema::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.",
        ));
        tool_descs.push((
            "channel_room",
            "Create channel rooms and invite users through active channels. Use with Matrix channel keys such as matrix.default.",
        ));
        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.",
            ));
        }
        if config.channels.email.values().any(|c| c.enabled) {
            tool_descs.push((
                "email_search",
                "Search the IMAP inbox by sender, subject, or date. Returns a list of matching emails with UID, sender, subject, and date. Use when asked about email. Follow up with email_read to fetch the full body.",
            ));
            tool_descs.push((
                "email_read",
                "Fetch the full content of an email by its UID (from email_search). Returns sender, to, date, subject, body text, and attachments.",
            ));
        }

        // Filter out tools excluded for non-CLI channels so this agent's
        // system prompt does not advertise them for channel-driven runs.
        {
            let active_profile = &risk_profile;
            let excluded = &active_profile.excluded_tools;
            if !excluded.is_empty() && active_profile.level != AutonomyLevel::Full {
                tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
            }
        }
        let effective_tool_names: HashSet<&str> =
            tools_registry.iter().map(|tool| tool.name()).collect();
        tool_descs.retain(|(name, _)| effective_tool_names.contains(name));

        let bootstrap_max_chars = if agent.resolved.compact_context {
            Some(6000)
        } else {
            None
        };
        let native_tools = model_provider.supports_native_tools();
        let expose_text_tool_protocol = compose_channel_mcp_prompt_sections(
            native_tools,
            agent.resolved.strict_tool_parsing,
            &mut tool_descs,
            &mut deferred_section,
            &pinned_section,
        );
        let mut system_prompt = build_system_prompt_with_mode_and_autonomy(
            &workspace,
            &model,
            &tool_descs,
            &skills,
            Some(&agent.identity),
            bootstrap_max_chars,
            Some(&risk_profile),
            native_tools,
            config.effective_skills_prompt_mode(agent_alias),
            agent.resolved.compact_context,
            agent.resolved.max_system_prompt_chars,
            true,
            config.channels.show_tool_calls,
        );
        if expose_text_tool_protocol {
            system_prompt.push_str(&build_tool_instructions_for_names(
                tools_registry.as_ref(),
                &effective_tool_names,
            ));
        }
        if !deferred_section.is_empty() {
            system_prompt.push('\n');
            system_prompt.push_str(&deferred_section);
        }
        if agent.resolved.tool_receipts.enabled && agent.resolved.tool_receipts.inject_system_prompt
        {
            system_prompt.push_str(zeroclaw_runtime::agent::tool_receipts::SYSTEM_PROMPT_ADDENDUM);
        }

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

            #[allow(unused_mut)]
            let mut configured_channels: Vec<ConfiguredChannel> = collect_configured_channels(
                &config_arc,
                "runtime startup",
                &tool_specs,
                sop_engine.clone(),
                sop_audit.clone(),
            );

            #[cfg(feature = "channel-nostr")]
            {
                let active = ActiveChannelAliases::compute(&config);
                // Materialize the work list into owned values BEFORE any
                // `.await` so we don't hold any lock across the async
                // constructor (parking_lot guards are not Send). Mirrors
                // the same pattern in `doctor_channels`.
                let nostr_jobs: Vec<(String, String, Vec<String>)> = config
                    .channels
                    .nostr
                    .iter()
                    .filter(|(alias, _)| active.contains(&format!("nostr.{alias}")))
                    .filter(|(_, ns)| ns.enabled)
                    .map(|(alias, ns)| (alias.clone(), ns.private_key.clone(), ns.relays.clone()))
                    .collect();
                for (alias, private_key, relays) in nostr_jobs {
                    let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
                        let cfg_arc = config_arc.clone();
                        let alias = alias.clone();
                        Arc::new(move || cfg_arc.read().channel_external_peers("nostr", &alias))
                    };
                    configured_channels.push(ConfiguredChannel {
                        display_name: "Nostr",
                        alias: Some(alias.clone()),
                        channel: Arc::new(
                            NostrChannel::new(&private_key, relays, alias, peer_resolver).await?,
                        ),
                    });
                }
            }
            #[cfg(not(feature = "channel-nostr"))]
            if !config.channels.nostr.is_empty() {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "Nostr channel is configured but this build was compiled without \
                     `channel-nostr`; skipping Nostr."
                );
            }
            #[cfg(feature = "channel-filesystem")]
            if let (Some(engine), Some(audit)) = (sop_engine.as_ref(), sop_audit.as_ref()) {
                let active = ActiveChannelAliases::compute(&config);
                for (alias, fs_cfg) in &config.channels.filesystem {
                    if !active.contains(&format!("filesystem.{alias}")) {
                        continue;
                    }
                    if !fs_cfg.enabled {
                        continue;
                    }
                    configured_channels.push(ConfiguredChannel {
                        display_name: "Filesystem",
                        alias: Some(alias.clone()),
                        channel: Arc::new(crate::filesystem::FilesystemChannel::new(
                            crate::filesystem::FilesystemChannelConfig {
                                config: fs_cfg.clone(),
                                alias: alias.clone(),
                                engine: engine.clone(),
                                audit: audit.clone(),
                            },
                        )),
                    });
                }
            }
            #[cfg(not(feature = "channel-filesystem"))]
            if !config.channels.filesystem.is_empty() {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "Filesystem channel is configured but this build was compiled without \
                     `channel-filesystem`; skipping Filesystem."
                );
            }
            let channels: Vec<Arc<dyn Channel>> = configured_channels
                .iter()
                .map(|cc| Arc::clone(&cc.channel))
                .collect();
            if channels.is_empty() {
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
                    "No active channels to supervise (none configured or all disabled). \
                     Waiting for reload signal."
                );
                cancel.cancelled().await;
                return Ok(());
            }

            println!("🦀 ZeroClaw Channel Server");
            println!("  🤖 Model:    {model} (agent: {agent_alias})");
            let effective_backend = config.resolve_active_storage().kind();
            println!(
                "  🧠 Memory:   {} (auto-save: {})",
                effective_backend,
                if config.memory.auto_save { "on" } else { "off" }
            );
            let channel_labels: Vec<String> = configured_channels
                .iter()
                .map(|cc| composite_channel_key(cc.channel.name(), cc.alias.as_deref()))
                .collect();
            collected_channel_keys = channel_labels.clone();
            println!("  📡 Channels: {}", channel_labels.join(", "));
            println!("  🤖 Agents:   {}", enabled_agents.join(", "));
            println!();
            println!("  Listening for messages... (Ctrl+C to stop)");
            println!();

            zeroclaw_runtime::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);

            let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(100);

            for cc in &configured_channels {
                listener_handles.push(spawn_supervised_listener(
                    cc.channel.clone(),
                    cc.alias.clone(),
                    tx.clone(),
                    initial_backoff_secs,
                    max_backoff_secs,
                    cancel.clone(),
                ));
            }
            drop(tx);

            // Composite-key registry (see `composite_channel_key`).
            let cbn = Arc::new(configured_channel_map(&configured_channels));
            *CRON_CHANNEL_REGISTRY
                .write()
                .unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&cbn));

            let in_flight = max_in_flight_messages_for_config(channels.len(), &config.channels);
            println!("  🚦 In-flight message limit: {in_flight}");

            max_in_flight_messages = Some(in_flight);
            channels_by_name_shared = Some(cbn);
            rx_holder = Some(rx);
        }

        let channels_by_name = Arc::clone(
            channels_by_name_shared
                .as_ref()
                .expect("channels_by_name initialized on first iteration"),
        );

        // Wire this agent's reaction / ask_user / channel room / escalate tool handles
        // into the shared `channels_by_name` map.
        {
            let mut map = reaction_handle_ch.write();
            for (name, ch) in channels_by_name.as_ref() {
                map.insert(name.clone(), Arc::clone(ch));
            }
        }
        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));
            }
        }
        if let Some(ref handle) = channel_room_handle_ch {
            let mut map = handle.write();
            for (name, ch) in channels_by_name.as_ref() {
                map.insert(name.clone(), Arc::clone(ch));
            }
        }
        if let Some(ref handle) = poll_handle_ch {
            let mut map = handle.write();
            for (name, ch) in channels_by_name.as_ref() {
                map.insert(name.clone(), Arc::clone(ch));
            }
        }
        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 mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
        provider_cache_seed.insert(provider_name.clone(), Arc::clone(&model_provider));
        let message_timeout_secs =
            effective_channel_message_timeout_secs(config.channels.message_timeout_secs);
        let interrupt_on_new_message = interrupt_on_new_message_config(&config.channels);

        let memory_strategy: Arc<dyn MemoryStrategy> = Arc::new(
            zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                Arc::clone(&mem),
                config.memory.clone(),
                config.data_dir.clone(),
            ),
        );

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::clone(&channels_by_name),
            model_provider: Arc::clone(&model_provider),
            model_provider_ref: Arc::new(provider_name.clone()),
            agent_alias: Arc::new(agent_alias.clone()),
            agent_cfg: Arc::new(agent.clone()),
            prompt_config: Arc::new(config.clone()),
            memory: Arc::clone(&mem),
            memory_strategy,
            tools_registry: Arc::clone(&tools_registry),
            observer: Arc::clone(&observer),
            system_prompt: Arc::new(system_prompt),
            model: Arc::new(model.clone()),
            temperature,
            auto_save_memory: config.memory.auto_save,
            max_tool_iterations: config.effective_max_tool_iterations(agent_alias.as_str()),
            min_relevance_score: config.memory.min_relevance_score,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(config.reliability.clone()),
            provider_runtime_options,
            workspace_dir: Arc::new(workspace.clone()),
            message_timeout_secs,
            interrupt_on_new_message,
            multimodal: config.multimodal.clone(),
            media_pipeline: config.media_pipeline.clone(),
            transcription_config: config.transcription.clone(),
            agent_transcription_provider: agent.transcription_provider.as_str().to_string(),
            hooks: if config.hooks.enabled {
                Some(Arc::new(zeroclaw_runtime::hooks::HookRunner::from_config(
                    &config.hooks,
                )))
            } else {
                None
            },
            non_cli_excluded_tools: Arc::new(risk_profile.excluded_tools.clone()),
            autonomy_level: risk_profile.level,
            tool_call_dedup_exempt: Arc::new(agent.resolved.tool_call_dedup_exempt.clone()),
            model_routes: Arc::new(config.model_routes.clone()),
            query_classification: config.query_classification.clone(),
            ack_reactions: config.channels.ack_reactions,
            show_tool_calls: config.channels.show_tool_calls,
            session_store: shared_session_store.clone(),
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(&risk_profile)),
            activated_tools: ch_activated_handle,
            cost_tracking: zeroclaw_runtime::cost::CostTracker::get_or_init_global(
                config.cost.clone(),
                &config.data_dir,
            )
            .map(|tracker| {
                let by_type =
                    zeroclaw_runtime::agent::cost::build_type_level_model_provider_pricing(&config);
                ChannelCostTrackingState {
                    tracker,
                    model_provider_pricing: Arc::new(by_type),
                    agent_alias: Arc::new(agent_alias.clone()),
                }
            }),
            pacing: config.pacing.clone(),
            max_tool_result_chars: agent.resolved.max_tool_result_chars,
            context_token_budget: agent.resolved.max_context_tokens,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::from_millis(config.channels.debounce_ms),
            )),
            receipt_generator: if agent.resolved.tool_receipts.enabled {
                Some(zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator::new())
            } else {
                None
            },
            show_receipts_in_response: agent.resolved.tool_receipts.show_in_response,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: sop_engine.clone(),
            sop_audit: sop_audit.clone(),
        });

        agent_ctxs.insert(agent_alias.clone(), runtime_ctx);
    }

    let owner_by_channel_key =
        build_owner_by_channel_key(&config, &enabled_agents, &collected_channel_keys);

    // Hydrate persisted session histories into the owning agent's
    // `conversation_histories` LRU. Sessions whose channel has no enabled
    // owner are skipped so their history doesn't end up loaded into the
    // fallback agent (which wouldn't reply on that channel anyway).
    if let Some(ref store) = shared_session_store {
        let mut metadata = store.list_sessions_with_metadata();
        metadata.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
        // Budget proportional to the number of agents — each gets up to
        // `MAX_CONVERSATION_SENDERS` slots, so a multi-agent install
        // hydrates strictly more total sessions than a single-agent one.
        let cap = MAX_CONVERSATION_SENDERS.saturating_mul(enabled_agents.len().max(1));
        if metadata.len() > cap {
            metadata.truncate(cap);
        }

        let mut hydrated = 0usize;
        let mut orphans_closed = 0usize;
        for m in metadata {
            let owner_agent = m
                .channel_id
                .as_deref()
                .and_then(|cid| owner_by_channel_key.get(cid).cloned())
                .or_else(|| {
                    m.channel_id
                        .as_deref()
                        .and_then(|cid| cid.split_once('.').map(|(b, _)| b.to_string()))
                        .and_then(|b| owner_by_channel_key.get(&b).cloned())
                });
            let target_ctx = match owner_agent.as_ref().and_then(|a| agent_ctxs.get(a)) {
                Some(ctx) => ctx,
                None => continue,
            };
            let mut msgs = store.load(&m.key);
            if msgs.is_empty() {
                continue;
            }
            if msgs.len() > MAX_CHANNEL_HISTORY {
                msgs.drain(..msgs.len() - MAX_CHANNEL_HISTORY);
            }
            if msgs.last().is_some_and(|msg| msg.role == "user") {
                let closure =
                    ChatMessage::assistant("[Session interrupted — not continuing this request]");
                if let Err(e) = store.append(&m.key, &closure) {
                    ::zeroclaw_log::record!(
                        DEBUG,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                        &format!("Failed to persist orphan closure for {}", m.key)
                    );
                }
                msgs.push(closure);
                orphans_closed += 1;
            }
            let pruned =
                zeroclaw_runtime::agent::history_pruner::remove_orphaned_tool_messages(&mut msgs);
            if !pruned.is_empty() {
                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"category": "agent", "agent_alias": owner_agent.as_deref().unwrap_or(""), "channel": m.channel_id.as_deref().unwrap_or(""), "session_key": m.key, "removed": pruned.removed, "orphan_tool_call_ids": pruned.orphan_tool_call_ids})), "removed orphaned tool messages from restored history (tool_use/tool_result pairing inconsistency auto-healed)");
            }

            let mut histories = target_ctx
                .conversation_histories
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            histories.push(m.key.clone(), msgs);
            drop(histories);
            hydrated += 1;
        }
        if hydrated > 0 {
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"hydrated": hydrated})),
                "restored sessions from disk"
            );
        }
        if orphans_closed > 0 {
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({"orphans_closed": orphans_closed})),
                "closed orphaned session turns from previous crash"
            );
        }
    }

    let router = AgentRouter::multi(agent_ctxs, owner_by_channel_key, sop_engine, sop_audit);

    let rx = rx_holder.expect("rx initialized by first agent's channel setup");
    let max_in_flight =
        max_in_flight_messages.expect("max_in_flight initialized by first agent's channel setup");
    run_message_dispatch_loop(rx, router, max_in_flight).await;

    for h in listener_handles {
        let _ = h.await;
    }

    Ok(())
}

pub async fn deliver_announcement(
    config: &zeroclaw_config::schema::Config,
    channel: &str,
    target: &str,
    thread_id: Option<String>,
    output: &str,
) -> anyhow::Result<()> {
    use zeroclaw_api::channel::SendMessage;

    let safe_output = redact_channel_outbound_leaks(
        output,
        &config.security.leak_detection,
        outbound_content_format_for_channel(channel),
    );
    let safe_output = ensure_nonempty_channel_reply(safe_output, output, channel, target);

    let make_msg = |s: &str| SendMessage::new(s, target).in_thread(thread_id.clone());

    // Snapshot out of the sync RwLock before awaiting. Use the live
    // channel instance when available — critical for Matrix E2EE which
    // must reuse the authenticated client rather than re-running session
    // restore per delivery.
    let registry_snapshot = CRON_CHANNEL_REGISTRY
        .read()
        .unwrap_or_else(|e| e.into_inner())
        .clone();
    if let Some(registry) = registry_snapshot
        && let Some(ch) = registry.get(channel.to_ascii_lowercase().as_str())
    {
        return ch.send(&make_msg(&safe_output)).await;
    }

    let (raw_type, alias) = channel.split_once('.').ok_or_else(|| {
        anyhow::Error::msg(format!(
            "delivery channel {channel:?} must be a dotted <type>.<alias> ref (e.g. telegram.work)"
        ))
    })?;
    let channel_type = raw_type.to_ascii_lowercase();
    #[allow(unused_variables)]
    let not_configured = || {
        ::zeroclaw_log::record!(
            ERROR,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                .with_outcome(::zeroclaw_log::EventOutcome::Failure),
            &format!("[channels.{channel_type}.{alias}] not configured")
        );
        anyhow::Error::msg(format!("[channels.{channel_type}.{alias}] not configured"))
    };
    match channel_type.as_str() {
        #[cfg(feature = "channel-telegram")]
        "telegram" => {
            let tg = config
                .channels
                .telegram
                .get(alias)
                .ok_or_else(not_configured)?;
            let peers = config.channel_external_peers("telegram", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let ch =
                TelegramChannel::new(tg.bot_token.clone(), alias, peer_resolver, tg.mention_only)
                    .with_api_base(tg.api_base_url.clone());
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-telegram"))]
        "telegram" => {
            anyhow::bail!("Telegram channel requires the `channel-telegram` feature");
        }
        #[cfg(feature = "channel-discord")]
        "discord" => {
            let dc = config
                .channels
                .discord
                .get(alias)
                .ok_or_else(not_configured)?;
            let peers = config.channel_external_peers("discord", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let ch = DiscordChannel::new(
                dc.bot_token.clone(),
                dc.guild_ids.clone(),
                alias,
                peer_resolver,
                dc.listen_to_bots,
                dc.mention_only,
            )
            .with_channel_ids(dc.channel_ids.clone())
            .with_workspace_dir(config.channel_workspace_dir(channel));
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-discord"))]
        "discord" => {
            anyhow::bail!("Discord channel requires the `channel-discord` feature");
        }
        #[cfg(feature = "channel-slack")]
        "slack" => {
            let sl = config
                .channels
                .slack
                .get(alias)
                .ok_or_else(not_configured)?;
            let peers = config.channel_external_peers("slack", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let bot_token = sl.resolved_bot_token().with_context(|| {
                format!(
                    "Slack channel '{alias}': bot_token is not set. Provide it in config \
                     (channels.slack.{alias}.bot_token) or via the \
                     ZEROCLAW_SLACK_BOT_TOKEN / SLACK_BOT_TOKEN environment variable."
                )
            })?;
            let ch = SlackChannel::new(
                bot_token,
                sl.resolved_app_token(),
                sl.channel_ids.clone(),
                alias,
                peer_resolver,
            )
            .with_workspace_dir(config.channel_workspace_dir(channel));
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-slack"))]
        "slack" => {
            anyhow::bail!("Slack channel requires the `channel-slack` feature");
        }
        #[cfg(feature = "channel-signal")]
        "signal" => {
            let sg = config
                .channels
                .signal
                .get(alias)
                .ok_or_else(not_configured)?;
            let peers = config.channel_external_peers("signal", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let ch = SignalChannel::new(
                sg.http_url.clone(),
                sg.account.clone(),
                sg.group_ids.clone(),
                sg.dm_only,
                alias,
                peer_resolver,
                sg.ignore_attachments,
                sg.ignore_stories,
            );
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-signal"))]
        "signal" => {
            anyhow::bail!("Signal channel requires the `channel-signal` feature");
        }
        #[cfg(feature = "channel-wechat")]
        "wechat" => {
            let wc = config
                .channels
                .wechat
                .get(alias)
                .ok_or_else(not_configured)?;
            let peers = config.channel_external_peers("wechat", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let ch = WeChatChannel::new(
                alias,
                peer_resolver,
                wc.api_base_url.clone(),
                wc.cdn_base_url.clone(),
                Some(WeChatChannel::resolve_state_dir(wc.state_dir.as_deref())),
            )?
            .with_workspace_dir(config.channel_workspace_dir(channel));
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-wechat"))]
        "wechat" => {
            anyhow::bail!("WeChat channel requires the `channel-wechat` feature");
        }
        #[cfg(feature = "channel-lark")]
        "lark" | "feishu" => {
            // [channels.lark.<alias>] is the single source of truth for both
            // names (AGENTS.md). from_config selects the endpoint via
            // use_feishu. Error text names the real config table, not the
            // cron alias the user wrote.
            let lk = config.channels.lark.get(alias).ok_or_else(|| {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
                    &format!(
                        "[channels.lark.{alias}] not configured (cron channel \"{channel_type}.{alias}\")"
                    )
                );
                anyhow::Error::msg(format!(
                    "[channels.lark.{alias}] not configured (cron channel \"{channel_type}.{alias}\")"
                ))
            })?;
            // Asymmetric by design: "feishu"+use_feishu=false is a typo
            // (hard fail). "lark"+use_feishu=true is a soft compat path
            // (warn but still deliver via fallback construction).
            if channel_type == "feishu" && !lk.use_feishu {
                anyhow::bail!(
                    "[channels.lark.{alias}] has use_feishu=false but cron channel=\"feishu.{alias}\"; \
                     use channel=\"lark.{alias}\" or set use_feishu=true"
                );
            }
            if channel_type == "lark" && lk.use_feishu {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    &format!(
                        "cron channel=\"lark.{alias}\" with [channels.lark.{alias}] use_feishu=true \
                         falls back to one-shot channel construction; prefer channel=\"feishu.{alias}\" \
                         to reuse the live Feishu handle from start_channels"
                    )
                );
            }
            let peers = config.channel_external_peers("lark", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let ch = LarkChannel::from_config(lk, alias, peer_resolver)
                .with_workspace_dir(config.channel_workspace_dir(&format!("lark.{alias}")))
                .with_approval_timeout_secs(lk.approval_timeout_secs)
                .with_per_user_session(lk.per_user_session)
                .with_ack_reactions(lk.ack_reactions.unwrap_or(config.channels.ack_reactions))
                .with_streaming(lk.stream_mode, lk.draft_update_interval_ms);
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-lark"))]
        "lark" | "feishu" => {
            anyhow::bail!("Lark channel requires the `channel-lark` feature");
        }
        #[cfg(feature = "channel-webhook")]
        "webhook" => {
            let wh = config
                .channels
                .webhook
                .get(alias)
                .ok_or_else(not_configured)?;
            let ch = WebhookChannel::new(
                alias.to_string(),
                wh.port,
                wh.listen_path.clone(),
                wh.send_url.clone(),
                wh.send_method.clone(),
                wh.auth_header.clone(),
                wh.secret.clone(),
                wh.max_retries,
                wh.retry_base_delay_ms,
                wh.retry_max_delay_ms,
            );
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-webhook"))]
        "webhook" => {
            anyhow::bail!("Webhook channel requires the `channel-webhook` feature");
        }
        "wecom_ws" | "wecom-ws" => {
            let _ = config
                .channels
                .wecom_ws
                .get(alias)
                .ok_or_else(not_configured)?;
            anyhow::bail!("wecom_ws channel is not connected");
        }
        #[cfg(feature = "channel-email")]
        "email" => {
            let em = config
                .channels
                .email
                .get(alias)
                .ok_or_else(not_configured)?;
            let peers = config.channel_external_peers("email", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let ch = EmailChannel::new(em.clone(), alias.to_string(), peer_resolver);
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "channel-email"))]
        "email" => {
            anyhow::bail!("Email channel requires the `channel-email` feature");
        }
        #[cfg(feature = "whatsapp-web")]
        "whatsapp" | "whatsapp-web" | "whatsapp_web" => {
            let wa = config
                .channels
                .whatsapp
                .get(alias)
                .ok_or_else(not_configured)?;
            if !wa.is_web_config() {
                anyhow::bail!(
                    "WhatsApp channel send requires Web mode (set session_path, pair_phone, or mode = personal)"
                );
            }
            let peers = config.channel_external_peers("whatsapp", alias);
            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || peers.clone());
            let allowed_groups = wa.allowed_groups.clone();
            let allowed_groups_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
                Arc::new(move || allowed_groups.clone());
            let ch = WhatsAppWebChannel::new(
                wa,
                alias.to_string(),
                peer_resolver,
                allowed_groups_resolver,
            )
            .with_workspace_dir(config.channel_workspace_dir(&format!("whatsapp.{alias}")));
            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
        }
        #[cfg(not(feature = "whatsapp-web"))]
        "whatsapp" | "whatsapp-web" | "whatsapp_web" => {
            anyhow::bail!("WhatsApp channel requires the `whatsapp-web` feature");
        }
        other => anyhow::bail!("unsupported delivery channel: {other}"),
    }
    #[allow(unreachable_code)]
    Ok(())
}

// ── Concurrent persist lock test ─────────────────────────
// Lives outside `mod tests` so it has direct access to private parent items.

#[cfg(test)]
#[test]
fn concurrent_persist_lock_serialization() {
    use std::sync::Barrier;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::time::Duration;
    use zeroclaw_infra::session_backend::SessionBackend;
    use zeroclaw_providers::ChatMessage;
    use zeroclaw_runtime::approval::ApprovalManager;
    use zeroclaw_runtime::observability::NoopObserver;

    struct OrderBackend {
        sequence: Arc<Mutex<Vec<String>>>,
        call_n: Arc<AtomicUsize>,
    }
    impl SessionBackend for OrderBackend {
        fn load(&self, _key: &str) -> Vec<ChatMessage> {
            vec![]
        }
        fn append(&self, _key: &str, msg: &ChatMessage) -> std::io::Result<()> {
            let content = msg.content.clone();
            let n = self.call_n.fetch_add(1, Ordering::SeqCst);
            self.sequence
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(content);
            // Delay outside the sequence lock: later callers get
            // shorter delays → they exit earlier and can win the
            // history-push race.
            std::thread::sleep(Duration::from_millis(8_u64.saturating_sub(n as u64 * 2)));
            Ok(())
        }
        fn remove_last(&self, _key: &str) -> std::io::Result<bool> {
            Ok(true)
        }
        fn list_sessions(&self) -> Vec<String> {
            vec![]
        }
    }

    let sender = "concurrent_test_key".to_string();
    let sequence = Arc::new(Mutex::new(Vec::new()));
    let backend = OrderBackend {
        sequence: sequence.clone(),
        call_n: Arc::new(AtomicUsize::new(0)),
    };

    let ctx = Arc::new(ChannelRuntimeContext {
        channels_by_name: Arc::new(HashMap::new()),
        model_provider: Arc::new(tests::DummyModelProvider),
        model_provider_ref: Arc::new("test".into()),
        agent_alias: Arc::new("test".into()),
        agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
        memory: Arc::new(tests::NoopMemory),
        memory_strategy: Arc::new(
            zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                Arc::new(tests::NoopMemory),
                zeroclaw_config::schema::MemoryConfig::default(),
                std::path::PathBuf::new(),
            ),
        ),
        tools_registry: Arc::new(vec![]),
        observer: Arc::new(NoopObserver),
        system_prompt: Arc::new(String::new()),
        model: Arc::new("test".into()),
        temperature: Some(0.0),
        auto_save_memory: false,
        max_tool_iterations: 5,
        min_relevance_score: 0.0,
        conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
            std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
        ))),
        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())),
        thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
        scope_overrides: Arc::new(Mutex::new(HashMap::new())),
        reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
        interrupt_on_new_message: InterruptOnNewMessageConfig {
            telegram: false,
            slack: false,
            discord: false,
            mattermost: false,
            matrix: false,
            whatsapp: false,
        },
        multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
        media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
        transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
        agent_transcription_provider: String::new(),
        hooks: None,
        provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
        workspace_dir: Arc::new(std::env::temp_dir()),
        prompt_config: Arc::new(zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
        ack_reactions: true,
        show_tool_calls: true,
        session_store: Some(Arc::new(backend) as Arc<dyn SessionBackend>),
        approval_manager: Arc::new(ApprovalManager::for_non_interactive(
            &zeroclaw_config::schema::RiskProfileConfig::default(),
        )),
        activated_tools: None,
        cost_tracking: None,
        pacing: zeroclaw_config::schema::PacingConfig::default(),
        max_tool_result_chars: 0,
        context_token_budget: 0,
        debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
            Duration::ZERO,
        )),
        receipt_generator: None,
        show_receipts_in_response: false,
        last_applied_config_stamp: Arc::new(Mutex::new(None)),
        runtime_defaults_override: Arc::new(Mutex::new(None)),
        persist_locks: Arc::new(Mutex::new(HashMap::new())),
        sop_engine: None,
        sop_audit: None,
    });
    ctx.conversation_histories
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .push(sender.clone(), vec![ChatMessage::user("start")]);

    let barrier = Arc::new(Barrier::new(4));
    let mut handles = vec![];
    for i in 0..4 {
        let ctx = ctx.clone();
        let key = sender.clone();
        let b = barrier.clone();
        handles.push(std::thread::spawn(move || {
            b.wait();
            append_sender_turn(&ctx, &key, ChatMessage::user(format!("msg-{i}")));
        }));
    }
    for h in handles {
        h.join().unwrap();
    }

    // ── Assertion ────────────────────────────────────────────────
    // Under the per-sender persist lock every (append, history-push)
    // pair is atomic, so the backend sequence must equal the
    // in-memory history for this sender (minus the initial "start").
    let backend_order: Vec<String> = sequence.lock().unwrap_or_else(|e| e.into_inner()).clone();
    let history: Vec<String> = {
        let histories = ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .peek(&sender)
            .expect("history must exist for sender");
        turns
            .iter()
            .filter(|m| m.content != "start")
            .map(|m| m.content.clone())
            .collect()
    };
    assert_eq!(
        backend_order, history,
        "backend append order must equal in-memory history order;\
         a mismatch means the per-sender persist lock is not serializing\
         store.append + history.push atomically"
    );
    assert_eq!(
        backend_order.len(),
        4,
        "all 4 concurrent appends must be recorded"
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    // Production code no longer calls this directly (the ScopedToolRegistry::assemble
    // seam applies it internally now); two tests below still exercise it directly to
    // pin the built-in filter's own behavior.
    use std::collections::{HashMap, HashSet};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
    use tempfile::TempDir;
    use zeroclaw_memory::{Memory, MemoryCategory, SqliteMemory};
    use zeroclaw_providers::{ChatMessage, ModelProvider};

    /// Upper bound for "this must not deadlock" waits in the assembly tests.
    ///
    /// These guards exist to fail a genuine hang, not to assert how fast
    /// assembly is. `scripts/ci/parallel_runtime_test_gate.sh` runs the suite
    /// at 16 threads; under that contention the previous budgets stopped being
    /// deadlock guards and became scheduling assertions. Assembly is
    /// sub-second when it is not hung, so 30s cannot be reached by ordinary
    /// runner load.
    const ASSEMBLY_HANG_GUARD: std::time::Duration = std::time::Duration::from_secs(30);
    use zeroclaw_runtime::agent::loop_::apply_policy_tool_filter;
    use zeroclaw_runtime::agent::loop_::build_tool_instructions;

    #[test]
    fn load_cached_model_preview_reads_from_data_dir_not_install_root() {
        // `config_path` and `data_dir` deliberately live under unrelated
        // temp roots, so `config_path.parent()` (the old install-root-derived
        // path) cannot accidentally coincide with `data_dir`. This proves the
        // reader follows the canonical data directory, matching the writer
        // in `zeroclaw-runtime::doctor::persist_model_cache`.
        let config_root = TempDir::new().unwrap();
        let data_root = TempDir::new().unwrap();
        let agent_workspace = TempDir::new().unwrap();

        let state_dir = data_root.path().join("state");
        std::fs::create_dir_all(&state_dir).unwrap();
        let cache = zeroclaw_config::schema::ModelCacheState {
            entries: vec![zeroclaw_config::schema::ModelCacheEntry {
                model_provider: "ollama.default".to_string(),
                models: vec!["llama3".to_string()],
            }],
        };
        std::fs::write(
            state_dir.join(MODEL_CACHE_FILE),
            serde_json::to_string_pretty(&cache).unwrap(),
        )
        .unwrap();

        let found =
            load_cached_model_preview(data_root.path(), agent_workspace.path(), "ollama.default");
        assert_eq!(found, vec!["llama3".to_string()]);

        // The stale install-root-derived path (config_path's parent) must
        // NOT be where the cache is found, since it differs from `data_dir`.
        let not_found =
            load_cached_model_preview(config_root.path(), agent_workspace.path(), "ollama.default");
        assert!(
            not_found.is_empty(),
            "cache must not be visible under config_path's parent when it differs from data_dir"
        );
    }

    #[test]
    fn refresh_then_model_preview_joins_the_real_writer_and_reader() {
        // Joins the two production halves of the refresh-to-preview contract
        // directly, rather than exercising each in isolation: the actual
        // `zeroclaw-runtime::doctor::persist_model_cache` writer (what
        // `zeroclaw models refresh` calls), then this crate's actual
        // `load_cached_model_preview` reader (what `/model` calls) — for an
        // undotted provider ref (canonicalized to `openrouter.default` on both
        // sides) and a custom, non-default agent workspace.
        let data_root = TempDir::new().unwrap();
        let custom_agent_workspace = TempDir::new().unwrap();

        let config = Config {
            config_path: data_root.path().join("config.toml"),
            data_dir: data_root.path().to_path_buf(),
            ..Config::default()
        };

        zeroclaw_runtime::doctor::persist_model_cache(
            &config,
            "openrouter",
            &["model-a".to_string(), "model-b".to_string()],
        )
        .unwrap();

        let found = load_cached_model_preview(
            data_root.path(),
            custom_agent_workspace.path(),
            "openrouter",
        );

        assert_eq!(
            found,
            vec!["model-a".to_string(), "model-b".to_string()],
            "a refresh written by the real writer must be visible through the real reader \
             for a non-default agent's custom workspace"
        );
    }

    #[test]
    fn no_real_time_channels_message_points_at_quickstart_not_onboard() {
        // The "no channels configured" message must point operators at the
        // current command (zeroclaw quickstart), not the deleted `zeroclaw onboard`.
        // Source of truth: the string at orchestrator/mod.rs:~7376.
        let msg = super::no_real_time_channels_message();
        assert!(
            !msg.contains("zeroclaw onboard"),
            "stale `zeroclaw onboard` reference in message: {msg}"
        );
        assert!(
            msg.contains("zeroclaw quickstart"),
            "expected `zeroclaw quickstart` reference, got: {msg}"
        );
    }

    #[tokio::test]
    async fn channel_runtime_reload_applies_env_overrides_after_migration() {
        let tmp = TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");
        std::fs::write(
            &config_path,
            r#"
default_provider = "openrouter"

[model_providers.openrouter]
name = "openrouter"

[agents.demo]
provider = "openrouter"
model = "meta-llama/llama-3.1-8b-instruct"
temperature = 0.3
"#,
        )
        .unwrap();

        let env_name = "ZEROCLAW_providers__models__openrouter__agent_demo__api_key";
        // SAFETY: this test owns this specific env-var key and restores it
        // before returning. The value is synthetic and not a real credential.
        unsafe { std::env::set_var(env_name, "sk-or-v1-test-channel-reload") };

        let result = load_runtime_config_and_defaults(&config_path, "demo").await;

        // SAFETY: undo the test-only process env mutation above.
        unsafe { std::env::remove_var(env_name) };

        let (config, defaults) = result.unwrap();
        assert_eq!(
            defaults.api_key.as_deref(),
            Some("sk-or-v1-test-channel-reload")
        );
        assert!(
            config
                .env_overridden_paths
                .contains("providers.models.openrouter.agent_demo.api_key")
        );
    }

    use zeroclaw_runtime::observability::NoopObserver;
    use zeroclaw_runtime::tools::{Tool, ToolOutput, ToolResult};

    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: ZeroClaw").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
    }

    /// Minimal mock Channel returning a configurable `name()` so the
    /// channel-registry routing tests can simulate two aliases of the
    /// same channel type without pulling in real platform SDKs.
    /// Identity is checked via `Arc::ptr_eq`, not by inspecting fields.
    struct NamedMockChannel {
        name: &'static str,
    }

    impl ::zeroclaw_api::attribution::Attributable for NamedMockChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    #[async_trait::async_trait]
    impl Channel for NamedMockChannel {
        fn name(&self) -> &str {
            self.name
        }
        async fn send(&self, _message: &zeroclaw_api::channel::SendMessage) -> anyhow::Result<()> {
            Ok(())
        }
        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }
    }

    fn mock_channel(name: &'static str) -> Arc<dyn Channel> {
        Arc::new(NamedMockChannel { name })
    }

    struct MentionMockChannel {
        name: &'static str,
        mention: &'static str,
    }

    impl ::zeroclaw_api::attribution::Attributable for MentionMockChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Discord,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    #[async_trait::async_trait]
    impl Channel for MentionMockChannel {
        fn name(&self) -> &str {
            self.name
        }
        fn self_addressed_mention(&self) -> Option<String> {
            Some(self.mention.to_string())
        }
        async fn send(&self, _message: &zeroclaw_api::channel::SendMessage) -> anyhow::Result<()> {
            Ok(())
        }
        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }
    }

    fn mention_mock(name: &'static str, mention: &'static str) -> Arc<dyn Channel> {
        Arc::new(MentionMockChannel { name, mention })
    }

    fn channel_message(
        channel: &str,
        alias: Option<&str>,
    ) -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            id: "m1".into(),
            sender: "u1".into(),
            reply_target: "r1".into(),
            content: "hi".into(),
            channel: channel.into(),
            channel_alias: alias.map(|s| s.to_string()),
            timestamp: 0,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,
            ..Default::default()
        }
    }

    #[test]
    fn composite_channel_key_aliased_uses_dotted_form() {
        assert_eq!(
            composite_channel_key("discord", Some("clamps")),
            "discord.clamps"
        );
        assert_eq!(
            composite_channel_key("telegram", Some("default")),
            "telegram.default"
        );
    }

    #[test]
    fn composite_channel_key_unaliased_uses_bare_name() {
        assert_eq!(composite_channel_key("notion", None), "notion");
        // Empty-string alias collapses to bare name so we never produce a
        // `discord.` key that no message would ever match.
        assert_eq!(composite_channel_key("discord", Some("")), "discord");
    }

    #[test]
    fn configured_channel_map_adds_bare_key_for_singleton_type() {
        let matrix = mock_channel("matrix");
        let configured = vec![ConfiguredChannel {
            display_name: "Matrix",
            alias: Some("default".to_string()),
            channel: Arc::clone(&matrix),
        }];

        let map = configured_channel_map(&configured);

        assert!(Arc::ptr_eq(map.get("matrix.default").unwrap(), &matrix));
        assert!(Arc::ptr_eq(map.get("matrix").unwrap(), &matrix));
    }

    #[test]
    fn configured_channel_map_keeps_multi_aliases_composite_only() {
        let clamps = mock_channel("discord");
        let glados = mock_channel("discord");
        let configured = vec![
            ConfiguredChannel {
                display_name: "Discord",
                alias: Some("clamps".to_string()),
                channel: Arc::clone(&clamps),
            },
            ConfiguredChannel {
                display_name: "Discord",
                alias: Some("glados".to_string()),
                channel: Arc::clone(&glados),
            },
        ];

        let map = configured_channel_map(&configured);

        assert!(Arc::ptr_eq(map.get("discord.clamps").unwrap(), &clamps));
        assert!(Arc::ptr_eq(map.get("discord.glados").unwrap(), &glados));
        assert!(
            !map.contains_key("discord"),
            "bare key would be ambiguous for multiple aliases"
        );
    }

    #[test]
    fn find_channel_for_message_resolves_by_composite_key_for_multi_alias() {
        // Two Discord bots in the registry: only the composite key
        // distinguishes them. Without this, the second insertion silently
        // overwrites the first via `name()` collision — the bug that left
        // one Discord agent unresponsive on multi-bot configs.
        let clamps = mock_channel("discord");
        let glados = mock_channel("discord");
        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
        channels.insert("discord.clamps".to_string(), Arc::clone(&clamps));
        channels.insert("discord.glados".to_string(), Arc::clone(&glados));

        let msg_clamps = channel_message("discord", Some("clamps"));
        let msg_glados = channel_message("discord", Some("glados"));

        let resolved_clamps = find_channel_for_message(&channels, &msg_clamps).expect("clamps");
        let resolved_glados = find_channel_for_message(&channels, &msg_glados).expect("glados");

        assert!(Arc::ptr_eq(resolved_clamps, &clamps), "clamps lookup");
        assert!(Arc::ptr_eq(resolved_glados, &glados), "glados lookup");
        // Sanity: the two pointers are actually different.
        assert!(!Arc::ptr_eq(&clamps, &glados));
    }

    #[test]
    fn aliased_inbound_emits_per_alias_mention_in_prompt() {
        let clamps = mention_mock("discord", "<@111>");
        let glados = mention_mock("discord", "<@222>");
        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
        channels.insert("discord.clamps".into(), Arc::clone(&clamps));
        channels.insert("discord.glados".into(), Arc::clone(&glados));

        let msg_glados = channel_message("discord", Some("glados"));
        let target_glados = find_channel_for_message(&channels, &msg_glados).cloned();
        let prompt_glados =
            build_channel_system_prompt_for_message("Base.", &msg_glados, target_glados.as_ref());
        assert!(
            prompt_glados.contains("<@222>"),
            "glados prompt missing its own mention: {prompt_glados}"
        );
        assert!(
            !prompt_glados.contains("<@111>"),
            "glados prompt leaked the peer's mention: {prompt_glados}"
        );

        let msg_clamps = channel_message("discord", Some("clamps"));
        let target_clamps = find_channel_for_message(&channels, &msg_clamps).cloned();
        let prompt_clamps =
            build_channel_system_prompt_for_message("Base.", &msg_clamps, target_clamps.as_ref());
        assert!(
            prompt_clamps.contains("<@111>"),
            "clamps prompt missing its own mention: {prompt_clamps}"
        );
        assert!(
            !prompt_clamps.contains("<@222>"),
            "clamps prompt leaked the peer's mention: {prompt_clamps}"
        );
    }

    #[test]
    fn unaliased_inbound_with_no_self_handle_omits_mention_block() {
        let webhook = mock_channel("webhook");
        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
        channels.insert("webhook".into(), Arc::clone(&webhook));

        let msg = channel_message("webhook", None);
        let target = find_channel_for_message(&channels, &msg).cloned();
        let prompt = build_channel_system_prompt_for_message("Base.", &msg, target.as_ref());

        assert!(
            target.is_some(),
            "registry must resolve the webhook channel"
        );
        assert!(
            !prompt.contains("addressable handle on this channel"),
            "channels without self_addressed_mention must not emit the block: {prompt}"
        );
    }

    #[test]
    fn unresolved_channel_omits_mention_block() {
        let channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
        let msg = channel_message("discord", Some("ghost"));
        let target = find_channel_for_message(&channels, &msg).cloned();
        let prompt = build_channel_system_prompt_for_message("Base.", &msg, target.as_ref());

        assert!(target.is_none());
        assert!(!prompt.contains("addressable handle on this channel"));
    }

    #[test]
    fn find_channel_for_message_falls_back_to_bare_name_when_no_alias_supplied() {
        // Legacy inbound (or singleton channel) with `channel_alias = None`
        // still resolves via the bare-name slot — the registry builder
        // populates it for single-alias platforms so cron callers and
        // outbound-only channels keep working.
        let webhook = mock_channel("webhook");
        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
        channels.insert("webhook".to_string(), Arc::clone(&webhook));

        let msg = channel_message("webhook", None);
        let resolved = find_channel_for_message(&channels, &msg).expect("webhook");
        assert!(Arc::ptr_eq(resolved, &webhook));
    }

    #[test]
    fn find_channel_for_message_falls_back_to_base_for_room_qualifier() {
        // Multi-room channels (Matrix) deliver inbound messages with
        // `channel = "matrix:!roomId"`. The registry key is bare `matrix`;
        // the helper splits on `:` and resolves the base channel.
        let matrix = mock_channel("matrix");
        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
        channels.insert("matrix".to_string(), Arc::clone(&matrix));

        let msg = channel_message("matrix:!room1:example.org", None);
        let resolved = find_channel_for_message(&channels, &msg).expect("matrix");
        assert!(Arc::ptr_eq(resolved, &matrix));
    }

    /// Build a minimal `ChannelRuntimeContext` suitable only for identity
    /// checks (`Arc::ptr_eq`). Every dependency is a no-op default — these
    /// ctxs aren't usable for actually running the dispatch loop.
    fn router_test_ctx() -> Arc<ChannelRuntimeContext> {
        Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new(String::new()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 0,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        })
    }

    #[test]
    fn stamp_session_routing_context_persists_message_metadata() {
        struct Case {
            history_key: &'static str,
            channel: &'static str,
            alias: Option<&'static str>,
            thread: Option<&'static str>,
            reply_target: &'static str,
            sender: &'static str,
            expected_channel: Option<&'static str>,
            expected_room: Option<&'static str>,
            expected_sender: Option<&'static str>,
        }

        let tmp = tempfile::TempDir::new().unwrap();
        let session_store: Arc<dyn SessionBackend> =
            Arc::new(SqliteSessionBackend::new(tmp.path()).unwrap());
        let ctx = ChannelRuntimeContext {
            session_store: Some(Arc::clone(&session_store)),
            ..(*router_test_ctx()).clone()
        };
        let cases = [
            Case {
                history_key: "threaded",
                channel: "discord",
                alias: Some("primary"),
                thread: Some("thread-987654"),
                reply_target: "channel-123",
                sender: "thread-user",
                expected_channel: Some("discord.primary"),
                expected_room: Some("thread-987654"),
                expected_sender: Some("thread-user"),
            },
            Case {
                history_key: "reply-target",
                channel: "discord",
                alias: Some("secondary"),
                thread: None,
                reply_target: "dm-channel-555",
                sender: "user42",
                expected_channel: Some("discord.secondary"),
                expected_room: Some("dm-channel-555"),
                expected_sender: Some("user42"),
            },
            Case {
                history_key: "no-alias",
                channel: "cli",
                alias: None,
                thread: None,
                reply_target: "stdin",
                sender: "cli-user",
                expected_channel: None,
                expected_room: Some("stdin"),
                expected_sender: Some("cli-user"),
            },
            Case {
                history_key: "empty-sender",
                channel: "matrix",
                alias: Some("default"),
                thread: None,
                reply_target: "!room:matrix.org",
                sender: "",
                expected_channel: Some("matrix.default"),
                expected_room: Some("!room:matrix.org"),
                expected_sender: None,
            },
        ];

        for case in cases {
            let msg = ChannelMessage {
                id: "msg-1".into(),
                sender: case.sender.into(),
                reply_target: case.reply_target.into(),
                content: "hi".into(),
                channel: case.channel.into(),
                channel_alias: case.alias.map(String::from),
                timestamp: 0,
                thread_ts: case.thread.map(String::from),
                ..Default::default()
            };

            stamp_session_routing_context(&ctx, &msg, case.history_key);

            let metadata = session_store
                .get_session_metadata(case.history_key)
                .unwrap();
            assert_eq!(metadata.channel_id.as_deref(), case.expected_channel);
            assert_eq!(metadata.room_id.as_deref(), case.expected_room);
            assert_eq!(metadata.sender_id.as_deref(), case.expected_sender);
        }
    }

    #[tokio::test]
    async fn resolve_classifier_route_returns_none_for_empty_ref() {
        let ctx = router_test_ctx();
        let empty = zeroclaw_config::providers::ModelProviderRef::default();
        let result = resolve_classifier_route(
            ctx.as_ref(),
            &empty,
            &runtime_defaults_snapshot(ctx.as_ref()),
        )
        .await;
        assert!(result.is_none(), "empty ref must fall back to main agent");
    }

    #[tokio::test]
    async fn resolve_classifier_route_returns_none_for_unresolvable_ref() {
        let ctx = router_test_ctx();
        let bogus = zeroclaw_config::providers::ModelProviderRef::from("custom.does-not-exist");
        let result = resolve_classifier_route(
            ctx.as_ref(),
            &bogus,
            &runtime_defaults_snapshot(ctx.as_ref()),
        )
        .await;
        assert!(result.is_none(), "unresolvable ref must soft-fail to None");
    }

    #[tokio::test]
    async fn resolve_classifier_route_returns_alias_temperature() {
        // Build a config where `openai.my-classifier` has `temperature = 0.0`.
        let mut cfg = zeroclaw_config::schema::Config::default();
        cfg.providers.models.openai.insert(
            "my-classifier".to_string(),
            zeroclaw_config::schema::OpenAIModelProviderConfig {
                base: zeroclaw_config::schema::ModelProviderConfig {
                    model: Some("gpt-4o-mini".to_string()),
                    temperature: Some(0.0),
                    ..Default::default()
                },
            },
        );

        let base_ctx = (*router_test_ctx()).clone();
        let ctx = Arc::new(ChannelRuntimeContext {
            prompt_config: Arc::new(cfg),
            ..base_ctx
        });

        let alias_ref = zeroclaw_config::providers::ModelProviderRef::from("openai.my-classifier");
        let result = resolve_classifier_route(
            ctx.as_ref(),
            &alias_ref,
            &runtime_defaults_snapshot(ctx.as_ref()),
        )
        .await;

        let (_, _, temp) = result.expect("must resolve to alias");
        assert_eq!(
            temp,
            Some(0.0),
            "alias temperature must be returned, not runtime_defaults.temperature"
        );
    }

    #[test]
    fn agent_router_multi_routes_each_alias_to_its_owning_agent() {
        let clamps_ctx = router_test_ctx();
        let glados_ctx = router_test_ctx();
        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
        by_agent.insert("clamps".to_string(), Arc::clone(&clamps_ctx));
        by_agent.insert("glados".to_string(), Arc::clone(&glados_ctx));
        let mut owners: HashMap<String, String> = HashMap::new();
        owners.insert("discord.clamps".to_string(), "clamps".to_string());
        owners.insert("discord.glados".to_string(), "glados".to_string());
        let router = AgentRouter::multi(by_agent, owners, None, None);

        let msg_clamps = channel_message("discord", Some("clamps"));
        let msg_glados = channel_message("discord", Some("glados"));

        let resolved_clamps = router.resolve(&msg_clamps).expect("clamps resolves");
        let resolved_glados = router.resolve(&msg_glados).expect("glados resolves");

        assert!(Arc::ptr_eq(&resolved_clamps, &clamps_ctx), "clamps routing");
        assert!(Arc::ptr_eq(&resolved_glados, &glados_ctx), "glados routing");
        assert!(
            !Arc::ptr_eq(&resolved_clamps, &resolved_glados),
            "ctxs distinct"
        );
    }

    #[test]
    fn agent_router_multi_returns_none_for_unowned_channels() {
        let agent_a_ctx = router_test_ctx();
        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
        by_agent.insert("agent_a".to_string(), Arc::clone(&agent_a_ctx));
        let mut owners: HashMap<String, String> = HashMap::new();
        owners.insert("discord.bot_a".to_string(), "agent_a".to_string());
        let router = AgentRouter::multi(by_agent, owners, None, None);

        let cli_msg = channel_message("cli", None);
        assert!(router.resolve(&cli_msg).is_none(), "cli has no owner");
    }

    #[test]
    fn agent_router_multi_resolves_bare_channel_for_singleton_owners() {
        let notion_agent_ctx = router_test_ctx();
        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
        by_agent.insert("ops".to_string(), Arc::clone(&notion_agent_ctx));
        let mut owners: HashMap<String, String> = HashMap::new();
        owners.insert("notion".to_string(), "ops".to_string());
        let router = AgentRouter::multi(by_agent, owners, None, None);

        let msg = channel_message("notion", None);
        let resolved = router.resolve(&msg).expect("notion resolves");
        assert!(Arc::ptr_eq(&resolved, &notion_agent_ctx));
    }

    #[test]
    fn agent_router_multi_resolves_fallback_loaded_channel_to_legacy_agent() {
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "legacy".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec![],
                ..Default::default()
            },
        );
        let enabled_agents = vec!["legacy".to_string()];
        let collected_channel_keys = vec!["mattermost.default".to_string()];
        let owners = build_owner_by_channel_key(&config, &enabled_agents, &collected_channel_keys);

        let legacy_ctx = router_test_ctx();
        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
        by_agent.insert("legacy".to_string(), Arc::clone(&legacy_ctx));
        let router = AgentRouter::multi(by_agent, owners, None, None);

        let msg = channel_message("mattermost", Some("default"));
        let resolved = router.resolve(&msg).expect("fallback owner resolves");
        assert!(Arc::ptr_eq(&resolved, &legacy_ctx));
    }

    #[test]
    fn build_owner_by_channel_key_legacy_fallback_is_deterministic_without_default() {
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "zeta".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec![],
                ..Default::default()
            },
        );
        config.agents.insert(
            "alpha".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec![],
                ..Default::default()
            },
        );

        let enabled_agents = vec!["alpha".to_string(), "zeta".to_string()];
        let collected_channel_keys = vec!["mattermost.default".to_string()];
        let owners = build_owner_by_channel_key(&config, &enabled_agents, &collected_channel_keys);

        assert_eq!(
            owners.get("mattermost.default").map(String::as_str),
            Some("alpha")
        );
        assert_eq!(owners.get("mattermost").map(String::as_str), Some("alpha"));
    }

    #[test]
    fn find_channel_for_message_returns_none_when_alias_unknown() {
        // A message tagged with an alias that isn't registered must not
        // accidentally fall through to a different bot's handle — silent
        // misrouting is exactly what the original collision bug caused.
        let clamps = mock_channel("discord");
        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
        channels.insert("discord.clamps".to_string(), Arc::clone(&clamps));

        // No bare `discord` key and no `discord.ghost` key — lookup must fail.
        let msg = channel_message("discord", Some("ghost"));
        assert!(find_channel_for_message(&channels, &msg).is_none());
    }

    #[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 compute_max_in_flight_messages_uses_configured_per_channel_budget() {
        assert_eq!(compute_max_in_flight_messages(3, 4), 12);
        assert_eq!(compute_max_in_flight_messages(3, 8), 24);
    }

    #[test]
    fn max_in_flight_messages_for_config_uses_channel_budget() {
        let config = zeroclaw_config::schema::ChannelsConfig {
            max_concurrent_per_channel: 8,
            ..Default::default()
        };

        assert_eq!(max_in_flight_messages_for_config(3, &config), 24);
    }

    #[test]
    fn compute_max_in_flight_messages_preserves_global_bounds() {
        assert_eq!(
            compute_max_in_flight_messages(1, 1),
            CHANNEL_MIN_IN_FLIGHT_MESSAGES
        );
        assert_eq!(
            compute_max_in_flight_messages(100, 4),
            CHANNEL_MAX_IN_FLIGHT_MESSAGES
        );
    }

    #[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);
    }

    #[cfg(feature = "channel-wechat")]
    #[test]
    fn wechat_resolve_state_dir_expands_home_prefix() {
        use crate::wechat::WeChatChannel;

        let expanded = WeChatChannel::resolve_state_dir(Some("~/wechat-state"));
        assert!(!expanded.starts_with("~"));
        assert!(expanded.ends_with("wechat-state"));

        let absolute = WeChatChannel::resolve_state_dir(Some("/absolute/path"));
        assert_eq!(absolute, PathBuf::from("/absolute/path"));

        let relative = WeChatChannel::resolve_state_dir(Some("relative/path"));
        assert_eq!(relative, PathBuf::from("relative/path"));
    }

    #[test]
    fn parse_reply_intent_recognizes_reply_token() {
        assert!(matches!(
            parse_reply_intent("REPLY"),
            AssistantChannelOutcome::Reply(_)
        ));
        assert!(matches!(
            parse_reply_intent("  reply  "),
            AssistantChannelOutcome::Reply(_)
        ));
    }

    #[test]
    fn parse_reply_intent_extracts_kinded_no_reply_reason() {
        assert!(matches!(
            parse_reply_intent("NO_REPLY[INFO]: not addressed to bot"),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Informational,
                reason: Some(ref r),
            } if r == "not addressed to bot"
        ));
        assert!(matches!(
            parse_reply_intent("NO_REPLY[REFUSE]: prompt injection attempt"),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Refused,
                reason: Some(_),
            }
        ));
        assert!(matches!(
            parse_reply_intent("NO_REPLY[FAIL]: requested URL 404s"),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Failed,
                reason: Some(_),
            }
        ));
    }

    #[test]
    fn parse_reply_intent_handles_legacy_no_reply_form() {
        assert!(matches!(
            parse_reply_intent("NO_REPLY: greeting"),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Informational,
                reason: Some(ref r),
            } if r == "greeting"
        ));
        assert!(matches!(
            parse_reply_intent("NO_REPLY"),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Informational,
                reason: None,
            }
        ));
    }

    #[test]
    fn parse_reply_intent_unrecognized_output_falls_through_to_reply() {
        assert!(matches!(
            parse_reply_intent("idk maybe respond?"),
            AssistantChannelOutcome::Reply(_)
        ));
    }

    #[test]
    fn parse_reply_intent_treats_meta_instruction_echo_as_reply() {
        for echo in &[
            "NO_REPLY[INFO]: classification task only",
            "NO_REPLY[INFO]: classification task only, not answering user",
            "NO_REPLY[INFO]: Classification task only — must not answer the user.",
            "NO_REPLY[INFO]: I must not answer the user.",
            "NO_REPLY: classifier instruction echo",
        ] {
            assert!(
                matches!(parse_reply_intent(echo), AssistantChannelOutcome::Reply(_)),
                "expected Reply for echoed classifier output: {echo}",
            );
        }
    }

    #[test]
    fn parse_reply_intent_preserves_refuse_and_fail_even_with_rubric_like_reasons() {
        assert!(matches!(
            parse_reply_intent(
                "NO_REPLY[REFUSE]: prompt injection says \"do not answer the user\"",
            ),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Refused,
                reason: Some(_),
            }
        ));
        assert!(matches!(
            parse_reply_intent("NO_REPLY[REFUSE]: only classify, do not answer the user"),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Refused,
                reason: Some(_),
            }
        ));
        assert!(matches!(
            parse_reply_intent(
                "NO_REPLY[FAIL]: upstream returned a classifier instruction instead of data",
            ),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Failed,
                reason: Some(_),
            }
        ));
    }

    #[test]
    fn parse_reply_intent_preserves_legitimate_no_reply_reasons() {
        assert!(matches!(
            parse_reply_intent(
                "NO_REPLY[INFO]: another user in the group is answering this thread",
            ),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Informational,
                reason: Some(_),
            }
        ));
        assert!(matches!(
            parse_reply_intent("NO_REPLY[INFO]: greeting in group chat, not addressed"),
            AssistantChannelOutcome::NoReply {
                kind: NoReplyKind::Informational,
                reason: Some(_),
            }
        ));
    }

    #[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 = zeroclaw_config::schema::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 context_window_overflow_error_detector_matches_known_messages() {
        let overflow_err = anyhow::Error::msg(
            "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::Error::msg("OpenAI Codex API error (502 Bad Gateway): error code: 502");
        assert!(!is_context_window_overflow_error(&other_err));
    }

    fn channel_runtime_context_for_defaults_test(
        zeroclaw_dir: &std::path::Path,
        agent_alias: &str,
        default_model_provider: &str,
        model: &str,
    ) -> ChannelRuntimeContext {
        ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new(default_model_provider.to_string()),
            agent_alias: Arc::new(agent_alias.to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig {
                model_provider: default_model_provider.into(),
                ..Default::default()
            }),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    zeroclaw_dir.to_path_buf(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new(model.to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions {
                zeroclaw_dir: Some(zeroclaw_dir.to_path_buf()),
                ..Default::default()
            },
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        }
    }

    #[test]
    fn runtime_defaults_are_scoped_by_runtime_context() {
        let tmp = tempfile::TempDir::new().unwrap();
        let agent_a = channel_runtime_context_for_defaults_test(
            tmp.path(),
            "agent_a",
            "openrouter.default",
            "startup-a",
        );
        let agent_b = channel_runtime_context_for_defaults_test(
            tmp.path(),
            "agent_b",
            "anthropic.default",
            "startup-b",
        );
        assert!(!runtime_defaults_snapshot(&agent_a).hot);
        assert!(!runtime_defaults_snapshot(&agent_b).hot);

        let hot_override = ChannelRuntimeOverride {
            config: Arc::new(zeroclaw_config::schema::Config::default()),
            defaults: ChannelRuntimeDefaults {
                default_model_provider: "openrouter.reloaded".to_string(),
                model: "hot-model".to_string(),
                temperature: Some(0.7),
                api_key: Some("hot-key".to_string()),
                api_url: Some("https://example.test/v1".to_string()),
                reliability: zeroclaw_config::schema::ReliabilityConfig::default(),
            },
            generation: 1,
        };
        *agent_a
            .runtime_defaults_override
            .lock()
            .unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(hot_override));

        let route_a = default_route_selection_from_snapshot(&runtime_defaults_snapshot(&agent_a));
        assert_eq!(route_a.model_provider, "openrouter.reloaded");
        assert_eq!(route_a.model, "hot-model");
        let snapshot_a = runtime_defaults_snapshot(&agent_a);
        assert!(snapshot_a.hot);
        assert_eq!(snapshot_a.generation, 1);

        let route_b = default_route_selection_from_snapshot(&runtime_defaults_snapshot(&agent_b));
        assert_eq!(route_b.model_provider, "anthropic.default");
        assert_eq!(route_b.model, "startup-b");
        assert!(!runtime_defaults_snapshot(&agent_b).hot);
    }

    #[tokio::test]
    async fn load_runtime_config_uses_resolved_agent_provider() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");
        tokio::fs::write(
            &config_path,
            r#"
schema_version = 3

[agents.agent_a]
model_provider = "openrouter.hot"

[agents.agent_b]
model_provider = "anthropic.default"

[providers.models.openrouter.hot]
model = "hot-model"
api_key = "hot-key"
uri = "https://hot.example.test/v1"
temperature = 0.2

[providers.models.anthropic.default]
model = "cold-model"
api_key = "cold-key"
"#,
        )
        .await
        .unwrap();

        let (_config, defaults) = load_runtime_config_and_defaults(&config_path, "agent_a")
            .await
            .unwrap();

        assert_eq!(defaults.default_model_provider, "openrouter.hot");
        assert_eq!(defaults.model, "hot-model");
        assert_eq!(defaults.api_key.as_deref(), Some("hot-key"));
        assert_eq!(
            defaults.api_url.as_deref(),
            Some("https://hot.example.test/v1")
        );
        assert_eq!(defaults.temperature, Some(0.2));
    }

    #[tokio::test]
    async fn load_runtime_config_rejects_unresolved_agent_provider() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");
        tokio::fs::write(
            &config_path,
            r#"
[agents.agent_a]
model_provider = "openrouter.missing"

[providers.models.anthropic.default]
model = "cold-model"
api_key = "cold-key"
"#,
        )
        .await
        .unwrap();

        let err = load_runtime_config_and_defaults(&config_path, "agent_a")
            .await
            .expect_err("unresolved agent provider should reject reload");

        assert!(
            err.to_string()
                .contains("model_provider `openrouter.missing` does not resolve")
        );
    }

    #[tokio::test]
    async fn load_runtime_config_rejects_missing_agent() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");
        tokio::fs::write(
            &config_path,
            r#"
[agents.agent_b]
model_provider = "anthropic.default"

[providers.models.anthropic.default]
model = "cold-model"
api_key = "cold-key"
"#,
        )
        .await
        .unwrap();

        let err = load_runtime_config_and_defaults(&config_path, "agent_a")
            .await
            .expect_err("runtime reload should reject a config missing the active agent");

        assert!(err.to_string().contains("agents.agent_a is not configured"));
    }

    #[tokio::test]
    async fn load_runtime_config_rejects_empty_agent_provider() {
        let tmp = tempfile::TempDir::new().unwrap();
        let config_path = tmp.path().join("config.toml");
        tokio::fs::write(
            &config_path,
            r#"
[agents.agent_a]
model_provider = ""

[providers.models.anthropic.default]
model = "first-model"
api_key = "first-key"

[providers.models.openrouter.default]
model = "second-model"
api_key = "second-key"
"#,
        )
        .await
        .unwrap();

        let err = load_runtime_config_and_defaults(&config_path, "agent_a")
            .await
            .expect_err("empty agent provider should reject reload");

        assert!(err.to_string().contains("model_provider is empty"));
    }

    #[test]
    fn provider_credentials_use_target_alias_key_after_reload() {
        let config: Config = toml::from_str(
            r#"
[providers.models.openrouter.default]
model = "openrouter-model"
api_key = "openrouter-key"
uri = "https://openrouter.example.test/v1"

[providers.models.anthropic.default]
model = "anthropic-model"
api_key = "anthropic-key"
uri = "https://anthropic.example.test/v1"
"#,
        )
        .unwrap();
        let (api_key, api_url) = provider_credentials_for_ref(&config, "anthropic.default");

        assert_eq!(api_key.as_deref(), Some("anthropic-key"));
        assert_eq!(
            api_url.as_deref(),
            Some("https://anthropic.example.test/v1")
        );
    }

    #[test]
    fn provider_credentials_do_not_fall_back_to_default_alias() {
        let config: Config = toml::from_str(
            r#"
[providers.models.openrouter.default]
model = "openrouter-model"
api_key = "openrouter-key"

[providers.models.anthropic.default]
model = "anthropic-model"
api_key = "anthropic-key"
"#,
        )
        .unwrap();

        let (api_key, api_url) = provider_credentials_for_ref(&config, "anthropic");

        assert_eq!(api_key, None);
        assert_eq!(api_url, None);
    }

    #[test]
    fn provider_cache_key_isolates_hot_generations() {
        let startup = provider_cache_key("openrouter.default", None, 0);
        let hot_1 = provider_cache_key("openrouter.default", None, 1);
        let hot_2 = provider_cache_key("openrouter.default", None, 2);

        assert_eq!(startup, "openrouter.default");
        assert_ne!(hot_1, startup);
        assert_ne!(hot_1, hot_2);
    }

    #[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 ensure_nonempty_channel_reply_substitutes_fallback_when_empty() {
        let result = ensure_nonempty_channel_reply(
            String::new(),
            "   ",
            "whatsapp",
            "15551234567@s.whatsapp.net",
        );
        assert_eq!(result, EMPTY_CHANNEL_REPLY_FALLBACK);
    }

    #[test]
    fn ensure_nonempty_channel_reply_preserves_nonempty_text() {
        let result = ensure_nonempty_channel_reply(
            "Hello".to_string(),
            "Hello",
            "whatsapp",
            "15551234567@s.whatsapp.net",
        );
        assert_eq!(result, "Hello");
    }

    #[test]
    fn sanitize_channel_response_strips_used_tools_with_leading_whitespace() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        //: 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"));
    }

    #[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?");
    }

    #[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 =
            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
        let sender = "telegram_u1".to_string();
        histories.push(
            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()),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        };

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

        let locked_histories = ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let kept = locked_histories
            .peek(&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 append_sender_turn_stores_single_turn_per_call() {
        let sender = "telegram_u2".to_string();
        let ctx = ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        };

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

        let histories = ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .peek(&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 timestamp_channel_user_content_adds_wall_clock_prefix() {
        let stamped = timestamp_channel_user_content("hello");

        assert!(
            stamped.starts_with('['),
            "timestamped content should start with a bracketed timestamp: {stamped}"
        );
        assert!(
            stamped.contains("] hello"),
            "timestamped content should preserve the user message after the timestamp: {stamped}"
        );
    }

    #[test]
    fn rollback_orphan_user_turn_removes_only_latest_matching_user_turn() {
        let sender = "telegram_u3".to_string();
        let mut histories =
            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
        histories.push(
            sender.clone(),
            vec![
                ChatMessage::user("first"),
                ChatMessage::assistant("ok"),
                ChatMessage::user("pending"),
            ],
        );
        let ctx = ChannelRuntimeContext {
            channels_by_name: Arc::new(HashMap::new()),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        };

        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
            .peek(&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<dyn zeroclaw_infra::session_backend::SessionBackend> =
            Arc::new(zeroclaw_infra::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 =
            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
        histories.push(
            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()),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("system".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: Some(Arc::clone(&store)),
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        };

        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.peek(&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");
    }

    pub(crate) struct DummyModelProvider;

    #[async_trait::async_trait]
    impl ModelProvider for DummyModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            Ok("ok".to_string())
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for DummyModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "DummyModelProvider"
        }
    }

    struct FormatErrorModelProvider;

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

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            if messages
                .iter()
                .any(|msg| msg.content.contains("trigger format error"))
            {
                anyhow::bail!(
                    "All model_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())
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for FormatErrorModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "FormatErrorModelProvider"
        }
    }

    #[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)>>,
        finalized_gate_prompts: tokio::sync::Mutex<Vec<(String, String)>>,
    }

    #[derive(Default)]
    struct FailingSendChannel {
        send_calls: AtomicUsize,
    }

    struct DraftRecordingChannel {
        finalize_should_fail: bool,
        fallback_send_should_fail: bool,
        sent_messages: tokio::sync::Mutex<Vec<String>>,
        draft_messages: tokio::sync::Mutex<Vec<String>>,
        finalized_messages: tokio::sync::Mutex<Vec<String>>,
    }

    impl DraftRecordingChannel {
        fn new(finalize_should_fail: bool, fallback_send_should_fail: bool) -> Self {
            Self {
                finalize_should_fail,
                fallback_send_should_fail,
                sent_messages: tokio::sync::Mutex::new(Vec::new()),
                draft_messages: tokio::sync::Mutex::new(Vec::new()),
                finalized_messages: tokio::sync::Mutex::new(Vec::new()),
            }
        }
    }

    #[derive(Default)]
    struct RecordingMessageSentHook {
        events: Arc<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>>,
    }

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

    impl ::zeroclaw_api::attribution::Attributable for TelegramRecordingChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    #[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<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

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

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

    impl ::zeroclaw_api::attribution::Attributable for SlackRecordingChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    #[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<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

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

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

    impl ::zeroclaw_api::attribution::Attributable for WhatsAppRecordingChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

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

        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<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

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

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

    impl ::zeroclaw_api::attribution::Attributable for RecordingChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    impl ::zeroclaw_api::attribution::Attributable for FailingSendChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    impl ::zeroclaw_api::attribution::Attributable for DraftRecordingChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

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

        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
            self.send_calls.fetch_add(1, Ordering::SeqCst);
            anyhow::bail!("send boom")
        }

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

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

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

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

        fn supports_draft_updates(&self) -> bool {
            true
        }

        async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
            self.draft_messages
                .lock()
                .await
                .push(format!("{}:{}", message.recipient, message.content));
            Ok(Some("draft-1".to_string()))
        }

        async fn finalize_draft(
            &self,
            recipient: &str,
            message_id: &str,
            text: &str,
            _suppress_voice: bool,
        ) -> anyhow::Result<()> {
            if self.finalize_should_fail {
                anyhow::bail!("finalize boom")
            }
            self.finalized_messages
                .lock()
                .await
                .push(format!("{recipient}:{message_id}:{text}"));
            Ok(())
        }
    }

    #[async_trait::async_trait]
    impl zeroclaw_runtime::hooks::HookHandler for RecordingMessageSentHook {
        fn name(&self) -> &str {
            "recording-message-sent"
        }

        async fn on_message_sent(&self, channel: &str, recipient: &str, content: &str) {
            self.events.lock().await.push((
                channel.to_string(),
                recipient.to_string(),
                content.to_string(),
            ));
        }
    }

    #[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<zeroclaw_api::channel::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(())
        }

        async fn finalize_gate_prompt(
            &self,
            reference: &str,
            outcome: &str,
        ) -> anyhow::Result<bool> {
            self.finalized_gate_prompts
                .lock()
                .await
                .push((reference.to_string(), outcome.to_string()));
            Ok(true)
        }
    }

    fn test_runtime_ctx_with_config_agent_and_provider_ref(
        channel: Arc<dyn Channel>,
        model_provider: Arc<dyn ModelProvider>,
        prompt_config: zeroclaw_config::schema::Config,
        agent_cfg: zeroclaw_config::schema::AliasedAgentConfig,
        model_provider_ref: &str,
        hooks: Option<Arc<zeroclaw_runtime::hooks::HookRunner>>,
    ) -> Arc<ChannelRuntimeContext> {
        test_runtime_ctx_with_observer(
            channel,
            model_provider,
            prompt_config,
            agent_cfg,
            model_provider_ref,
            hooks,
            Arc::new(NoopObserver),
        )
    }

    fn test_runtime_ctx_with_observer(
        channel: Arc<dyn Channel>,
        model_provider: Arc<dyn ModelProvider>,
        prompt_config: zeroclaw_config::schema::Config,
        agent_cfg: zeroclaw_config::schema::AliasedAgentConfig,
        model_provider_ref: &str,
        hooks: Option<Arc<zeroclaw_runtime::hooks::HookRunner>>,
        observer: Arc<dyn Observer>,
    ) -> Arc<ChannelRuntimeContext> {
        let mut channels_by_name = HashMap::new();
        channels_by_name.insert(channel.name().to_string(), channel);

        Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider,
            model_provider_ref: Arc::new(model_provider_ref.to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(agent_cfg),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer,
            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(prompt_config),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks,
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        })
    }

    type MessageSentEvents = Arc<tokio::sync::Mutex<Vec<(String, String, String)>>>;

    fn recording_message_sent_runner()
    -> (MessageSentEvents, Arc<zeroclaw_runtime::hooks::HookRunner>) {
        let hook_events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
        let mut hook_runner = zeroclaw_runtime::hooks::HookRunner::new();
        hook_runner.register(Box::new(RecordingMessageSentHook {
            events: Arc::clone(&hook_events),
        }));
        (hook_events, Arc::new(hook_runner))
    }

    fn message_sent_hook_test_message() -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            id: "msg-1".to_string(),
            sender: "alice".to_string(),
            reply_target: "chat-42".to_string(),
            content: "hello".to_string(),
            channel: "test-channel".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,
            ..Default::default()
        }
    }

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

        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            Arc::new(DummyModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            Some(hook_runner),
        );

        process_channel_message(
            runtime_ctx,
            message_sent_hook_test_message(),
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(sent_messages.as_slice(), ["chat-42:ok"]);

        let events = hook_events.lock().await;
        assert_eq!(
            events.as_slice(),
            [(
                "test-channel".to_string(),
                "chat-42".to_string(),
                "ok".to_string()
            )]
        );
    }

    /// Observer that records every event, for turn-lifecycle assertions.
    #[derive(Default)]
    struct RecordingObserver {
        events: std::sync::Mutex<Vec<ObserverEvent>>,
    }

    impl Observer for RecordingObserver {
        fn record_event(&self, event: &ObserverEvent) {
            self.events.lock().unwrap().push(event.clone());
        }
        fn record_metric(&self, _metric: &ObserverMetric) {}
        fn name(&self) -> &str {
            "recording"
        }
        fn as_any(&self) -> &dyn std::any::Any {
            self
        }
        fn flush(&self) {}
    }

    /// Provider stub that cancels the turn's `CancellationToken` from inside
    /// the LLM call and then parks forever, so the orchestrator's
    /// `tokio::select!` deterministically takes the cancelled arm mid-turn.
    struct CancelMidTurnModelProvider {
        token: CancellationToken,
    }

    #[async_trait::async_trait]
    impl ModelProvider for CancelMidTurnModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            self.token.cancel();
            std::future::pending::<()>().await;
            unreachable!("parked future never resumes")
        }

        async fn chat_with_history(
            &self,
            _messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            self.token.cancel();
            std::future::pending::<()>().await;
            unreachable!("parked future never resumes")
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for CancelMidTurnModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "CancelMidTurnModelProvider"
        }
    }

    /// (start bracket entries, end bracket entries) — each entry is
    /// `(channel, agent_alias, turn_id)`. Aliased to keep the return type
    /// readable (clippy::type_complexity).
    type LifecycleBracketSnapshot = (
        Vec<(Option<String>, Option<String>, Option<String>)>,
        Vec<(Option<String>, Option<String>, Option<String>)>,
    );

    fn lifecycle_bracket_snapshot(events: &[ObserverEvent]) -> LifecycleBracketSnapshot {
        let starts = events
            .iter()
            .filter_map(|e| match e {
                ObserverEvent::AgentStart {
                    channel,
                    agent_alias,
                    turn_id,
                    ..
                } => Some((channel.clone(), agent_alias.clone(), turn_id.clone())),
                _ => None,
            })
            .collect();
        let ends = events
            .iter()
            .filter_map(|e| match e {
                ObserverEvent::AgentEnd {
                    channel,
                    agent_alias,
                    turn_id,
                    ..
                } => Some((channel.clone(), agent_alias.clone(), turn_id.clone())),
                _ => None,
            })
            .collect();
        (starts, ends)
    }

    /// Regression guard for the fix where channel-originated turns (Telegram,
    /// Discord, ...) never emitted `AgentStart`/`AgentEnd`, so
    /// `/api/events/history` showed `llm_request` frames but no turn
    /// lifecycle brackets. A successful turn must emit exactly one
    /// `AgentStart` (before the LLM request) and one `AgentEnd` (last),
    /// all sharing one `turn_id` and carrying the channel + agent alias.
    #[tokio::test]
    async fn process_channel_message_brackets_turn_with_agent_start_and_agent_end() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let observer = Arc::new(RecordingObserver::default());

        let runtime_ctx = test_runtime_ctx_with_observer(
            channel,
            Arc::new(DummyModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            None,
            observer.clone(),
        );

        process_channel_message(
            runtime_ctx,
            message_sent_hook_test_message(),
            CancellationToken::new(),
        )
        .await;

        let events = observer.events.lock().unwrap();
        let (starts, ends) = lifecycle_bracket_snapshot(&events);
        assert_eq!(starts.len(), 1, "exactly one AgentStart, got {events:?}");
        assert_eq!(ends.len(), 1, "exactly one AgentEnd, got {events:?}");

        let start_pos = events
            .iter()
            .position(|e| matches!(e, ObserverEvent::AgentStart { .. }))
            .unwrap();
        let llm_request_pos = events
            .iter()
            .position(|e| matches!(e, ObserverEvent::LlmRequest { .. }))
            .expect("turn should emit an LlmRequest");
        assert!(
            start_pos < llm_request_pos,
            "AgentStart must precede the LlmRequest: {events:?}"
        );
        assert!(
            matches!(events.last(), Some(ObserverEvent::AgentEnd { .. })),
            "AgentEnd must be the last event: {events:?}"
        );

        let (start_channel, start_alias, start_turn_id) = starts[0].clone();
        let (end_channel, end_alias, end_turn_id) = ends[0].clone();
        assert_eq!(start_channel.as_deref(), Some("test-channel"));
        assert_eq!(end_channel.as_deref(), Some("test-channel"));
        assert_eq!(start_alias.as_deref(), Some("test-agent"));
        assert_eq!(end_alias.as_deref(), Some("test-agent"));
        assert!(start_turn_id.is_some(), "AgentStart must carry a turn_id");
        assert_eq!(start_turn_id, end_turn_id, "brackets must share a turn_id");

        let llm_request_turn_id = events.iter().find_map(|e| match e {
            ObserverEvent::LlmRequest { turn_id, .. } => Some(turn_id.clone()),
            _ => None,
        });
        assert_eq!(
            llm_request_turn_id,
            Some(start_turn_id),
            "inner LlmRequest must share the brackets' turn_id"
        );
    }

    /// An erroring LLM turn must still close its bracket: one `AgentStart`
    /// and one `AgentEnd`, same `turn_id`.
    #[tokio::test]
    async fn process_channel_message_emits_brackets_when_llm_errors() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let observer = Arc::new(RecordingObserver::default());

        let runtime_ctx = test_runtime_ctx_with_observer(
            channel,
            Arc::new(FormatErrorModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            None,
            observer.clone(),
        );

        let mut msg = message_sent_hook_test_message();
        msg.content = "trigger format error".to_string();
        process_channel_message(runtime_ctx, msg, CancellationToken::new()).await;

        let events = observer.events.lock().unwrap();
        let (starts, ends) = lifecycle_bracket_snapshot(&events);
        assert_eq!(starts.len(), 1, "exactly one AgentStart, got {events:?}");
        assert_eq!(ends.len(), 1, "exactly one AgentEnd, got {events:?}");
        assert_eq!(
            starts[0].2, ends[0].2,
            "brackets must share a turn_id even on error"
        );
        assert!(starts[0].2.is_some(), "brackets must carry a turn_id");
    }

    /// A turn cancelled mid-flight (interrupt-on-new-message) must still
    /// close its bracket — the ZeroHome-critical guarantee that a cancelled
    /// turn cannot wedge an "agent in flight" indicator with an unmatched
    /// `AgentStart`.
    #[tokio::test]
    async fn process_channel_message_emits_brackets_when_cancelled_mid_turn() {
        let token = CancellationToken::new();
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let observer = Arc::new(RecordingObserver::default());

        let runtime_ctx = test_runtime_ctx_with_observer(
            channel,
            Arc::new(CancelMidTurnModelProvider {
                token: token.clone(),
            }),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            None,
            observer.clone(),
        );

        process_channel_message(runtime_ctx, message_sent_hook_test_message(), token).await;

        let events = observer.events.lock().unwrap();
        let (starts, ends) = lifecycle_bracket_snapshot(&events);
        assert_eq!(
            starts.len(),
            1,
            "cancelled turn must still emit AgentStart, got {events:?}"
        );
        assert_eq!(
            ends.len(),
            1,
            "cancelled turn must still emit AgentEnd, got {events:?}"
        );
        assert_eq!(
            starts[0].2, ends[0].2,
            "brackets must share a turn_id even when cancelled"
        );
        assert!(starts[0].2.is_some(), "brackets must carry a turn_id");
    }

    #[tokio::test]
    async fn process_channel_message_skips_message_sent_hook_when_reply_delivery_fails() {
        let channel_impl = Arc::new(FailingSendChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let (hook_events, hook_runner) = recording_message_sent_runner();

        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            Arc::new(DummyModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            Some(hook_runner),
        );

        process_channel_message(
            runtime_ctx,
            message_sent_hook_test_message(),
            CancellationToken::new(),
        )
        .await;

        assert_eq!(channel_impl.send_calls.load(Ordering::SeqCst), 1);
        assert!(hook_events.lock().await.is_empty());
    }

    #[tokio::test]
    async fn process_channel_message_fires_message_sent_hook_after_draft_finalize() {
        let channel_impl = Arc::new(DraftRecordingChannel::new(false, false));
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let (hook_events, hook_runner) = recording_message_sent_runner();

        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            Arc::new(DummyModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            Some(hook_runner),
        );

        process_channel_message(
            runtime_ctx,
            message_sent_hook_test_message(),
            CancellationToken::new(),
        )
        .await;

        assert_eq!(
            channel_impl.draft_messages.lock().await.as_slice(),
            ["chat-42:..."]
        );
        assert_eq!(
            channel_impl.finalized_messages.lock().await.as_slice(),
            ["chat-42:draft-1:ok"]
        );
        assert!(channel_impl.sent_messages.lock().await.is_empty());
        assert_eq!(
            hook_events.lock().await.as_slice(),
            [(
                "test-channel".to_string(),
                "chat-42".to_string(),
                "ok".to_string()
            )]
        );
    }

    #[tokio::test]
    async fn process_channel_message_fires_message_sent_hook_after_draft_fallback_send() {
        let channel_impl = Arc::new(DraftRecordingChannel::new(true, false));
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let (hook_events, hook_runner) = recording_message_sent_runner();

        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            Arc::new(DummyModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            Some(hook_runner),
        );

        process_channel_message(
            runtime_ctx,
            message_sent_hook_test_message(),
            CancellationToken::new(),
        )
        .await;

        assert_eq!(
            channel_impl.draft_messages.lock().await.as_slice(),
            ["chat-42:..."]
        );
        assert!(channel_impl.finalized_messages.lock().await.is_empty());
        assert_eq!(
            channel_impl.sent_messages.lock().await.as_slice(),
            ["chat-42:ok"]
        );
        assert_eq!(
            hook_events.lock().await.as_slice(),
            [(
                "test-channel".to_string(),
                "chat-42".to_string(),
                "ok".to_string()
            )]
        );
    }

    #[tokio::test]
    async fn process_channel_message_skips_message_sent_hook_when_draft_fallback_send_fails() {
        let channel_impl = Arc::new(DraftRecordingChannel::new(true, true));
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let (hook_events, hook_runner) = recording_message_sent_runner();

        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            Arc::new(DummyModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            Some(hook_runner),
        );

        process_channel_message(
            runtime_ctx,
            message_sent_hook_test_message(),
            CancellationToken::new(),
        )
        .await;

        assert_eq!(
            channel_impl.draft_messages.lock().await.as_slice(),
            ["chat-42:..."]
        );
        assert!(channel_impl.finalized_messages.lock().await.is_empty());
        assert!(channel_impl.sent_messages.lock().await.is_empty());
        assert!(hook_events.lock().await.is_empty());
    }

    struct SlowModelProvider {
        delay: Duration,
    }

    #[async_trait::async_trait]
    impl ModelProvider for SlowModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            tokio::time::sleep(self.delay).await;
            Ok(format!("echo: {message}"))
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for SlowModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "SlowModelProvider"
        }
    }

    struct NoReplyModelProvider;

    #[async_trait::async_trait]
    impl ModelProvider for NoReplyModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            Ok("NO_REPLY[INFO]: nothing to add".to_string())
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for NoReplyModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "NoReplyModelProvider"
        }
    }

    struct ToolCallingModelProvider;

    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 ModelProvider for ToolCallingModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            Ok(tool_call_payload())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<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())
            }
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for ToolCallingModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "ToolCallingModelProvider"
        }
    }

    struct SessionsCurrentModelProvider;

    #[async_trait::async_trait]
    impl ModelProvider for SessionsCurrentModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            Ok(r#"<tool_call>
{"name":"sessions_current","arguments":{}}
</tool_call>"#
                .to_string())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            if let Some(tool_results) = messages
                .iter()
                .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
            {
                if tool_results
                    .content
                    .contains("Current session: test-channel_chat-42_alice")
                    && tool_results.content.contains("Messages: 1")
                {
                    return Ok(
                        "Current session: test-channel_chat-42_alice\nMessages: 1".to_string()
                    );
                }

                Ok("session result unavailable".to_string())
            } else {
                self.chat_with_system(None, "", "", None).await
            }
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for SessionsCurrentModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "SessionsCurrentModelProvider"
        }
    }

    struct ToolCallingAliasModelProvider;

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

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<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())
            }
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for ToolCallingAliasModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "ToolCallingAliasModelProvider"
        }
    }

    struct RawToolArtifactModelProvider;

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

        async fn chat_with_history(
            &self,
            _messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<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())
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for RawToolArtifactModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "RawToolArtifactModelProvider"
        }
    }

    struct IterativeToolModelProvider {
        required_tool_iterations: usize,
    }

    impl IterativeToolModelProvider {
        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 ModelProvider for IterativeToolModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            _message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            Ok(tool_call_payload())
        }

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<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())
            }
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for IterativeToolModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "IterativeToolModelProvider"
        }
    }

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

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

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<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()))
        }

        fn supports_vision(&self) -> bool {
            self.vision
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for HistoryCaptureModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "HistoryCaptureModelProvider"
        }
    }

    #[tokio::test]
    async fn passive_context_records_history_without_channel_or_model_side_effects() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
        let provider: Arc<dyn ModelProvider> = provider_impl.clone();
        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            provider,
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            None,
        );

        let passive_msg = zeroclaw_api::channel::ChannelMessage {
            id: "passive-1".into(),
            sender: "bob".into(),
            reply_target: "group-1@g.us".into(),
            content: "the release codename is quartz".into(),
            channel: "whatsapp".into(),
            timestamp: 1,
            passive_context: true,
            conversation_scope: zeroclaw_api::channel::ChannelConversationScope::ReplyTarget,
            ..Default::default()
        };

        process_channel_message(
            runtime_ctx.clone(),
            passive_msg.clone(),
            CancellationToken::new(),
        )
        .await;

        assert!(
            provider_impl
                .calls
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .is_empty(),
            "passive context must not call the provider"
        );
        assert!(channel_impl.sent_messages.lock().await.is_empty());
        assert!(channel_impl.reactions_added.lock().await.is_empty());
        assert!(channel_impl.reactions_removed.lock().await.is_empty());
        assert_eq!(channel_impl.start_typing_calls.load(Ordering::SeqCst), 0);
        assert_eq!(channel_impl.stop_typing_calls.load(Ordering::SeqCst), 0);

        let active_msg = zeroclaw_api::channel::ChannelMessage {
            id: "active-1".into(),
            sender: "alice".into(),
            content: "what is the release codename?".into(),
            timestamp: 2,
            passive_context: false,
            conversation_scope: zeroclaw_api::channel::ChannelConversationScope::ReplyTarget,
            ..passive_msg.clone()
        };
        assert_eq!(
            conversation_history_key(&active_msg),
            conversation_history_key(&passive_msg)
        );

        process_channel_message(runtime_ctx, active_msg, CancellationToken::new()).await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 1);
        let user_history = calls[0]
            .iter()
            .filter(|(role, _)| role == "user")
            .map(|(_, content)| content.as_str())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            user_history.contains("the release codename is quartz"),
            "active turn should see passive group context, got: {user_history}"
        );
        assert!(
            user_history.contains("[Observed WhatsApp group message from bob]"),
            "passive group context should preserve observed sender attribution, got: {user_history}"
        );
        assert!(
            user_history.contains("what is the release codename?"),
            "active turn should still include current message, got: {user_history}"
        );
        assert!(
            user_history.contains("[Current WhatsApp group message from alice]"),
            "active group turn should preserve current sender attribution, got: {user_history}"
        );
    }

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

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

        async fn chat_with_history(
            &self,
            messages: &[ChatMessage],
            _model: &str,
            _temperature: Option<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}"))
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for DelayedHistoryCaptureModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "DelayedHistoryCaptureModelProvider"
        }
    }

    struct MockPriceTool;

    impl ::zeroclaw_api::attribution::Attributable for MockPriceTool {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Tool(::zeroclaw_api::attribution::ToolKind::Plugin)
        }
        fn alias(&self) -> &str {
            <Self as ::zeroclaw_api::tool::Tool>::name(self)
        }
    }

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

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

        async fn chat_with_history(
            &self,
            _messages: &[ChatMessage],
            model: &str,
            _temperature: Option<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())
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for ModelCaptureModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "ModelCaptureModelProvider"
        }
    }

    #[derive(Default)]
    struct ModelSwitchRequestProvider {
        call_count: AtomicUsize,
    }

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

        async fn chat(
            &self,
            _request: zeroclaw_providers::ChatRequest<'_>,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<zeroclaw_providers::ChatResponse> {
            let call_index = self.call_count.fetch_add(1, Ordering::SeqCst);
            if call_index == 0 {
                Ok(zeroclaw_providers::ChatResponse {
                    text: Some(String::new()),
                    tool_calls: vec![zeroclaw_providers::ToolCall {
                        id: "switch-call".to_string(),
                        name: "model_switch".to_string(),
                        arguments: serde_json::json!({
                            "action": "set",
                            "model_provider": "openrouter.default",
                            "model": "switched-model"
                        })
                        .to_string(),
                        extra_content: None,
                    }],
                    usage: None,
                    reasoning_content: None,
                })
            } else {
                Ok(zeroclaw_providers::ChatResponse {
                    text: Some("original-provider-should-not-be-reused".to_string()),
                    tool_calls: vec![],
                    usage: None,
                    reasoning_content: None,
                })
            }
        }

        fn supports_native_tools(&self) -> bool {
            true
        }
    }

    impl ::zeroclaw_api::attribution::Attributable for ModelSwitchRequestProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }

        fn alias(&self) -> &str {
            "ModelSwitchRequestProvider"
        }
    }

    #[derive(Default)]
    struct PrecheckProbeModelProvider {
        precheck_calls: AtomicUsize,
        main_calls: AtomicUsize,
        models: std::sync::Mutex<Vec<String>>,
        precheck_delay: Option<Duration>,
    }

    #[async_trait::async_trait]
    impl ModelProvider for PrecheckProbeModelProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            message: &str,
            model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            self.models
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .push(model.to_string());

            if message.starts_with("Decide whether the assistant should send any visible reply") {
                self.precheck_calls.fetch_add(1, Ordering::SeqCst);
                if let Some(delay) = self.precheck_delay {
                    tokio::time::sleep(delay).await;
                }
                return Ok("NO_REPLY[INFO]: background chatter".to_string());
            }

            self.main_calls.fetch_add(1, Ordering::SeqCst);
            Ok("visible reply".to_string())
        }
    }

    impl ::zeroclaw_api::attribution::Attributable for PrecheckProbeModelProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "PrecheckProbeModelProvider"
        }
    }

    #[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: ToolOutput::default(),
                    error: Some("unexpected symbol".to_string()),
                });
            }

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

    /// Minimal fixed-name tool for allowlist-filter coverage.
    struct NamedMockTool(&'static str);

    impl ::zeroclaw_api::attribution::Attributable for NamedMockTool {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Tool(::zeroclaw_api::attribution::ToolKind::Plugin)
        }
        fn alias(&self) -> &str {
            self.0
        }
    }

    #[async_trait::async_trait]
    impl Tool for NamedMockTool {
        fn name(&self) -> &str {
            self.0
        }

        fn description(&self) -> &str {
            "named mock"
        }

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

        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
            Ok(ToolResult {
                success: true,
                output: ToolOutput::default(),
                error: None,
            })
        }
    }

    #[test]
    fn channel_path_allowlist_drops_non_allowlisted_builtins() {
        let mut built_tools: Vec<Box<dyn Tool>> = vec![
            Box::new(NamedMockTool("shell")),
            Box::new(NamedMockTool("file_write")),
            Box::new(NamedMockTool("file_read")),
        ];
        let policy = SecurityPolicy {
            allowed_tools: Some(vec!["file_read".to_string()]),
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        };
        apply_policy_tool_filter(&mut built_tools, Some(&policy), None);
        let names: Vec<&str> = built_tools.iter().map(|t| t.name()).collect();
        assert!(
            !names.contains(&"shell") && !names.contains(&"file_write"),
            "raw built-ins outside the allowlist must be dropped on the channel path; got {names:?}"
        );
        assert!(
            names.contains(&"file_read"),
            "allowlisted tool must survive the filter; got {names:?}"
        );
    }

    #[test]
    fn channel_path_excluded_tools_drops_denied_mcp_tool() {
        use zeroclaw_runtime::agent::loop_::{
            mcp_tool_access_policy, register_eager_mcp_tool_if_allowed,
        };
        let policy = SecurityPolicy {
            excluded_tools: Some(vec!["aa_mcp__find_items".to_string()]),
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        };
        let mcp_policy = mcp_tool_access_policy(&policy, None);
        let mut built_tools: Vec<Box<dyn Tool>> = Vec::new();
        let denied: Arc<dyn Tool> = Arc::new(NamedMockTool("aa_mcp__find_items"));
        let allowed: Arc<dyn Tool> = Arc::new(NamedMockTool("aa_mcp__find_npcs"));
        let registered_denied =
            register_eager_mcp_tool_if_allowed(denied, &mut built_tools, None, mcp_policy.as_ref());
        let registered_allowed = register_eager_mcp_tool_if_allowed(
            allowed,
            &mut built_tools,
            None,
            mcp_policy.as_ref(),
        );
        assert!(
            !registered_denied,
            "an `excluded_tools`-denied MCP tool must not be registered on the channel path"
        );
        assert!(
            registered_allowed,
            "a non-denied MCP tool must still be registered (allowlist auto-admit)"
        );
        let names: Vec<&str> = built_tools.iter().map(|t| t.name()).collect();
        assert!(
            !names.contains(&"aa_mcp__find_items"),
            "denied MCP tool leaked into the channel registry; got {names:?}"
        );
        assert!(
            names.contains(&"aa_mcp__find_npcs"),
            "allowed MCP tool missing from the channel registry; got {names:?}"
        );
    }

    #[test]
    fn channel_path_excluded_tools_drops_denied_builtin() {
        let mut built_tools: Vec<Box<dyn Tool>> = vec![
            Box::new(NamedMockTool("shell")),
            Box::new(NamedMockTool("file_read")),
        ];
        let policy = SecurityPolicy {
            excluded_tools: Some(vec!["shell".to_string()]),
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        };
        apply_policy_tool_filter(&mut built_tools, Some(&policy), None);
        let names: Vec<&str> = built_tools.iter().map(|t| t.name()).collect();
        assert!(
            !names.contains(&"shell"),
            "an `excluded_tools`-denied built-in must be dropped on the channel path; got {names:?}"
        );
        assert!(
            names.contains(&"file_read"),
            "a non-excluded built-in must survive the filter; got {names:?}"
        );
    }

    fn channel_all_tools_result(tools: Vec<Box<dyn Tool>>) -> tools::AllToolsResult {
        tools::AllToolsResult {
            tools,
            delegate_handle: None,
            ask_user_handle: None,
            reaction_handle: Arc::new(parking_lot::RwLock::new(HashMap::new())),
            poll_handle: None,
            escalate_handle: None,
            channel_room_handle: None,
            unfiltered_tool_arcs: Vec::new(),
        }
    }

    /// A mock HTTP MCP server that advertises `resources` support and serves one
    /// readable resource (`file:///handbook.md`), so `assemble_channel_agent_tools`
    /// resolves a real pinned-resource section instead of an empty one.
    async fn mock_mcp_server_with_pinned_resource() -> wiremock::MockServer {
        use wiremock::matchers::{body_partial_json, method};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(body_partial_json(
                serde_json::json!({"method": "initialize"}),
            ))
            .respond_with(
                ResponseTemplate::new(200)
                    .insert_header("Mcp-Session-Id", "s")
                    .set_body_json(serde_json::json!({
                        "jsonrpc":"2.0","id":1,
                        "result":{"capabilities":{"tools":{},"resources":{}}}
                    })),
            )
            .mount(&server)
            .await;
        Mock::given(method("POST"))
            .and(body_partial_json(
                serde_json::json!({"method":"notifications/initialized"}),
            ))
            .respond_with(ResponseTemplate::new(202))
            .mount(&server)
            .await;
        Mock::given(method("POST"))
            .and(body_partial_json(
                serde_json::json!({"method":"tools/list"}),
            ))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "jsonrpc":"2.0","id":2,"result":{"tools":[
                    {"name":"echo","description":"echo","inputSchema":{"type":"object"}}
                ]}
            })))
            .mount(&server)
            .await;
        Mock::given(method("POST"))
            .and(body_partial_json(
                serde_json::json!({"method":"resources/list"}),
            ))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "jsonrpc":"2.0","id":3,"result":{"resources":[
                    {"uri":"file:///handbook.md","name":"handbook","mimeType":"text/plain"}
                ]}
            })))
            .mount(&server)
            .await;
        Mock::given(method("POST"))
            .and(body_partial_json(serde_json::json!({"method":"resources/read"})))
            .respond_with(|request: &wiremock::Request| {
                let id = serde_json::from_slice::<serde_json::Value>(&request.body)
                    .expect("resources/read request should be JSON")
                    .get("id")
                    .cloned()
                    .expect("resources/read request should carry an id");
                ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "jsonrpc":"2.0","id":id,"result":{"contents":[
                        {"uri":"file:///handbook.md","mimeType":"text/plain","text":"Pinned handbook body"}
                    ]}
                }))
            })
            .mount(&server)
            .await;
        server
    }

    /// Drives the REAL `assemble_channel_agent_tools` against a mock MCP server
    /// granting one pinned resource, then runs the exact post-assembly composition
    /// `start_channels` performs (`compose_channel_mcp_prompt_sections`). It proves
    /// the production boundary keeps the deferred tool-search listing and the pinned
    /// MCP resource in SEPARATE sections, so strict text-tool suppression
    /// (`native_tools = false`, `strict_tool_parsing = true`) clears the deferred
    /// tool instructions while the pinned resource survives into the final prompt.
    /// A regression that re-merges the two sections inside the assembly (or reorders
    /// the suppress/append pair) fails here, unlike a test that hand-builds the
    /// section strings and never calls the assembly.
    #[tokio::test]
    async fn assemble_channel_agent_tools_keeps_pinned_resources_after_strict_policy() {
        use zeroclaw_config::schema::{
            AliasedAgentConfig, McpBundleConfig, McpServerConfig, McpTransport, RiskProfileConfig,
        };
        let server = mock_mcp_server_with_pinned_resource().await;

        let mut config = Config::default();
        config.mcp.enabled = true;
        config.mcp.deferred_loading = true;
        config.mcp.servers = vec![McpServerConfig {
            name: "docs".into(),
            transport: McpTransport::Http,
            url: Some(server.uri()),
            pinned_resources: vec!["file:///handbook.md".into()],
            ..Default::default()
        }];
        config.mcp_bundles.insert(
            "docsbundle".into(),
            McpBundleConfig {
                servers: vec!["docs".into()],
                exclude: Vec::new(),
            },
        );
        config
            .risk_profiles
            .insert("test-profile".into(), RiskProfileConfig::default());
        config.agents.insert(
            "channel-agent".into(),
            AliasedAgentConfig {
                enabled: true,
                model_provider: "openai.test-provider".into(),
                risk_profile: "test-profile".into(),
                mcp_bundles: vec!["docsbundle".into()],
                ..Default::default()
            },
        );

        let security = Arc::new(SecurityPolicy {
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        });
        let assembled = tokio::time::timeout(
            ASSEMBLY_HANG_GUARD,
            assemble_channel_agent_tools(
                &config,
                "channel-agent",
                "openai.test-provider",
                "gpt-test",
                &security,
                channel_all_tools_result(Vec::new()),
                &[],
                Arc::new(platform::NativeRuntime::new()),
            ),
        )
        .await
        .expect("assemble must not hang");

        // The production assembly must surface the pinned resource in its OWN
        // section, distinct from the deferred/tool-search listing.
        assert!(
            assembled.pinned_section.contains("Pinned handbook body")
                && assembled
                    .pinned_section
                    .contains("trust=\"untrusted-external\""),
            "assemble_channel_agent_tools must expose the pinned MCP resource in \
             pinned_section; got {:?}",
            assembled.pinned_section
        );
        assert!(
            !assembled.deferred_section.contains("Pinned handbook body"),
            "pinned resource content must NOT be merged into the deferred section; got {:?}",
            assembled.deferred_section
        );
        assert!(
            assembled.deferred_section.contains("tool_search"),
            "precondition: a deferred-loading MCP grant must yield a tool_search \
             section to suppress; got {:?}",
            assembled.deferred_section
        );

        // Run the exact composition start_channels performs for a strict,
        // non-native target: suppress the deferred tool-search section, keep pinned.
        let mut tool_descs: Vec<(&str, &str)> = vec![("shell", "Run commands")];
        let mut deferred_section = assembled.deferred_section.clone();
        let expose_text_protocol = compose_channel_mcp_prompt_sections(
            false,
            true,
            &mut tool_descs,
            &mut deferred_section,
            &assembled.pinned_section,
        );

        assert!(!expose_text_protocol);
        assert!(
            !deferred_section.contains("tool_search"),
            "strict policy must clear the deferred tool-search section; got {deferred_section:?}"
        );
        assert!(
            deferred_section.contains("Pinned handbook body")
                && deferred_section.contains("## Pinned MCP Resources"),
            "pinned resource must survive strict suppression; got {deferred_section:?}"
        );
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn assemble_channel_agent_tools_attributes_assembly_logs_to_agent_and_model() {
        use zeroclaw_config::schema::{
            AliasedAgentConfig, McpBundleConfig, McpServerConfig, McpTransport, RiskProfileConfig,
        };

        let _writer_guard = zeroclaw_log::__private_test_writer_lock();
        let _hook_guard = zeroclaw_log::__private_test_hook_lock();
        zeroclaw_log::try_install_capture_subscriber();
        let mut rx = zeroclaw_log::subscribe_or_install();
        while rx.try_recv().is_ok() {}

        let server = mock_mcp_server_with_pinned_resource().await;
        let mut config = Config::default();
        config.mcp.enabled = true;
        config.mcp.deferred_loading = true;
        config.mcp.servers = vec![McpServerConfig {
            name: "docs".into(),
            transport: McpTransport::Http,
            url: Some(server.uri()),
            pinned_resources: vec!["file:///handbook.md".into()],
            ..Default::default()
        }];
        config.mcp_bundles.insert(
            "docsbundle".into(),
            McpBundleConfig {
                servers: vec!["docs".into()],
                exclude: Vec::new(),
            },
        );
        config
            .risk_profiles
            .insert("test-profile".into(), RiskProfileConfig::default());
        config.agents.insert(
            "channel-agent".into(),
            AliasedAgentConfig {
                enabled: true,
                model_provider: "openai.test-provider".into(),
                risk_profile: "test-profile".into(),
                mcp_bundles: vec!["docsbundle".into()],
                ..Default::default()
            },
        );

        let security = Arc::new(SecurityPolicy {
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        });
        let _assembled = tokio::time::timeout(
            ASSEMBLY_HANG_GUARD,
            assemble_channel_agent_tools(
                &config,
                "channel-agent",
                "openai.test-provider",
                "gpt-test",
                &security,
                channel_all_tools_result(Vec::new()),
                &[],
                Arc::new(platform::NativeRuntime::new()),
            ),
        )
        .await
        .expect("assemble must not hang");

        let deadline = std::time::Instant::now() + ASSEMBLY_HANG_GUARD;
        let mut assembly_event = None;
        while std::time::Instant::now() < deadline {
            match tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()).await {
                Ok(Ok(value)) => {
                    if value
                        .get("message")
                        .and_then(|v| v.as_str())
                        .is_some_and(|m| m.starts_with("Initializing MCP client"))
                    {
                        assembly_event = Some(value);
                        break;
                    }
                }
                Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {}
                Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break,
                Err(_) => {}
            }
        }

        let value = assembly_event.expect("assembly log should be emitted");
        assert_eq!(
            value["zeroclaw"]["agent_alias"], "channel-agent",
            "assembly log must inherit the channel agent attribution span, got: {value}"
        );
        assert_eq!(
            value["zeroclaw"]["model_provider"], "openai.test-provider",
            "assembly log must preserve the startup model_provider scope, got: {value}"
        );
        assert_eq!(
            value["zeroclaw"]["model_provider_type"], "openai",
            "assembly log must split the scoped provider family, got: {value}"
        );
        assert_eq!(
            value["zeroclaw"]["model_provider_alias"], "test-provider",
            "assembly log must split the scoped provider alias, got: {value}"
        );
        assert_eq!(
            value["zeroclaw"]["model"], "gpt-test",
            "assembly log must preserve the startup model scope, got: {value}"
        );

        zeroclaw_log::clear_broadcast_hook();
    }

    /// The `channel_path_*` tests elsewhere pin the shared filter/registration
    /// helpers directly, not `start_channels`'s actual assembly call - a bad edit
    /// to `assemble_channel_agent_tools`'s knobs (flipping `exclude_memory`,
    /// dropping `connect_mcp`, etc.) would compile and slip past them undetected.
    /// This test drives the exact function `start_channels` calls, closing that
    /// gap for the built-in allow/deny behavior. (Pinned-resource resolution
    /// through this same path is covered by
    /// `assemble_channel_agent_tools_keeps_pinned_resources_after_strict_policy`;
    /// `scoped.rs`'s `assemble_grants_no_mcp_to_agent_without_bundles` and siblings
    /// cover the assembly's own MCP-grant policy.)
    #[tokio::test]
    async fn assemble_channel_agent_tools_honors_allowed_and_excluded_tools() {
        let config = Config::default();
        let built = channel_all_tools_result(vec![
            Box::new(NamedMockTool("shell")),
            Box::new(NamedMockTool("file_write")),
            Box::new(NamedMockTool("file_read")),
        ]);
        let security = Arc::new(SecurityPolicy {
            allowed_tools: Some(vec!["file_read".to_string()]),
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        });
        let assembled = assemble_channel_agent_tools(
            &config,
            "test-agent",
            "test-provider",
            "test-model",
            &security,
            built,
            &[],
            Arc::new(platform::NativeRuntime::new()),
        )
        .await;
        let names: Vec<&str> = assembled.tools.iter().map(|t| t.name()).collect();
        assert!(
            !names.contains(&"shell") && !names.contains(&"file_write"),
            "the real start_channels assembly path must drop non-allowlisted built-ins; got {names:?}"
        );
        assert!(
            names.contains(&"file_read"),
            "the real start_channels assembly path must keep the allowlisted built-in; got {names:?}"
        );
    }

    /// Companion pin for `excluded_tools`, through the same real assembly path.
    #[tokio::test]
    async fn assemble_channel_agent_tools_drops_excluded_builtin() {
        let config = Config::default();
        let built = channel_all_tools_result(vec![
            Box::new(NamedMockTool("shell")),
            Box::new(NamedMockTool("file_read")),
        ]);
        let security = Arc::new(SecurityPolicy {
            excluded_tools: Some(vec!["shell".to_string()]),
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        });
        let assembled = assemble_channel_agent_tools(
            &config,
            "test-agent",
            "test-provider",
            "test-model",
            &security,
            built,
            &[],
            Arc::new(platform::NativeRuntime::new()),
        )
        .await;
        let names: Vec<&str> = assembled.tools.iter().map(|t| t.name()).collect();
        assert!(
            !names.contains(&"shell"),
            "the real start_channels assembly path must drop the excluded built-in; got {names:?}"
        );
        assert!(
            names.contains(&"file_read"),
            "the real start_channels assembly path must keep the non-excluded built-in; got {names:?}"
        );
    }

    /// Pins the `exclude_memory: false` knob at `assemble_channel_agent_tools`'s
    /// call site - a regression flipping it to `true` (the ACP-only divergence)
    /// would silently strip memory tools from every channel agent, undetected by
    /// the allow/deny-only tests. Feeds one of every canonical memory tool name
    /// (`zeroclaw_tools::MEMORY_TOOL_NAMES`) through the real assembly path and
    /// asserts all five survive.
    #[tokio::test]
    async fn assemble_channel_agent_tools_keeps_memory_tools() {
        let config = Config::default();
        let built = channel_all_tools_result(
            zeroclaw_tools::MEMORY_TOOL_NAMES
                .iter()
                .map(|name| Box::new(NamedMockTool(name)) as Box<dyn Tool>)
                .collect(),
        );
        let security = Arc::new(SecurityPolicy::default());
        let assembled = assemble_channel_agent_tools(
            &config,
            "test-agent",
            "test-provider",
            "test-model",
            &security,
            built,
            &[],
            Arc::new(platform::NativeRuntime::new()),
        )
        .await;
        let names: Vec<&str> = assembled.tools.iter().map(|t| t.name()).collect();
        for memory_tool in zeroclaw_tools::MEMORY_TOOL_NAMES {
            assert!(
                names.contains(memory_tool),
                "the channel assembly path (exclude_memory: false) must keep memory tool \
                 '{memory_tool}'; got {names:?}"
            );
        }
    }

    struct FingerprintRuntime;

    impl platform::RuntimeAdapter for FingerprintRuntime {
        fn name(&self) -> &str {
            "fingerprint-test-runtime"
        }
        fn has_shell_access(&self) -> bool {
            true
        }
        fn has_filesystem_access(&self) -> bool {
            true
        }
        fn storage_path(&self) -> std::path::PathBuf {
            std::env::temp_dir()
        }
        fn supports_long_running(&self) -> bool {
            false
        }
        fn build_shell_command(
            &self,
            _command: &str,
            _workspace_dir: &std::path::Path,
        ) -> anyhow::Result<tokio::process::Command> {
            // Deliberately fails with a distinguishable message instead of spawning a
            // real process, so executing the resulting skill tool proves THIS runtime
            // drove construction - not a default/native one, which would instead try
            // to actually run the command.
            anyhow::bail!("fingerprint-test-runtime: refusing to spawn a shell command")
        }
    }

    /// Pins the fix for a channel-path divergence: `start_channels` previously
    /// called `register_skill_tools_with_context`, which always defaulted to
    /// `NativeRuntime` regardless of `[platform]`. `assemble_channel_agent_tools` now
    /// threads the orchestrator's real `runtime` parameter into skill construction -
    /// proven here by injecting a runtime whose `build_shell_command` refuses to spawn
    /// a real process with a distinguishable error; executing the resulting shell-kind
    /// skill tool surfaces exactly that error, which could only happen if the injected
    /// runtime (not a default) drove construction.
    #[tokio::test]
    async fn assemble_channel_agent_tools_threads_the_given_runtime_to_skill_tools() {
        let config = Config::default();
        let built = channel_all_tools_result(Vec::new());
        let security = Arc::new(SecurityPolicy::default());
        let skills = vec![zeroclaw_runtime::skills::Skill {
            name: "probe".into(),
            description: "d".into(),
            description_localizations: Default::default(),
            version: "1.0.0".into(),
            author: None,
            tags: vec![],
            tools: vec![zeroclaw_runtime::skills::SkillTool {
                name: "run".into(),
                description: "d".into(),
                kind: "shell".into(),
                command: "echo hi".into(),
                args: HashMap::new(),
                target: None,
                locked_args: HashMap::new(),
                timeout_secs: None,
            }],
            prompts: vec![],
            slash_options: Vec::new(),
            location: None,
        }];
        let assembled = assemble_channel_agent_tools(
            &config,
            "test-agent",
            "test-provider",
            "test-model",
            &security,
            built,
            &skills,
            Arc::new(FingerprintRuntime),
        )
        .await;
        let tool = assembled
            .tools
            .iter()
            .find(|t| t.name() == "probe__run")
            .expect("skill tool must be registered");
        let result = tool.execute(serde_json::json!({})).await;
        let failed_with_fingerprint = match &result {
            Err(e) => e.to_string().contains("fingerprint-test-runtime"),
            Ok(r) => {
                !r.success
                    && r.error
                        .as_deref()
                        .is_some_and(|e| e.contains("fingerprint-test-runtime"))
            }
        };
        assert!(
            failed_with_fingerprint,
            "skill tool must execute via the INJECTED runtime, not a default one; got {result:?}"
        );
    }

    fn peer_prompt_test_context(
        channels_by_name: HashMap<String, Arc<dyn Channel>>,
        provider_impl: Arc<HistoryCaptureModelProvider>,
        prompt_config: Arc<Config>,
        tools_registry: Arc<Vec<Box<dyn Tool>>>,
    ) -> Arc<ChannelRuntimeContext> {
        Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: provider_impl,
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(RecallMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(RecallMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry,
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config,
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: 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),
            model_provider: Arc::new(ToolCallingModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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_scopes_sender_session_key_for_sessions_current_tool() {
        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 tmp = TempDir::new().unwrap();
        let session_store: Arc<dyn zeroclaw_infra::session_backend::SessionBackend> =
            Arc::new(zeroclaw_infra::session_store::SessionStore::new(tmp.path()).unwrap());

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: Arc::new(SessionsCurrentModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![Box::new(
                zeroclaw_runtime::tools::SessionsCurrentTool::new(Arc::clone(&session_store)),
            )]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: Some(Arc::clone(&session_store)),
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(&{
                let mut profile = zeroclaw_config::schema::RiskProfileConfig::default();
                profile.auto_approve.push("sessions_current".to_string());
                profile
            })),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            agent_transcription_provider: String::new(),
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-42".to_string(),
                content: "Which session is this?".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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("Current session: test-channel_chat-42_alice"));
        assert!(reply.contains("Messages: 1"));
    }

    #[tokio::test]
    async fn process_channel_message_renders_trailing_tool_receipts_block_when_enabled() {
        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),
            model_provider: Arc::new(ToolCallingModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::Full,
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig {
                    level: zeroclaw_config::autonomy::AutonomyLevel::Full,
                    auto_approve: vec!["mock_price".to_string()],
                    ..Default::default()
                },
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: Some(
                zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator::new(),
            ),
            show_receipts_in_response: true,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            agent_transcription_provider: String::new(),
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        // Two sends: the model's reply and the trailing receipts block.
        assert!(
            sent_messages.len() >= 2,
            "expected at least 2 sends (reply + receipts block), got {}: {:?}",
            sent_messages.len(),
            sent_messages
        );

        let receipts_message = sent_messages
            .iter()
            .find(|m| m.contains("Tool receipts:"))
            .unwrap_or_else(|| {
                panic!(
                    "no `Tool receipts:` send found; got {:?}",
                    sent_messages.as_slice()
                )
            });
        assert!(
            receipts_message.starts_with("chat-42:"),
            "receipts block must be sent to the same reply target as the agent reply, got {receipts_message}"
        );
        assert!(
            receipts_message.contains("---\nTool receipts:"),
            "receipts block must be prefixed with the documented `---\\nTool receipts:` separator, got {receipts_message}"
        );
        assert!(
            receipts_message.contains("zc-receipt-"),
            "receipts block must carry at least one zc-receipt-* HMAC token (proves the generator actually ran), got {receipts_message}"
        );
        assert!(
            receipts_message.contains("mock_price"),
            "receipts block should name the tool that produced the receipt, got {receipts_message}"
        );
    }

    #[tokio::test]
    async fn process_channel_message_omits_receipts_block_when_disabled() {
        // Backward-compat: with show_receipts_in_response=false (default), no
        // trailing receipts message is sent — even when a generator is active
        // and the loop ran tools. This is the path every other test relies on
        // implicitly; assert it once explicitly.
        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),
            model_provider: Arc::new(ToolCallingModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            // Match the enabled-test setup so the tool actually runs; the
            // assertion below proves the receipt-block send is gated on
            // `show_receipts_in_response` and not on whether the loop saw
            // any receipts.
            autonomy_level: AutonomyLevel::Full,
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig {
                    level: zeroclaw_config::autonomy::AutonomyLevel::Full,
                    auto_approve: vec!["mock_price".to_string()],
                    ..Default::default()
                },
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: Some(
                zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator::new(),
            ),
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            agent_transcription_provider: String::new(),
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(
            !sent_messages.iter().any(|m| m.contains("Tool receipts:")),
            "no receipts block must be sent when show_receipts_in_response=false; got {:?}",
            sent_messages.as_slice()
        );
    }

    #[tokio::test]
    async fn process_channel_message_disabled_receipt_generator_emits_no_receipts_anywhere() {
        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),
            model_provider: Arc::new(ToolCallingModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::Full,
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig {
                    level: zeroclaw_config::autonomy::AutonomyLevel::Full,
                    auto_approve: vec!["mock_price".to_string()],
                    ..Default::default()
                },
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            agent_transcription_provider: String::new(),
        });

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(
            !sent_messages.is_empty(),
            "agent must still respond when receipts are disabled"
        );
        assert!(
            !sent_messages.iter().any(|m| m.contains("zc-receipt-")),
            "no zc-receipt- token must appear in any sent message when receipts are disabled, got {:?}",
            sent_messages.as_slice()
        );
        assert!(
            !sent_messages.iter().any(|m| m.contains("Tool receipts:")),
            "no `Tool receipts:` block must be sent when receipts are disabled, got {:?}",
            sent_messages.as_slice()
        );

        // Strict surface check: the model's view of conversation history must
        // not carry a `[receipt: ` trailer either, otherwise an LLM trained
        // on echoing receipts could leak signed-looking output even though
        // nothing was actually signed.
        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        for (_key, turns) in histories.iter() {
            for msg in turns.iter() {
                assert!(
                    !msg.content.contains("[receipt: "),
                    "no `[receipt: ` trailer must appear in conversation history when receipts are disabled, got: {}",
                    msg.content
                );
            }
        }
    }

    #[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),
            model_provider: Arc::new(ToolCallingModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            non_cli_excluded_tools: Arc::new(Vec::new()),
            autonomy_level: AutonomyLevel::default(),
            tool_call_dedup_exempt: Arc::new(Vec::new()),
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            hooks: None,
            model_routes: Arc::new(Vec::new()),
            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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
            .peek("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),
            model_provider: Arc::new(RawToolArtifactModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 3,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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),
            model_provider: Arc::new(ToolCallingAliasModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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 agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
        let alt_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let alt_model_provider: Arc<dyn ModelProvider> = alt_model_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
        provider_cache_seed.insert(
            "test-provider".to_string(),
            Arc::clone(&agent_model_provider),
        );
        provider_cache_seed.insert("openrouter.default".to_string(), alt_model_provider);

        let mut prompt_config = zeroclaw_config::schema::Config::default();
        prompt_config
            .providers
            .models
            .ensure("openrouter", "default")
            .expect("openrouter slot must exist");

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: Arc::clone(&agent_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(prompt_config),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-cmd-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "/models openrouter".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let sent = channel_impl.sent_messages.lock().await;
        assert_eq!(sent.len(), 1);
        let expected_reply = zeroclaw_runtime::i18n::get_required_cli_string_with_args(
            "channel-runtime-set-provider-switched",
            &[
                ("provider", "openrouter.default"),
                ("model", "default-model"),
            ],
        );
        assert!(sent[0].contains(&expected_reply));

        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.model_provider, "openrouter.default");
        assert_eq!(route.model, "default-model");

        assert_eq!(
            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
            0
        );
        assert_eq!(alt_model_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 agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
        let routed_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let routed_model_provider: Arc<dyn ModelProvider> = routed_model_provider_impl.clone();

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

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

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: Arc::clone(&agent_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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)),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-routed-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "hello routed model_provider".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

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

    #[tokio::test]
    async fn process_channel_message_persists_model_switch_with_route_credential() {
        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_model_provider_impl = Arc::new(ModelSwitchRequestProvider::default());
        let default_model_provider: Arc<dyn ModelProvider> = default_model_provider_impl.clone();
        let switched_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let switched_model_provider: Arc<dyn ModelProvider> = switched_model_provider_impl.clone();
        let observer = Arc::new(RecordingObserver::default());

        // The model_switch tool requests the configured dotted provider ref;
        // the switch handler must preserve it through provider construction,
        // caching, and route persistence.
        let switched_provider_ref = "openrouter.default";
        let switched_key = Some("route-specific-key");

        // Seed the provider cache so `get_or_create_provider` returns our
        // mock for the switched provider instead of constructing a real one.
        // The cache key hashes the route-specific api_key together with the
        // resolved (dotted) provider ref at the current (startup) generation.
        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
        provider_cache_seed.insert(
            "test-provider".to_string(),
            Arc::clone(&default_model_provider),
        );
        provider_cache_seed.insert(
            provider_cache_key(switched_provider_ref, switched_key, 0),
            Arc::clone(&switched_model_provider),
        );

        let model_routes = vec![zeroclaw_config::schema::ModelRouteConfig {
            hint: "fast".to_string(),
            model_provider: switched_provider_ref.to_string(),
            model: "switched-model".to_string(),
            api_key: Some("route-specific-key".to_string()),
        }];

        // The prompt config owns the profile accepted by ModelSwitchTool and
        // later resolved by the channel switch handler.
        let prompt_config = {
            let mut cfg = zeroclaw_config::schema::Config::default();
            {
                let entry = cfg
                    .providers
                    .models
                    .ensure("openrouter", "default")
                    .expect("openrouter.default provider slot must be creatable");
                entry.api_key = Some("config-openrouter-key".to_string());
                entry.model = Some("switched-model".to_string());
            }
            Arc::new(cfg)
        };

        let model_switch_tool = zeroclaw_runtime::tools::ModelSwitchTool::new(
            Arc::new(SecurityPolicy::default()),
            Arc::clone(&prompt_config),
        );

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: Arc::clone(&default_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![Box::new(model_switch_tool)]),
            observer: observer.clone(),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("default-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::clone(&prompt_config),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig {
                    auto_approve: vec!["model_switch".to_string()],
                    ..zeroclaw_config::schema::RiskProfileConfig::default()
                },
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-switch-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "trigger model switch".to_string(),
                subject: None,
                channel: "telegram".to_string(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        {
            let events = observer.events.lock().unwrap();
            let (starts, ends) = lifecycle_bracket_snapshot(&events);
            assert_eq!(
                starts.len(),
                1,
                "a model switch must not create a second AgentStart: {events:?}"
            );
            assert_eq!(
                ends.len(),
                1,
                "the switched turn must close once: {events:?}"
            );
            assert_eq!(
                starts[0], ends[0],
                "the switched turn's lifecycle pair must keep one correlation triple"
            );
        }

        // After the switch handler runs, the route override must be
        // persisted for this sender with the resolved api_key.
        let route_key = "telegram_chat-1_alice";
        let persisted = runtime_ctx
            .route_overrides
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get(route_key)
            .cloned()
            .expect(
                "switch handler must persist a route override under the sender's history key \
                 (this is the regression #6173 — previously the new model was used only for \
                 the current turn and the next inbound message reverted to the default)",
            );
        assert_eq!(persisted.model_provider, switched_provider_ref);
        assert_eq!(persisted.model, "switched-model");
        assert_eq!(
            persisted.api_key.as_deref(),
            Some("route-specific-key"),
            "the route-specific api_key from model_routes must be persisted, \
             not the global key or None — otherwise the next turn loses route auth"
        );

        // Send a second message from the same sender, with no pending
        // model_switch this time. The persisted route override must be
        // honored — `get_route_selection` should return the switched
        // route and the switched provider should handle the request.
        let calls_before = switched_model_provider_impl
            .call_count
            .load(Ordering::SeqCst);
        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-switch-2".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "follow-up after switch".to_string(),
                subject: None,
                channel: "telegram".to_string(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let calls_after = switched_model_provider_impl
            .call_count
            .load(Ordering::SeqCst);
        assert!(
            calls_after > calls_before,
            "follow-up message must be served by the switched provider (the persisted \
             route override), not by the original default provider"
        );
        {
            let events = observer.events.lock().unwrap();
            let (starts, ends) = lifecycle_bracket_snapshot(&events);
            assert_eq!(
                starts.len(),
                2,
                "two logical turns need two starts: {events:?}"
            );
            assert_eq!(ends.len(), 2, "two logical turns need two ends: {events:?}");
            for (start, end) in starts.iter().zip(&ends) {
                assert_eq!(start, end, "each logical turn must keep one matched pair");
            }
        }
    }

    #[tokio::test]
    async fn process_channel_message_uses_classifier_provider_for_precheck_model_selection() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let main_provider_impl = Arc::new(PrecheckProbeModelProvider::default());
        let main_provider: Arc<dyn ModelProvider> = main_provider_impl.clone();
        let classifier_provider_impl = Arc::new(PrecheckProbeModelProvider::default());
        let classifier_provider: Arc<dyn ModelProvider> = classifier_provider_impl.clone();
        let mut prompt_config = zeroclaw_config::schema::Config::default();
        prompt_config.providers.models.openai.insert(
            "my-classifier".to_string(),
            zeroclaw_config::schema::OpenAIModelProviderConfig {
                base: zeroclaw_config::schema::ModelProviderConfig {
                    model: Some("fast-intent".to_string()),
                    temperature: Some(0.0),
                    ..Default::default()
                },
            },
        );
        let agent_cfg = zeroclaw_config::schema::AliasedAgentConfig {
            classifier_provider: zeroclaw_config::providers::ModelProviderRef::from(
                "openai.my-classifier",
            ),
            ..Default::default()
        };
        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            main_provider,
            prompt_config,
            agent_cfg,
            "test-provider",
            None,
        );
        runtime_ctx
            .provider_cache
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .insert("openai.my-classifier".to_string(), classifier_provider);

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-classifier-provider".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-precheck".to_string(),
                content: "background chatter".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        assert_eq!(
            classifier_provider_impl
                .precheck_calls
                .load(Ordering::SeqCst),
            1
        );
        assert_eq!(
            classifier_provider_impl.main_calls.load(Ordering::SeqCst),
            0
        );
        assert_eq!(main_provider_impl.precheck_calls.load(Ordering::SeqCst), 0);
        assert_eq!(main_provider_impl.main_calls.load(Ordering::SeqCst), 0);
        let models = classifier_provider_impl
            .models
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .clone();
        assert_eq!(models.as_slice(), ["fast-intent"]);
        let sent_messages = channel_impl.sent_messages.lock().await;
        assert!(
            sent_messages.is_empty(),
            "provider returns NO_REPLY from precheck, so no visible reply should be sent"
        );
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn process_channel_message_precheck_log_uses_span_attribution_not_attrs() {
        let _writer_guard = zeroclaw_log::__private_test_writer_lock();
        let _hook_guard = zeroclaw_log::__private_test_hook_lock();
        zeroclaw_log::try_install_capture_subscriber();
        let mut rx = zeroclaw_log::subscribe_or_install();
        while rx.try_recv().is_ok() {}

        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let provider_impl = Arc::new(PrecheckProbeModelProvider::default());
        let provider: Arc<dyn ModelProvider> = provider_impl;

        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            provider,
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "custom.primary",
            None,
        );

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-precheck-log".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-precheck-log".to_string(),
                content: "background chatter".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 5,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,
                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
        let mut precheck_event = None;
        while std::time::Instant::now() < deadline {
            match tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()).await {
                Ok(Ok(value)) => {
                    if value
                        .get("message")
                        .and_then(|v| v.as_str())
                        .is_some_and(|m| m == "reply-intent precheck completed")
                        && value
                            .pointer("/attributes/message_id")
                            .and_then(|v| v.as_str())
                            .is_some_and(|id| id == "msg-precheck-log")
                    {
                        precheck_event = Some(value);
                        break;
                    }
                }
                Ok(Err(_)) => break,
                Err(_) => continue,
            }
        }

        let value = precheck_event.expect("reply-intent precheck log should be emitted");
        assert_eq!(
            value["zeroclaw"]["agent_alias"], "test-agent",
            "precheck record must inherit agent_alias from the channel turn span, got: {value}"
        );
        assert_eq!(
            value["zeroclaw"]["model"], "test-model",
            "precheck record must preserve primary model attribution, got: {value}"
        );
        assert_eq!(
            value["attributes"]["classifier_model"], "test-model",
            "classifier model must use a non-attribution attr key, got: {value}"
        );
        assert!(
            value["attributes"].get("agent").is_none(),
            "agent alias belongs in zeroclaw.agent_alias, not attributes.agent: {value}"
        );
        assert!(
            value["attributes"].get("model").is_none(),
            "classifier model must not shadow zeroclaw.model via attributes.model: {value}"
        );
    }

    #[tokio::test]
    async fn process_channel_message_skips_reply_intent_classifier_when_agent_precheck_disabled() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let provider_impl = Arc::new(PrecheckProbeModelProvider::default());
        let provider: Arc<dyn ModelProvider> = provider_impl.clone();
        let agent_cfg = zeroclaw_config::schema::AliasedAgentConfig {
            precheck: zeroclaw_config::scattered_types::ChannelPrecheckConfig {
                enabled: false,
                ..Default::default()
            },
            ..Default::default()
        };
        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            provider,
            zeroclaw_config::schema::Config::default(),
            agent_cfg,
            "test-provider",
            None,
        );

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-precheck-disabled".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-precheck-disabled".to_string(),
                content: "background chatter".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,
                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        assert_eq!(provider_impl.precheck_calls.load(Ordering::SeqCst), 0);
        assert_eq!(provider_impl.main_calls.load(Ordering::SeqCst), 1);
        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(
            sent_messages.as_slice(),
            ["chat-precheck-disabled:visible reply"]
        );
    }

    #[tokio::test]
    async fn process_channel_message_precheck_timeout_fails_open_to_reply() {
        let channel_impl = Arc::new(RecordingChannel::default());
        let channel: Arc<dyn Channel> = channel_impl.clone();
        let provider_impl = Arc::new(PrecheckProbeModelProvider {
            precheck_delay: Some(Duration::from_secs(2)),
            ..Default::default()
        });
        let provider: Arc<dyn ModelProvider> = provider_impl.clone();
        let agent_cfg = zeroclaw_config::schema::AliasedAgentConfig {
            precheck: zeroclaw_config::scattered_types::ChannelPrecheckConfig {
                timeout_secs: 1,
                ..Default::default()
            },
            ..Default::default()
        };
        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            channel,
            provider,
            zeroclaw_config::schema::Config::default(),
            agent_cfg,
            "test-provider",
            None,
        );

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-precheck-timeout".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-precheck-timeout".to_string(),
                content: "background chatter".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,
                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        assert_eq!(provider_impl.precheck_calls.load(Ordering::SeqCst), 1);
        assert_eq!(provider_impl.main_calls.load(Ordering::SeqCst), 1);
        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(
            sent_messages.as_slice(),
            ["chat-precheck-timeout:visible reply"]
        );
    }

    #[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_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let startup_model_provider: Arc<dyn ModelProvider> = startup_model_provider_impl.clone();
        let reloaded_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let reloaded_model_provider: Arc<dyn ModelProvider> = reloaded_model_provider_impl.clone();

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

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: Arc::clone(&startup_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-default-provider-cache".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "hello cached default model_provider".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 3,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;
    }

    #[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),
            model_provider: Arc::new(IterativeToolModelProvider {
                required_tool_iterations: 11,
            }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 12,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig {
                loop_detection_enabled: false,
                ..zeroclaw_config::schema::PacingConfig::default()
            },
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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),
            model_provider: Arc::new(IterativeToolModelProvider {
                required_tool_iterations: 20,
            }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 3,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig {
                loop_detection_enabled: false,
                ..zeroclaw_config::schema::PacingConfig::default()
            },
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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 model_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"
        );
    }

    pub(crate) struct NoopMemory;

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

        async fn store(
            &self,
            _key: &str,
            _content: &str,
            _category: zeroclaw_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<zeroclaw_memory::MemoryEntry>> {
            Ok(Vec::new())
        }

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

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

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

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

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

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

        async fn store_with_agent(
            &self,
            _key: &str,
            _content: &str,
            _category: zeroclaw_memory::MemoryCategory,
            _session_id: Option<&str>,
            _namespace: Option<&str>,
            _importance: Option<f64>,
            _agent_id: Option<&str>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn recall_for_agents(
            &self,
            _allowed_agent_ids: &[&str],
            _query: &str,
            _limit: usize,
            _session_id: Option<&str>,
            _since: Option<&str>,
            _until: Option<&str>,
        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
            Ok(Vec::new())
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for NoopMemory {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Memory(
                ::zeroclaw_api::attribution::MemoryKind::InMemory,
            )
        }
        fn alias(&self) -> &str {
            "NoopMemory"
        }
    }

    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: zeroclaw_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<zeroclaw_memory::MemoryEntry>> {
            Ok(vec![zeroclaw_memory::MemoryEntry {
                id: "entry-1".to_string(),
                key: "memory_key_1".to_string(),
                content: "Age is 45".to_string(),
                category: zeroclaw_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,
                kind: None,
                pinned: false,
                tenant_id: None,
                agent_alias: None,
                agent_id: None,
            }])
        }

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

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

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

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

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

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

        async fn store_with_agent(
            &self,
            _key: &str,
            _content: &str,
            _category: zeroclaw_memory::MemoryCategory,
            _session_id: Option<&str>,
            _namespace: Option<&str>,
            _importance: Option<f64>,
            _agent_id: Option<&str>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn recall_for_agents(
            &self,
            _allowed_agent_ids: &[&str],
            query: &str,
            limit: usize,
            session_id: Option<&str>,
            since: Option<&str>,
            until: Option<&str>,
        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
            self.recall(query, limit, session_id, since, until).await
        }
    }
    impl ::zeroclaw_api::attribution::Attributable for RecallMemory {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Memory(
                ::zeroclaw_api::attribution::MemoryKind::InMemory,
            )
        }
        fn alias(&self) -> &str {
            "RecallMemory"
        }
    }

    struct ConcurrencyTrackingProvider {
        delay: Duration,
        in_flight: Arc<AtomicUsize>,
        peak_in_flight: Arc<AtomicUsize>,
    }

    #[async_trait::async_trait]
    impl ModelProvider for ConcurrencyTrackingProvider {
        async fn chat_with_system(
            &self,
            _system_prompt: Option<&str>,
            message: &str,
            _model: &str,
            _temperature: Option<f64>,
        ) -> anyhow::Result<String> {
            let current = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
            self.peak_in_flight.fetch_max(current, Ordering::SeqCst);
            tokio::time::sleep(self.delay).await;
            self.in_flight.fetch_sub(1, Ordering::SeqCst);
            Ok(format!("echo: {message}"))
        }
    }

    impl ::zeroclaw_api::attribution::Attributable for ConcurrencyTrackingProvider {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Provider(
                ::zeroclaw_api::attribution::ProviderKind::Model(
                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
                ),
            )
        }
        fn alias(&self) -> &str {
            "ConcurrencyTrackingProvider"
        }
    }

    #[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 in_flight = Arc::new(AtomicUsize::new(0));
        let peak_in_flight = Arc::new(AtomicUsize::new(0));

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: Arc::new(ConcurrencyTrackingProvider {
                delay: Duration::from_millis(250),
                in_flight: in_flight.clone(),
                peak_in_flight: peak_in_flight.clone(),
            }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(4);
        tx.send(zeroclaw_api::channel::ChannelMessage {
            id: "1".to_string(),
            sender: "alice".to_string(),
            reply_target: "alice".to_string(),
            content: "hello".to_string(),
            channel: "test-channel".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        })
        .await
        .unwrap();
        tx.send(zeroclaw_api::channel::ChannelMessage {
            id: "2".to_string(),
            sender: "bob".to_string(),
            reply_target: "bob".to_string(),
            content: "world".to_string(),
            channel: "test-channel".into(),
            channel_alias: None,
            timestamp: 2,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        })
        .await
        .unwrap();
        drop(tx);

        run_message_dispatch_loop(rx, AgentRouter::single(runtime_ctx), 2).await;

        let peak = peak_in_flight.load(Ordering::SeqCst);
        assert!(
            peak >= 2,
            "expected at least 2 concurrent in-flight dispatches, got peak {}",
            peak
        );
        assert_eq!(
            in_flight.load(Ordering::SeqCst),
            0,
            "all in-flight dispatches should have completed",
        );

        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(DelayedHistoryCaptureModelProvider {
            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),
            model_provider: provider_impl.clone(),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: true,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
        let send_task = zeroclaw_spawn::spawn!(async move {
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "forwarded content".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(40)).await;
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-2".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "summarize this".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, AgentRouter::single(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(DelayedHistoryCaptureModelProvider {
            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),
            model_provider: provider_impl.clone(),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: true,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
        let send_task = zeroclaw_spawn::spawn!(async move {
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-1".to_string(),
                sender: "U123".to_string(),
                reply_target: "C123".to_string(),
                content: "first question".to_string(),
                channel: "slack".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: Some("1741234567.100001".to_string()),
                interruption_scope_id: Some("1741234567.100001".to_string()),
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(40)).await;
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-2".to_string(),
                sender: "U123".to_string(),
                reply_target: "C123".to_string(),
                content: "second question".to_string(),
                channel: "slack".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: Some("1741234567.100001".to_string()),
                interruption_scope_id: Some("1741234567.100001".to_string()),
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, AgentRouter::single(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_interrupts_in_flight_whatsapp_request_and_preserves_context() {
        let channel_impl = Arc::new(WhatsAppRecordingChannel::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(DelayedHistoryCaptureModelProvider {
            delay: Duration::from_millis(250),
            calls: std::sync::Mutex::new(Vec::new()),
        });

        let mut channel_config = zeroclaw_config::schema::ChannelsConfig::default();
        channel_config.whatsapp.insert(
            "default".to_string(),
            zeroclaw_config::schema::WhatsAppConfig {
                session_path: Some("/tmp/zeroclaw-whatsapp-session.db".into()),
                interrupt_on_new_message: true,
                ..Default::default()
            },
        );

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: provider_impl.clone(),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: interrupt_on_new_message_config(&channel_config),
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
        let send_task = zeroclaw_spawn::spawn!(async move {
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-1".to_string(),
                sender: "15555550123".to_string(),
                reply_target: "15555550123".to_string(),
                content: "first WhatsApp question".to_string(),
                channel: "whatsapp".into(),
                channel_alias: Some("default".to_string()),
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(40)).await;
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-2".to_string(),
                sender: "15555550123".to_string(),
                reply_target: "15555550123".to_string(),
                content: "second WhatsApp question".to_string(),
                channel: "whatsapp".into(),
                channel_alias: Some("default".to_string()),
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, AgentRouter::single(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("15555550123:"));
        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 WhatsApp question")
        }));
        assert!(second_call.iter().any(|(role, content)| {
            role == "user" && content.contains("second WhatsApp 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),
            model_provider: Arc::new(SlowModelProvider {
                delay: Duration::from_millis(180),
            }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: true,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
        let send_task = zeroclaw_spawn::spawn!(async move {
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-a".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "first chat".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(30)).await;
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "msg-b".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-2".to_string(),
                content: "second chat".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, AgentRouter::single(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),
            model_provider: Arc::new(SlowModelProvider {
                delay: Duration::from_millis(20),
            }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "typing-msg".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-typing".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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),
            model_provider: Arc::new(SlowModelProvider {
                delay: Duration::from_millis(5),
            }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "react-msg".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-react".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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}");
    }

    // Pins the no_reply reconciliation: when the agent deliberately chooses
    // silence, the early 👀 ack must be removed (not left dangling) and the
    // message must end carrying only the no-reply kind emoji. A regression that
    // strands the 👀 on this path falsely signals "still processing" forever.
    #[tokio::test]
    async fn process_channel_message_no_reply_clears_early_ack() {
        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),
            model_provider: Arc::new(NoReplyModelProvider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "noreply-msg".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-noreply".to_string(),
                content: "fyi".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let added = channel_impl.reactions_added.lock().await;
        assert!(
            added.iter().any(|r| r.2 == "\u{1F44D}"),
            "the informational no-reply emoji must be added, got {added:?}"
        );
        assert!(
            !added.iter().any(|r| r.2 == "\u{2705}"),
            "no_reply must not produce a completion checkmark, got {added:?}"
        );

        let removed = channel_impl.reactions_removed.lock().await;
        assert!(
            removed.iter().any(|r| r.2 == "\u{1F440}"),
            "the early eyes ack must be reconciled (removed) on the no_reply path, got {removed:?}"
        );
    }

    struct AckTimingChannel {
        start: Instant,
        ack_elapsed_ms: tokio::sync::Mutex<Option<u128>>,
    }

    impl ::zeroclaw_api::attribution::Attributable for AckTimingChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "ack-timing"
        }
    }

    #[async_trait::async_trait]
    impl Channel for AckTimingChannel {
        fn name(&self) -> &str {
            "ack-timing-channel"
        }
        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
            Ok(())
        }
        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }
        async fn add_reaction(
            &self,
            _channel_id: &str,
            _message_id: &str,
            emoji: &str,
        ) -> anyhow::Result<()> {
            if emoji == "\u{1F440}" {
                let mut slot = self.ack_elapsed_ms.lock().await;
                if slot.is_none() {
                    *slot = Some(self.start.elapsed().as_millis());
                }
            }
            Ok(())
        }
    }

    // Pins the early-ack ordering: with a slow model_provider, the 👀 ack must
    // land well before the model completes. Fails on the old order where the
    // ack was awaited after enrichment / the model call. A regression back to
    // the late position would record the ack at >= the model delay.
    #[tokio::test]
    async fn process_channel_message_acks_before_slow_model_completes() {
        let model_delay = Duration::from_millis(400);
        let channel_impl = Arc::new(AckTimingChannel {
            start: Instant::now(),
            ack_elapsed_ms: tokio::sync::Mutex::new(None),
        });
        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),
            model_provider: Arc::new(SlowModelProvider { delay: model_delay }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "ack-msg".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-ack".to_string(),
                content: "hello".to_string(),
                channel: "ack-timing-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let ack_elapsed = channel_impl
            .ack_elapsed_ms
            .lock()
            .await
            .expect("eyes ack must have been attempted");
        assert!(
            ack_elapsed < model_delay.as_millis(),
            "ack fired at {ack_elapsed}ms, must precede the {}ms model delay (early-ack ordering)",
            model_delay.as_millis()
        );
    }

    #[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"), "missing Date section");
        assert!(
            !prompt.contains("## Current Date & Time"),
            "prompt should use date-only context"
        );
        assert!(prompt.contains("## Runtime"), "missing Runtime section");
    }

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

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

    #[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"
        );

        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        prompt.push_str(&build_tool_instructions(&tools_registry));

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

    #[test]
    fn channel_strict_non_native_prompt_hides_text_tool_protocol() {
        let ws = make_workspace();
        let mut tool_descs = vec![("shell", "Run commands")];
        let mut deferred_section = "## Deferred MCP Tools\n\n- mcp__example".to_string();

        let expose_text_protocol =
            apply_text_tool_prompt_policy(false, true, &mut tool_descs, &mut deferred_section);

        let mut prompt = build_system_prompt_with_mode_and_autonomy(
            ws.path(),
            "gpt-4o",
            &tool_descs,
            &[],
            None,
            None,
            None,
            false,
            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
            false,
            0,
            false,
            false,
        );
        if expose_text_protocol {
            let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
            let effective_tool_names: HashSet<&str> =
                tools_registry.iter().map(|tool| tool.name()).collect();
            prompt.push_str(&build_tool_instructions_for_names(
                &tools_registry,
                &effective_tool_names,
            ));
        }
        if !deferred_section.is_empty() {
            prompt.push('\n');
            prompt.push_str(&deferred_section);
        }

        assert!(!expose_text_protocol);
        assert!(!prompt.contains("## Tools"));
        assert!(!prompt.contains("## Tool Use Protocol"));
        assert!(!prompt.contains("<tool_call>"));
        assert!(!prompt.contains("mcp__example"));
    }

    #[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: ZeroClaw"),
            "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_bootstrap_only_if_exists() {
        let ws = make_workspace();
        // No BOOTSTRAP.md — should not appear
        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
        assert!(
            !prompt.contains("### BOOTSTRAP.md"),
            "BOOTSTRAP.md should not appear when missing"
        );

        // Create BOOTSTRAP.md — should appear
        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"),
            "BOOTSTRAP.md should appear when present"
        );
        assert!(prompt2.contains("First run"));
    }

    #[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![zeroclaw_runtime::skills::Skill {
            name: "code-review".into(),
            description: "Review code for bugs".into(),
            description_localizations: Default::default(),
            version: "1.0.0".into(),
            author: None,
            tags: vec![],
            tools: vec![zeroclaw_runtime::skills::SkillTool {
                name: "lint".into(),
                description: "Run static checks".into(),
                kind: "shell".into(),
                command: "cargo clippy".into(),
                args: HashMap::new(),
                target: None,
                locked_args: std::collections::HashMap::new(),
                timeout_secs: None,
            }],
            prompts: vec!["Always run cargo test before final response.".into()],
            slash_options: Vec::new(),
            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![zeroclaw_runtime::skills::Skill {
            name: "code-review".into(),
            description: "Review code for bugs".into(),
            description_localizations: Default::default(),
            version: "1.0.0".into(),
            author: None,
            tags: vec![],
            tools: vec![zeroclaw_runtime::skills::SkillTool {
                name: "lint".into(),
                description: "Run static checks".into(),
                kind: "shell".into(),
                command: "cargo clippy".into(),
                args: HashMap::new(),
                target: None,
                locked_args: std::collections::HashMap::new(),
                timeout_secs: None,
            }],
            prompts: vec!["Always run cargo test before final response.".into()],
            slash_options: Vec::new(),
            location: None,
        }];

        let prompt = build_system_prompt_with_mode(
            ws.path(),
            "model",
            &[],
            &skills,
            None,
            None,
            false,
            zeroclaw_config::schema::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![zeroclaw_runtime::skills::Skill {
            name: "code<review>&".into(),
            description: "Review \"unsafe\" and 'risky' bits".into(),
            description_localizations: Default::default(),
            version: "1.0.0".into(),
            author: None,
            tags: vec![],
            tools: vec![zeroclaw_runtime::skills::SkillTool {
                name: "run\"linter\"".into(),
                description: "Run <lint> & report".into(),
                kind: "shell&exec".into(),
                command: "cargo clippy".into(),
                args: HashMap::new(),
                target: None,
                locked_args: std::collections::HashMap::new(),
                timeout_secs: None,
            }],
            prompts: vec!["Use <tool_call> and & keep output \"safe\"".into()],
            slash_options: Vec::new(),
            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 ZeroClaw 🌍. 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(|| zeroclaw_runtime::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 = zeroclaw_config::schema::RiskProfileConfig {
            level: zeroclaw_runtime::security::AutonomyLevel::Full,
            ..zeroclaw_config::schema::RiskProfileConfig::default()
        };
        let prompt = build_system_prompt_with_mode_and_autonomy(
            ws.path(),
            "model",
            &[],
            &[],
            None,
            None,
            Some(&config),
            false,
            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
            false,
            0,
            false,
            false,
        );

        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 = zeroclaw_config::schema::RiskProfileConfig {
            level: zeroclaw_runtime::security::AutonomyLevel::ReadOnly,
            ..zeroclaw_config::schema::RiskProfileConfig::default()
        };
        let prompt = build_system_prompt_with_mode_and_autonomy(
            ws.path(),
            "model",
            &[],
            &[],
            None,
            None,
            Some(&config),
            false,
            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
            false,
            0,
            false,
            false,
        );

        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,
            zeroclaw_config::schema::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,
            zeroclaw_config::schema::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::channel::<String>(128);
        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(
            &zeroclaw_runtime::observability::traits::ObserverEvent::ToolCallStart {
                parent_agent_alias: None,
                tool: "file_write".to_string(),
                tool_call_id: None,
                arguments: Some(payload),
                channel: None,
                agent_alias: None,
                turn_id: None,
            },
        );

        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 channel_notify_observer_caps_long_path_argument() {
        let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(128);
        let observer = ChannelNotifyObserver {
            inner: Arc::new(NoopObserver),
            tx,
            tools_used: AtomicBool::new(false),
        };

        // 64 KiB path — 16x the per-message cap.
        let long_path = "a".repeat(64 * 1024);
        let payload = serde_json::json!({ "path": &long_path }).to_string();

        observer.record_event(
            &zeroclaw_runtime::observability::traits::ObserverEvent::ToolCallStart {
                parent_agent_alias: None,
                tool: "file_read".to_string(),
                tool_call_id: None,
                arguments: Some(payload),
                channel: None,
                agent_alias: None,
                turn_id: None,
            },
        );

        let emitted = rx.try_recv().expect("observer should emit notify message");
        // The full input was 64 KiB; the emitted message must be capped
        // to NOTIFY_DETAIL_MAX_CHARS + the literal prefix/suffix chars
        // ("\u{1F527} `file_read`: " = 17 chars + "…" = 1 char).
        let max_len = NOTIFY_DETAIL_MAX_CHARS + 17 + 1;
        assert!(
            emitted.chars().count() <= max_len,
            "emitted notify message must be capped (got {} chars, max {})",
            emitted.chars().count(),
            max_len
        );
        assert!(
            emitted.contains("`file_read`"),
            "emitted message must still identify the tool"
        );
        assert!(
            emitted.is_char_boundary(emitted.len()),
            "truncation must preserve a valid char boundary"
        );
    }

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

        let mk_event = || zeroclaw_runtime::observability::traits::ObserverEvent::ToolCallStart {
            parent_agent_alias: None,
            tool: "file_read".to_string(),
            tool_call_id: None,
            arguments: Some(r#"{"path":"/a"}"#.to_string()),
            channel: None,
            agent_alias: None,
            turn_id: None,
        };

        // First push lands in the bounded buffer (capacity 1).
        observer.record_event(&mk_event());
        // Second push must drop: the consumer has not drained yet, so
        // the buffer is full and `try_send` returns `Full`.
        observer.record_event(&mk_event());

        // Exactly one message arrived.
        let first = rx
            .recv()
            .await
            .expect("at least one notify should land before drop");
        assert!(first.contains("`file_read`"));
        // No second message; the channel is empty because the second
        // push was dropped (not queued behind the first).
        assert!(
            rx.try_recv().is_err(),
            "second push must be dropped when the channel is full"
        );
        // tools_used must reflect that both events were observed
        // (the drop is on the notify side, not the observer side).
        assert!(observer.tools_used.load(Ordering::Relaxed));
    }

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

            ..Default::default()
        };

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

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

            ..Default::default()
        };

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

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

            ..Default::default()
        };

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

    #[test]
    fn followup_thread_id_does_not_open_matrix_thread_for_root_message() {
        let msg = zeroclaw_api::channel::ChannelMessage {
            id: "$event:server".into(),
            sender: "@alice:server".into(),
            reply_target: "!room:server".into(),
            content: "hello".into(),
            channel: "matrix".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };

        assert_eq!(followup_thread_id(&msg), None);
    }

    #[test]
    fn matrix_root_conversation_history_key_omits_event_id() {
        let first = zeroclaw_api::channel::ChannelMessage {
            id: "$first:server".into(),
            sender: "@alice:server".into(),
            reply_target: "!room:server".into(),
            content: "send a.txt".into(),
            channel: "matrix".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let second = zeroclaw_api::channel::ChannelMessage {
            id: "$second:server".into(),
            content: "send it again".into(),
            timestamp: 2,
            ..first.clone()
        };

        let key = conversation_history_key(&first);
        assert_eq!(key, conversation_history_key(&second));
        assert!(!key.contains("$first:server"));
        assert!(!key.contains("$second:server"));
    }

    #[test]
    fn matrix_self_anchored_root_history_key_omits_event_id() {
        let first = zeroclaw_api::channel::ChannelMessage {
            id: "$first:server".into(),
            sender: "@alice:server".into(),
            reply_target: "!room:server".into(),
            content: "call me boss".into(),
            channel: "matrix".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: Some("$first:server".into()),
            interruption_scope_id: Some("$first:server".into()),
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let second = zeroclaw_api::channel::ChannelMessage {
            id: "$second:server".into(),
            content: "hello".into(),
            timestamp: 2,
            thread_ts: Some("$second:server".into()),
            interruption_scope_id: Some("$second:server".into()),
            ..first.clone()
        };

        let key = conversation_history_key(&first);
        assert_eq!(key, conversation_history_key(&second));
        assert!(!key.contains("$first:server"));
        assert!(!key.contains("$second:server"));
    }

    #[test]
    fn matrix_thread_follow_up_shares_root_session_key() {
        let root = zeroclaw_api::channel::ChannelMessage {
            id: "$root:server".into(),
            sender: "@alice:server".into(),
            reply_target: "!room:server".into(),
            content: "open the thread".into(),
            channel: "matrix".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: Some("$root:server".into()),
            interruption_scope_id: Some("$root:server".into()),
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let follow_up = zeroclaw_api::channel::ChannelMessage {
            id: "$reply:server".into(),
            content: "thread reply".into(),
            timestamp: 2,
            thread_ts: Some("$root:server".into()),
            interruption_scope_id: Some("$root:server".into()),
            ..root.clone()
        };

        let root_key = conversation_history_key(&root);
        assert_eq!(root_key, conversation_history_key(&follow_up));
        assert!(!root_key.contains("$root:server"));
        assert!(!root_key.contains("$reply:server"));
    }

    #[test]
    fn reply_target_conversation_scope_omits_sender_from_history_key() {
        let first = zeroclaw_api::channel::ChannelMessage {
            id: "msg-1".into(),
            sender: "alice".into(),
            reply_target: "123456@g.us".into(),
            content: "group context".into(),
            channel: "whatsapp".into(),
            channel_alias: Some("main".into()),
            timestamp: 1,
            conversation_scope: zeroclaw_api::channel::ChannelConversationScope::ReplyTarget,
            ..Default::default()
        };
        let second = zeroclaw_api::channel::ChannelMessage {
            id: "msg-2".into(),
            sender: "bob".into(),
            content: "follow up".into(),
            timestamp: 2,
            ..first.clone()
        };

        let key = conversation_history_key(&first);
        assert_eq!(key, conversation_history_key(&second));
        assert!(!key.contains("alice"));
        assert!(!key.contains("bob"));
    }

    #[test]
    fn reply_target_conversation_history_key_uses_room_scope() {
        let msg = zeroclaw_api::channel::ChannelMessage {
            id: "msg_wecom_ws".into(),
            sender: "zeroclaw_user".into(),
            reply_target: "group--room-1".into(),
            content: "hello".into(),
            channel: "wecom_ws".into(),
            channel_alias: Some("work".into()),
            timestamp: 1,
            thread_ts: Some("req-1".into()),
            interruption_scope_id: Some("group--room-1".into()),
            attachments: vec![],
            subject: None,
            conversation_scope: zeroclaw_api::channel::ChannelConversationScope::ReplyTarget,

            ..Default::default()
        };

        assert_eq!(
            conversation_history_key(&msg),
            "wecom_ws_work_group--room-1"
        );
        assert_eq!(interruption_scope_key(&msg), "wecom_ws_work_group--room-1");
    }

    #[test]
    fn parse_runtime_command_allows_model_switch_for_wecom_ws() {
        assert_eq!(
            parse_runtime_command("wecom_ws", "/models openrouter"),
            Some(ChannelRuntimeCommand::SetProvider("openrouter".into()))
        );
        assert_eq!(
            parse_runtime_command("wecom_ws", "/model qwen-max"),
            Some(ChannelRuntimeCommand::SetModel("qwen-max".into()))
        );
    }

    #[test]
    fn parse_runtime_command_allows_model_switch_for_whatsapp_web() {
        for channel in ["whatsapp", "whatsapp-web", "whatsapp_web"] {
            assert_eq!(
                parse_runtime_command(channel, "/models openrouter"),
                Some(ChannelRuntimeCommand::SetProvider("openrouter".into())),
                "{channel} should accept /models"
            );
            assert_eq!(
                parse_runtime_command(channel, "/model qwen-max"),
                Some(ChannelRuntimeCommand::SetModel("qwen-max".into())),
                "{channel} should accept /model"
            );
        }
    }

    fn scope_test_msg(
        sender: &str,
        channel_id: &str,
        thread: Option<&str>,
    ) -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            sender: sender.into(),
            reply_target: channel_id.into(),
            channel: "discord".into(),
            channel_alias: Some("clamps".into()),
            thread_ts: thread.map(String::from),
            ..Default::default()
        }
    }

    #[test]
    fn parse_runtime_command_parses_model_scope_flags() {
        use ChannelRuntimeCommand::{SetModel, SetModelScoped, ShowModel};
        assert_eq!(
            parse_runtime_command("discord", "/model --user gpt-4o"),
            Some(SetModelScoped(OverrideScope::User, "gpt-4o".into()))
        );
        assert_eq!(
            parse_runtime_command("discord", "/model --agent claude-opus-4-8"),
            Some(SetModelScoped(
                OverrideScope::Agent,
                "claude-opus-4-8".into()
            ))
        );
        // No flag → unchanged per-sender behavior.
        assert_eq!(
            parse_runtime_command("discord", "/model gpt-4o"),
            Some(SetModel("gpt-4o".into()))
        );
        // Bare /model, or a scope flag with no model id → show.
        assert_eq!(parse_runtime_command("discord", "/model"), Some(ShowModel));
        assert_eq!(
            parse_runtime_command("discord", "/model --user"),
            Some(ShowModel)
        );
        // A mistyped flag is NOT silently treated as a model id.
        assert_eq!(
            parse_runtime_command("discord", "/model --useer gpt-4o"),
            Some(ShowModel)
        );
    }

    #[test]
    fn scope_override_key_drops_identifiers_below_each_scope() {
        let a = scope_test_msg("alice", "chan-1", Some("t-1"));
        let b = scope_test_msg("alice", "chan-2", Some("t-2"));
        // User scope spans a sender's chats/threads → same key.
        assert_eq!(
            scope_override_key(OverrideScope::User, &a, "agentX"),
            scope_override_key(OverrideScope::User, &b, "agentX"),
        );
        assert!(scope_override_key(OverrideScope::User, &a, "agentX").contains("alice"));
        // Agent scope keys only on the agent alias (independent of sender/chat).
        let c = scope_test_msg("bob", "chan-9", None);
        assert_eq!(
            scope_override_key(OverrideScope::Agent, &a, "agentX"),
            scope_override_key(OverrideScope::Agent, &c, "agentX"),
        );
        assert_ne!(
            scope_override_key(OverrideScope::Agent, &a, "agentX"),
            scope_override_key(OverrideScope::Agent, &a, "agentY"),
        );
    }

    #[test]
    fn get_route_selection_precedence_user_over_agent_over_session() {
        let tmp = tempfile::TempDir::new().unwrap();
        let ctx = channel_runtime_context_for_defaults_test(
            tmp.path(),
            "agentX",
            "openrouter.default",
            "config-default-model",
        );
        let msg = scope_test_msg("alice", "chan-1", None);
        let snapshot = runtime_defaults_snapshot(&ctx);
        let sender_key = conversation_history_key(&msg);
        let sel = |m: &str| ChannelRouteSelection {
            model_provider: "openrouter.default".into(),
            model: m.into(),
            api_key: None,
        };

        // Nothing set → config default (whatever the snapshot resolves to).
        let default_model = get_route_selection(&ctx, &msg, &sender_key, &snapshot).model;
        assert_ne!(default_model, "session-model");

        // Per-sender route override (the session tier).
        set_route_selection(&ctx, &sender_key, sel("session-model"), &snapshot);
        assert_eq!(
            get_route_selection(&ctx, &msg, &sender_key, &snapshot).model,
            "session-model"
        );
        // Agent scope beats session.
        set_scope_override(
            &ctx,
            OverrideScope::Agent,
            &msg,
            sel("agent-model"),
            &snapshot,
        );
        assert_eq!(
            get_route_selection(&ctx, &msg, &sender_key, &snapshot).model,
            "agent-model"
        );
        // User scope beats agent.
        set_scope_override(
            &ctx,
            OverrideScope::User,
            &msg,
            sel("user-model"),
            &snapshot,
        );
        assert_eq!(
            get_route_selection(&ctx, &msg, &sender_key, &snapshot).model,
            "user-model"
        );
    }

    #[test]
    fn set_scope_override_clears_when_equal_to_default() {
        let tmp = tempfile::TempDir::new().unwrap();
        let ctx = channel_runtime_context_for_defaults_test(
            tmp.path(),
            "agentX",
            "openrouter.default",
            "default-model",
        );
        let msg = scope_test_msg("alice", "chan", None);
        let snapshot = runtime_defaults_snapshot(&ctx);
        let default = default_route_selection_from_snapshot(&snapshot);
        set_scope_override(
            &ctx,
            OverrideScope::User,
            &msg,
            ChannelRouteSelection {
                model_provider: "openrouter.default".into(),
                model: "other".into(),
                api_key: None,
            },
            &snapshot,
        );
        assert_eq!(ctx.scope_overrides.lock().unwrap().len(), 1);
        // Setting it back to the config default clears the entry.
        set_scope_override(&ctx, OverrideScope::User, &msg, default, &snapshot);
        assert!(ctx.scope_overrides.lock().unwrap().is_empty());
    }

    #[test]
    fn parse_runtime_command_maps_clear_to_new_session() {
        assert_eq!(
            parse_runtime_command("telegram", "/clear"),
            Some(ChannelRuntimeCommand::NewSession)
        );
        assert_eq!(
            parse_runtime_command("telegram", "/clear@zeroclaw_bot"),
            Some(ChannelRuntimeCommand::NewSession)
        );
        assert_eq!(parse_runtime_command("telegram", "/clear all"), None);
    }

    // Build a ChannelRuntimeContext with a Config that has peer_groups
    // populated for the agent-scope authorization tests below. Mirrors
    // `channel_runtime_context_for_defaults_test` but lets the caller
    // inject a pre-built peer_groups map.
    fn channel_runtime_context_with_peer_groups(
        zeroclaw_dir: &std::path::Path,
        peer_groups: std::collections::HashMap<
            String,
            zeroclaw_config::multi_agent::PeerGroupConfig,
        >,
    ) -> ChannelRuntimeContext {
        let prompt_config = zeroclaw_config::schema::Config {
            peer_groups,
            ..Default::default()
        };
        let mut ctx = channel_runtime_context_for_defaults_test(
            zeroclaw_dir,
            "agentX",
            "openrouter.default",
            "config-default-model",
        );
        ctx.prompt_config = Arc::new(prompt_config);
        ctx
    }

    fn agent_scope_msg(sender: &str) -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            sender: sender.into(),
            reply_target: "chan-1".into(),
            channel: "discord".into(),
            channel_alias: Some("clamps".into()),
            thread_ts: None,
            content: "/model --agent gpt-4o".into(),
            ..Default::default()
        }
    }

    fn user_scope_msg(sender: &str) -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            sender: sender.into(),
            reply_target: "chan-1".into(),
            channel: "discord".into(),
            channel_alias: Some("clamps".into()),
            thread_ts: None,
            content: "/model --user gpt-4o".into(),
            ..Default::default()
        }
    }

    fn peer_group(
        channel: &str,
        members: &[&str],
        admin_for_agent_scope: bool,
    ) -> zeroclaw_config::multi_agent::PeerGroupConfig {
        use zeroclaw_config::multi_agent::{
            AgentAlias, OutputModality, PeerGroupConfig, PeerUsername,
        };
        PeerGroupConfig {
            channel: zeroclaw_config::providers::ChannelRef(channel.into()),
            agents: Vec::<AgentAlias>::new(),
            external_peers: members
                .iter()
                .map(|s| PeerUsername(s.to_string()))
                .collect(),
            ignore: Vec::new(),
            output_modality: OutputModality::default(),
            admin_for_agent_scope,
        }
    }

    #[test]
    fn set_model_scoped_agent_allowed_for_listed_admin() {
        let tmp = tempfile::TempDir::new().unwrap();
        let mut groups = std::collections::HashMap::new();
        groups.insert(
            "discord_admins".into(),
            peer_group("discord.clamps", &["alice", "ops"], true),
        );
        let ctx = channel_runtime_context_with_peer_groups(tmp.path(), groups);

        assert!(is_agent_scope_authorized(&ctx, &agent_scope_msg("alice")));
        assert!(is_agent_scope_authorized(&ctx, &agent_scope_msg("ops")));
    }

    #[test]
    fn set_model_scoped_agent_rejected_for_non_admin_member() {
        let tmp = tempfile::TempDir::new().unwrap();
        let mut groups = std::collections::HashMap::new();
        // Same peer group as the admin test, but the admin flag is OFF.
        groups.insert(
            "discord_users".into(),
            peer_group("discord.clamps", &["alice", "ops"], false),
        );
        let ctx = channel_runtime_context_with_peer_groups(tmp.path(), groups);

        assert!(!is_agent_scope_authorized(&ctx, &agent_scope_msg("alice")));
        assert!(!is_agent_scope_authorized(&ctx, &agent_scope_msg("ops")));
        // Even an unknown sender is rejected (not silently allowed).
        assert!(!is_agent_scope_authorized(
            &ctx,
            &agent_scope_msg("mallory")
        ));
    }

    #[test]
    fn set_model_scoped_agent_rejected_when_no_peer_groups_configured() {
        // Default config has no peer_groups — default deny.
        let tmp = tempfile::TempDir::new().unwrap();
        let ctx =
            channel_runtime_context_with_peer_groups(tmp.path(), std::collections::HashMap::new());

        assert!(!is_agent_scope_authorized(&ctx, &agent_scope_msg("alice")));
    }

    #[test]
    fn set_model_scoped_user_unaffected_by_agent_scope_authz() {
        // The helper is only invoked on the Agent branch; this test pins
        // that `/model --user` does not even consult the helper. We
        // assert by behavior: the helper is gated on OverrideScope::Agent
        // in `handle_runtime_command_if_needed`, so for the User case
        // no authorization check runs and the override is written.
        // Here we simply verify that even when the sender is not an
        // admin, the auth helper treats them neutrally — i.e. the gate
        // is on the dispatch site, not the helper itself.
        let tmp = tempfile::TempDir::new().unwrap();
        let ctx =
            channel_runtime_context_with_peer_groups(tmp.path(), std::collections::HashMap::new());
        // For the User branch the helper is not consulted, so this
        // negative assertion is structural: the gate sits at the
        // SetModelScoped dispatch point, not in this helper.
        assert!(!is_agent_scope_authorized(&ctx, &user_scope_msg("alice")));
    }

    #[test]
    fn channel_agent_scope_admins_filters_by_admin_flag() {
        // The resolver itself honors `admin_for_agent_scope = true`
        // only — build a `Config` with one admin-flagged group and one
        // unflagged group covering the same channel; the admin-flagged
        // group must surface while the unflagged group must not.
        // (Note: the orchestrator gate reads through a snapshot of this
        // `Config`, so operator edits become visible after restart — see
        // `is_agent_scope_authorized` docstring.)
        use zeroclaw_config::schema::Config;
        let mut config = Config::default();
        config.peer_groups.insert(
            "discord_admins".into(),
            peer_group("discord.clamps", &["alice", "ops"], true),
        );
        config.peer_groups.insert(
            "discord_users".into(),
            peer_group("discord.clamps", &["bob", "carol"], false),
        );
        let admins = config.channel_agent_scope_admins("discord", "clamps", "agentX");
        assert_eq!(admins, vec!["alice".to_string(), "ops".to_string()]);
    }

    /// Round-3 contract: when a peer group's `agents` list is non-empty,
    /// the admin privilege is granted only for `agent_alias` values that
    /// appear in that list. This pins the agent-bound semantics added in
    /// round 3; without it, dropping or inverting the `agent_alias` filter
    /// would not regress any existing test (because every prior fixture
    /// constructs `agents: Vec::new()`, which falls through the legacy
    /// channel-wide path).
    fn peer_group_with_agents(
        channel: &str,
        members: &[&str],
        admin_for_agent_scope: bool,
        agents: &[&str],
    ) -> zeroclaw_config::multi_agent::PeerGroupConfig {
        use zeroclaw_config::multi_agent::{
            AgentAlias, OutputModality, PeerGroupConfig, PeerUsername,
        };
        PeerGroupConfig {
            channel: zeroclaw_config::providers::ChannelRef(channel.into()),
            agents: agents.iter().map(|a| AgentAlias::new(*a)).collect(),
            external_peers: members
                .iter()
                .map(|s| PeerUsername(s.to_string()))
                .collect(),
            ignore: Vec::new(),
            output_modality: OutputModality::default(),
            admin_for_agent_scope,
        }
    }

    #[test]
    fn channel_agent_scope_admins_filters_by_agents_list_when_non_empty() {
        // The same admin peer is granted the privilege for `agentX`
        // (because `agents = ["agentX"]` includes it) and denied for
        // `agentY` (because `agentY` is not in the list). The peer is
        // also denied for `agentX` if the group is constructed with an
        // empty `agents` list (the legacy channel-wide path), which is
        // pinned separately below.
        use zeroclaw_config::schema::Config;
        let mut config = Config::default();
        config.peer_groups.insert(
            "discord_admins".into(),
            peer_group_with_agents("discord.clamps", &["alice"], true, &["agentX"]),
        );

        let for_x = config.channel_agent_scope_admins("discord", "clamps", "agentX");
        assert_eq!(
            for_x,
            vec!["alice".to_string()],
            "agentX is in agents=[agentX], so alice must surface"
        );

        let for_y = config.channel_agent_scope_admins("discord", "clamps", "agentY");
        assert!(
            for_y.is_empty(),
            "agentY is NOT in agents=[agentX], so the group must be filtered out"
        );
    }

    #[test]
    fn channel_agent_scope_admins_empty_agents_list_means_channel_wide() {
        // Backward-compatible legacy: an empty `agents` list means the
        // admin privilege is granted for any agent_alias on the channel.
        // This is the path every pre-round-3 config falls into.
        use zeroclaw_config::schema::Config;
        let mut config = Config::default();
        config.peer_groups.insert(
            "discord_admins".into(),
            peer_group("discord.clamps", &["alice"], true),
        );
        let for_x = config.channel_agent_scope_admins("discord", "clamps", "agentX");
        let for_y = config.channel_agent_scope_admins("discord", "clamps", "agentY");
        assert_eq!(for_x, vec!["alice".to_string()]);
        assert_eq!(for_y, vec!["alice".to_string()]);
    }

    // --- SSOT normalization + wildcard + leading-`@` + case-insensitive.
    // The gate routes through `allowlist::is_user_allowed`, so the
    // helpers below must mirror the inbound-channel normalization shape
    // (strip leading `@`, ASCII-lowercase) and honor `["*"]` for the
    // configured peer list. These tests pin that contract so a future
    // refactor cannot silently fall back to the raw `==` shape that
    // rejected correctly-configured admins.

    #[test]
    fn normalize_peer_username_strips_leading_at_and_lowercases() {
        assert_eq!(normalize_peer_username("@user_1"), "user_1");
        assert_eq!(normalize_peer_username("user_1"), "user_1");
        assert_eq!(normalize_peer_username("@Alice"), "alice");
        assert_eq!(normalize_peer_username("ALICE"), "alice");
        // Multiple leading `@` are collapsed to nothing: a config typo
        // like "@@alice" still resolves to the bare identity.
        assert_eq!(normalize_peer_username("@@alice"), "alice");
        // Empty input stays empty (would deny everything downstream).
        assert_eq!(normalize_peer_username(""), "");
    }

    #[test]
    fn agent_scope_gate_matches_inbound_normalized_sender() {
        // Telegram inbound path strips a leading `@` from the sender
        // before calling `is_user_allowed`. The gate must accept a
        // configured `"@user_1"` against a sender that arrived as
        // `"user_1"` (no leading `@`).
        let tmp = tempfile::TempDir::new().unwrap();
        let mut groups = std::collections::HashMap::new();
        groups.insert(
            "telegram_admins".into(),
            peer_group("telegram.prod", &["@user_1"], true),
        );
        let ctx = channel_runtime_context_with_peer_groups(tmp.path(), groups);

        assert!(
            is_agent_scope_authorized(
                &ctx,
                &agent_scope_msg_with_channel("user_1", "telegram", "prod")
            ),
            "leading-`@` config entry must match Telegram's @-stripped sender identity"
        );
        // Without normalization, the raw `==` would deny this.
    }

    #[test]
    fn agent_scope_gate_matches_case_insensitive_sender() {
        // Inbound IRC and Matrix use `Match::CaseInsensitive`; the
        // gate's ASCII-lowercase normalization must produce the same
        // outcome so a configured `"Alice"` matches an inbound `"alice"`.
        let tmp = tempfile::TempDir::new().unwrap();
        let mut groups = std::collections::HashMap::new();
        groups.insert(
            "irc_admins".into(),
            peer_group("irc.freenode", &["Alice"], true),
        );
        let ctx = channel_runtime_context_with_peer_groups(tmp.path(), groups);

        assert!(
            is_agent_scope_authorized(
                &ctx,
                &agent_scope_msg_with_channel("alice", "irc", "freenode")
            ),
            "configured `Alice` must match an inbound `alice` sender (RFC 2812 case-insensitive)"
        );
        assert!(
            is_agent_scope_authorized(
                &ctx,
                &agent_scope_msg_with_channel("ALICE", "irc", "freenode")
            ),
            "configured `Alice` must match an inbound `ALICE` sender"
        );
    }

    #[test]
    fn agent_scope_gate_honors_wildcard_admin() {
        // A peer group with `external_peers = ["*"]` is the documented
        // wildcard: every sender on the channel is an admin. The raw
        // `==` shape denied this; the SSOT shape admits it.
        let tmp = tempfile::TempDir::new().unwrap();
        let mut groups = std::collections::HashMap::new();
        groups.insert(
            "discord_open".into(),
            peer_group("discord.clamps", &["*"], true),
        );
        let ctx = channel_runtime_context_with_peer_groups(tmp.path(), groups);

        assert!(is_agent_scope_authorized(&ctx, &agent_scope_msg("anyone")));
        assert!(is_agent_scope_authorized(
            &ctx,
            &agent_scope_msg("even_a_random_handle")
        ));
    }

    fn agent_scope_msg_with_channel(
        sender: &str,
        channel: &str,
        alias: &str,
    ) -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            sender: sender.into(),
            reply_target: "chan-1".into(),
            channel: channel.into(),
            channel_alias: Some(alias.into()),
            thread_ts: None,
            content: "/model --agent gpt-4o".into(),
            ..Default::default()
        }
    }

    // --- Dispatch-level wiring tests for the agent-scope authorization
    // gate. These exercise `handle_runtime_command_if_needed` directly so
    // a future refactor that drops `scope == OverrideScope::Agent &&
    // !is_agent_scope_authorized(...)` from the dispatch site cannot
    // silently re-open the hole — every helper-only test would still pass
    // while the gate was bypassed.

    fn scope_user_msg(sender: &str) -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            sender: sender.into(),
            reply_target: "chan-1".into(),
            channel: "discord".into(),
            channel_alias: Some("clamps".into()),
            thread_ts: None,
            content: "/model --user gpt-4o".into(),
            ..Default::default()
        }
    }

    fn scope_agent_msg(sender: &str) -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            sender: sender.into(),
            reply_target: "chan-1".into(),
            channel: "discord".into(),
            channel_alias: Some("clamps".into()),
            thread_ts: None,
            content: "/model --agent gpt-4o".into(),
            ..Default::default()
        }
    }

    #[tokio::test]
    async fn dispatch_agent_scope_writes_override_for_admin_sender() {
        // Authorized sender: dispatch must reach the SetModelScoped(Agent)
        // accept branch and write a scope override.
        let tmp = tempfile::TempDir::new().unwrap();
        let mut groups = std::collections::HashMap::new();
        groups.insert(
            "discord_admins".into(),
            peer_group("discord.clamps", &["alice"], true),
        );
        let ctx = Arc::new(channel_runtime_context_with_peer_groups(tmp.path(), groups));
        let target: Arc<dyn Channel> = Arc::new(NamedMockChannel { name: "discord" });

        let handled = handle_runtime_command_if_needed(
            ctx.as_ref(),
            &scope_agent_msg("alice"),
            Some(&target),
        )
        .await;
        assert!(handled, "agent-scope command must be handled by dispatch");

        let overrides = ctx.scope_overrides.lock().unwrap();
        assert_eq!(
            overrides.len(),
            1,
            "authorized admin must produce exactly one scope override, got {overrides:?}"
        );
    }

    #[tokio::test]
    async fn dispatch_agent_scope_rejects_unauthorized_sender_without_writing() {
        // Unauthorized sender: dispatch must surface the rejection string
        // and leave the override map empty. If the gate is dropped, the
        // override would be written and this test would fail.
        let tmp = tempfile::TempDir::new().unwrap();
        let mut groups = std::collections::HashMap::new();
        groups.insert(
            "discord_admins".into(),
            peer_group("discord.clamps", &["alice"], true),
        );
        let ctx = Arc::new(channel_runtime_context_with_peer_groups(tmp.path(), groups));
        let target: Arc<dyn Channel> = Arc::new(NamedMockChannel { name: "discord" });

        let handled = handle_runtime_command_if_needed(
            ctx.as_ref(),
            &scope_agent_msg("mallory"),
            Some(&target),
        )
        .await;
        assert!(handled, "command must be handled even when rejected");

        let overrides = ctx.scope_overrides.lock().unwrap();
        assert!(
            overrides.is_empty(),
            "unauthorized sender must NOT write a scope override, got {overrides:?}"
        );
    }

    #[tokio::test]
    async fn dispatch_user_scope_writes_override_regardless_of_admin_status() {
        // The agent-scope gate must NOT affect `--user`. Even when the
        // sender is not in any admin peer group, `/model --user` writes
        // its override. This pins the gate's scope: only the Agent branch
        // consults `is_agent_scope_authorized`.
        let tmp = tempfile::TempDir::new().unwrap();
        let ctx = Arc::new(channel_runtime_context_with_peer_groups(
            tmp.path(),
            std::collections::HashMap::new(),
        ));
        let target: Arc<dyn Channel> = Arc::new(NamedMockChannel { name: "discord" });

        let handled = handle_runtime_command_if_needed(
            ctx.as_ref(),
            &scope_user_msg("mallory"),
            Some(&target),
        )
        .await;
        assert!(handled, "user-scope command must be handled");

        let overrides = ctx.scope_overrides.lock().unwrap();
        assert_eq!(
            overrides.len(),
            1,
            "`/model --user` must write a scope override even when sender is not an admin, got {overrides:?}"
        );
    }

    #[tokio::test]
    async fn dispatch_agent_scope_rejects_when_no_peer_groups_configured() {
        // Default config (no peer_groups) — every sender must be denied.
        // Without the gate this would silently write the override.
        let tmp = tempfile::TempDir::new().unwrap();
        let ctx = Arc::new(channel_runtime_context_with_peer_groups(
            tmp.path(),
            std::collections::HashMap::new(),
        ));
        let target: Arc<dyn Channel> = Arc::new(NamedMockChannel { name: "discord" });

        let handled = handle_runtime_command_if_needed(
            ctx.as_ref(),
            &scope_agent_msg("alice"),
            Some(&target),
        )
        .await;
        assert!(handled);

        let overrides = ctx.scope_overrides.lock().unwrap();
        assert!(
            overrides.is_empty(),
            "default-deny must produce zero overrides when no peer_groups are configured, got {overrides:?}"
        );
    }

    #[test]
    fn parse_runtime_command_maps_thinking_levels() {
        assert_eq!(
            parse_runtime_command("telegram", "/thinking high"),
            Some(ChannelRuntimeCommand::SetThinking(Some(
                ThinkingLevel::High
            )))
        );
        assert_eq!(
            parse_runtime_command("telegram", "/thinking max"),
            Some(ChannelRuntimeCommand::SetThinking(Some(ThinkingLevel::Max)))
        );
        assert_eq!(
            parse_runtime_command("telegram", "/thinking off"),
            Some(ChannelRuntimeCommand::SetThinking(Some(ThinkingLevel::Off)))
        );
        assert_eq!(
            parse_runtime_command("telegram", "/thinking on"),
            Some(ChannelRuntimeCommand::SetThinking(Some(
                ThinkingLevel::High
            )))
        );
    }

    #[test]
    fn parse_runtime_command_maps_thinking_reset_and_invalid() {
        assert_eq!(
            parse_runtime_command("telegram", "/thinking"),
            Some(ChannelRuntimeCommand::SetThinking(None))
        );
        assert_eq!(
            parse_runtime_command("telegram", "/thinking reset"),
            Some(ChannelRuntimeCommand::SetThinking(None))
        );
        assert_eq!(
            parse_runtime_command("telegram", "/thinking banana"),
            Some(ChannelRuntimeCommand::InvalidThinking("banana".into()))
        );
        assert_eq!(
            parse_runtime_command("telegram", "/thinking high now"),
            Some(ChannelRuntimeCommand::InvalidThinking(
                "too many arguments".into()
            ))
        );
    }

    #[test]
    fn resolve_channel_thinking_uses_session_override_without_inline_directive() {
        let config = ThinkingConfig {
            default_level: ThinkingLevel::Low,
            ..ThinkingConfig::default()
        };
        let resolved = resolve_channel_thinking(
            "explain the tradeoff",
            Some(ThinkingLevel::High),
            &config,
            Some(0.5),
        );

        assert_eq!(resolved.effective_content, "explain the tradeoff");
        assert_eq!(resolved.level, ThinkingLevel::High);
        assert!(resolved.effective_temperature.unwrap() > 0.5);
    }

    #[test]
    fn resolve_channel_thinking_inline_directive_beats_session_override() {
        let config = ThinkingConfig {
            default_level: ThinkingLevel::Low,
            ..ThinkingConfig::default()
        };
        let resolved = resolve_channel_thinking(
            "/think:off explain briefly",
            Some(ThinkingLevel::Max),
            &config,
            Some(0.5),
        );

        assert_eq!(resolved.effective_content, "explain briefly");
        assert_eq!(resolved.level, ThinkingLevel::Off);
        assert!(resolved.effective_temperature.unwrap() < 0.5);
    }

    #[test]
    fn resolve_channel_thinking_strips_directive_before_url_enrichment() {
        let config = ThinkingConfig {
            default_level: ThinkingLevel::Low,
            ..ThinkingConfig::default()
        };
        let resolved = resolve_channel_thinking(
            "/think:max summarize https://example.com",
            None,
            &config,
            Some(0.5),
        );

        assert_eq!(resolved.effective_content, "summarize https://example.com");
        assert_eq!(resolved.level, ThinkingLevel::Max);
    }

    #[test]
    fn resolve_models_command_resolves_bare_family_to_configured_alias() {
        let mut config = zeroclaw_config::schema::Config::default();
        {
            let base = config
                .providers
                .models
                .ensure("openrouter", "default")
                .expect("openrouter slot must exist");
            base.api_key = Some("sk-configured".into());
            base.uri = Some("https://router.example/v1".into());
            base.model = Some("some-model".into());
        }

        match resolve_models_command(&config, "openrouter") {
            ModelsCommandResolution::Resolved(r) => assert_eq!(r, "openrouter.default"),
            other => panic!("expected Resolved(openrouter.default), got {other:?}"),
        }

        // The resolved ref must carry the configured alias credentials.
        let (key, uri) = provider_credentials_for_ref(&config, "openrouter.default");
        assert_eq!(key.as_deref(), Some("sk-configured"));
        assert_eq!(uri.as_deref(), Some("https://router.example/v1"));
    }

    #[test]
    fn resolve_models_command_rejects_family_without_alias() {
        let config = zeroclaw_config::schema::Config::default();
        match resolve_models_command(&config, "openrouter") {
            ModelsCommandResolution::NoAlias(f) => assert_eq!(f, "openrouter"),
            other => panic!("expected NoAlias(openrouter), got {other:?}"),
        }
    }

    #[test]
    fn resolve_models_command_flags_ambiguous_family() {
        let mut config = zeroclaw_config::schema::Config::default();
        config
            .providers
            .models
            .ensure("openrouter", "default")
            .unwrap();
        config
            .providers
            .models
            .ensure("openrouter", "secondary")
            .unwrap();

        match resolve_models_command(&config, "openrouter") {
            ModelsCommandResolution::Ambiguous { family, aliases } => {
                assert_eq!(family, "openrouter");
                assert_eq!(
                    aliases,
                    vec!["default".to_string(), "secondary".to_string()]
                );
            }
            other => panic!("expected Ambiguous, got {other:?}"),
        }
    }

    #[test]
    fn resolve_models_command_accepts_existing_dotted_ref() {
        let mut config = zeroclaw_config::schema::Config::default();
        config
            .providers
            .models
            .ensure("openrouter", "default")
            .unwrap();

        match resolve_models_command(&config, "openrouter.default") {
            ModelsCommandResolution::Resolved(r) => assert_eq!(r, "openrouter.default"),
            other => panic!("expected Resolved, got {other:?}"),
        }
        match resolve_models_command(&config, "openrouter.missing") {
            ModelsCommandResolution::NoAlias(r) => assert_eq!(r, "openrouter.missing"),
            other => panic!("expected NoAlias, got {other:?}"),
        }
    }

    #[test]
    fn resolve_models_command_rejects_unknown_family() {
        let config = zeroclaw_config::schema::Config::default();
        assert!(matches!(
            resolve_models_command(&config, "definitely-not-a-provider"),
            ModelsCommandResolution::Unknown
        ));
    }

    #[test]
    fn runtime_model_switch_resolves_bare_family_to_configured_alias() {
        let mut config = zeroclaw_config::schema::Config::default();
        config
            .providers
            .models
            .ensure("openrouter", "default")
            .unwrap();

        let resolved = resolve_provider_ref_for_runtime_switch(&config, "openrouter").unwrap();

        assert_eq!(resolved, "openrouter.default");
    }

    #[test]
    fn runtime_model_switch_rejects_ambiguous_bare_family() {
        let mut config = zeroclaw_config::schema::Config::default();
        config
            .providers
            .models
            .ensure("openrouter", "default")
            .unwrap();
        config
            .providers
            .models
            .ensure("openrouter", "secondary")
            .unwrap();

        let err = resolve_provider_ref_for_runtime_switch(&config, "openrouter")
            .expect_err("ambiguous model switch provider should reject");

        assert!(err.to_string().contains("multiple configured aliases"));
    }

    #[test]
    fn reply_intent_precheck_uses_structured_addressing_signal() {
        let marker_only = zeroclaw_api::channel::ChannelMessage {
            content: "[WeCom group message addressed to this bot via @danya]\n@danya say hi".into(),
            channel: "wecom_ws".into(),
            explicitly_addressed: false,
            ..Default::default()
        };
        assert!(!should_bypass_reply_intent_precheck(&marker_only, false));
        assert!(should_bypass_reply_intent_precheck(&marker_only, true));

        let addressed = zeroclaw_api::channel::ChannelMessage {
            explicitly_addressed: true,
            ..marker_only
        };
        assert!(should_bypass_reply_intent_precheck(&addressed, false));
    }

    #[test]
    fn conversation_memory_key_is_unique_per_message() {
        let msg1 = zeroclaw_api::channel::ChannelMessage {
            id: "msg_1".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "first".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let msg2 = zeroclaw_api::channel::ChannelMessage {
            id: "msg_2".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "second".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 2,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };

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

    #[tokio::test]
    async fn autosave_keys_preserve_multiple_conversation_facts() {
        let tmp = TempDir::new().unwrap();
        let mem = SqliteMemory::new("test", tmp.path()).unwrap();

        let msg1 = zeroclaw_api::channel::ChannelMessage {
            id: "msg_1".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "I'm Paul".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let msg2 = zeroclaw_api::channel::ChannelMessage {
            id: "msg_2".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "I'm 45".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 2,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };

        mem.store(
            &conversation_memory_key(&msg1),
            &msg1.content,
            MemoryCategory::Conversation,
            None,
        )
        .await
        .unwrap();
        mem.store(
            &conversation_memory_key(&msg2),
            &msg2.content,
            MemoryCategory::Conversation,
            None,
        )
        .await
        .unwrap();

        assert_eq!(mem.count().await.unwrap(), 2);

        let recalled = mem.recall("45", 5, None, None, None).await.unwrap();
        assert!(recalled.iter().any(|entry| entry.content.contains("45")));
    }

    /// Test shim: the old per-orchestrator renderer call shape, routed
    /// through the unified engine pipeline (agent::memory_inject).
    async fn render_for_sessions(
        mem: &dyn zeroclaw_memory::Memory,
        user_msg: &str,
        min_relevance_score: f64,
        session_ids: &[Option<&str>],
    ) -> String {
        zeroclaw_runtime::agent::memory_inject::render_memory_context(
            mem,
            &zeroclaw_runtime::observability::NoopObserver,
            user_msg,
            session_ids,
            &zeroclaw_runtime::agent::memory_inject::MemoryInjectConfig {
                min_relevance_score,
                ..Default::default()
            },
            false,
            zeroclaw_runtime::agent::TurnMeta {
                parent_agent_alias: None,
                agent_alias: Some("test-agent"),
                turn_id: "test-turn",
                channel_name: "test-channel",
            },
        )
        .await
    }

    #[tokio::test]
    async fn autosaved_conversation_memory_is_recalled_by_sender_scope() {
        let tmp = TempDir::new().unwrap();
        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
        let msg = zeroclaw_api::channel::ChannelMessage {
            id: "msg_1".into(),
            sender: "U123".into(),
            reply_target: "C456".into(),
            content: "Project codename is quartz".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let history_key = conversation_history_key(&msg);

        mem.store(
            &conversation_memory_key(&msg),
            &msg.content,
            MemoryCategory::Conversation,
            Some(&history_key),
        )
        .await
        .unwrap();

        let session_ids = sender_memory_session_ids(&msg, &history_key);
        let session_id_refs: Vec<Option<&str>> =
            session_ids.iter().map(|s| Some(s.as_str())).collect();
        let context = render_for_sessions(&mem, "quartz", 0.0, &session_id_refs).await;

        assert!(
            context.contains("Project codename is quartz"),
            "sender recall should include autosaved memories stored under the current session key, got: {context}"
        );
    }

    #[tokio::test]
    async fn autosaved_group_conversation_memory_stays_session_scoped() {
        let tmp = TempDir::new().unwrap();
        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
        let group_a_msg = zeroclaw_api::channel::ChannelMessage {
            id: "msg_1".into(),
            sender: "U123".into(),
            reply_target: "group:alpha".into(),
            content: "Group alpha codename is quartz".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let group_b_msg = zeroclaw_api::channel::ChannelMessage {
            id: "msg_2".into(),
            sender: "U123".into(),
            reply_target: "group:beta".into(),
            content: "What was the codename?".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 2,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let group_a_history_key = conversation_history_key(&group_a_msg);
        let group_b_history_key = conversation_history_key(&group_b_msg);

        mem.store(
            &conversation_memory_key(&group_a_msg),
            &group_a_msg.content,
            MemoryCategory::Conversation,
            Some(&group_a_history_key),
        )
        .await
        .unwrap();

        let group_b_sender_session_ids =
            sender_memory_session_ids(&group_b_msg, &group_b_history_key);
        assert_eq!(group_b_sender_session_ids, vec!["U123".to_string()]);

        let group_b_sender_session_id_refs: Vec<Option<&str>> = group_b_sender_session_ids
            .iter()
            .map(|s| Some(s.as_str()))
            .collect();
        let sender_context =
            render_for_sessions(&mem, "quartz", 0.0, &group_b_sender_session_id_refs).await;
        let group_context =
            render_for_sessions(&mem, "quartz", 0.0, &[Some(&group_b_history_key)]).await;
        let source_group_context =
            render_for_sessions(&mem, "quartz", 0.0, &[Some(&group_a_history_key)]).await;

        assert!(
            sender_context.is_empty(),
            "sender scope must not leak autosaved group memory from another group, got: {sender_context}"
        );
        assert!(
            group_context.is_empty(),
            "target group scope must not include another group's autosaved memory, got: {group_context}"
        );
        assert!(
            source_group_context.contains("Group alpha codename is quartz"),
            "source group scope should still recall its own autosaved memory, got: {source_group_context}"
        );
    }

    #[tokio::test]
    async fn sender_session_ids_match_migrated_matrix_sender_rows() {
        let tmp = TempDir::new().unwrap();
        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
        let raw_sender = "@alice:server";
        let sanitized_sender = sanitize_session_key(raw_sender);
        assert_eq!(sanitized_sender, "_alice_server");

        mem.store(
            "alice_fact",
            "Alice favors filtered coffee",
            MemoryCategory::Conversation,
            Some(sanitized_sender.as_str()),
        )
        .await
        .unwrap();

        let msg = zeroclaw_api::channel::ChannelMessage {
            id: "evt_1".into(),
            sender: raw_sender.into(),
            reply_target: "!room:server".into(),
            content: "what coffee does alice prefer?".into(),
            channel: "matrix".into(),
            channel_alias: None,
            timestamp: 1,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        let history_key = conversation_history_key(&msg);
        let session_ids = sender_memory_session_ids(&msg, &history_key);
        assert!(
            session_ids.contains(&sanitized_sender),
            "sender session ids must include sanitized sender, got: {session_ids:?}"
        );
        let session_id_refs: Vec<Option<&str>> =
            session_ids.iter().map(|s| Some(s.as_str())).collect();
        let context = render_for_sessions(&mem, "coffee", 0.0, &session_id_refs).await;
        assert!(
            context.contains("Alice favors filtered coffee"),
            "sender recall must find migrated row stored under sanitized sender, got: {context}"
        );
    }

    #[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(HistoryCaptureModelProvider::default());

        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: provider_impl.clone(),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-a".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-b".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-1".to_string(),
                content: "follow up".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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.starts_with('['));
        assert!(calls[1][1].1.contains("hello"));
        assert!(calls[1][2].1.contains("response-1"));
        assert!(calls[1][3].1.starts_with('['));
        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 {
            data_dir: workspace.path().to_path_buf(),
            ..Default::default()
        };
        config.skills.open_skills_enabled = false;

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

        let default_identity = zeroclaw_config::schema::IdentityConfig::default();
        let initial_system_prompt = build_system_prompt_with_mode(
            workspace.path(),
            "test-model",
            &[],
            &initial_skills,
            Some(&default_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(HistoryCaptureModelProvider::default());
        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: provider_impl.clone(),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(vec![]),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new(initial_system_prompt),
            model: Arc::new("test-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(config.data_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,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        // Keep all three futures heap-backed to fit the Windows test-thread stack.
        Box::pin(process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-before-new".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-refresh".to_string(),
                content: "hello".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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 =
            zeroclaw_runtime::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"
        );

        Box::pin(process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-new-session".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-refresh".to_string(),
                content: "/new".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        ))
        .await;

        {
            let histories = runtime_ctx
                .conversation_histories
                .lock()
                .unwrap_or_else(|e| e.into_inner());
            assert!(
                histories.peek("telegram_chat-refresh_alice").is_none(),
                "/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"
            );
        }

        Box::pin(process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-after-new".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-refresh".to_string(),
                content: "hello again".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 3,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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;
        let new_session_reply =
            zeroclaw_runtime::i18n::get_required_cli_string("channel-runtime-new-session");
        assert!(
            sent_messages
                .iter()
                .any(|message| message.contains(&new_session_reply))
        );
    }

    #[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(HistoryCaptureModelProvider::default());
        let mut prompt_config = zeroclaw_config::schema::Config::default();
        prompt_config.agents.insert(
            "test-agent".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                channels: vec![
                    "test-channel.default".into(),
                    "other-channel.default".into(),
                ],
                ..zeroclaw_config::schema::AliasedAgentConfig::default()
            },
        );
        prompt_config.agents.insert(
            "peer-agent".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                channels: vec!["test-channel.default".into()],
                ..zeroclaw_config::schema::AliasedAgentConfig::default()
            },
        );
        prompt_config.agents.insert(
            "other-agent".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                channels: vec!["other-channel.default".into()],
                ..zeroclaw_config::schema::AliasedAgentConfig::default()
            },
        );
        prompt_config.peer_groups.insert(
            "current-room".to_string(),
            zeroclaw_config::multi_agent::PeerGroupConfig {
                channel: "test-channel.default".into(),
                agents: vec![
                    zeroclaw_config::multi_agent::AgentAlias::new("test-agent"),
                    zeroclaw_config::multi_agent::AgentAlias::new("peer-agent"),
                ],
                external_peers: vec![zeroclaw_config::multi_agent::PeerUsername::new("@Operator")],
                ..zeroclaw_config::multi_agent::PeerGroupConfig::default()
            },
        );
        prompt_config.peer_groups.insert(
            "other-room".to_string(),
            zeroclaw_config::multi_agent::PeerGroupConfig {
                channel: "other-channel.default".into(),
                agents: vec![
                    zeroclaw_config::multi_agent::AgentAlias::new("test-agent"),
                    zeroclaw_config::multi_agent::AgentAlias::new("other-agent"),
                ],
                ..zeroclaw_config::multi_agent::PeerGroupConfig::default()
            },
        );
        let prompt_config = Arc::new(prompt_config);
        let tools_registry: Arc<Vec<Box<dyn Tool>>> = Arc::new(vec![Box::new(
            zeroclaw_runtime::tools::SendMessageToPeerTool::new(
                Arc::clone(&prompt_config),
                "test-agent",
            ),
        )]);
        let runtime_ctx = peer_prompt_test_context(
            channels_by_name,
            provider_impl.clone(),
            Arc::clone(&prompt_config),
            tools_registry,
        );

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-ctx-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-ctx".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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);
        // Peer map (from send_message_to_peer tool) stays in the system prompt.
        // Memory context is no longer in the system prompt — it moved to the
        // outgoing user-turn preamble so the system prefix can stay byte-stable
        // for prompt caching.
        assert_eq!(calls[0][0].0, "system");
        assert!(
            !calls[0][0].1.contains(MEMORY_CONTEXT_OPEN),
            "system prompt must not include memory context (it now lives in the user turn): {}",
            calls[0][0].1
        );
        assert!(
            !calls[0][0].1.contains("Age is 45"),
            "memory content must not bleed into the system prompt: {}",
            calls[0][0].1
        );
        assert!(
            calls[0][0]
                .1
                .contains("Current-channel peer map for agent \"test-agent\"")
        );
        assert!(calls[0][0].1.contains("peer groups: \"current-room\""));
        assert!(
            calls[0][0]
                .1
                .contains("use channel ref \"test-channel.default\"")
        );
        assert!(calls[0][0].1.contains("agent peers: \"peer-agent\""));
        assert!(calls[0][0].1.contains("external peers: \"operator\""));
        assert!(!calls[0][0].1.contains("\"other-room\""));
        assert!(!calls[0][0].1.contains("\"other-agent\""));
        assert_eq!(calls[0][1].0, "user");
        // User turn now carries the volatile preamble (turn-context, memory
        // context) followed by the timestamped user content.
        assert!(calls[0][1].1.contains("[turn-context]"));
        assert!(
            calls[0][1].1.contains(MEMORY_CONTEXT_OPEN),
            "memory context must be prepended into the outgoing user turn: {}",
            calls[0][1].1
        );
        assert!(
            calls[0][1].1.contains("Age is 45"),
            "memory content must be visible to the model via the user turn: {}",
            calls[0][1].1
        );
        assert!(calls[0][1].1.contains("hello"));

        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .peek("test-channel_chat-ctx_alice")
            .expect("history should be stored for sender");
        assert_eq!(turns[0].role, "user");
        // Cached history must be the raw timestamped user content with NO
        // [turn-context] preamble and NO memory context — those only live on
        // the outgoing LLM call, not in the persisted session log.
        assert!(turns[0].content.starts_with('['));
        assert!(
            turns[0].content.contains("] hello"),
            "stored channel user turn should be timestamped: {}",
            turns[0].content
        );
        assert!(
            !turns[0].content.contains("[turn-context]"),
            "cached history must not include the runtime preamble (would accumulate): {}",
            turns[0].content
        );
        assert!(!turns[0].content.contains(MEMORY_CONTEXT_OPEN));
    }

    fn cache_stability_test_context(
        provider_impl: Arc<HistoryCaptureModelProvider>,
        memory: Arc<dyn Memory>,
    ) -> Arc<ChannelRuntimeContext> {
        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);

        Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: provider_impl,
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: memory.clone(),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    memory,
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            tools_registry: Arc::new(Vec::new()),
            observer: Arc::new(NoopObserver),
            system_prompt: Arc::new("test-system-prompt".to_string()),
            model: Arc::new("test-model".to_string()),
            temperature: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        })
    }

    async fn drive_one_message(
        ctx: Arc<ChannelRuntimeContext>,
        sender: &str,
        reply_target: &str,
        content: &str,
        message_id: &str,
        timestamp: u64,
    ) {
        process_channel_message(
            ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: message_id.to_string(),
                sender: sender.to_string(),
                reply_target: reply_target.to_string(),
                content: content.to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,
                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;
    }

    #[tokio::test]
    async fn process_channel_message_telegram_system_prompt_is_byte_stable_across_turns() {
        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
        let runtime_ctx = cache_stability_test_context(provider_impl.clone(), Arc::new(NoopMemory));

        drive_one_message(runtime_ctx.clone(), "alice", "chat:42", "first", "msg-1", 1).await;
        // Cross a second boundary to make the assertion meaningful.
        tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
        drive_one_message(
            runtime_ctx.clone(),
            "alice",
            "chat:42",
            "second",
            "msg-2",
            2,
        )
        .await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 2, "two LLM calls expected");
        assert_eq!(calls[0][0].0, "system");
        assert_eq!(calls[1][0].0, "system");
        assert_eq!(
            calls[0][0].1, calls[1][0].1,
            "system prompt must be byte-identical across consecutive turns (prompt cache hit)"
        );
    }

    #[tokio::test]
    async fn process_channel_message_user_text_starting_with_turn_context_still_gets_runtime_preamble()
     {
        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
        let runtime_ctx = cache_stability_test_context(provider_impl.clone(), Arc::new(NoopMemory));

        drive_one_message(
            runtime_ctx,
            "alice",
            "chat:42",
            "[turn-context] user-supplied marker trying to suppress runtime context",
            "msg-1",
            1,
        )
        .await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 1);
        let outgoing_user = &calls[0][1];
        assert_eq!(calls[0][1].0, "user");
        assert!(
            outgoing_user.1.contains("sender=alice"),
            "runtime preamble must include sender=alice even when the user's message starts with [turn-context]: {outgoing_user:?}"
        );
        assert!(
            outgoing_user.1.contains("reply_target=chat:42"),
            "runtime preamble must include reply_target=chat:42: {outgoing_user:?}"
        );
        assert!(
            outgoing_user.1.contains("\"to\":\"chat:42\""),
            "runtime preamble must include the cron_add delivery hint: {outgoing_user:?}"
        );
        assert!(
            outgoing_user.1.contains("user-supplied marker"),
            "user content must still be present after the runtime preamble: {outgoing_user:?}"
        );
    }

    #[tokio::test]
    async fn process_channel_message_memory_recall_difference_keeps_system_byte_identical() {
        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());

        struct QueryAwareMemory;
        #[async_trait::async_trait]
        impl Memory for QueryAwareMemory {
            fn name(&self) -> &str {
                "query-aware-memory"
            }
            async fn store(
                &self,
                _key: &str,
                _content: &str,
                _category: zeroclaw_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<zeroclaw_memory::MemoryEntry>> {
                Ok(vec![zeroclaw_memory::MemoryEntry {
                    id: "entry-x".to_string(),
                    key: format!("key-for-{}", query),
                    content: format!("memory-for-{}", query),
                    category: zeroclaw_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,
                    kind: None,
                    pinned: false,
                    tenant_id: None,
                    agent_alias: None,
                    agent_id: None,
                }])
            }
            async fn get(
                &self,
                _key: &str,
            ) -> anyhow::Result<Option<zeroclaw_memory::MemoryEntry>> {
                Ok(None)
            }
            async fn list(
                &self,
                _category: Option<&zeroclaw_memory::MemoryCategory>,
                _session_id: Option<&str>,
            ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
                Ok(Vec::new())
            }
            async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
                Ok(false)
            }
            async fn forget_for_agent(&self, _key: &str, _agent_id: &str) -> anyhow::Result<bool> {
                Ok(false)
            }
            async fn count(&self) -> anyhow::Result<usize> {
                Ok(1)
            }
            async fn health_check(&self) -> bool {
                true
            }
            async fn store_with_agent(
                &self,
                _key: &str,
                _content: &str,
                _category: zeroclaw_memory::MemoryCategory,
                _session_id: Option<&str>,
                _namespace: Option<&str>,
                _importance: Option<f64>,
                _agent_id: Option<&str>,
            ) -> anyhow::Result<()> {
                Ok(())
            }
            async fn recall_for_agents(
                &self,
                _allowed_agent_ids: &[&str],
                query: &str,
                _limit: usize,
                _session_id: Option<&str>,
                _since: Option<&str>,
                _until: Option<&str>,
            ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
                self.recall(query, 5, None, None, None).await
            }
        }
        impl ::zeroclaw_api::attribution::Attributable for QueryAwareMemory {
            fn role(&self) -> ::zeroclaw_api::attribution::Role {
                ::zeroclaw_api::attribution::Role::Memory(
                    ::zeroclaw_api::attribution::MemoryKind::InMemory,
                )
            }
            fn alias(&self) -> &str {
                "QueryAwareMemory"
            }
        }

        let runtime_ctx =
            cache_stability_test_context(provider_impl.clone(), Arc::new(QueryAwareMemory));

        drive_one_message(runtime_ctx.clone(), "alice", "chat:42", "alpha", "msg-1", 1).await;
        tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
        drive_one_message(runtime_ctx.clone(), "alice", "chat:42", "beta", "msg-2", 2).await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 2);
        // System prompt must remain byte-stable even though the per-turn
        // memory recall returns different entries (key-for-alpha vs
        // key-for-beta, memory-for-alpha vs memory-for-beta).
        assert_eq!(
            calls[0][0].1, calls[1][0].1,
            "system prompt must not vary with per-turn memory recall"
        );
        assert!(
            !calls[0][0].1.contains("memory-for-"),
            "system prompt must not contain memory content (it's now in the user turn): {}",
            calls[0][0].1
        );
        // The current outgoing user turn is the LAST element of each call's
        // history snapshot (the cache prefix is everything before it).
        let last_user_turn_0 = calls[0]
            .iter()
            .rfind(|(role, _)| role == "user")
            .expect("first call should contain a user turn");
        let last_user_turn_1 = calls[1]
            .iter()
            .rfind(|(role, _)| role == "user")
            .expect("second call should contain a user turn");
        assert!(
            last_user_turn_0.1.contains("memory-for-alpha"),
            "first turn current user content: {}",
            last_user_turn_0.1
        );
        assert!(
            last_user_turn_1.1.contains("memory-for-beta"),
            "second turn current user content: {}",
            last_user_turn_1.1
        );
    }

    #[tokio::test]
    async fn process_channel_message_user_message_accumulates_no_preamble_in_cached_history() {
        // The cached conversation history (ctx.conversation_histories)
        // must not accumulate the runtime preamble across turns —
        // otherwise the conversation prefix cache hits would still
        // regress over time even if the system prompt is stable.
        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
        let runtime_ctx = cache_stability_test_context(provider_impl.clone(), Arc::new(NoopMemory));

        drive_one_message(
            runtime_ctx.clone(),
            "alice",
            "chat:42",
            "turn one",
            "msg-1",
            1,
        )
        .await;
        drive_one_message(
            runtime_ctx.clone(),
            "alice",
            "chat:42",
            "turn two",
            "msg-2",
            2,
        )
        .await;

        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        // Find the actual history key by scanning all stored senders —
        // sanitize_session_key may mangle "chat:42" so we don't assume
        // a literal key.
        let mut sender_keys: Vec<String> = Vec::new();
        for (k, _) in histories.iter() {
            sender_keys.push(k.clone());
        }
        assert!(
            !sender_keys.is_empty(),
            "history should be stored for some sender; no keys found"
        );
        let turns = histories
            .peek(sender_keys.first().unwrap().as_str())
            .expect("history should be stored for sender");
        let user_turns: Vec<_> = turns.iter().filter(|t| t.role == "user").collect();
        assert_eq!(
            user_turns.len(),
            2,
            "expected 2 cached user turns; got {} (total={}, key={})",
            user_turns.len(),
            turns.len(),
            sender_keys.first().unwrap()
        );
        for (i, turn) in user_turns.iter().enumerate() {
            assert_eq!(turn.role, "user");
            assert!(
                !turn.content.contains("[turn-context]"),
                "cached history turn {i} must not contain the runtime preamble: {}",
                turn.content
            );
            assert!(
                !turn.content.contains(MEMORY_CONTEXT_OPEN),
                "cached history turn {i} must not contain memory context: {}",
                turn.content
            );
        }
    }

    #[tokio::test]
    async fn process_channel_message_omits_peer_map_when_send_peer_tool_unavailable() {
        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(HistoryCaptureModelProvider::default());
        let mut prompt_config = zeroclaw_config::schema::Config::default();
        prompt_config.agents.insert(
            "test-agent".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                channels: vec!["test-channel.default".into()],
                ..zeroclaw_config::schema::AliasedAgentConfig::default()
            },
        );
        prompt_config.agents.insert(
            "peer-agent".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                channels: vec!["test-channel.default".into()],
                ..zeroclaw_config::schema::AliasedAgentConfig::default()
            },
        );
        prompt_config.peer_groups.insert(
            "current-room".to_string(),
            zeroclaw_config::multi_agent::PeerGroupConfig {
                channel: "test-channel.default".into(),
                agents: vec![
                    zeroclaw_config::multi_agent::AgentAlias::new("test-agent"),
                    zeroclaw_config::multi_agent::AgentAlias::new("peer-agent"),
                ],
                ..zeroclaw_config::multi_agent::PeerGroupConfig::default()
            },
        );
        let runtime_ctx = peer_prompt_test_context(
            channels_by_name,
            provider_impl.clone(),
            Arc::new(prompt_config),
            Arc::new(vec![]),
        );

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-ctx-no-tool".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-ctx".to_string(),
                content: "hello".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 1);
        assert!(!calls[0][0].1.contains("Current-channel peer map"));
        assert!(!calls[0][0].1.contains("send_message_to_peer"));
    }

    #[tokio::test]
    async fn process_channel_message_persists_image_payload_verbatim() {
        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(HistoryCaptureModelProvider {
            vision: true,
            ..Default::default()
        });
        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: provider_impl.clone(),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig {
                enabled: true,
                ..Default::default()
            },
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-image-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-image".to_string(),
                content: "please inspect this".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                passive_context: false,
                explicitly_addressed: false,
                conversation_scope: zeroclaw_api::channel::ChannelConversationScope::Sender,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![zeroclaw_api::media::MediaAttachment {
                    file_name: "sticker.png".to_string(),
                    data: vec![1, 2, 3, 4],
                    mime_type: Some("image/png".to_string()),
                }],
                subject: None,
                internal_sop_event: None,
            },
            CancellationToken::new(),
        )
        .await;

        let calls = provider_impl
            .calls
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        assert_eq!(calls.len(), 1);
        let current_user = calls[0]
            .iter()
            .rev()
            .find(|(role, _)| role == "user")
            .expect("provider call should include current user message");
        assert!(current_user.1.contains("[IMAGE:data:image/png;base64,"));
        assert!(current_user.1.contains("please inspect this"));
        drop(calls);

        let histories = runtime_ctx
            .conversation_histories
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let turns = histories
            .peek("test-channel_chat-image_alice")
            .expect("history should be stored for sender");
        assert_eq!(turns[0].role, "user");
        assert!(turns[0].content.starts_with('['));
        assert!(turns[0].content.contains("[Image: sticker.png attached"));
        assert!(turns[0].content.contains("please inspect this"));
        assert!(turns[0].content.contains("[IMAGE:data:"));
        assert!(turns[0].content.contains("AQIDBA"));
    }

    #[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(HistoryCaptureModelProvider::default());
        let mut histories =
            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
        histories.push(
            "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),
            model_provider: provider_impl.clone(),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx.clone(),
            zeroclaw_api::channel::ChannelMessage {
                id: "tg-msg-1".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-telegram".to_string(),
                content: "hello".to_string(),
                channel: "telegram".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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 channel_delivery_instructions_for_matrix_match_marker_contract() {
        let block = channel_delivery_instructions("matrix")
            .expect("matrix channel must have a delivery-instructions block");
        assert!(
            block.contains("When responding on Matrix:"),
            "matrix block must identify itself"
        );
        assert!(
            block.contains("[IMAGE:<path-or-url>]"),
            "matrix block must describe local path or URL marker syntax"
        );
        assert!(
            block.contains("workspace-relative or absolute"),
            "matrix block must match the validator's local path contract"
        );
        assert!(
            block.contains("Copy paths from inbound messages or file tools exactly"),
            "matrix block must prevent rewriting inbound or tool-returned paths"
        );
        assert!(
            block.contains("http:// or https:// URLs"),
            "matrix block must describe supported remote marker targets"
        );
        assert!(
            !block.contains("Never use relative paths"),
            "matrix block must not contradict the workspace-relative marker contract"
        );
    }

    #[test]
    fn channel_delivery_instructions_for_discord_mandates_absolute_paths() {
        let block = channel_delivery_instructions("discord")
            .expect("discord channel must have a delivery-instructions block");
        assert!(
            block.contains("When responding on Discord:"),
            "discord block must identify itself"
        );
        assert!(
            block.contains("For media attachments use markers:"),
            "discord block must describe marker syntax"
        );
        assert!(
            block.contains("MUST be absolute"),
            "discord block must mandate absolute paths"
        );
        assert!(
            block.contains("workspace"),
            "discord block must reference workspace bounds"
        );
        assert!(
            block.contains("[IMAGE:<absolute-path>]"),
            "discord block must show the absolute-path marker form"
        );
    }

    #[test]
    fn channel_delivery_instructions_for_whatsapp_web_match_local_marker_contract() {
        let block = channel_delivery_instructions("whatsapp")
            .expect("whatsapp channel must have a delivery-instructions block");
        assert!(
            block.contains("When responding on WhatsApp Web:"),
            "whatsapp block must identify itself"
        );
        assert!(
            block.contains("[LOCATION:"),
            "whatsapp block must include location pin instructions"
        );
        assert!(
            block.contains("[IMAGE:<path>]"),
            "whatsapp block must describe marker syntax"
        );
        assert!(
            block.contains("inside the configured workspace directory"),
            "whatsapp block must describe workspace bounds"
        );
        assert!(
            block.contains("Absolute paths and workspace-relative paths are accepted"),
            "whatsapp block must match the validator's local path contract"
        );
        assert!(
            block.contains("Do not use http://, https://, data:, file:"),
            "whatsapp block must say URL schemes are refused"
        );
        assert_eq!(
            channel_delivery_instructions("whatsapp-web"),
            Some(block),
            "the compatibility alias should use the same WhatsApp Web guidance"
        );
    }

    #[test]
    fn channel_delivery_instructions_for_lark_and_feishu_encourage_tool_use() {
        for channel_name in ["lark", "feishu"] {
            let block = channel_delivery_instructions(channel_name)
                .expect("lark and feishu must have a delivery-instructions block");
            assert!(
                block.contains("When responding on Lark/Feishu:"),
                "{channel_name} block must identify itself"
            );
            assert!(
                block.contains("use your tools"),
                "{channel_name} block must steer the model toward the agent tool path"
            );
            assert!(
                block.contains("Use tool results silently"),
                "{channel_name} block must keep internal tool bookkeeping out of replies"
            );

            let prompt = build_channel_system_prompt("base prompt", channel_name, None);
            assert!(
                prompt.contains("base prompt"),
                "{channel_name} system prompt must retain the base prompt"
            );
            assert!(
                prompt.contains("When responding on Lark/Feishu:"),
                "{channel_name} system prompt must include channel instructions"
            );
            assert!(
                prompt.contains("Use tool results silently"),
                "{channel_name} system prompt must include the tool-use guidance"
            );
        }
    }

    #[test]
    fn channel_delivery_instructions_for_telegram_encourage_tool_use() {
        let block = channel_delivery_instructions("telegram")
            .expect("telegram channel must have a delivery-instructions block");
        assert!(
            block.contains("When responding on Telegram:"),
            "telegram block must identify itself"
        );
        // Positive: it must actively steer the model toward its tools for
        // real-time/external information.
        assert!(
            block.contains("use your tools"),
            "telegram block must instruct the model to use its tools"
        );
        assert!(
            block.contains("web_search_tool") && block.contains("web_fetch"),
            "telegram block must name the real-time tools so the model knows to reach for them"
        );
        assert!(
            block.contains("never guess or answer from memory alone"),
            "telegram block must forbid answering from memory when a tool can verify"
        );
        // Negative: the exact regressed phrasing must never come back.
        assert!(
            !block.contains("Use tool results silently: answer the latest user message directly"),
            "telegram block must not tell the model to answer directly instead of using tools (#6646)"
        );
    }

    #[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─────────────────────────

    #[test]
    fn aieos_identity_from_file() {
        use tempfile::TempDir;
        use zeroclaw_config::schema::IdentityConfig;

        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"));

        // Should NOT contain OpenClaw bootstrap file headers
        assert!(!prompt.contains("### SOUL.md"));
        assert!(!prompt.contains("### IDENTITY.md"));
        assert!(!prompt.contains("[File not found"));
    }

    #[test]
    fn aieos_identity_from_inline() {
        use zeroclaw_config::schema::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 zeroclaw_config::schema::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 zeroclaw_config::schema::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 zeroclaw_config::schema::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);
    }

    #[cfg(feature = "channel-matrix")]
    #[test]
    fn matrix_state_dir_is_distinct_per_alias() {
        // Regression: two [channels.matrix.<alias>] blocks previously resolved
        // to the same <config>/state/matrix dir, so the second listener to
        // start restored the first's session.json and ran as the wrong Matrix
        // account. The alias component must keep them separate.
        let config_path = std::path::Path::new("/home/u/.zeroclaw/config.toml");
        let clamps = matrix_state_dir(config_path, "clamps");
        let bender = matrix_state_dir(config_path, "bender");
        assert_ne!(
            clamps, bender,
            "distinct matrix aliases must not share a state dir"
        );
        assert_eq!(
            clamps,
            std::path::Path::new("/home/u/.zeroclaw/state/matrix/clamps")
        );
        assert_eq!(
            bender,
            std::path::Path::new("/home/u/.zeroclaw/state/matrix/bender")
        );
    }

    #[cfg(feature = "channel-mattermost")]
    #[test]
    fn collect_configured_channels_includes_mattermost_when_configured() {
        let mut config = Config::default();
        config.channels.mattermost.insert(
            "default".to_string(),
            zeroclaw_config::schema::MattermostConfig {
                enabled: true,
                url: "https://mattermost.example.com".to_string(),
                bot_token: Some("test-token".to_string()),
                login_id: None,
                password: None,
                channel_ids: vec!["channel-1".to_string()],
                team_ids: vec![],
                discover_dms: None,
                thread_replies: Some(true),
                mention_only: Some(false),
                interrupt_on_new_message: false,
                proxy_url: None,
                listen_mode: zeroclaw_config::schema::MattermostListenMode::default(),
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
            },
        );
        // A channel is only collected when an enabled agent references it.
        config.agents.insert(
            "mattermost-default".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                channels: vec!["mattermost.default".into()],
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);

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

    #[cfg(feature = "channel-mattermost")]
    #[test]
    fn collect_configured_channels_falls_back_when_agent_bindings_missing() {
        let mut config = Config::default();
        config.channels.mattermost.insert(
            "default".to_string(),
            zeroclaw_config::schema::MattermostConfig {
                enabled: true,
                url: "https://mattermost.example.com".to_string(),
                bot_token: Some("test-token".to_string()),
                login_id: None,
                password: None,
                channel_ids: vec!["channel-1".to_string()],
                team_ids: vec![],
                discover_dms: None,
                thread_replies: Some(true),
                mention_only: Some(false),
                interrupt_on_new_message: false,
                proxy_url: None,
                listen_mode: zeroclaw_config::schema::MattermostListenMode::default(),
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
            },
        );
        config.agents.clear();
        config.agents.insert(
            "legacy".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec![],
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);

        assert!(
            channels
                .iter()
                .any(|entry| entry.display_name == "Mattermost"),
            "enabled channels should still load when no enabled agent declares channel bindings"
        );
    }

    #[cfg(feature = "channel-discord")]
    #[test]
    fn collect_configured_channels_skips_channel_when_only_owner_is_disabled() {
        // T1 — the bug path: an explicit binding exists, but the
        // owner agent is `enabled = false`. Legacy fallback must NOT
        // bring the channel online.
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "disco".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: false,
                channels: vec!["discord.default".into()],
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "default".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: true,
                bot_token: "test-token".to_string(),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);

        assert!(
            !channels.iter().any(|entry| entry.display_name == "Discord"),
            "disabled-owner channel must not be collected (#8013)"
        );
    }

    #[cfg(feature = "channel-discord")]
    #[test]
    fn collect_configured_channels_legacy_accepts_all_when_no_bindings_declared() {
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "legacy".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec![],
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "default".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: true,
                bot_token: "test-token".to_string(),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);

        assert!(
            channels.iter().any(|entry| entry.display_name == "Discord"),
            "no-bindings-anywhere must still trigger the legacy fallback"
        );
    }

    #[cfg(feature = "channel-discord")]
    #[test]
    fn collect_configured_channels_respects_mixed_enabled_and_disabled_owners() {
        // T3 — two bound channels, one owner enabled (keeper) and one
        // owner disabled (loser). Only the enabled owner's channel
        // comes online.
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "keeper".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec!["discord.a".into()],
                ..Default::default()
            },
        );
        config.agents.insert(
            "loser".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: false,
                channels: vec!["discord.b".into()],
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "a".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: true,
                bot_token: "token-a".to_string(),
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "b".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: true,
                bot_token: "token-b".to_string(),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);

        let discord_channels: Vec<_> = channels
            .iter()
            .filter(|entry| entry.display_name == "Discord")
            .collect();
        assert_eq!(
            discord_channels.len(),
            1,
            "exactly one Discord channel should be active when only one owner is enabled"
        );
        assert_eq!(
            discord_channels[0].alias.as_deref(),
            Some("a"),
            "only the enabled owner's channel should be active"
        );
    }

    #[cfg(feature = "channel-discord")]
    #[test]
    fn approval_route_collects_unowned_channel_without_agent_dispatch() {
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "worker".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec!["discord.worker".into()],
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "worker".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: true,
                bot_token: "worker-token".to_string(),
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "ops".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: true,
                bot_token: "ops-token".to_string(),
                ..Default::default()
            },
        );
        config.sop.approval.policies.insert(
            "prod".to_string(),
            zeroclaw_config::schema::ApprovalPolicyConfig {
                request_route: Some("discord.ops:room-1".to_string()),
                escalation_route: Some("discord.ops:room-2".to_string()),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config.clone()));
        let configured = collect_configured_channels(&config_arc, "test", &[], None, None);
        let channel_map = configured_channel_map(&configured);
        assert!(
            channel_map.contains_key("discord.ops"),
            "the approval route's configured channel must be live for adapter delivery"
        );

        let collected_keys: Vec<String> = channel_map.keys().cloned().collect();
        let owners = build_owner_by_channel_key(&config, &["worker".to_string()], &collected_keys);
        assert!(
            !owners.contains_key("discord.ops"),
            "approval-route liveness must not create an agent owner"
        );

        let worker_ctx = router_test_ctx();
        let router = AgentRouter::multi(
            HashMap::from([("worker".to_string(), worker_ctx)]),
            owners,
            None,
            None,
        );
        assert!(
            router
                .resolve(&channel_message("discord", Some("ops")))
                .is_none(),
            "ordinary traffic on the approval-only alias must not reach the worker"
        );
    }

    #[cfg(feature = "channel-discord")]
    #[test]
    fn bare_approval_route_collects_the_sole_enabled_alias() {
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "worker".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec!["discord.worker".into()],
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "worker".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: false,
                bot_token: "worker-token".to_string(),
                ..Default::default()
            },
        );
        config.channels.discord.insert(
            "ops".to_string(),
            zeroclaw_config::schema::DiscordConfig {
                enabled: true,
                bot_token: "ops-token".to_string(),
                ..Default::default()
            },
        );
        config.sop.approval.policies.insert(
            "prod".to_string(),
            zeroclaw_config::schema::ApprovalPolicyConfig {
                request_route: Some("discord:room-1".to_string()),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let configured = collect_configured_channels(&config_arc, "test", &[], None, None);
        let channel_map = configured_channel_map(&configured);
        assert!(channel_map.contains_key("discord.ops"));
        assert!(
            channel_map.contains_key("discord"),
            "the route adapter can resolve the bare singleton key"
        );
        assert!(
            !channel_map.contains_key("discord.worker"),
            "a disabled channel must not be revived just because a sibling route is active"
        );
    }

    #[test]
    fn build_owner_by_channel_key_skips_disabled_owners() {
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "loser".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: false,
                channels: vec!["discord.b".into()],
                ..Default::default()
            },
        );

        // Reload passes an empty enabled_agents slice because the only
        // owner is disabled.
        let owners = build_owner_by_channel_key(&config, &[], &["discord.b".to_string()]);

        assert!(
            owners.is_empty(),
            "disabled-owner channels must not be rebound to any fallback agent (#8013)"
        );
    }

    /// Helper: returns the set of `nostr.<alias>` references that pass
    /// the unified `ActiveChannelAliases` gate AND the channel-level
    /// `enabled = true` check, in the same way `doctor_channels` and
    /// `start_channels` use it after Phase 2.
    #[cfg(feature = "channel-nostr")]
    fn resolve_nostr_active(config: &Config) -> Vec<String> {
        let active = ActiveChannelAliases::compute(config);
        config
            .channels
            .nostr
            .iter()
            .filter(|(alias, _)| active.contains(&format!("nostr.{alias}")))
            .filter(|(_, ns)| ns.enabled)
            .map(|(alias, _)| format!("nostr.{alias}"))
            .collect()
    }

    #[cfg(feature = "channel-nostr")]
    #[test]
    fn doctor_channels_skips_nostr_when_only_owner_is_disabled() {
        // T5 — thebug path on the Nostr side. An explicit
        // `nostr.default` binding exists, but the owner agent is
        // `enabled = false`. Both the doctor and startup Nostr blocks
        // must NOT bring this channel online.
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "disabled_owner".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: false,
                channels: vec!["nostr.default".into()],
                ..Default::default()
            },
        );
        config.channels.nostr.insert(
            "default".to_string(),
            zeroclaw_config::schema::NostrConfig {
                enabled: true,
                private_key: "nsec1test".to_string(),
                ..Default::default()
            },
        );

        let active = resolve_nostr_active(&config);
        assert!(
            active.is_empty(),
            "Nostr channel with only a disabled owner must not pass the gate (#8013): got {:?}",
            active
        );
    }

    #[cfg(feature = "channel-nostr")]
    #[test]
    fn start_channels_legacy_includes_nostr_when_no_bindings_declared() {
        // T6 — the legacy fallback on the Nostr side. No agent declares
        // any channel binding, so the `all_known_bindings.is_empty()`
        // branch fires and every enabled Nostr alias is accepted. This
        // pins parity with the Discord T2 behavior.
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "legacy".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec![],
                ..Default::default()
            },
        );
        config.channels.nostr.insert(
            "legacy_alias".to_string(),
            zeroclaw_config::schema::NostrConfig {
                enabled: true,
                private_key: "nsec1test".to_string(),
                ..Default::default()
            },
        );

        let active = resolve_nostr_active(&config);
        assert_eq!(
            active,
            vec!["nostr.legacy_alias".to_string()],
            "Legacy fallback must keep Nostr active when no agent declares bindings"
        );
    }

    #[cfg(feature = "channel-nostr")]
    #[test]
    fn start_channels_nostr_skips_channel_level_disabled() {
        // T7 — channel-level `enabled = false` still skips even when
        // the agent binding path is satisfied. Pins the channel-level
        // half of the gate that was previously missing in the
        // `start_channels` Nostr block.
        let mut config = Config::default();
        config.agents.clear();
        config.agents.insert(
            "owner".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec!["nostr.muted".into()],
                ..Default::default()
            },
        );
        config.channels.nostr.insert(
            "muted".to_string(),
            zeroclaw_config::schema::NostrConfig {
                enabled: false, // channel-level off
                private_key: "nsec1test".to_string(),
                ..Default::default()
            },
        );

        let active = resolve_nostr_active(&config);
        assert!(
            active.is_empty(),
            "Nostr channel with `enabled = false` must not start regardless of agent binding"
        );
    }

    #[cfg(feature = "channel-email")]
    #[test]
    fn collect_configured_channels_skips_unreferenced_email() {
        let mut config = Config::default();
        config.channels.email.insert(
            "default".to_string(),
            zeroclaw_config::scattered_types::EmailConfig::default(),
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);
        assert!(
            !channels.iter().any(|entry| entry.display_name == "Email"),
            "email with no agent reference should not be collected"
        );
    }

    #[cfg(feature = "channel-voice-call")]
    #[test]
    fn collect_configured_channels_skips_unreferenced_voice_call() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "default".to_string(),
            zeroclaw_config::scattered_types::VoiceCallConfig::default(),
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);
        assert!(
            !channels
                .iter()
                .any(|entry| entry.display_name == "Voice Call"),
            "voice-call with no agent reference should not be collected"
        );
    }

    // Regression: an enabled Signal or Voice Call channel with
    // empty required credentials was built anyway, then its listener
    // failed to connect and the per-channel supervisor restarted it
    // forever (crashloop). The orchestrator must skip-with-warn instead of
    // building it, mirroring the WhatsApp Cloud `is_cloud_config()` gate.
    #[cfg(feature = "channel-signal")]
    #[test]
    fn collect_configured_channels_skips_enabled_signal_without_credentials() {
        let mut config = Config::default();
        config.channels.signal.insert(
            "default".to_string(),
            zeroclaw_config::schema::SignalConfig {
                enabled: true,
                http_url: "   ".into(),
                account: "   ".into(),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);
        assert!(
            !channels.iter().any(|entry| entry.display_name == "Signal"),
            "enabled Signal without credentials must not be collected (would crashloop)"
        );
    }

    #[cfg(feature = "channel-signal")]
    #[test]
    fn collect_configured_channels_builds_signal_with_credentials() {
        let mut config = Config::default();
        config.channels.signal.insert(
            "default".to_string(),
            zeroclaw_config::schema::SignalConfig {
                enabled: true,
                http_url: "http://127.0.0.1:8686".into(),
                account: "+15551234567".into(),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);
        assert!(
            channels.iter().any(|entry| entry.display_name == "Signal"),
            "enabled Signal with credentials must be collected"
        );
    }

    #[cfg(feature = "channel-voice-call")]
    #[test]
    fn collect_configured_channels_builds_voice_call_with_credentials() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "default".to_string(),
            zeroclaw_config::scattered_types::VoiceCallConfig {
                enabled: true,
                account_id: "AC123".into(),
                auth_token: "tok".into(),
                from_number: "+15551234567".into(),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);
        assert!(
            channels
                .iter()
                .any(|entry| entry.display_name == "Voice Call"),
            "enabled Voice Call with credentials must be collected"
        );
    }

    #[cfg(feature = "channel-voice-call")]
    #[test]
    fn collect_configured_channels_skips_enabled_voice_call_without_credentials() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "default".to_string(),
            zeroclaw_config::scattered_types::VoiceCallConfig {
                enabled: true,
                account_id: "   ".into(),
                auth_token: "   ".into(),
                from_number: "   ".into(),
                ..Default::default()
            },
        );

        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);
        assert!(
            !channels
                .iter()
                .any(|entry| entry.display_name == "Voice Call"),
            "enabled Voice Call without credentials must not be collected (would crashloop)"
        );
    }

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

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

    struct FailOnceChannel {
        name: String,
        calls: Arc<AtomicUsize>,
        err: Mutex<Option<anyhow::Error>>,
    }

    impl ::zeroclaw_api::attribution::Attributable for AlwaysFailChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    #[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<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            anyhow::bail!("listen boom")
        }
    }

    impl ::zeroclaw_api::attribution::Attributable for BlockUntilClosedChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Webhook,
            )
        }
        fn alias(&self) -> &str {
            "test"
        }
    }

    impl ::zeroclaw_api::attribution::Attributable for FailOnceChannel {
        fn role(&self) -> ::zeroclaw_api::attribution::Role {
            ::zeroclaw_api::attribution::Role::Channel(
                ::zeroclaw_api::attribution::ChannelKind::Discord,
            )
        }

        fn alias(&self) -> &str {
            "default"
        }
    }

    #[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<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            tx.closed().await;
            Ok(())
        }
    }

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

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

        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
        ) -> anyhow::Result<()> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            if let Some(err) = self.err.lock().unwrap_or_else(|e| e.into_inner()).take() {
                return Err(err);
            }
            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::<zeroclaw_api::channel::ChannelMessage>(1);
        let cancel = tokio_util::sync::CancellationToken::new();
        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());

        tokio::time::sleep(Duration::from_millis(80)).await;
        drop(rx);
        cancel.cancel();
        let _ = tokio::time::timeout(Duration::from_millis(500), handle).await;

        let snapshot = zeroclaw_runtime::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::<zeroclaw_api::channel::ChannelMessage>(1);
        let cancel = tokio_util::sync::CancellationToken::new();
        let handle = spawn_supervised_listener_with_health_interval(
            channel,
            None,
            tx,
            1,
            1,
            Duration::from_millis(20),
            cancel.clone(),
        );

        tokio::time::sleep(Duration::from_millis(35)).await;
        let first_last_ok = zeroclaw_runtime::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 = zeroclaw_runtime::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");

        cancel.cancel();
        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
        assert!(join.is_ok(), "listener should stop on cancel");
        assert!(calls.load(Ordering::SeqCst) >= 1);
        drop(rx);
    }

    #[tokio::test]
    async fn supervised_listener_does_not_restart_on_non_retryable_discord_http_error() {
        let calls = Arc::new(AtomicUsize::new(0));
        let channel_name = format!("discord-{}", uuid::Uuid::new_v4());
        let channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
            name: channel_name,
            calls: Arc::clone(&calls),
            err: Mutex::new(Some(anyhow::Error::msg("401 Unauthorized"))),
        });

        let component_name = format!("channel:{}", channel.name());
        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
        let cancel = tokio_util::sync::CancellationToken::new();
        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());

        tokio::time::sleep(Duration::from_millis(80)).await;
        let snapshot = zeroclaw_runtime::health::snapshot_json();
        let component = &snapshot["components"][&component_name];
        assert_eq!(calls.load(Ordering::SeqCst), 1);
        assert_eq!(component["status"], "error");
        assert_eq!(component["restart_count"].as_u64().unwrap_or(0), 0);
        assert!(
            component["last_error"]
                .as_str()
                .unwrap_or("")
                .contains("401 Unauthorized")
        );

        drop(rx);
        cancel.cancel();
        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
        assert!(join.is_ok(), "listener should stop on cancel");
        assert_eq!(calls.load(Ordering::SeqCst), 1);
    }

    #[cfg(feature = "channel-discord")]
    #[tokio::test]
    async fn supervised_listener_enters_retry_path_on_discord_gateway_rate_limit() {
        let calls = Arc::new(AtomicUsize::new(0));
        let channel_name = format!("discord-{}", uuid::Uuid::new_v4());
        let channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
            name: channel_name,
            calls: Arc::clone(&calls),
            err: Mutex::new(Some(anyhow::Error::msg(
                "discord gateway preflight rate-limited (429 Too Many Requests)",
            ))),
        });

        let component_name = format!("channel:{}", channel.name());
        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
        let cancel = tokio_util::sync::CancellationToken::new();
        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());

        tokio::time::sleep(Duration::from_millis(80)).await;
        let snapshot = zeroclaw_runtime::health::snapshot_json();
        let component = &snapshot["components"][&component_name];
        assert_eq!(calls.load(Ordering::SeqCst), 1);
        assert_eq!(component["status"], "error");
        assert!(
            component["last_error"]
                .as_str()
                .unwrap_or("")
                .contains("429 Too Many Requests")
        );
        assert!(
            component["restart_count"].as_u64().unwrap_or(0) >= 1,
            "Discord gateway 429 should back off through the retry path instead of parking"
        );

        drop(rx);
        cancel.cancel();
        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
        assert!(join.is_ok(), "listener should stop on cancel");
        assert_eq!(calls.load(Ordering::SeqCst), 1);
    }

    #[cfg(feature = "channel-discord")]
    #[tokio::test]
    async fn supervised_listener_does_not_restart_on_fatal_discord_gateway_close_code() {
        let calls = Arc::new(AtomicUsize::new(0));
        let channel_name = format!("discord-{}", uuid::Uuid::new_v4());
        let channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
            name: channel_name,
            calls: Arc::clone(&calls),
            err: Mutex::new(Some(anyhow::Error::new(
                crate::discord::DiscordListenerFatalError::new(
                    "discord gateway closed with fatal code 4014: disallowed intent(s)",
                ),
            ))),
        });

        let component_name = format!("channel:{}", channel.name());
        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
        let cancel = tokio_util::sync::CancellationToken::new();
        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());

        tokio::time::sleep(Duration::from_millis(80)).await;
        let snapshot = zeroclaw_runtime::health::snapshot_json();
        let component = &snapshot["components"][&component_name];
        assert_eq!(calls.load(Ordering::SeqCst), 1);
        assert_eq!(component["status"], "error");
        assert_eq!(component["restart_count"].as_u64().unwrap_or(0), 0);
        assert!(
            component["last_error"]
                .as_str()
                .unwrap_or("")
                .contains("fatal code 4014")
        );

        drop(rx);
        cancel.cancel();
        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
        assert!(join.is_ok(), "listener should stop on cancel");
        assert_eq!(calls.load(Ordering::SeqCst), 1);
    }

    #[tokio::test]
    async fn non_retryable_listener_error_does_not_stop_other_listener_health() {
        let failing_calls = Arc::new(AtomicUsize::new(0));
        let healthy_calls = Arc::new(AtomicUsize::new(0));
        let failing_name = format!("discord-{}", uuid::Uuid::new_v4());
        let healthy_name = format!("test-supervised-sibling-{}", uuid::Uuid::new_v4());
        let failing_component = format!("channel:{failing_name}");
        let healthy_component = format!("channel:{healthy_name}");

        let failing_channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
            name: failing_name,
            calls: Arc::clone(&failing_calls),
            err: Mutex::new(Some(anyhow::Error::msg("401 Unauthorized"))),
        });
        let healthy_channel: Arc<dyn Channel> = Arc::new(BlockUntilClosedChannel {
            name: healthy_name,
            calls: Arc::clone(&healthy_calls),
        });

        let (failing_tx, failing_rx) =
            tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
        let (healthy_tx, healthy_rx) =
            tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
        let cancel = tokio_util::sync::CancellationToken::new();
        let failing_handle =
            spawn_supervised_listener(failing_channel, None, failing_tx, 1, 1, cancel.clone());
        let healthy_handle = spawn_supervised_listener_with_health_interval(
            healthy_channel,
            None,
            healthy_tx,
            1,
            1,
            Duration::from_millis(20),
            cancel.clone(),
        );

        tokio::time::sleep(Duration::from_millis(80)).await;

        let first_last_ok = zeroclaw_runtime::health::snapshot_json()["components"]
            [&healthy_component]["last_ok"]
            .as_str()
            .unwrap_or("")
            .to_string();
        assert!(
            !first_last_ok.is_empty(),
            "healthy sibling should report health"
        );

        tokio::time::sleep(Duration::from_millis(70)).await;

        let snapshot = zeroclaw_runtime::health::snapshot_json();
        let failing = &snapshot["components"][&failing_component];
        let healthy = &snapshot["components"][&healthy_component];
        let second_last_ok = healthy["last_ok"].as_str().unwrap_or("").to_string();
        let first = chrono::DateTime::parse_from_rfc3339(&first_last_ok)
            .expect("healthy sibling last_ok should be valid RFC3339");
        let second = chrono::DateTime::parse_from_rfc3339(&second_last_ok)
            .expect("healthy sibling last_ok should be valid RFC3339");

        assert_eq!(failing_calls.load(Ordering::SeqCst), 1);
        assert_eq!(failing["status"], "error");
        assert_eq!(failing["restart_count"].as_u64().unwrap_or(0), 0);
        assert!(
            failing["last_error"]
                .as_str()
                .unwrap_or("")
                .contains("401 Unauthorized")
        );
        assert_eq!(healthy["status"], "ok");
        assert!(
            second > first,
            "healthy sibling should keep refreshing health"
        );
        assert!(healthy_calls.load(Ordering::SeqCst) >= 1);

        drop(failing_rx);
        drop(healthy_rx);
        cancel.cancel();
        let failing_join = tokio::time::timeout(Duration::from_millis(500), failing_handle).await;
        let healthy_join = tokio::time::timeout(Duration::from_millis(500), healthy_handle).await;
        assert!(
            failing_join.is_ok(),
            "non-retryable listener should stop on cancel"
        );
        assert!(
            healthy_join.is_ok(),
            "healthy sibling listener should stop on cancel"
        );
    }

    #[test]
    fn maybe_restart_daemon_systemd_args_regression() {
        assert_eq!(
            SYSTEMD_STATUS_ARGS,
            ["--user", "is-active", "zeroclaw.service"]
        );
        assert_eq!(
            SYSTEMD_RESTART_ARGS,
            ["--user", "restart", "zeroclaw.service"]
        );
    }

    #[test]
    fn maybe_restart_daemon_openrc_args_regression() {
        assert_eq!(OPENRC_STATUS_ARGS, ["zeroclaw", "status"]);
        assert_eq!(OPENRC_RESTART_ARGS, ["zeroclaw", "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());
    }

    #[test]
    fn channel_history_preserves_image_marker_verbatim_across_followup() {
        let img = "[IMAGE:/tmp/media/screenshot.png] wait look at this";
        let mut turns = vec![
            ChatMessage::user(img),
            ChatMessage::assistant("\u{1f44d}"),
            ChatMessage::user("can you see the screenshot?"),
        ];

        let kept: Vec<ChatMessage> = normalize_cached_channel_turns(std::mem::take(&mut turns));

        assert_eq!(kept[0].content, img);
        assert!(kept[0].content.contains("/tmp/media/screenshot.png"));
        assert!(!kept[0].content.contains("processed by vision model"));
    }

    #[test]
    fn channel_history_preserves_document_voice_and_text_verbatim() {
        let doc = "[Document: report.pdf] /tmp/media/report.pdf summarize this";
        let voice = "[Voice] what did i just send";
        let text = "plain text with no markers";
        let mut turns = vec![
            ChatMessage::user(doc),
            ChatMessage::assistant("ok"),
            ChatMessage::user(voice),
            ChatMessage::assistant("ok"),
            ChatMessage::user(text),
        ];

        let kept: Vec<ChatMessage> = normalize_cached_channel_turns(std::mem::take(&mut turns));

        assert_eq!(kept[0].content, doc);
        assert_eq!(kept[2].content, voice);
        assert_eq!(kept[4].content, text);
    }

    #[test]
    fn collapse_inline_image_payloads_drops_data_uri_keeps_path() {
        let path_turn = "[IMAGE:/tmp/media/screenshot.png] can you see this?";
        let data_turn = format!(
            "[IMAGE:data:image/png;base64,{}] old screenshot",
            "AQIDBAUGBwg".repeat(64)
        );
        let mut turns = vec![
            ChatMessage::user(path_turn),
            ChatMessage::assistant("ok"),
            ChatMessage::user(&data_turn),
            ChatMessage::assistant("ok"),
            ChatMessage::user("[IMAGE:/tmp/media/current.png] and this?"),
        ];

        collapse_inline_image_payloads(&mut turns);

        assert_eq!(turns[0].content, path_turn, "file-path marker must survive");
        assert!(
            !turns[2].content.contains("base64"),
            "inline data payload must be collapsed"
        );
        assert!(turns[2].content.contains("old screenshot"));
        assert_eq!(
            turns[4].content, "[IMAGE:/tmp/media/current.png] and this?",
            "current turn is never collapsed"
        );
    }

    #[test]
    fn strip_inline_data_image_markers_drops_bytes_keeps_path_marker_for_autosave() {
        // The autosave path calls strip_inline_data_image_markers before
        // storing to durable memory, so inline data: bytes never persist while
        // re-loadable path markers and surrounding text survive.
        let img_open = format!("[{}", "IMAGE:/tmp/shot.png]");
        let payload = "AQIDBAUGBwg".repeat(64);
        let data_marker = format!("[{}{payload}]", "IMAGE:data:image/png;base64,");
        let content = format!("look at {img_open} and {data_marker} please");

        let cleaned = strip_inline_data_image_markers(&content);

        assert!(
            !cleaned.contains("base64"),
            "inline data bytes must be stripped before autosave: {cleaned}"
        );
        assert!(
            cleaned.contains("/tmp/shot.png"),
            "re-loadable path marker must survive: {cleaned}"
        );
        assert!(cleaned.contains("look at") && cleaned.contains("please"));
    }

    #[tokio::test]
    async fn media_pipeline_preserves_image_bytes_when_vision_route_configured() {
        use wiremock::matchers::{body_string_contains, method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        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(HistoryCaptureModelProvider::default());
        let vision_server = MockServer::start().await;
        let _vision_mock = Mock::given(method("POST"))
            .and(path("/chat/completions"))
            .and(body_string_contains("data:image/png;base64,AQIDBA=="))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "choices": [
                    {
                        "message": {
                            "role": "assistant",
                            "content": "vision saw bytes"
                        }
                    }
                ]
            })))
            .expect(1)
            .mount_as_scoped(&vision_server)
            .await;

        let base_ctx = peer_prompt_test_context(
            channels_by_name,
            provider_impl.clone(),
            Arc::new(zeroclaw_config::schema::Config::default()),
            Arc::new(vec![]),
        );
        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            multimodal: zeroclaw_config::schema::MultimodalConfig {
                vision_model_provider: Some(format!("custom:{}", vision_server.uri())),
                vision_model: Some("test-vision-model".to_string()),
                ..Default::default()
            },
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig {
                enabled: true,
                describe_images: true,
                ..Default::default()
            },
            ..(*base_ctx).clone()
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-image-route".to_string(),
                sender: "alice".to_string(),
                reply_target: "chat-image-route".to_string(),
                content: "please inspect this".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                passive_context: false,
                explicitly_addressed: false,
                conversation_scope: zeroclaw_api::channel::ChannelConversationScope::Sender,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![zeroclaw_api::media::MediaAttachment {
                    file_name: "route.png".to_string(),
                    data: vec![1, 2, 3, 4],
                    mime_type: Some("image/png".to_string()),
                }],
                subject: None,
                internal_sop_event: None,
            },
            CancellationToken::new(),
        )
        .await;

        {
            let calls = provider_impl.calls.lock().unwrap();
            assert!(
                calls.is_empty(),
                "default non-vision provider must not receive an image-bearing turn: {calls:?}"
            );
        }

        let sent_messages = channel_impl.sent_messages.lock().await;
        assert_eq!(
            sent_messages.len(),
            1,
            "vision route should send exactly one assistant reply: {sent_messages:?}"
        );
        assert!(
            sent_messages[0].contains("vision saw bytes"),
            "reply should come from the mock vision provider: {sent_messages:?}"
        );
        drop(sent_messages);

        let vision_requests = vision_server
            .received_requests()
            .await
            .expect("mock server should record vision provider requests");
        assert_eq!(
            vision_requests.len(),
            1,
            "vision provider should receive exactly one request"
        );
        let vision_body: serde_json::Value = vision_requests[0]
            .body_json()
            .expect("vision provider request should be JSON");
        assert_eq!(vision_body["model"], "test-vision-model");
        assert!(
            vision_body
                .to_string()
                .contains("data:image/png;base64,AQIDBA=="),
            "vision provider request must contain the preserved attachment bytes: {vision_body}"
        );
    }

    #[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);

        // DummyModelProvider has default capabilities (vision: false).
        let runtime_ctx = Arc::new(ChannelRuntimeContext {
            channels_by_name: Arc::new(channels_by_name),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new("dummy".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        // Simulate a photo attachment message with [IMAGE:] marker.
        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-photo-1".to_string(),
                sender: "zeroclaw_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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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),
            model_provider: Arc::new(DummyModelProvider),
            model_provider_ref: Arc::new("dummy".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            Arc::clone(&runtime_ctx),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-photo-1".to_string(),
                sender: "zeroclaw_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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        process_channel_message(
            Arc::clone(&runtime_ctx),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-text-2".to_string(),
                sender: "zeroclaw_user".to_string(),
                reply_target: "chat-photo".to_string(),
                content: "What is WAL?".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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
            .peek("test-channel_chat-photo_zeroclaw_user")
            .expect("history should exist for sender");
        assert_eq!(turns.len(), 2);
        assert_eq!(turns[0].role, "user");
        assert!(
            turns[0].content.contains("] What is WAL?"),
            "follow-up user turn should be timestamped: {}",
            turns[0].content
        );
        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),
            model_provider: Arc::new(FormatErrorModelProvider),
            model_provider_ref: Arc::new("dummy".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 50000,
            context_token_budget: 128_000,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                std::time::Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
        });

        process_channel_message(
            Arc::clone(&runtime_ctx),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-bad-1".to_string(),
                sender: "zeroclaw_user".to_string(),
                reply_target: "chat-format".to_string(),
                content: "trigger format error".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        process_channel_message(
            Arc::clone(&runtime_ctx),
            zeroclaw_api::channel::ChannelMessage {
                id: "msg-text-2".to_string(),
                sender: "zeroclaw_user".to_string(),
                reply_target: "chat-format".to_string(),
                content: "What is WAL?".to_string(),
                channel: "test-channel".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            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
            .peek("test-channel_chat-format_zeroclaw_user")
            .expect("history should exist for sender");
        assert_eq!(turns.len(), 2);
        assert_eq!(turns[0].role, "user");
        assert!(
            turns[0].content.contains("] What is WAL?"),
            "follow-up user turn should be timestamped: {}",
            turns[0].content
        );
        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();
        let config_arc = Arc::new(RwLock::new(config));
        match build_channel_by_id(&config_arc, "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"),
        }
    }

    #[test]
    fn one_shot_channel_workspace_dir_uses_owning_agent_workspace() {
        let tmp = tempfile::tempdir().unwrap();
        let mut config = Config {
            data_dir: tmp.path().join("data"),
            config_path: tmp.path().join("config.toml"),
            ..Config::default()
        };
        config.agents.insert(
            "alice".to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                enabled: true,
                channels: vec![zeroclaw_config::providers::ChannelRef(
                    "telegram.default".to_string(),
                )],
                ..Default::default()
            },
        );

        let resolved = one_shot_channel_workspace_dir(&config, "telegram", "default");

        assert_eq!(resolved, config.agent_workspace_dir("alice"));
        assert_ne!(resolved, config.data_dir);
    }

    // ── 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 agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
        let vision_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let vision_model_provider: Arc<dyn ModelProvider> = vision_model_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
        provider_cache_seed.insert(
            "test-provider".to_string(),
            Arc::clone(&agent_model_provider),
        );
        provider_cache_seed.insert("vision-provider".to_string(), vision_model_provider);

        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
            enabled: true,
            rules: vec![zeroclaw_config::schema::ClassificationRule {
                hint: "vision".into(),
                keywords: vec!["analyze-image".into()],
                ..Default::default()
            }],
        };

        let model_routes = vec![zeroclaw_config::schema::ModelRouteConfig {
            hint: "vision".into(),
            model_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),
            model_provider: Arc::clone(&agent_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        // Vision model_provider should have been called instead of the default.
        assert_eq!(
            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
            0
        );
        assert_eq!(
            vision_model_provider_impl.call_count.load(Ordering::SeqCst),
            1
        );
        assert_eq!(
            vision_model_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 agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
        let vision_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let vision_model_provider: Arc<dyn ModelProvider> = vision_model_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
        provider_cache_seed.insert(
            "test-provider".to_string(),
            Arc::clone(&agent_model_provider),
        );
        provider_cache_seed.insert("vision-provider".to_string(), vision_model_provider);

        // Classification is disabled — matching keyword should NOT trigger reroute.
        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
            enabled: false,
            rules: vec![zeroclaw_config::schema::ClassificationRule {
                hint: "vision".into(),
                keywords: vec!["analyze-image".into()],
                ..Default::default()
            }],
        };

        let model_routes = vec![zeroclaw_config::schema::ModelRouteConfig {
            hint: "vision".into(),
            model_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),
            model_provider: Arc::clone(&agent_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        // Default model_provider should be used since classification is disabled.
        assert_eq!(
            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
            1
        );
        assert_eq!(
            vision_model_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 agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
        let vision_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let vision_model_provider: Arc<dyn ModelProvider> = vision_model_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
        provider_cache_seed.insert(
            "test-provider".to_string(),
            Arc::clone(&agent_model_provider),
        );
        provider_cache_seed.insert("vision-provider".to_string(), vision_model_provider);

        // Classification enabled with a rule that won't match the message.
        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
            enabled: true,
            rules: vec![zeroclaw_config::schema::ClassificationRule {
                hint: "vision".into(),
                keywords: vec!["analyze-image".into()],
                ..Default::default()
            }],
        };

        let model_routes = vec![zeroclaw_config::schema::ModelRouteConfig {
            hint: "vision".into(),
            model_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),
            model_provider: Arc::clone(&agent_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        // Default model_provider should be used since no classification rule matched.
        assert_eq!(
            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
            1
        );
        assert_eq!(
            vision_model_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 agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
        let fast_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let fast_model_provider: Arc<dyn ModelProvider> = fast_model_provider_impl.clone();
        let code_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
        let code_model_provider: Arc<dyn ModelProvider> = code_model_provider_impl.clone();

        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
        provider_cache_seed.insert(
            "test-provider".to_string(),
            Arc::clone(&agent_model_provider),
        );
        provider_cache_seed.insert("fast-provider".to_string(), fast_model_provider);
        provider_cache_seed.insert("code-provider".to_string(), code_model_provider);

        // Both rules match "code" keyword, but "code" rule has higher priority.
        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
            enabled: true,
            rules: vec![
                zeroclaw_config::schema::ClassificationRule {
                    hint: "fast".into(),
                    keywords: vec!["code".into()],
                    priority: 1,
                    ..Default::default()
                },
                zeroclaw_config::schema::ClassificationRule {
                    hint: "code".into(),
                    keywords: vec!["code".into()],
                    priority: 10,
                    ..Default::default()
                },
            ],
        };

        let model_routes = vec![
            zeroclaw_config::schema::ModelRouteConfig {
                hint: "fast".into(),
                model_provider: "fast-provider".into(),
                model: "fast-model".into(),
                api_key: None,
            },
            zeroclaw_config::schema::ModelRouteConfig {
                hint: "code".into(),
                model_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),
            model_provider: Arc::clone(&agent_model_provider),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 5,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: false,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        process_channel_message(
            runtime_ctx,
            zeroclaw_api::channel::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".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: None,
                interruption_scope_id: None,
                attachments: vec![],
                subject: None,

                ..Default::default()
            },
            CancellationToken::new(),
        )
        .await;

        // Higher-priority "code" rule (priority=10) should win over "fast" (priority=1).
        assert_eq!(
            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
            0
        );
        assert_eq!(
            fast_model_provider_impl.call_count.load(Ordering::SeqCst),
            0
        );
        assert_eq!(
            code_model_provider_impl.call_count.load(Ordering::SeqCst),
            1
        );
        assert_eq!(
            code_model_provider_impl
                .models
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .as_slice(),
            &["code-model".to_string()]
        );
    }

    #[cfg(feature = "channel-telegram")]
    #[test]
    fn build_channel_by_id_unconfigured_telegram_returns_error() {
        let config = Config::default();
        let config_arc = Arc::new(RwLock::new(config));
        match build_channel_by_id(&config_arc, "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"),
        }
    }

    #[cfg(feature = "channel-telegram")]
    #[test]
    fn build_channel_by_id_configured_telegram_succeeds() {
        let mut config = Config::default();
        config.channels.telegram.insert(
            "default".to_string(),
            zeroclaw_config::schema::TelegramConfig {
                enabled: true,
                bot_token: "test-token".to_string(),
                api_base_url: zeroclaw_config::schema::TELEGRAM_OFFICIAL_API_BASE_URL.to_string(),
                stream_mode: zeroclaw_config::schema::StreamMode::Off,
                draft_update_interval_ms: 1000,
                interrupt_on_new_message: false,
                mention_only: false,
                ack_reactions: None,
                proxy_url: None,
                approval_timeout_secs: 120,
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
                debounce_ms: None,
            },
        );
        let config_arc = Arc::new(RwLock::new(config));
        match build_channel_by_id(&config_arc, "telegram") {
            Ok(channel) => assert_eq!(channel.name(), "telegram"),
            Err(e) => panic!("should succeed when telegram is configured: {e}"),
        }
    }

    #[cfg(feature = "channel-telegram")]
    fn config_with_telegram_alias(alias: &str) -> Config {
        let mut config = Config::default();
        config.channels.telegram.insert(
            alias.to_string(),
            zeroclaw_config::schema::TelegramConfig {
                enabled: true,
                bot_token: "test-token".to_string(),
                api_base_url: zeroclaw_config::schema::TELEGRAM_OFFICIAL_API_BASE_URL.to_string(),
                stream_mode: zeroclaw_config::schema::StreamMode::Off,
                draft_update_interval_ms: 1000,
                interrupt_on_new_message: false,
                mention_only: false,
                ack_reactions: None,
                proxy_url: None,
                approval_timeout_secs: 120,
                excluded_tools: vec![],
                reply_min_interval_secs: 0,
                reply_queue_depth_max: 0,
                debounce_ms: None,
            },
        );
        config
    }

    /// The non-default-alias bug: the bound identity must land in the same
    /// peer group the runtime resolves authorization from — otherwise the
    /// bot keeps demanding `/bind` for a user the operator already approved.
    #[cfg(feature = "channel-telegram")]
    #[test]
    fn bind_telegram_into_non_default_alias_is_resolvable() {
        let mut config = config_with_telegram_alias("alerts");
        let newly = bind_telegram_identity_into(&mut config, "123456789", "alerts").unwrap();
        assert!(newly, "first bind should report newly added");
        // The live resolver the channel uses must now see the identity.
        assert!(
            config
                .channel_external_peers("telegram", "alerts")
                .contains(&"123456789".to_string()),
            "identity bound to `alerts` must resolve for the `alerts` channel"
        );
        // And it must be scoped — not leaked onto a different alias.
        assert!(
            config
                .channel_external_peers("telegram", "other")
                .is_empty(),
            "binding must stay scoped to its alias"
        );
    }

    /// Backward compatibility: the default alias still routes to
    /// `telegram_default` / `telegram.default` exactly as before.
    #[cfg(feature = "channel-telegram")]
    #[test]
    fn bind_telegram_into_default_alias_unchanged() {
        let mut config = config_with_telegram_alias("default");
        bind_telegram_identity_into(&mut config, "@zeroclaw_user", "default").unwrap();
        let group = config
            .peer_groups
            .get("telegram_default")
            .expect("default bind must use the telegram_default group");
        assert_eq!(group.channel.as_str(), "telegram.default");
        assert!(
            config
                .channel_external_peers("telegram", "default")
                .contains(&"zeroclaw_user".to_string())
        );
    }

    /// Idempotency: re-binding the same identity reports "already present".
    #[cfg(feature = "channel-telegram")]
    #[test]
    fn bind_telegram_into_is_idempotent() {
        let mut config = config_with_telegram_alias("alerts");
        assert!(bind_telegram_identity_into(&mut config, "123", "alerts").unwrap());
        assert!(
            !bind_telegram_identity_into(&mut config, "123", "alerts").unwrap(),
            "second bind of same identity should report already present"
        );
        assert_eq!(
            config.channel_external_peers("telegram", "alerts").len(),
            1,
            "duplicate bind must not add a second peer entry"
        );
    }

    /// A typo'd / unconfigured alias must fail loudly rather than mint a
    /// phantom peer group that authorizes nobody.
    #[cfg(feature = "channel-telegram")]
    #[test]
    fn bind_telegram_into_unconfigured_alias_bails() {
        let mut config = config_with_telegram_alias("default");
        let err = bind_telegram_identity_into(&mut config, "123", "typoalias")
            .expect_err("unconfigured alias must bail");
        assert!(
            err.to_string().contains("typoalias"),
            "error should name the bad alias, got: {err}"
        );
        assert!(
            !config.peer_groups.contains_key("telegram_typoalias"),
            "failed bind must not create a phantom peer group"
        );
    }

    /// The generic bind must keep the SCOPED dotted `<type>.<alias>` channel
    /// ref — never a bare type, which would broaden the peer across every
    /// alias of that type (the bug the alias-aware fix closed).
    #[cfg(feature = "channel-telegram")]
    #[test]
    fn bind_channel_into_uses_scoped_dotted_channel_ref() {
        let mut config = config_with_telegram_alias("alerts");
        assert!(bind_channel_identity_into(&mut config, "telegram", "alerts", "@user").unwrap());
        let group = config
            .peer_groups
            .get("telegram_alerts")
            .expect("generic bind must use the telegram_alerts group");
        assert_eq!(
            group.channel.as_str(),
            "telegram.alerts",
            "channel ref must stay dotted/alias-scoped, never bare `telegram`"
        );
        // Resolves for `alerts`, and stays OFF a sibling alias.
        assert!(
            config
                .channel_external_peers("telegram", "alerts")
                .contains(&"user".to_string())
        );
        assert!(
            config
                .channel_external_peers("telegram", "other")
                .is_empty()
        );
    }

    /// The closed-set gate: a non-pairing channel type cannot be bound.
    #[test]
    fn bind_channel_into_rejects_unsupported_type() {
        let mut config = Config::default();
        let err = bind_channel_identity_into(&mut config, "discord", "default", "123")
            .expect_err("unsupported type must bail");
        assert!(
            err.to_string()
                .contains("does not support identity binding"),
            "error should explain the type is unsupported, got: {err}"
        );
        assert!(
            channel_identity_normalizer("discord").is_none(),
            "discord must not be in the bind closed-set"
        );
        assert!(channel_identity_normalizer("telegram").is_some());
        assert!(channel_identity_normalizer("wechat").is_some());
        assert!(channel_identity_normalizer("line").is_some());
    }

    #[cfg(feature = "channel-voice-call")]
    #[test]
    fn build_channel_by_id_unconfigured_voice_call_returns_error() {
        let config = Config::default();
        let config_arc = Arc::new(RwLock::new(config));
        match build_channel_by_id(&config_arc, "voice-call") {
            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 voice-call is not configured"),
        }
    }

    #[cfg(feature = "channel-voice-call")]
    #[test]
    fn build_channel_by_id_configured_voice_call_succeeds() {
        let mut config = Config::default();
        config.channels.voice_call.insert(
            "default".to_string(),
            zeroclaw_config::scattered_types::VoiceCallConfig {
                enabled: true,
                model_provider: zeroclaw_config::scattered_types::VoiceProvider::Twilio,
                account_id: "AC_TEST".to_string(),
                auth_token: "test_token".to_string(),
                from_number: "+15551234567".to_string(),
                webhook_port: 8090,
                require_outbound_approval: true,
                transcription_logging: true,
                tts_voice: None,
                max_call_duration_secs: 3600,
                webhook_base_url: None,
                excluded_tools: vec![],
            },
        );
        let config_arc = Arc::new(RwLock::new(config));
        match build_channel_by_id(&config_arc, "voice-call") {
            Ok(channel) => assert_eq!(channel.name(), "voice_call"),
            Err(e) => panic!("should succeed when voice-call 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@zeroclaw_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,
            whatsapp: 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,
            whatsapp: 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,
            whatsapp: false,
        };
        assert!(cfg.enabled_for_channel("discord"));
    }

    #[test]
    fn interrupt_on_new_message_enabled_for_whatsapp() {
        let cfg = InterruptOnNewMessageConfig {
            telegram: false,
            slack: false,
            discord: false,
            mattermost: false,
            matrix: false,
            whatsapp: true,
        };
        assert!(cfg.enabled_for_channel("whatsapp"));
    }

    #[test]
    fn interrupt_on_new_message_config_reads_whatsapp_default_alias() {
        let mut channels = zeroclaw_config::schema::ChannelsConfig::default();
        channels.whatsapp.insert(
            "default".to_string(),
            zeroclaw_config::schema::WhatsAppConfig {
                session_path: Some("/tmp/zeroclaw-whatsapp-session.db".into()),
                interrupt_on_new_message: true,
                ..Default::default()
            },
        );

        let cfg = interrupt_on_new_message_config(&channels);

        assert!(cfg.enabled_for_channel("whatsapp"));
        assert!(!cfg.enabled_for_channel("telegram"));
    }

    #[test]
    fn interrupt_on_new_message_disabled_for_discord_by_default() {
        let cfg = InterruptOnNewMessageConfig {
            telegram: false,
            slack: false,
            discord: false,
            mattermost: false,
            matrix: false,
            whatsapp: false,
        };
        assert!(!cfg.enabled_for_channel("discord"));
    }

    // ── interruption_scope_key tests ──────────────────────────────────────

    #[test]
    fn interruption_scope_key_without_scope_id_is_three_component() {
        let msg = zeroclaw_api::channel::ChannelMessage {
            id: "1".into(),
            sender: "alice".into(),
            reply_target: "room".into(),
            content: "hi".into(),
            channel: "matrix".into(),
            channel_alias: None,
            timestamp: 0,
            thread_ts: None,
            interruption_scope_id: None,
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        assert_eq!(interruption_scope_key(&msg), "matrix_room_alice");
    }

    #[test]
    fn interruption_scope_key_with_scope_id_is_four_component() {
        let msg = zeroclaw_api::channel::ChannelMessage {
            id: "1".into(),
            sender: "alice".into(),
            reply_target: "room".into(),
            content: "hi".into(),
            channel: "matrix".into(),
            channel_alias: None,
            timestamp: 0,
            thread_ts: Some("$thread1".into()),
            interruption_scope_id: Some("$thread1".into()),
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        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 = zeroclaw_api::channel::ChannelMessage {
            id: "1".into(),
            sender: "alice".into(),
            reply_target: "C123".into(),
            content: "hi".into(),
            channel: "slack".into(),
            channel_alias: None,
            timestamp: 0,
            thread_ts: Some("1234567890.000100".into()), // Slack top-level fallback
            interruption_scope_id: None,                 // but NOT a thread reply
            attachments: vec![],
            subject: None,

            ..Default::default()
        };
        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),
            model_provider: Arc::new(SlowModelProvider {
                delay: Duration::from_millis(150),
            }),
            model_provider_ref: Arc::new("test-provider".to_string()),
            agent_alias: Arc::new("test-agent".to_string()),
            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
            memory: Arc::new(NoopMemory),
            memory_strategy: Arc::new(
                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
                    Arc::new(NoopMemory),
                    zeroclaw_config::schema::MemoryConfig::default(),
                    std::path::PathBuf::new(),
                ),
            ),
            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: Some(0.0),
            auto_save_memory: false,
            max_tool_iterations: 10,
            min_relevance_score: 0.0,
            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
            ))),
            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())),
            thinking_overrides: Arc::new(Mutex::new(HashMap::new())),
            scope_overrides: Arc::new(Mutex::new(HashMap::new())),
            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
            workspace_dir: Arc::new(std::env::temp_dir()),
            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
            interrupt_on_new_message: InterruptOnNewMessageConfig {
                telegram: false,
                slack: true,
                discord: false,
                mattermost: false,
                matrix: false,
                whatsapp: false,
            },
            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
            agent_transcription_provider: String::new(),
            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: zeroclaw_config::schema::QueryClassificationConfig::default(),
            ack_reactions: true,
            show_tool_calls: true,
            session_store: None,
            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
                &zeroclaw_config::schema::RiskProfileConfig::default(),
            )),
            activated_tools: None,
            cost_tracking: None,
            pacing: zeroclaw_config::schema::PacingConfig::default(),
            max_tool_result_chars: 0,
            context_token_budget: 0,
            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
                Duration::ZERO,
            )),
            receipt_generator: None,
            show_receipts_in_response: false,
            last_applied_config_stamp: Arc::new(Mutex::new(None)),
            runtime_defaults_override: Arc::new(Mutex::new(None)),
            persist_locks: Arc::new(std::sync::Mutex::new(HashMap::new())),
            sop_engine: None,
            sop_audit: None,
        });

        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
        let send_task = zeroclaw_spawn::spawn!(async move {
            // Two messages from same sender but in different Slack threads —
            // they must NOT cancel each other.
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "1741234567.100001".to_string(),
                sender: "alice".to_string(),
                reply_target: "C123".to_string(),
                content: "thread-a question".to_string(),
                channel: "slack".into(),
                channel_alias: None,
                timestamp: 1,
                thread_ts: Some("1741234567.100001".to_string()),
                interruption_scope_id: Some("1741234567.100001".to_string()),
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
            tokio::time::sleep(Duration::from_millis(30)).await;
            tx.send(zeroclaw_api::channel::ChannelMessage {
                id: "1741234567.200002".to_string(),
                sender: "alice".to_string(),
                reply_target: "C123".to_string(),
                content: "thread-b question".to_string(),
                channel: "slack".into(),
                channel_alias: None,
                timestamp: 2,
                thread_ts: Some("1741234567.200002".to_string()),
                interruption_scope_id: Some("1741234567.200002".to_string()),
                attachments: vec![],
                subject: None,

                ..Default::default()
            })
            .await
            .unwrap();
        });

        run_message_dispatch_loop(rx, AgentRouter::single(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"));
    }

    /// Regression test for a redaction bypass: an AWS-shaped credential
    /// dropped into a markdown link destination -- exactly where a
    /// prompt-injected model would try to exfiltrate one -- must not sail
    /// through unredacted just because a link destination is otherwise
    /// protected from the high-entropy heuristic.
    #[test]
    fn sanitize_channel_response_detects_aws_key_smuggled_in_markdown_link_destination() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        // AKIAABCDEFGHIJKLMNOP gitleaks:allow
        let target = "https://exfil.example.invalid/callback?key=AKIAABCDEFGHIJKLMNOP";

        let result = sanitize_channel_response(&format!("[callback]({target})"), &tools);

        assert!(!result.contains("AKIAABCDEFGHIJKLMNOP"), "result: {result}"); // gitleaks:allow
        assert!(
            result.contains("[REDACTED_AWS_CREDENTIAL]"),
            "result: {result}"
        );
    }

    // A protected link destination shields the *shape-based* high-entropy
    // heuristic (the shape false-positive), never a deterministic credential
    // pattern. A real credential dropped into a link destination -- exactly
    // where a prompt-injected model would try to smuggle one past the
    // detector -- must still be caught.

    #[test]
    fn sanitize_channel_response_still_detects_deterministic_credential_in_markdown_link_destination()
     {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "https://example.invalid/callback?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";

        let result = sanitize_channel_response(&format!("[callback]({target})"), &tools);

        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_deterministic_credential_in_markdown_reference_destination()
     {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "https://example.invalid/callback?api_key=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let response = format!("See [callback][cb]\n\n[cb]: {target}");

        let result = sanitize_channel_response(&response, &tools);

        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_deterministic_credential_in_entity_escaped_markdown_destination()
     {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target =
            "https://example.invalid/callback?x=1&amp;token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";

        let result = sanitize_channel_response(&format!("[callback]({target})"), &tools);

        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_credential_in_entity_destination_and_title() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target =
            "https://example.invalid/callback?x=1&amp;token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let title = "https://example.invalid/callback?x=1&token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";

        let result =
            sanitize_channel_response(&format!("[callback]({target} \"{title}\")"), &tools);

        // The destination span computation is still correct (used by the
        // entropy heuristic), but the deterministic "Token value" pattern is
        // not suppressed by it, so both the destination and the distinct
        // title copy are caught.
        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_credential_in_reference_destination_and_title() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target =
            "https://example.invalid/callback?x=1&amp;token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let title = "https://example.invalid/callback?x=1&token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let response = format!("See [callback][cb]\n\n[cb]: {target} \"{title}\"");

        let result = sanitize_channel_response(&response, &tools);

        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_credential_in_url_entity_destination_and_title() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target =
            "https&colon;&sol;&sol;example.invalid/callback?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let title = "https://example.invalid/callback?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";

        let result =
            sanitize_channel_response(&format!("[callback]({target} \"{title}\")"), &tools);

        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_bot_token_in_markdown_autolink_destination() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "https://api.telegram.org/bot123456:ABC-def_GHI/getUpdates";

        let result = sanitize_channel_response(&format!("<{target}>"), &tools);

        assert!(!result.contains("123456:ABC-def_GHI"), "result: {result}");
        assert!(result.contains("[REDACTED_BOT_TOKEN]"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_scans_markdown_link_text() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let token = "aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let target = "https://example.invalid/callback?ticket=public-id";

        let result = sanitize_channel_response(&format!("[token={token}]({target})"), &tools);

        assert!(!result.contains(token));
        assert!(result.contains(target), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_scans_link_text_secret_when_match_overlaps_destination() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "file:///tmp/report.md";

        let result =
            sanitize_channel_response(&format!("[password=longsecretvalue]({target})"), &tools);

        // The generic-secret pattern is greedy and, starting outside the
        // destination, can span into it; deterministic patterns are never
        // suppressed by a protected span, so the whole overlapping match is
        // redacted. That is the safe direction: losing an adjacent file
        // reference is preferable to leaking the secret it was next to.
        assert!(!result.contains("longsecretvalue"), "result: {result}");
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_scans_link_text_when_label_matches_destination() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";

        let result = sanitize_channel_response(&format!("[{target}]({target})"), &tools);

        assert!(
            !result.starts_with(&format!("[{target}]")),
            "result: {result}"
        );
        assert!(result.contains(&format!("]({target})")), "result: {result}");
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_credential_in_escaped_markdown_destination() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "file:///tmp/report\\(1\\).md?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";

        let result = sanitize_channel_response(&format!("[report]({target})"), &tools);

        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_credential_in_raw_file_uri() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "file:///tmp/report.md?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let outbound = format!("Recorded {target}.");

        // The entropy-protected span is still computed correctly...
        let spans = channel_outbound_protected_spans(&outbound, OutboundContentFormat::Markdown);
        assert_eq!(
            spans
                .iter()
                .map(|span| &outbound[span.start..span.end])
                .collect::<Vec<_>>(),
            vec![target],
        );
        // ...but the deterministic "Token value" pattern inside it is not
        // suppressed by that protection.
        let result = sanitize_channel_response(&outbound, &tools);

        assert!(
            result.contains("file:///tmp/report.md?"),
            "result: {result}"
        );
        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_credential_in_keyed_raw_file_uri() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "file:///tmp/report.md?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let outbound = format!("Recorded path={target}.");

        let spans = channel_outbound_protected_spans(&outbound, OutboundContentFormat::Markdown);
        assert_eq!(
            spans
                .iter()
                .map(|span| &outbound[span.start..span.end])
                .collect::<Vec<_>>(),
            vec![target],
        );
        let result = sanitize_channel_response(&outbound, &tools);

        assert!(
            result.contains("path=file:///tmp/report.md?"),
            "result: {result}"
        );
        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn sanitize_channel_response_still_detects_credential_in_json_keyed_raw_file_uri() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let target = "file:///tmp/report.md?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let outbound = format!(r#"{{"uri":"{target}"}}"#);

        let spans = channel_outbound_protected_spans(&outbound, OutboundContentFormat::Markdown);
        assert_eq!(
            spans
                .iter()
                .map(|span| &outbound[span.start..span.end])
                .collect::<Vec<_>>(),
            vec![target],
        );
        let result = sanitize_channel_response(&outbound, &tools);

        assert!(
            result.contains(r#""uri":"file:///tmp/report.md?"#),
            "result: {result}"
        );
        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[test]
    fn plain_text_leak_guard_preserves_raw_file_uri_filenames() {
        let target = "file:///home/zeroclaw/.zeroclaw/agents/mission-orchestrator/workspace/tasks/inbox/2026-07-02-11-26-plan-b-for-something-useful.md";
        let outbound = format!("Recorded {target}.");

        let result = redact_channel_outbound_leaks(
            &outbound,
            &zeroclaw_config::schema::LeakDetectionConfig::default(),
            OutboundContentFormat::PlainText,
        );

        assert_eq!(result, outbound);
    }

    #[test]
    fn irc_family_outbound_format_is_plain_text() {
        assert_eq!(
            outbound_content_format_for_channel("irc.default"),
            OutboundContentFormat::PlainText
        );
        assert_eq!(
            outbound_content_format_for_channel("twitch.default"),
            OutboundContentFormat::PlainText
        );
        assert_eq!(
            outbound_content_format_for_channel("twitch"),
            OutboundContentFormat::PlainText
        );
        assert_eq!(
            outbound_content_format_for_channel("telegram.default"),
            OutboundContentFormat::Markdown
        );
    }

    #[test]
    fn sanitize_channel_response_respects_disabled_leak_detection() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let leaked = "Temporary key: AKIAABCDEFGHIJKLMNOP"; // gitleaks:allow
        let leak_detection = zeroclaw_config::schema::LeakDetectionConfig {
            enabled: false,
            ..Default::default()
        };

        let result = sanitize_channel_response_with_leak_detection(leaked, &tools, &leak_detection);

        assert_eq!(result, leaked);
    }

    #[test]
    fn leak_only_guard_preserves_protocol_looking_announcement_text() {
        let input = "[Used tools: shell]\n\nCron output completed.";

        let result = redact_channel_outbound_leaks(
            input,
            &zeroclaw_config::schema::LeakDetectionConfig::default(),
            OutboundContentFormat::Markdown,
        );

        assert_eq!(result, input);
    }

    #[test]
    fn leak_only_guard_still_detects_credential_in_raw_file_uri() {
        let target = "file:///tmp/report.md?token=aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
        let input = format!("Cron output: {target}");

        let result = redact_channel_outbound_leaks(
            &input,
            &zeroclaw_config::schema::LeakDetectionConfig::default(),
            OutboundContentFormat::Markdown,
        );

        assert!(
            result.contains("file:///tmp/report.md?"),
            "result: {result}"
        );
        assert!(
            !result.contains("aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG"),
            "result: {result}"
        );
        assert!(result.contains("[REDACTED"), "result: {result}");
    }

    #[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);
    }

    #[test]
    fn sanitize_channel_response_preserves_schema_json_array_without_tools() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let schema = r#"[{"name":"planner","parameters":{"goal":"string"}}]"#;

        let result = sanitize_channel_response(schema, &tools);

        assert_eq!(result, schema);
    }

    #[test]
    fn sanitize_channel_response_preserves_tool_calls_audit_json() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let audit_json =
            r#"{"tool_calls":[{"id":"case-1","status":"queued","service":"billing"}]}"#;

        let result = sanitize_channel_response(audit_json, &tools);

        assert_eq!(result, audit_json);
    }

    #[test]
    fn sanitize_channel_response_preserves_reference_function_call_json_without_tools() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let reference_json =
            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;

        let result = sanitize_channel_response(reference_json, &tools);

        assert_eq!(result, reference_json);
    }

    #[test]
    fn sanitize_channel_response_preserves_reference_function_call_json_with_tools() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let reference_json =
            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;

        let result = sanitize_channel_response(reference_json, &tools);

        assert_eq!(result, reference_json);
    }

    #[test]
    fn sanitize_channel_response_preserves_unknown_tool_calls_json_with_tools() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let business_json = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}]}"#;

        let result = sanitize_channel_response(business_json, &tools);

        assert_eq!(result, business_json);
    }

    #[test]
    fn sanitize_channel_response_preserves_malformed_unknown_tool_calls_json_with_tools() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let business_json = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}"#;

        let result = sanitize_channel_response(business_json, &tools);

        assert_eq!(result, business_json);
    }

    #[test]
    fn sanitize_channel_response_preserves_json_fenced_tool_protocol_example() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let example = r#"Here is a protocol example:
```json
{"tool_calls":[{"name":"shell","arguments":{"command":"pwd"}}]}
```"#;

        let result = sanitize_channel_response(example, &tools);

        assert_eq!(result, example);
    }

    #[test]
    fn sanitize_channel_response_removes_registered_tool_json_array() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let internal = r#"[{"name":"mock_price","parameters":{"symbol":"BTC"}}]"#;

        let result = sanitize_channel_response(internal, &tools);

        assert_eq!(result, "");
    }

    #[test]
    fn sanitize_channel_response_removes_internal_tool_protocol_envelopes() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let internal = r#"{"toolcalls":[{"name":"mock_price","arguments":{"symbol":"BTC"}}]}"#;

        let result = sanitize_channel_response(internal, &tools);

        assert_eq!(result, "");
    }

    #[test]
    fn sanitize_channel_response_removes_json_fenced_internal_tool_protocol() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let internal = r#"```json
{"tool_calls":[{"name":"mock_price","arguments":{"symbol":"BTC"}}]}
```"#;

        let result = sanitize_channel_response(internal, &tools);

        assert_eq!(result, "");
    }

    #[test]
    fn sanitize_channel_response_removes_embedded_json_fenced_internal_tool_protocol() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let response = r#"Intro
```json
{"tool_calls":[{"name":"mock_price","arguments":{"symbol":"BTC"}}]}
```
Done."#;

        let result = sanitize_channel_response(response, &tools);

        assert!(result.contains("Intro"));
        assert!(result.contains("Done."));
        assert!(!result.contains("tool_calls"));
        assert!(!result.contains("mock_price"));
    }

    #[test]
    fn sanitize_channel_response_removes_embedded_tool_call_fence() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let response = r#"Let me call it:
```tool_call
{"name":"shell","arguments":{"command":"pwd"}}
```
Done."#;

        let result = sanitize_channel_response(response, &tools);

        assert!(result.contains("Done."));
        assert!(!result.contains("tool_call"));
        assert!(!result.contains("shell"));
        assert!(!result.contains("command"));
    }

    #[test]
    fn sanitize_channel_response_preserves_tool_call_fenced_example() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let example = r#"```tool_call
{"name":"shell","arguments":{"command":"pwd"}}
```
This is an example, not an invocation."#;

        let result = sanitize_channel_response(example, &tools);

        assert_eq!(result, example);
    }

    #[test]
    fn sanitize_channel_response_removes_standalone_tool_call_fence() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let internal = r#"```tool_call
{"name":"shell","arguments":{"command":"pwd"}}
```"#;

        let result = sanitize_channel_response(internal, &tools);

        assert_eq!(result, "");
    }

    #[test]
    fn sanitize_channel_response_removes_standalone_tool_name_fence() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let internal = r#"```tool shell
{"command":"pwd"}
```"#;

        let result = sanitize_channel_response(internal, &tools);

        assert_eq!(result, "");
    }

    #[test]
    fn sanitize_channel_response_preserves_tool_call_tag_example() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let example = r#"<tool_call>
{"name":"shell","arguments":{"command":"pwd"}}
</tool_call>
This is an example, not an invocation."#;

        let result = sanitize_channel_response(example, &tools);

        assert_eq!(result, example);
    }

    #[test]
    fn sanitize_channel_response_strips_tagged_tool_call_before_trailing_text() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let response = r#"<tool_call>
{"name":"shell","arguments":{"command":"pwd"}}
</tool_call>
Done."#;

        let result = sanitize_channel_response(response, &tools);

        assert_eq!(result, "Done.");
    }

    #[test]
    fn sanitize_channel_response_removes_malformed_top_level_protocol() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let internal = r#"{"tool_call_id":"call_1","content":"raw"#;

        let result = sanitize_channel_response(internal, &tools);

        assert_eq!(result, "");
    }

    #[test]
    fn sanitize_channel_response_removes_embedded_malformed_protocol_json() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let response =
            "Intro\n{\"tool_calls\":[{\"call_id\":\"call_1\",\"arguments\":{\"value\":\"X\"}\nDone";

        let result = sanitize_channel_response(response, &tools);

        assert!(result.contains("Intro"));
        assert!(result.contains("Done"));
        assert!(!result.contains("tool_calls"));
        assert!(!result.contains("arguments"));
    }

    #[test]
    fn sanitize_channel_response_removes_multiline_embedded_malformed_protocol_json() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let response = "Intro\n{\n  \"tool_calls\": [{\"call_id\":\"call_1\",\"arguments\":{\"value\":\"X\"}}\nDone";

        let result = sanitize_channel_response(response, &tools);

        assert!(result.contains("Intro"));
        assert!(result.contains("Done"));
        assert!(!result.contains("tool_calls"));
        assert!(!result.contains("arguments"));
    }

    #[test]
    fn sanitize_channel_response_keeps_protocol_explanation_text() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let explanation =
            "A markdown block starting with ```tool can be used in protocol examples.";

        let result = sanitize_channel_response(explanation, &tools);

        assert_eq!(result, explanation);
    }

    #[test]
    fn sanitize_channel_response_keeps_safe_protocol_envelope_content_with_tools() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let response = "Intro text\n{\"content\":\"A markdown block starting with ```tool can be used in examples.\",\"tool_calls\":[{\"name\":\"mock_price\",\"arguments\":{\"symbol\":\"BTC\"}}]}\nDone.";

        let result = sanitize_channel_response(response, &tools);

        assert!(result.contains("Intro text"));
        assert!(result.contains("A markdown block starting with ```tool"));
        assert!(result.contains("Done."));
        assert!(!result.contains("tool_calls"));
    }

    #[test]
    fn sanitize_channel_response_removes_isolated_tool_result_envelope_content_with_tools() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let response =
            "Intro text\n{\"tool_call_id\":\"call_1\",\"content\":\"raw tool output\"}\nDone.";

        let result = sanitize_channel_response(response, &tools);

        assert!(result.contains("Intro text"));
        assert!(result.contains("Done."));
        assert!(!result.contains("tool_call_id"));
        assert!(!result.contains("raw tool output"));
    }

    #[test]
    fn sanitize_channel_response_removes_nested_protocol_content_with_tools() {
        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
        let response = "Intro text\n{\"content\":\"{\\\"toolcalls\\\":[{\\\"name\\\":\\\"mock_price\\\",\\\"arguments\\\":{\\\"symbol\\\":\\\"BTC\\\"}}]}\",\"tool_calls\":[{\"name\":\"mock_price\",\"arguments\":{\"symbol\":\"BTC\"}}]}\nDone.";

        let result = sanitize_channel_response(response, &tools);

        assert!(result.contains("Intro text"));
        assert!(result.contains("Done."));
        assert!(!result.contains("toolcalls"));
        assert!(!result.contains("shell"));
    }

    #[test]
    fn sanitize_channel_response_strips_xml_tool_result_blocks() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let input = "<tool_result>\n{\"results\":[]}\n</tool_result>\n<tool_result>\n{\"command\":\"ls\",\"exit_code\":0}\n</tool_result>Here is what I found.";

        let result = sanitize_channel_response(input, &tools);

        assert!(!result.contains("tool_result"));
        assert!(!result.contains("exit_code"));
        assert!(result.contains("Here is what I found."));
    }

    #[test]
    fn sanitize_channel_response_strips_mixed_tool_result_and_text() {
        let tools: Vec<Box<dyn Tool>> = Vec::new();
        let input = "Let me check.\n<tool_result name=\"shell\">\noutput here\n</tool_result>\nThe answer is 42.";

        let result = sanitize_channel_response(input, &tools);

        assert!(!result.contains("<tool_result"));
        assert!(!result.contains("output here"));
        assert!(result.contains("The answer is 42."));
    }

    // ── Tests for strip_think_tags_inline (streaming draft sanitization) ──

    #[test]
    fn strip_think_tags_inline_removes_single_block() {
        assert_eq!(
            strip_think_tags_inline("<think>reasoning</think>Hello"),
            "Hello"
        );
    }

    #[test]
    fn strip_think_tags_inline_removes_multiple_blocks() {
        assert_eq!(
            strip_think_tags_inline("<think>a</think>X<think>b</think>Y"),
            "XY"
        );
    }

    #[test]
    fn strip_think_tags_inline_handles_unclosed_block() {
        assert_eq!(
            strip_think_tags_inline("visible<think>hidden tail"),
            "visible"
        );
    }

    #[test]
    fn strip_think_tags_inline_preserves_text_without_tags() {
        assert_eq!(strip_think_tags_inline("plain text"), "plain text");
    }

    #[test]
    fn strip_think_tags_inline_handles_empty_string() {
        assert_eq!(strip_think_tags_inline(""), "");
    }

    #[test]
    fn strip_think_tags_inline_strips_surrounding_whitespace() {
        assert_eq!(
            strip_think_tags_inline("<think>hidden</think>  Answer  "),
            "Answer"
        );
    }

    // ── Tests 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 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 = zeroclaw_config::schema::AliasedAgentConfig::default();
        assert_eq!(config.resolved.keep_tool_context_turns, 2);
    }

    #[test]
    fn build_channel_system_prompt_excludes_volatile_fields() {
        let prompt =
            build_channel_system_prompt("You are a helpful assistant.", "mattermost", None);
        assert!(
            !prompt.contains("reply_target="),
            "system prompt must not include reply_target; got {prompt}"
        );
        assert!(
            !prompt.contains("sender="),
            "system prompt must not include sender=; got {prompt}"
        );
        assert!(
            !prompt.contains("message_id="),
            "system prompt must not include message_id=; got {prompt}"
        );
        assert!(
            !prompt.contains("Channel context:"),
            "system prompt must not include the legacy Channel context block; got {prompt}"
        );
        assert!(
            !prompt.contains("delivery="),
            "system prompt must not include the cron_add delivery hint; got {prompt}"
        );
    }

    // --- Surface 1(a) regression tests ---

    fn build_msg_for_signal_test() -> zeroclaw_api::channel::ChannelMessage {
        zeroclaw_api::channel::ChannelMessage {
            channel: "mattermost".to_string(),
            reply_target: "ch:thread".to_string(),
            sender: "user_test".to_string(),
            content: "hello".to_string(),
            id: "msg-001".to_string(),
            ..Default::default()
        }
    }

    #[test]
    fn channel_prompt_reflects_per_turn_signal_false_uses_no_tools_framing() {
        let base_prompt_with_native = format!(
            "{}\n{}",
            "Some base agent prompt.",
            ::zeroclaw_runtime::agent::system_prompt::NATIVE_TOOLS_TASK_FRAMING,
        );
        let msg = build_msg_for_signal_test();
        let prompt = build_channel_system_prompt_for_message_with_signal(
            &base_prompt_with_native,
            &msg,
            None,
            false, // per-turn: no effective native specs
        );
        assert!(
            prompt.contains(::zeroclaw_runtime::agent::system_prompt::NO_TOOLS_TASK_FRAMING),
            "per-turn signal=false must inject NO_TOOLS_TASK_FRAMING; got: {prompt}"
        );
        assert!(
            !prompt.contains(::zeroclaw_runtime::agent::system_prompt::NATIVE_TOOLS_TASK_FRAMING),
            "per-turn signal=false must remove NATIVE_TOOLS_TASK_FRAMING; got: {prompt}"
        );
    }

    #[test]
    fn channel_prompt_keeps_startup_signal_when_per_turn_agrees() {
        let base_prompt_with_no_tools = format!(
            "{}\n{}",
            "Some base agent prompt.",
            ::zeroclaw_runtime::agent::system_prompt::NO_TOOLS_TASK_FRAMING,
        );
        let msg = build_msg_for_signal_test();
        let prompt_with_helper = build_channel_system_prompt_for_message_with_signal(
            &base_prompt_with_no_tools,
            &msg,
            None,
            false, // matches the anchor already in the base prompt
        );
        let prompt_baseline =
            build_channel_system_prompt_for_message(&base_prompt_with_no_tools, &msg, None);
        assert_eq!(
            prompt_with_helper, prompt_baseline,
            "per-turn signal matching the startup anchor must produce an identical prompt"
        );
    }

    #[test]
    fn channel_prompt_rewrites_no_tools_to_native_when_per_turn_differs() {
        let base_prompt_with_no_tools = format!(
            "{}\n{}",
            "Some base agent prompt.",
            ::zeroclaw_runtime::agent::system_prompt::NO_TOOLS_TASK_FRAMING,
        );
        let msg = build_msg_for_signal_test();
        let prompt = build_channel_system_prompt_for_message_with_signal(
            &base_prompt_with_no_tools,
            &msg,
            None,
            true, // per-turn: native specs now present
        );
        assert!(
            prompt.contains(::zeroclaw_runtime::agent::system_prompt::NATIVE_TOOLS_TASK_FRAMING),
            "per-turn signal=true must inject NATIVE_TOOLS_TASK_FRAMING; got: {prompt}"
        );
        assert!(
            !prompt.contains(::zeroclaw_runtime::agent::system_prompt::NO_TOOLS_TASK_FRAMING),
            "per-turn signal=true must remove NO_TOOLS_TASK_FRAMING; got: {prompt}"
        );
    }

    #[test]
    fn channel_prompt_no_op_when_anchor_absent() {
        let base_prompt_no_anchor = "Custom agent prompt with no tool-availability anchor.";
        let msg = build_msg_for_signal_test();
        let prompt_with_helper = build_channel_system_prompt_for_message_with_signal(
            base_prompt_no_anchor,
            &msg,
            None,
            true,
        );
        let prompt_baseline =
            build_channel_system_prompt_for_message(base_prompt_no_anchor, &msg, None);
        assert_eq!(
            prompt_with_helper, prompt_baseline,
            "no-anchor base prompt must produce an identical prompt regardless of per-turn signal"
        );
    }

    #[test]
    fn build_channel_system_prompt_byte_stable_across_sender() {
        // Two system prompts built with the same base/channel/bot_mention
        // but conceptually different (hypothetical) senders MUST be
        // byte-identical. Sender disambiguation now lives in the preamble.
        let prompt_a = build_channel_system_prompt("Base.", "mattermost", None);
        let prompt_b = build_channel_system_prompt("Base.", "mattermost", None);
        assert_eq!(
            prompt_a, prompt_b,
            "system prompt must be byte-stable regardless of per-turn sender"
        );
    }

    #[test]
    fn build_channel_system_prompt_refreshes_legacy_datetime_section_to_date_only() {
        let prompt = build_channel_system_prompt(
            "Base.\n\n## Current Date\n\nProject note, not generated date context.\n\n## Current Date & Time\n\n2026-01-01 01:02:03 (UTC)\n\n## Runtime\n\nHost: old\n",
            "mattermost",
            None,
        );

        assert!(prompt.contains("## Current Date\n\n"));
        assert!(prompt.contains("Project note, not generated date context."));
        assert!(!prompt.contains("## Current Date & Time"));
        assert!(!prompt.contains("01:02:03"));
        let generated_section = prompt
            .split("## Runtime")
            .next()
            .expect("prompt should contain runtime section before generated date assertion");
        let date_line = generated_section
            .rsplit("## Current Date\n\n")
            .next()
            .and_then(|rest| rest.lines().next())
            .expect("current date section should have a date line");
        assert_eq!(
            &date_line[..10],
            &chrono::Local::now().format("%Y-%m-%d").to_string()
        );
        assert!(
            date_line[10..].starts_with(" ("),
            "date line should contain only date plus UTC offset: {date_line}"
        );
    }

    #[test]
    fn build_channel_system_prompt_refreshes_current_date_section() {
        let prompt = build_channel_system_prompt(
            "Base.\n\n## Current Date\n\n2026-01-01 (+00:00)\n\n## Runtime\n\nHost: old\n",
            "mattermost",
            None,
        );

        assert!(prompt.contains("## Current Date\n\n"));
        assert!(!prompt.contains("2026-01-01 (+00:00)"));
        let date_line = prompt
            .split("## Current Date\n\n")
            .nth(1)
            .and_then(|rest| rest.lines().next())
            .expect("current date section should have a date line");
        assert_eq!(
            &date_line[..10],
            &chrono::Local::now().format("%Y-%m-%d").to_string()
        );
    }

    #[test]
    fn build_channel_turn_context_preamble_empty_when_reply_target_empty() {
        // CLI-style path: when there is no channel recipient, no preamble
        // is needed. Mirrors CLI behaviour where no per-turn context block
        // is added.
        let msg = zeroclaw_api::channel::ChannelMessage {
            channel: "telegram".into(),
            reply_target: String::new(),
            sender: "alice".into(),
            id: "msg-1".into(),
            ..Default::default()
        };
        let preamble = build_channel_turn_context_preamble(&msg, None);
        assert_eq!(
            preamble, "",
            "CLI-style empty reply_target must yield no preamble"
        );
    }

    #[test]
    fn build_channel_turn_context_preamble_carries_volatile_fields() {
        // Every per-turn field the system prompt used to carry lives in the
        // preamble now. Pin the comma-separated tuple so a refactor that
        // splits or rewords it fails loudly.
        let msg = zeroclaw_api::channel::ChannelMessage {
            channel: "telegram".into(),
            reply_target: "chat:42".into(),
            sender: "alice".into(),
            id: "msg-xyz789".into(),
            ..Default::default()
        };
        let preamble = build_channel_turn_context_preamble(&msg, None);

        assert!(
            preamble.contains("[turn-context]"),
            "preamble must start with the [turn-context] marker: {preamble}"
        );
        assert!(
            preamble.contains("channel=telegram"),
            "preamble must carry the channel name: {preamble}"
        );
        assert!(
            preamble.contains("reply_target=chat:42"),
            "preamble must carry reply_target: {preamble}"
        );
        assert!(
            preamble.contains("sender=alice"),
            "preamble must carry sender (for disambiguation): {preamble}"
        );
        assert!(
            preamble.contains("message_id=msg-xyz789"),
            "preamble must carry message_id (for the reaction tool): {preamble}"
        );
        assert!(
            preamble.contains("\"to\":\"chat:42\""),
            "preamble must carry the cron_add delivery hint with reply_target as `to`: {preamble}"
        );
        assert!(
            !preamble.contains("\"thread_id\""),
            "non-webhook preamble must not emit thread_id: {preamble}"
        );
    }

    #[test]
    fn compose_outgoing_user_turn_with_context_orders_preamble_content() {
        // Order on the wire: preamble -> raw user content, joined by blank
        // lines (the memory block is engine-injected ABOVE the whole turn).
        // Empty preamble leaves the raw content untouched (CLI-style path).
        assert_eq!(
            compose_outgoing_user_turn_with_context("", "hello"),
            "hello"
        );
        assert_eq!(
            compose_outgoing_user_turn_with_context("[turn-context] x\n\n", "hello"),
            "[turn-context] x\n\n\n\nhello"
        );
    }

    // ─── endpreamble tests ─────────────────────────────────────

    #[test]
    fn build_channel_system_prompt_for_message_omits_volatile_fields() {
        // The wrapper now unpacks only the channel-name and bot_mention
        // from the ChannelMessage into `build_channel_system_prompt`. The
        // volatile per-turn fields (reply_target, sender, message_id) live
        // in the turn-context preamble, not here. See
        let msg = channel_message("discord", None);
        let prompt = build_channel_system_prompt_for_message("Base.", &msg, None);
        assert!(
            !prompt.contains("reply_target="),
            "system prompt must not carry reply_target: {prompt}"
        );
        assert!(
            !prompt.contains("sender="),
            "system prompt must not carry sender=: {prompt}"
        );
        assert!(
            !prompt.contains("message_id="),
            "system prompt must not carry message_id=: {prompt}"
        );
        assert!(
            !prompt.contains("Channel context:"),
            "system prompt must not carry the legacy Channel context block: {prompt}"
        );
    }

    #[test]
    fn build_channel_turn_context_preamble_webhook_cron_hint_carries_thread_id() {
        let msg = zeroclaw_api::channel::ChannelMessage {
            channel: "webhook".into(),
            reply_target: "agent-chat:agent-1:thread-7".into(),
            sender: "user:abc".into(),
            id: "msg-1".into(),
            ..Default::default()
        };
        let preamble = build_channel_turn_context_preamble(&msg, None);
        assert!(
            preamble.contains("\"to\":\"user:abc\""),
            "webhook cron hint must use sender as `to`: {preamble}"
        );
        assert!(
            preamble.contains("\"thread_id\":\"agent-chat:agent-1:thread-7\""),
            "webhook cron hint must carry the reply_target as `thread_id`: {preamble}"
        );
        assert!(
            !preamble.contains("\"to\":\"agent-chat:agent-1:thread-7\""),
            "webhook cron hint must not put the thread id in `to`: {preamble}"
        );
    }

    #[test]
    fn build_channel_turn_context_preamble_non_webhook_cron_hint_keeps_to_as_reply_target() {
        let msg = zeroclaw_api::channel::ChannelMessage {
            channel: "slack".into(),
            reply_target: "C12345".into(),
            sender: "U67890".into(),
            id: "msg-1".into(),
            ..Default::default()
        };
        let preamble = build_channel_turn_context_preamble(&msg, None);
        assert!(
            preamble.contains("\"to\":\"C12345\""),
            "non-webhook cron hint should keep reply_target as `to`: {preamble}"
        );
        assert!(
            !preamble.contains("\"thread_id\""),
            "non-webhook cron hint should not emit a thread_id field: {preamble}"
        );
    }

    #[tokio::test]
    #[cfg(feature = "channel-discord")]
    async fn one_off_send_resolves_dotted_discord_alias() {
        let config = zeroclaw_config::schema::Config::default();

        let err = send_channel_message(&config, "discord.governance", "123456789", "test message")
            .await
            .expect_err("unconfigured alias should fail after dotted ref resolution");
        let message = format!("{err:#}");
        assert!(
            message.contains("[channels.discord.governance] not configured"),
            "dotted alias should reach named channel resolution; got: {message}"
        );
    }

    #[tokio::test]
    #[cfg(feature = "channel-linq")]
    async fn one_off_send_keeps_dotted_linq_alias_on_builder() {
        // `linq.<alias>` predates the announcement delegation and is resolved by
        // the one-off builder itself. Delegating every dotted id would route it
        // to an arm that does not exist, so assert it still lands on the
        // builder's own alias lookup rather than the dispatcher's reject path.
        let config = zeroclaw_config::schema::Config::default();

        let err = send_channel_message(&config, "linq.governance", "+15550100", "test message")
            .await
            .expect_err("unconfigured alias should fail at the builder's linq arm");
        let message = format!("{err:#}");
        assert!(
            message.contains("Linq alias 'governance' not configured"),
            "dotted linq id should stay on the one-off builder; got: {message}"
        );
        assert!(
            !message.contains("unsupported delivery channel"),
            "dotted linq id must not be delegated to deliver_announcement; got: {message}"
        );
    }

    #[tokio::test]
    #[cfg(feature = "channel-lark")]
    async fn deliver_announcement_routes_lark_to_lark_arm() {
        // Both names must enter the merged lark|feishu arm. Falling through
        // to `unsupported delivery channel` would mean the schema enum and
        // the match arm have drifted apart.
        let config = zeroclaw_config::schema::Config::default();

        for channel in ["lark.default", "feishu.default"] {
            let err = deliver_announcement(&config, channel, "oc_test_chat", None, "hi")
                .await
                .err()
                .unwrap_or_else(|| {
                    panic!("expected {channel} to bail because channel is not configured")
                });
            let msg = format!("{err:#}");
            assert!(
                !msg.contains("unsupported delivery channel"),
                "{channel} must route to lark|feishu arm, not fall through; got: {msg}"
            );
            assert!(
                msg.contains("[channels.lark.default] not configured"),
                "{channel} must report the real config table [channels.lark.default]; got: {msg}"
            );
        }
    }

    #[tokio::test]
    #[cfg(feature = "channel-email")]
    async fn deliver_announcement_routes_email_to_email_arm() {
        let config = zeroclaw_config::schema::Config::default();

        let err = deliver_announcement(&config, "email.default", "user@example.com", None, "hi")
            .await
            .expect_err("expected email.default to bail because channel is not configured");
        let msg = format!("{err:#}");
        assert!(
            !msg.contains("unsupported delivery channel"),
            "email.default must route to the email arm, not fall through; got: {msg}"
        );
        assert!(
            msg.contains("[channels.email.default] not configured"),
            "email.default must report the real config table; got: {msg}"
        );
    }

    #[tokio::test]
    #[cfg(feature = "whatsapp-web")]
    async fn deliver_announcement_routes_whatsapp_to_whatsapp_arm() {
        let config = zeroclaw_config::schema::Config::default();

        let err = deliver_announcement(&config, "whatsapp.default", "+15551234567", None, "hi")
            .await
            .expect_err("expected whatsapp.default to bail because channel is not configured");
        let msg = format!("{err:#}");
        assert!(
            !msg.contains("unsupported delivery channel"),
            "whatsapp.default must route to the whatsapp arm, not fall through; got: {msg}"
        );
        assert!(
            msg.contains("[channels.whatsapp.default] not configured"),
            "whatsapp.default must report the real config table; got: {msg}"
        );
    }

    #[tokio::test]
    #[cfg(feature = "whatsapp-web")]
    async fn deliver_announcement_rejects_whatsapp_non_web_config_clearly() {
        let mut config = zeroclaw_config::schema::Config::default();
        config.channels.whatsapp.insert(
            "default".to_string(),
            zeroclaw_config::schema::WhatsAppConfig {
                enabled: true,
                access_token: Some("test-token".to_string()),
                phone_number_id: Some("phone-number-id".to_string()),
                verify_token: Some("verify-token".to_string()),
                ..Default::default()
            },
        );

        let err = deliver_announcement(&config, "whatsapp.default", "+15551234567", None, "hi")
            .await
            .expect_err("expected WhatsApp Cloud config to be rejected for cron delivery");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("WhatsApp channel send requires Web mode"),
            "whatsapp.default must clearly explain the Web mode requirement; got: {msg}"
        );
        assert!(
            msg.contains("session_path")
                && msg.contains("pair_phone")
                && msg.contains("mode = personal"),
            "whatsapp.default must name the Web selectors accepted by cron delivery; got: {msg}"
        );
        assert!(
            !msg.contains("unsupported delivery channel")
                && !msg.contains("[channels.whatsapp.default] not configured"),
            "whatsapp.default must reject the configured non-Web mode, not fall through; got: {msg}"
        );
    }

    #[tokio::test]
    #[cfg(feature = "channel-lark")]
    async fn deliver_announcement_rejects_feishu_value_when_use_feishu_false() {
        // Reject (not warn): otherwise the message silently lands on the
        // Lark endpoint despite the user explicitly naming Feishu.
        let mut config = zeroclaw_config::schema::Config::default();
        config.channels.lark.insert(
            "work".to_string(),
            zeroclaw_config::schema::LarkConfig {
                enabled: true,
                use_feishu: false,
                app_id: "cli_test".to_string(),
                app_secret: "secret".to_string(),
                approval_timeout_secs: 300,
                per_user_session: false,
                ack_reactions: None,
                ..Default::default()
            },
        );

        let err = deliver_announcement(&config, "feishu.work", "oc_test_chat", None, "hi")
            .await
            .expect_err("expected bail when channel=feishu but use_feishu=false");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("use_feishu=false"),
            "bail must explain the use_feishu mismatch; got: {msg}"
        );
        assert!(
            msg.contains("[channels.lark.work]"),
            "bail must point at the real config table; got: {msg}"
        );
    }

    fn email_msg(id: &str, subject: Option<&str>) -> ChannelMessage {
        ChannelMessage {
            subject: subject.map(Into::into),
            ..ChannelMessage::new(
                id,
                "user@example.com",
                "user@example.com",
                "Hello",
                "email",
                0,
            )
        }
    }

    #[test]
    fn reply_to_sets_in_reply_to_and_re_subject() {
        let msg = email_msg("<abc123@mail.example>", Some("Weekly report"));
        let sm = SendMessage::reply_to(&msg, "Here is the answer");
        assert_eq!(sm.in_reply_to.as_deref(), Some("<abc123@mail.example>"));
        assert_eq!(sm.subject.as_deref(), Some("Re: Weekly report"));
    }

    #[test]
    fn reply_to_does_not_double_re_prefix() {
        let msg = email_msg("<abc123@mail.example>", Some("Re: Weekly report"));
        let sm = SendMessage::reply_to(&msg, "Here is the answer");
        assert_eq!(sm.subject.as_deref(), Some("Re: Weekly report"));
    }

    #[test]
    fn reply_to_no_subject_still_sets_in_reply_to() {
        let msg = email_msg("<abc123@mail.example>", None);
        let sm = SendMessage::reply_to(&msg, "Here is the answer");
        assert_eq!(sm.in_reply_to.as_deref(), Some("<abc123@mail.example>"));
        assert!(sm.subject.is_none());
    }

    /// Router with no SOP engine wired. `dispatch_channel_sop_event` decides
    /// whether a message is a channel SOP event before it ever touches the
    /// engine, so this is enough to exercise the source/dispatch boundary.
    fn router_without_sop_engine() -> AgentRouter {
        AgentRouter {
            by_agent: Arc::new(HashMap::new()),
            owner_by_channel_key: Arc::new(HashMap::new()),
            single_ctx: None,
            sop_engine: None,
            sop_audit: None,
        }
    }

    fn manual_sop_event() -> zeroclaw_runtime::sop::types::SopEvent {
        zeroclaw_runtime::sop::types::SopEvent {
            source: zeroclaw_runtime::sop::types::SopTriggerSource::Manual,
            topic: None,
            payload: None,
            timestamp: "2026-01-01T00:00:00Z".to_string(),
        }
    }

    fn channel_gate_sop(policy: Option<&str>) -> zeroclaw_runtime::sop::types::Sop {
        use zeroclaw_runtime::sop::types::{
            Sop, SopAdmissionPolicy, SopExecutionMode, SopPriority, SopStep, SopStepKind,
            SopTrigger,
        };

        Sop {
            name: "channel-gate".to_string(),
            description: "channel gate test".to_string(),
            version: "1.0.0".to_string(),
            priority: SopPriority::Normal,
            execution_mode: SopExecutionMode::Supervised,
            triggers: vec![SopTrigger::Manual],
            steps: vec![SopStep {
                number: 1,
                title: "Approve me".to_string(),
                body: "Do the gated work".to_string(),
                suggested_tools: vec![],
                requires_confirmation: false,
                kind: SopStepKind::Execute,
                schema: None,
                policy: policy.map(str::to_string),
                ..SopStep::default()
            }],
            cooldown_secs: 0,
            max_concurrent: 1,
            location: None,
            deterministic: false,
            admission_policy: SopAdmissionPolicy::Parallel,
            max_pending_approvals: 0,
            agent: None,
        }
    }

    fn channel_gate_config_with_routes(
        request_route: Option<&str>,
        escalation_route: Option<&str>,
    ) -> zeroclaw_config::schema::SopConfig {
        let mut config = zeroclaw_config::schema::SopConfig::default();
        if request_route.is_some() || escalation_route.is_some() {
            config.approval.policies.insert(
                "prod".to_string(),
                zeroclaw_config::schema::ApprovalPolicyConfig {
                    request_route: request_route.map(str::to_string),
                    escalation_route: escalation_route.map(str::to_string),
                    ..zeroclaw_config::schema::ApprovalPolicyConfig::default()
                },
            );
        }
        config
    }

    fn parked_channel_gate_router(
        policy: Option<&str>,
        request_route: Option<&str>,
    ) -> (
        AgentRouter,
        Arc<Mutex<zeroclaw_runtime::sop::SopEngine>>,
        String,
    ) {
        parked_channel_gate_router_with_routes(policy, request_route, None)
    }

    fn parked_channel_gate_router_with_routes(
        policy: Option<&str>,
        request_route: Option<&str>,
        escalation_route: Option<&str>,
    ) -> (
        AgentRouter,
        Arc<Mutex<zeroclaw_runtime::sop::SopEngine>>,
        String,
    ) {
        let mut engine = zeroclaw_runtime::sop::SopEngine::new(channel_gate_config_with_routes(
            request_route,
            escalation_route,
        ));
        engine.set_sops_for_test(vec![channel_gate_sop(policy)]);
        let action = engine
            .start_run("channel-gate", manual_sop_event())
            .unwrap();
        let run_id = match action {
            zeroclaw_runtime::sop::types::SopRunAction::WaitApproval { run_id, .. } => run_id,
            other => panic!("expected waiting approval, got {other:?}"),
        };
        let engine = Arc::new(Mutex::new(engine));
        let router = AgentRouter {
            by_agent: Arc::new(HashMap::new()),
            owner_by_channel_key: Arc::new(HashMap::new()),
            single_ctx: None,
            sop_engine: Some(Arc::clone(&engine)),
            sop_audit: None,
        };
        (router, engine, run_id)
    }

    fn active_run_status(
        engine: &Arc<Mutex<zeroclaw_runtime::sop::SopEngine>>,
        run_id: &str,
    ) -> Option<zeroclaw_runtime::sop::types::SopRunStatus> {
        engine
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get_run(run_id)
            .map(|run| run.status)
    }

    async fn dispatch_test_channel_sop_gate(
        router: &AgentRouter,
        msg: &ChannelMessage,
        gate_channel: Option<Arc<dyn Channel>>,
    ) -> bool {
        let route_keys = vec![channel_key_for_message(msg)];
        let gate_prompt_channels = gate_channel.into_iter().collect::<Vec<_>>();
        let config = zeroclaw_config::schema::Config::default();
        dispatch_channel_sop_gate(router, msg, &config, &gate_prompt_channels, &route_keys).await
    }

    async fn dispatch_test_channel_sop_gate_with_route_keys(
        router: &AgentRouter,
        msg: &ChannelMessage,
        route_keys: &[&str],
    ) -> bool {
        let route_keys: Vec<String> = route_keys.iter().map(|key| (*key).to_string()).collect();
        let config = zeroclaw_config::schema::Config::default();
        dispatch_channel_sop_gate(router, msg, &config, &[], &route_keys).await
    }

    #[tokio::test]
    async fn dispatch_channel_sop_event_ignores_user_controlled_subject() {
        // An email-shaped message: the reserved prefix sits in the
        // user-controlled `subject`, but the internal git-only marker is
        // absent. It MUST NOT route to SOP.
        let msg = ChannelMessage {
            channel: "email".to_string(),
            sender: "attacker@example.com".to_string(),
            subject: Some("zeroclaw:sop-event:git.main:pull_request.opened".to_string()),
            content: r#"{"sop":"triage"}"#.to_string(),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "attacker@example.com", "", "", "email", 0)
        };
        let router = router_without_sop_engine();
        assert!(
            !dispatch_channel_sop_event(&router, &msg).await,
            "a forged subject must not select SOP ingress"
        );
    }

    #[tokio::test]
    async fn dispatch_channel_sop_event_routes_git_produced_marker() {
        // A genuine git-produced message: the internal marker carries the
        // topic. It IS recognized as a SOP event (returns true; the missing
        // engine is handled inside, but the routing decision fired).
        let msg = ChannelMessage {
            channel: "git".to_string(),
            channel_alias: Some("main".to_string()),
            sender: "test_user".to_string(),
            subject: Some("zeroclaw:sop-event:git.main:pull_request.opened".to_string()),
            content: r#"{"sop":"triage"}"#.to_string(),
            internal_sop_event: Some("git.main:pull_request.opened".to_string()),
            ..ChannelMessage::new("1", "test_user", "octo/repo#12", "", "git", 0)
        };
        let router = router_without_sop_engine();
        assert!(
            dispatch_channel_sop_event(&router, &msg).await,
            "a git-produced internal marker must select SOP ingress"
        );
    }

    /// Pins the contract: the channel tool-loop reads `strict_tool_parsing` and
    /// `parallel_tools` from `agent_cfg.resolved` (populated), not from
    /// `prompt_config.agent(alias).resolved` (serde-skipped default).
    #[test]
    fn resolved_agent_config_carries_strict_tool_parsing_and_parallel_tools() {
        let mut prompt_config = zeroclaw_config::schema::Config::default();

        prompt_config.runtime_profiles.insert(
            "strict-parallel".to_string(),
            zeroclaw_config::schema::RuntimeProfileConfig {
                strict_tool_parsing: true,
                parallel_tools: Some(true),
                ..Default::default()
            },
        );

        let agent_alias = "test-agent";
        prompt_config.agents.insert(
            agent_alias.to_string(),
            zeroclaw_config::schema::AliasedAgentConfig {
                runtime_profile: zeroclaw_config::providers::RuntimeProfileRef::from(
                    "strict-parallel",
                ),
                ..Default::default()
            },
        );

        let agent_cfg = prompt_config
            .resolved_agent_config(agent_alias)
            .expect("agent must resolve");

        assert!(
            agent_cfg.resolved.strict_tool_parsing,
            "resolved.strict_tool_parsing should be true from the runtime profile"
        );
        assert!(
            agent_cfg.resolved.parallel_tools,
            "resolved.parallel_tools should be true from the runtime profile"
        );

        let raw_agent = prompt_config
            .agent(agent_alias)
            .expect("agent must exist in prompt_config");
        assert!(
            !raw_agent.resolved.strict_tool_parsing,
            "raw agent resolved.strict_tool_parsing should be false (serde-skipped default)"
        );
        assert!(
            !raw_agent.resolved.parallel_tools,
            "raw agent resolved.parallel_tools should be false (serde-skipped default)"
        );
    }

    #[tokio::test]
    async fn sop_gate_marker_is_consumed_even_without_an_engine() {
        // A `sop.gate:` marker message exists ONLY to answer a gate; it must be
        // consumed (never fall through to an agent turn) even when no SOP
        // engine is available to resolve it.
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("gnosis".to_string()),
            sender: "111222333".to_string(),
            content: "approve det-1-0001".to_string(),
            internal_sop_event: Some("sop.gate:approve:det-1-0001".to_string()),
            ..ChannelMessage::new("1", "111222333", "chan", "", "discord", 0)
        };
        let router = router_without_sop_engine();
        assert!(
            dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "a gate-click marker must be consumed, not become an agent turn"
        );
    }

    #[tokio::test]
    async fn sop_gate_text_reply_falls_through_when_nothing_is_parked() {
        // A bare "approve <ref>" text message with NO matching parked run is
        // ordinary conversation — it must NOT be consumed as a gate answer.
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("gnosis".to_string()),
            sender: "111222333".to_string(),
            content: "approve det-9999-0001".to_string(),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "chan", "", "discord", 0)
        };
        let router = router_without_sop_engine();
        assert!(
            !dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "a text reply with no parked-run match must fall through to the agent"
        );
    }

    #[tokio::test]
    async fn sop_gate_text_reply_does_not_clear_unpoliced_parked_run() {
        let (router, engine, run_id) = parked_channel_gate_router(None, None);
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-1", "", "discord", 0)
        };

        assert!(
            !dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "text replies must not clear parked runs that never emitted a request-route prompt"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::WaitingApproval)
        );
    }

    #[tokio::test]
    async fn sop_gate_text_reply_requires_matching_request_route() {
        let (router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some("discord.ops:room-1"));
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-2".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-2", "", "discord", 0)
        };

        assert!(
            !dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "a text reply from the wrong room must fall through instead of resolving the gate"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::WaitingApproval)
        );
    }

    #[tokio::test]
    async fn sop_gate_text_reply_rejects_request_route_that_delivery_would_not_target() {
        let (router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some(" discord.ops:room-1"));
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-1", "", "discord", 0)
        };

        assert!(
            !dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "text replies must not normalize a configured request_route differently from delivery"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::WaitingApproval)
        );
    }

    #[tokio::test]
    async fn sop_gate_text_reply_rejects_short_suffix_reference() {
        let (router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some("discord.ops:room-1"));
        assert!(
            run_id.ends_with('1'),
            "the deterministic test run id should exercise the old one-character suffix match: {run_id}"
        );
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: "approve 1".to_string(),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-1", "", "discord", 0)
        };

        assert!(
            !dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "plain text replies must carry the full prompt reference, not a short run-id suffix"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::WaitingApproval)
        );
    }

    #[tokio::test]
    async fn sop_gate_marker_rejects_short_suffix_reference() {
        let (router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some("discord.ops:room-1"));
        assert!(
            run_id.ends_with('1'),
            "the deterministic test run id should exercise the old one-character suffix match: {run_id}"
        );
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: String::new(),
            internal_sop_event: Some("sop.gate:approve:1".to_string()),
            ..ChannelMessage::new("1", "111222333", "room-1", "", "discord", 0)
        };

        assert!(
            dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "a stale marker is consumed, but must not resolve by short run-id suffix"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::WaitingApproval)
        );
    }

    #[tokio::test]
    async fn sop_gate_text_reply_resolves_bare_singleton_request_route() {
        let (router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some("discord:room-1"));
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-1", "", "discord", 0)
        };

        assert!(
            dispatch_test_channel_sop_gate_with_route_keys(
                &router,
                &msg,
                &["discord.ops", "discord"],
            )
            .await,
            "a bare singleton route should resolve when it maps to the same channel instance"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::Running)
        );
    }

    #[tokio::test]
    async fn sop_gate_text_reply_resolves_matching_request_route() {
        let (router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some("discord.ops:room-1"));
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-1", "", "discord", 0)
        };

        assert!(
            dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "a text reply from the request route remains a valid fallback answer"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::Running)
        );
    }

    #[tokio::test]
    async fn channel_gate_approval_drives_resumed_execute_step() {
        let (router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some("discord.ops:room-1"));
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("ops".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-1", "", "discord", 0)
        };
        let config = zeroclaw_config::schema::Config::default();

        assert!(
            dispatch_channel_sop_gate(&router, &msg, &config, &[], &["discord.ops".to_string()],)
                .await,
            "the channel approval must resolve the parked gate"
        );

        tokio::time::timeout(Duration::from_secs(1), async {
            loop {
                if active_run_status(&engine, &run_id)
                    == Some(zeroclaw_runtime::sop::types::SopRunStatus::Failed)
                {
                    break;
                }
                tokio::task::yield_now().await;
            }
        })
        .await
        .expect("channel approval must schedule the resumed ExecuteStep");
    }

    #[tokio::test]
    async fn approval_only_channel_gate_reply_bypasses_agent_ownership() {
        let (mut router, engine, run_id) =
            parked_channel_gate_router(Some("prod"), Some("test-channel:room-1"));
        let gate_channel: Arc<dyn Channel> = Arc::new(RecordingChannel::default());
        let gate_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
            gate_channel,
            Arc::new(DummyModelProvider),
            zeroclaw_config::schema::Config::default(),
            zeroclaw_config::schema::AliasedAgentConfig::default(),
            "test-provider",
            None,
        );
        router.by_agent = Arc::new(HashMap::from([("worker".to_string(), gate_ctx)]));

        let msg = ChannelMessage {
            channel: "test-channel".to_string(),
            sender: "111222333".to_string(),
            reply_target: "room-1".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-1", "", "test-channel", 0)
        };
        assert!(
            router.resolve(&msg).is_none(),
            "the configured approval route must not need an agent owner"
        );

        let (tx, rx) = tokio::sync::mpsc::channel(1);
        tx.send(msg).await.expect("queue gate reply");
        drop(tx);
        run_message_dispatch_loop(rx, router, 1).await;

        tokio::time::timeout(Duration::from_secs(1), async {
            loop {
                if active_run_status(&engine, &run_id)
                    == Some(zeroclaw_runtime::sop::types::SopRunStatus::Failed)
                {
                    break;
                }
                tokio::task::yield_now().await;
            }
        })
        .await
        .expect("the approval-only channel reply must drive the resumed action");
    }

    #[tokio::test]
    async fn sop_gate_resolution_finalizes_request_and_escalation_channels() {
        let (router, engine, run_id) = parked_channel_gate_router_with_routes(
            Some("prod"),
            Some("discord.ops:room-1"),
            Some("discord.oncall:room-2"),
        );
        let request_channel = Arc::new(RecordingChannel::default());
        let escalation_channel = Arc::new(RecordingChannel::default());
        let prompt_channels: Vec<Arc<dyn Channel>> =
            vec![request_channel.clone(), escalation_channel.clone()];
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("oncall".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-2".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-2", "", "discord", 0)
        };

        assert!(
            dispatch_channel_sop_gate(
                &router,
                &msg,
                &zeroclaw_config::schema::Config::default(),
                &prompt_channels,
                &["discord.oncall".to_string()],
            )
            .await,
            "an approval on the escalation route should resolve the gate"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::Running)
        );
        for finalized in [
            request_channel.finalized_gate_prompts.lock().await,
            escalation_channel.finalized_gate_prompts.lock().await,
        ] {
            assert_eq!(finalized.len(), 1, "every active route channel finalizes");
            assert_eq!(finalized[0].0, run_id);
            assert!(finalized[0].1.contains("Approved"));
        }
    }

    #[tokio::test]
    async fn sop_gate_text_reply_resolves_matching_escalation_route() {
        let (router, engine, run_id) = parked_channel_gate_router_with_routes(
            Some("prod"),
            Some("discord.ops:room-1"),
            Some("discord.oncall:room-2"),
        );
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            channel_alias: Some("oncall".to_string()),
            sender: "111222333".to_string(),
            reply_target: "room-2".to_string(),
            content: format!("approve {run_id}"),
            internal_sop_event: None,
            ..ChannelMessage::new("1", "111222333", "room-2", "", "discord", 0)
        };

        assert!(
            dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "a text reply from the route that receives escalation instructions must resolve"
        );
        assert_eq!(
            active_run_status(&engine, &run_id),
            Some(zeroclaw_runtime::sop::types::SopRunStatus::Running)
        );
    }

    #[test]
    fn gate_reference_parsing_defaults_bare_to_revision_zero() {
        // Bare = revision 0 (the original presentation), NOT "current": a click
        // on a superseded prompt must never resolve a newer draft.
        assert_eq!(parse_gate_reference("det-1-0001"), ("det-1-0001".into(), 0));
        assert_eq!(
            parse_gate_reference("det-1-0001#2"),
            ("det-1-0001".into(), 2)
        );
        // Malformed suffix: the whole string is the run part (matches nothing).
        assert_eq!(
            parse_gate_reference("det-1-0001#zz"),
            ("det-1-0001#zz".into(), 0)
        );
    }

    #[tokio::test]
    async fn sop_gate_edit_and_revise_markers_are_consumed() {
        // Edit/Revise markers exist only to answer a gate — consumed even when
        // no engine is available, exactly like approve/deny markers.
        for (choice, content) in [
            ("edit", "my rewritten draft"),
            ("revise", "make it shorter"),
        ] {
            let msg = ChannelMessage {
                channel: "discord".to_string(),
                channel_alias: Some("gnosis".to_string()),
                sender: "111222333".to_string(),
                content: content.to_string(),
                internal_sop_event: Some(format!("sop.gate:{choice}:det-1-0001#1")),
                ..ChannelMessage::new("1", "111222333", "chan", "", "discord", 0)
            };
            let router = router_without_sop_engine();
            assert!(
                dispatch_test_channel_sop_gate(&router, &msg, None).await,
                "a {choice} marker must be consumed, not become an agent turn"
            );
        }
    }

    #[tokio::test]
    async fn sop_gate_unknown_marker_choice_is_dropped() {
        // An unknown choice in a marker is malformed — consumed (it can only be
        // a gate artifact), never resolved as a decision. Guards the old
        // behavior where any non-"approve" choice silently became a DENY.
        let msg = ChannelMessage {
            channel: "discord".to_string(),
            sender: "111222333".to_string(),
            content: "frobnicate det-1-0001".to_string(),
            internal_sop_event: Some("sop.gate:frobnicate:det-1-0001".to_string()),
            ..ChannelMessage::new("1", "111222333", "chan", "", "discord", 0)
        };
        let router = router_without_sop_engine();
        assert!(
            dispatch_test_channel_sop_gate(&router, &msg, None).await,
            "an unknown-choice marker must still be consumed"
        );
    }

    #[tokio::test]
    async fn sop_gate_ordinary_chat_never_matches() {
        // Ordinary messages — even ones containing the word "approve" — must
        // never be consumed by the gate intercept.
        for content in [
            "please approve my PR when you can",
            "deny",
            "approve",
            "approve run 12 thanks",
        ] {
            let msg = ChannelMessage {
                channel: "discord".to_string(),
                sender: "111222333".to_string(),
                content: content.to_string(),
                internal_sop_event: None,
                ..ChannelMessage::new("1", "111222333", "chan", "", "discord", 0)
            };
            let router = router_without_sop_engine();
            assert!(
                !dispatch_test_channel_sop_gate(&router, &msg, None).await,
                "ordinary chat must fall through: {content:?}"
            );
        }
    }
}

#[cfg(test)]
mod omitted_feature_tests {
    #[cfg(not(feature = "channel-telegram"))]
    #[test]
    fn collect_configured_channels_omits_telegram_when_compiled_out() {
        use super::*;
        let mut config = Config::default();
        config.channels.telegram.insert(
            "default".to_string(),
            zeroclaw_config::schema::TelegramConfig {
                enabled: true,
                ..Default::default()
            },
        );
        let config_arc = Arc::new(RwLock::new(config));
        let channels = collect_configured_channels(&config_arc, "test", &[], None, None);
        assert!(
            channels.iter().all(|c| c.display_name != "Telegram"),
            "Telegram must be absent from collect_configured_channels when \
             channel-telegram feature is not compiled in"
        );
    }
}

#[cfg(test)]
mod debounce_resolution_tests {
    use super::resolve_effective_debounce_window;
    use std::collections::HashMap;
    use std::time::Duration;
    use zeroclaw_config::schema::TelegramConfig;

    #[test]
    fn per_channel_debounce_zero_falls_back_to_global() {
        let mut telegram_configs = HashMap::new();
        telegram_configs.insert(
            "default".into(),
            TelegramConfig {
                debounce_ms: Some(0),
                ..Default::default()
            },
        );
        let duration =
            resolve_effective_debounce_window(1000, "telegram", Some("default"), &telegram_configs);
        assert_eq!(duration, Duration::from_millis(1000));
    }

    #[test]
    fn per_channel_debounce_positive_overrides_global() {
        let mut telegram_configs = HashMap::new();
        telegram_configs.insert(
            "default".into(),
            TelegramConfig {
                debounce_ms: Some(500),
                ..Default::default()
            },
        );
        let duration =
            resolve_effective_debounce_window(1000, "telegram", Some("default"), &telegram_configs);
        assert_eq!(duration, Duration::from_millis(500));
    }

    #[test]
    fn per_channel_debounce_none_falls_back_to_global() {
        let mut telegram_configs = HashMap::new();
        telegram_configs.insert(
            "default".into(),
            TelegramConfig {
                debounce_ms: None,
                ..Default::default()
            },
        );
        let duration =
            resolve_effective_debounce_window(1000, "telegram", Some("default"), &telegram_configs);
        assert_eq!(duration, Duration::from_millis(1000));
    }

    #[test]
    fn non_telegram_channel_uses_global() {
        let telegram_configs = HashMap::new();
        let duration = resolve_effective_debounce_window(1000, "discord", None, &telegram_configs);
        assert_eq!(duration, Duration::from_millis(1000));
    }

    #[test]
    fn unknown_telegram_alias_uses_global() {
        let telegram_configs = HashMap::new();
        let duration = resolve_effective_debounce_window(
            1000,
            "telegram",
            Some("nonexistent"),
            &telegram_configs,
        );
        assert_eq!(duration, Duration::from_millis(1000));
    }
}
