Skip to main content

zeroclaw_channels/orchestrator/
mod.rs

1//! Channel subsystem for messaging platform integrations.
2//!
3//! This module provides the multi-channel messaging infrastructure that connects
4//! ZeroClaw to external platforms. Each channel implements the [`Channel`] trait
5//! defined in the `traits` submodule, which provides a uniform interface for
6//! sending messages, listening for incoming messages, health checking, and typing
7//! indicators.
8//!
9//! Channels are instantiated by [`start_channels`] based on the runtime configuration.
10//! The subsystem manages per-sender conversation history, concurrent message processing
11//! with configurable parallelism, and exponential-backoff reconnection for resilience.
12//!
13//! # Extension
14//!
15//! To add a new channel, implement [`Channel`] in a new top-level module in
16//! `zeroclaw-channels/src/`, declare it in `lib.rs` behind the appropriate feature
17//! gate, and wire it into [`start_channels`] here. See `AGENTS.md` §7.2 for the
18//! full change playbook.
19
20#[cfg(feature = "channel-acp-server")]
21pub mod acp_server;
22pub mod media_pipeline;
23#[cfg(feature = "channel-mqtt")]
24pub mod mqtt;
25
26// Channel types imported directly from source crates (no shim files)
27#[cfg(feature = "channel-amqp")]
28pub use crate::amqp::AmqpChannel;
29#[cfg(feature = "channel-bluesky")]
30pub use crate::bluesky::BlueskyChannel;
31#[cfg(feature = "channel-clawdtalk")]
32pub use crate::clawdtalk::ClawdTalkChannel;
33#[cfg(feature = "channel-dingtalk")]
34pub use crate::dingtalk::DingTalkChannel;
35#[cfg(feature = "channel-discord")]
36pub use crate::discord::DiscordChannel;
37#[cfg(feature = "channel-email")]
38pub use crate::email_channel::EmailChannel;
39#[cfg(feature = "channel-email")]
40pub use crate::gmail_push::GmailPushChannel;
41#[cfg(feature = "channel-imessage")]
42pub use crate::imessage::IMessageChannel;
43#[cfg(feature = "channel-irc")]
44pub use crate::irc::IrcChannel;
45#[cfg(feature = "channel-lark")]
46pub use crate::lark::LarkChannel;
47#[cfg(feature = "channel-line")]
48pub use crate::line::LineChannel;
49#[cfg(feature = "channel-linq")]
50pub use crate::linq::LinqChannel;
51#[cfg(feature = "channel-mattermost")]
52pub use crate::mattermost::MattermostChannel;
53#[cfg(feature = "channel-mochat")]
54pub use crate::mochat::MochatChannel;
55#[cfg(feature = "channel-nextcloud")]
56pub use crate::nextcloud_talk::NextcloudTalkChannel;
57#[cfg(feature = "channel-nostr")]
58pub use crate::nostr::NostrChannel;
59#[cfg(feature = "channel-notion")]
60pub use crate::notion::NotionChannel;
61#[cfg(feature = "channel-qq")]
62pub use crate::qq::QQChannel;
63#[cfg(feature = "channel-reddit")]
64pub use crate::reddit::RedditChannel;
65#[cfg(feature = "channel-signal")]
66pub use crate::signal::SignalChannel;
67#[cfg(feature = "channel-slack")]
68pub use crate::slack::SlackChannel;
69pub use crate::transcription;
70pub use crate::tts::{TtsManager, TtsProvider};
71#[cfg(feature = "channel-twitch")]
72pub use crate::twitch::TwitchChannel;
73#[cfg(feature = "channel-twitter")]
74pub use crate::twitter::TwitterChannel;
75#[cfg(feature = "channel-voice-call")]
76pub use crate::voice_call::VoiceCallChannel;
77#[cfg(feature = "voice-wake")]
78pub use crate::voice_wake::VoiceWakeChannel;
79#[cfg(feature = "channel-wati")]
80pub use crate::wati::WatiChannel;
81#[cfg(feature = "channel-webhook")]
82pub use crate::webhook::WebhookChannel;
83#[cfg(feature = "channel-wechat")]
84pub use crate::wechat::WeChatChannel;
85#[cfg(feature = "channel-wecom")]
86pub use crate::wecom::WeComChannel;
87#[cfg(feature = "channel-wecom-ws")]
88pub use crate::wecom_ws::WeComWsChannel;
89#[cfg(feature = "channel-whatsapp-cloud")]
90pub use crate::whatsapp::WhatsAppChannel;
91pub use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
92// Local channel types (in misc, not zeroclaw-channels)
93pub use crate::cli::CliChannel;
94pub use crate::link_enricher;
95#[cfg(feature = "channel-matrix")]
96pub use crate::matrix::MatrixChannel;
97#[cfg(feature = "channel-telegram")]
98pub use crate::telegram::TelegramChannel;
99#[cfg(feature = "whatsapp-web")]
100pub use crate::whatsapp_web::WhatsAppWebChannel;
101pub use zeroclaw_infra::debounce::MessageDebouncer;
102pub use zeroclaw_infra::session_backend::SessionBackend;
103pub use zeroclaw_infra::session_sqlite::SqliteSessionBackend;
104pub use zeroclaw_infra::stall_watchdog::StallWatchdog;
105
106use anyhow::{Context, Result};
107use parking_lot::RwLock;
108use portable_atomic::{AtomicU64, Ordering};
109use serde::Deserialize;
110use std::collections::{HashMap, HashSet};
111use std::fmt::Write;
112use std::path::{Path, PathBuf};
113use std::process::Command;
114use std::sync::atomic::AtomicBool;
115use std::sync::{Arc, Mutex};
116use std::time::{Duration, Instant, SystemTime};
117use tokio_util::sync::CancellationToken;
118
119use zeroclaw_api::memory_traits::MemoryStrategy;
120use zeroclaw_api::session_keys::sanitize_session_key;
121use zeroclaw_config::schema::Config;
122use zeroclaw_memory::{self, MEMORY_CONTEXT_CLOSE, MEMORY_CONTEXT_OPEN, Memory};
123use zeroclaw_providers::reliable::{scope_provider_fallback, take_last_provider_fallback};
124use zeroclaw_providers::{self, ChatMessage, ModelProvider};
125use zeroclaw_runtime::agent::loop_::{
126    apply_policy_tool_filter, apply_text_tool_prompt_policy, build_tool_instructions_for_names,
127    clear_model_switch_request, get_model_switch_state, is_model_switch_requested,
128    run_tool_call_loop, scope_session_key, scope_thread_id, scrub_credentials,
129};
130use zeroclaw_runtime::approval::ApprovalManager;
131use zeroclaw_runtime::observability::traits::{ObserverEvent, ObserverMetric};
132use zeroclaw_runtime::observability::{self, Observer};
133use zeroclaw_runtime::platform;
134use zeroclaw_runtime::security::{AutonomyLevel, SecurityPolicy};
135use zeroclaw_runtime::tools::{self, Tool};
136use zeroclaw_runtime::util::truncate_with_ellipsis;
137
138type CronChannelRegistry = Arc<HashMap<String, Arc<dyn Channel>>>;
139
140/// Live channel registry consulted by `deliver_announcement` so cron sends reuse the
141/// authenticated channel instance (Matrix E2EE can't tolerate per-send session restore).
142/// Replaced wholesale by each `start_channels` call.
143static CRON_CHANNEL_REGISTRY: std::sync::RwLock<Option<CronChannelRegistry>> =
144    std::sync::RwLock::new(None);
145
146/// Observer wrapper that forwards tool-call events to a channel sender
147/// for real-time threaded notifications.
148struct ChannelNotifyObserver {
149    inner: Arc<dyn Observer>,
150    tx: tokio::sync::mpsc::UnboundedSender<String>,
151    tools_used: AtomicBool,
152}
153
154impl Observer for ChannelNotifyObserver {
155    fn record_event(&self, event: &ObserverEvent) {
156        if let ObserverEvent::ToolCallStart {
157            tool, arguments, ..
158        } = event
159        {
160            self.tools_used.store(true, Ordering::Relaxed);
161            let detail = match arguments {
162                Some(args) if !args.is_empty() => {
163                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
164                        if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
165                            format!(": `{}`", truncate_with_ellipsis(cmd, 200))
166                        } else if let Some(q) = v.get("query").and_then(|c| c.as_str()) {
167                            format!(": {}", truncate_with_ellipsis(q, 200))
168                        } else if let Some(p) = v.get("path").and_then(|c| c.as_str()) {
169                            format!(": {p}")
170                        } else if let Some(u) = v.get("url").and_then(|c| c.as_str()) {
171                            format!(": {u}")
172                        } else {
173                            let s = args.to_string();
174                            format!(": {}", truncate_with_ellipsis(&s, 120))
175                        }
176                    } else {
177                        let s = args.to_string();
178                        format!(": {}", truncate_with_ellipsis(&s, 120))
179                    }
180                }
181                _ => String::new(),
182            };
183            let _ = self.tx.send(format!("\u{1F527} `{tool}`{detail}"));
184        }
185        self.inner.record_event(event);
186    }
187    fn record_metric(&self, metric: &ObserverMetric) {
188        self.inner.record_metric(metric);
189    }
190    fn flush(&self) {
191        self.inner.flush();
192    }
193    fn name(&self) -> &str {
194        "channel-notify"
195    }
196    fn as_any(&self) -> &dyn std::any::Any {
197        self
198    }
199}
200
201/// Per-sender conversation history for channel messages.
202/// Bounded by `MAX_CONVERSATION_SENDERS` — oldest-accessed senders are evicted.
203type ConversationHistoryMap = Arc<Mutex<lru::LruCache<String, Vec<ChatMessage>>>>;
204/// Senders that requested `/new` and must force a fresh prompt on their next message.
205type PendingNewSessionSet = Arc<Mutex<HashSet<String>>>;
206/// Maximum conversation senders kept in memory (LRU eviction beyond this).
207const MAX_CONVERSATION_SENDERS: usize = 1000;
208/// Maximum history messages to keep per sender.
209const MAX_CHANNEL_HISTORY: usize = 50;
210/// Minimum user-message length (in chars) for auto-save to memory.
211/// Messages shorter than this (e.g. "ok", "thanks") are not stored,
212/// reducing noise in memory recall.
213const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
214const CURRENT_DATE_HEADING: &str = "## Current Date\n\n";
215const LEGACY_CURRENT_DATE_TIME_HEADING: &str = "## Current Date & Time\n\n";
216
217// System prompt functions live in `zeroclaw_runtime::agent::system_prompt`.
218#[allow(unused_imports)]
219pub use zeroclaw_runtime::agent::system_prompt::{
220    BOOTSTRAP_MAX_CHARS, build_system_prompt, build_system_prompt_with_mode,
221    build_system_prompt_with_mode_and_autonomy,
222};
223
224const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
225const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
226const MIN_CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 30;
227/// Default timeout for processing a single channel message (LLM + tools).
228/// Used as fallback when not configured in channels_config.message_timeout_secs.
229#[cfg(test)]
230const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
231/// Cap timeout scaling so large max_tool_iterations values do not create unbounded waits.
232const CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP: u64 = 4;
233const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8;
234const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
235const CHANNEL_TYPING_REFRESH_INTERVAL_SECS: u64 = 4;
236const CHANNEL_HEALTH_HEARTBEAT_SECS: u64 = 30;
237const MODEL_CACHE_FILE: &str = "models_cache.json";
238const MODEL_CACHE_PREVIEW_LIMIT: usize = 10;
239const MEMORY_CONTEXT_MAX_ENTRIES: usize = 4;
240const MEMORY_CONTEXT_ENTRY_MAX_CHARS: usize = 800;
241const MEMORY_CONTEXT_MAX_CHARS: usize = 4_000;
242const CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES: usize = 12;
243const CHANNEL_HISTORY_COMPACT_CONTENT_CHARS: usize = 600;
244/// Proactive context-window budget in estimated characters (~4 chars/token).
245/// When the total character count of conversation history exceeds this limit,
246/// older turns are dropped before the request is sent to the model_provider,
247/// preventing context-window-exceeded errors.  Set conservatively below
248/// common context windows (128 k tokens ≈ 512 k chars) to leave room for
249/// system prompt, memory context, and model output.
250const PROACTIVE_CONTEXT_BUDGET_CHARS: usize = 400_000;
251/// Guardrail for hook-modified outbound channel content.
252const CHANNEL_HOOK_MAX_OUTBOUND_CHARS: usize = 20_000;
253
254type ProviderCacheMap = Arc<Mutex<HashMap<String, Arc<dyn ModelProvider>>>>;
255type RouteSelectionMap = Arc<Mutex<HashMap<String, ChannelRouteSelection>>>;
256
257fn effective_channel_message_timeout_secs(configured: u64) -> u64 {
258    configured.max(MIN_CHANNEL_MESSAGE_TIMEOUT_SECS)
259}
260
261#[cfg(test)]
262fn channel_message_timeout_budget_secs(
263    message_timeout_secs: u64,
264    max_tool_iterations: usize,
265) -> u64 {
266    channel_message_timeout_budget_secs_with_cap(
267        message_timeout_secs,
268        max_tool_iterations,
269        CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP,
270    )
271}
272
273fn channel_message_timeout_budget_secs_with_cap(
274    message_timeout_secs: u64,
275    max_tool_iterations: usize,
276    scale_cap: u64,
277) -> u64 {
278    let iterations = max_tool_iterations.max(1) as u64;
279    let scale = iterations.min(scale_cap);
280    message_timeout_secs.saturating_mul(scale)
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284struct ChannelRouteSelection {
285    model_provider: String,
286    model: String,
287    /// Route-specific API key override. When set, this credential is passed
288    /// directly to the requested provider instead of the alias entry's key.
289    api_key: Option<String>,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293enum ChannelRuntimeCommand {
294    ShowProviders,
295    SetProvider(String),
296    ShowModel,
297    SetModel(String),
298    ShowConfig,
299    NewSession,
300}
301
302#[derive(Debug, Clone, Default, Deserialize)]
303struct ModelCacheState {
304    entries: Vec<ModelCacheEntry>,
305}
306
307#[derive(Debug, Clone, Default, Deserialize)]
308struct ModelCacheEntry {
309    model_provider: String,
310    models: Vec<String>,
311}
312
313#[derive(Debug, Clone)]
314struct ChannelRuntimeDefaults {
315    default_model_provider: String,
316    model: String,
317    temperature: Option<f64>,
318    api_key: Option<String>,
319    api_url: Option<String>,
320    reliability: zeroclaw_config::schema::ReliabilityConfig,
321}
322
323#[derive(Debug, Clone)]
324struct ChannelRuntimeDefaultsSnapshot {
325    config: Arc<Config>,
326    defaults: ChannelRuntimeDefaults,
327    hot: bool,
328    generation: u64,
329}
330
331#[derive(Debug, Clone)]
332struct ChannelRuntimeOverride {
333    config: Arc<Config>,
334    defaults: ChannelRuntimeDefaults,
335    generation: u64,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339struct ConfigFileStamp {
340    modified: SystemTime,
341    len: u64,
342}
343
344const SYSTEMD_STATUS_ARGS: [&str; 3] = ["--user", "is-active", "zeroclaw.service"];
345const SYSTEMD_RESTART_ARGS: [&str; 3] = ["--user", "restart", "zeroclaw.service"];
346const OPENRC_STATUS_ARGS: [&str; 2] = ["zeroclaw", "status"];
347const OPENRC_RESTART_ARGS: [&str; 2] = ["zeroclaw", "restart"];
348
349#[derive(Clone, Copy)]
350#[allow(clippy::struct_excessive_bools)]
351struct InterruptOnNewMessageConfig {
352    telegram: bool,
353    slack: bool,
354    discord: bool,
355    mattermost: bool,
356    matrix: bool,
357}
358
359impl InterruptOnNewMessageConfig {
360    fn enabled_for_channel(self, channel: &str) -> bool {
361        match channel {
362            "telegram" => self.telegram,
363            "slack" => self.slack,
364            "discord" => self.discord,
365            "mattermost" => self.mattermost,
366            "matrix" => self.matrix,
367            _ => false,
368        }
369    }
370}
371
372#[derive(Clone)]
373struct ChannelCostTrackingState {
374    tracker: Arc<zeroclaw_runtime::cost::CostTracker>,
375    model_provider_pricing: Arc<zeroclaw_runtime::agent::cost::ModelProviderPricing>,
376    agent_alias: Arc<String>,
377}
378
379#[derive(Clone)]
380struct ChannelRuntimeContext {
381    channels_by_name: Arc<HashMap<String, Arc<dyn Channel>>>,
382    model_provider: Arc<dyn ModelProvider>,
383    model_provider_ref: Arc<String>,
384    /// Alias of the agent that owns this runtime context. Stamped onto
385    /// every per-message tracing span so descendant events inherit the
386    /// attribution without each call site re-passing it.
387    agent_alias: Arc<String>,
388    /// Resolved aliased-agent config for the agent owning this
389    /// runtime context. Per-channel agent dispatch (one agent per
390    /// channel.`<type>`.`<alias>`) is a follow-up.
391    agent_cfg: Arc<zeroclaw_config::schema::AliasedAgentConfig>,
392    prompt_config: Arc<zeroclaw_config::schema::Config>,
393    memory: Arc<dyn Memory>,
394    memory_strategy: Arc<dyn MemoryStrategy>,
395    tools_registry: Arc<Vec<Box<dyn Tool>>>,
396    observer: Arc<dyn Observer>,
397    system_prompt: Arc<String>,
398    model: Arc<String>,
399    temperature: Option<f64>,
400    auto_save_memory: bool,
401    max_tool_iterations: usize,
402    min_relevance_score: f64,
403    conversation_histories: ConversationHistoryMap,
404    pending_new_sessions: PendingNewSessionSet,
405    provider_cache: ProviderCacheMap,
406    route_overrides: RouteSelectionMap,
407    reliability: Arc<zeroclaw_config::schema::ReliabilityConfig>,
408    provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
409    workspace_dir: Arc<PathBuf>,
410    message_timeout_secs: u64,
411    interrupt_on_new_message: InterruptOnNewMessageConfig,
412    multimodal: zeroclaw_config::schema::MultimodalConfig,
413    media_pipeline: zeroclaw_config::schema::MediaPipelineConfig,
414    transcription_config: zeroclaw_config::schema::TranscriptionConfig,
415    /// Resolved per-agent transcription provider alias (`<type>.<alias>`)
416    /// for the runtime-active agent that owns this channel context.
417    /// Empty when the agent has no transcription_provider set; downstream
418    /// `TranscriptionManager.transcribe` calls then fail loud.
419    agent_transcription_provider: String,
420    hooks: Option<Arc<zeroclaw_runtime::hooks::HookRunner>>,
421    non_cli_excluded_tools: Arc<Vec<String>>,
422    autonomy_level: AutonomyLevel,
423    tool_call_dedup_exempt: Arc<Vec<String>>,
424    model_routes: Arc<Vec<zeroclaw_config::schema::ModelRouteConfig>>,
425    query_classification: zeroclaw_config::schema::QueryClassificationConfig,
426    ack_reactions: bool,
427    show_tool_calls: bool,
428    session_store: Option<Arc<dyn zeroclaw_infra::session_backend::SessionBackend>>,
429    /// Non-interactive approval manager for channel-driven runs.
430    /// Enforces `auto_approve` / `always_ask` / supervised policy from
431    /// `[autonomy]` config; auto-denies tools that would need interactive
432    /// approval since no operator is present on channel runs.
433    approval_manager: Arc<ApprovalManager>,
434    activated_tools:
435        Option<std::sync::Arc<std::sync::Mutex<zeroclaw_runtime::tools::ActivatedToolSet>>>,
436    cost_tracking: Option<ChannelCostTrackingState>,
437    pacing: zeroclaw_config::schema::PacingConfig,
438    max_tool_result_chars: usize,
439    context_token_budget: usize,
440    debouncer: Arc<zeroclaw_infra::debounce::MessageDebouncer>,
441    /// HMAC receipt generator. `Some` when `[agent.resolved.tool_receipts] enabled = true`.
442    /// Threaded into `run_tool_call_loop` so `tool_execution::execute_one_tool`
443    /// can sign each result.
444    receipt_generator: Option<zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator>,
445    /// Mirror of `[agent.resolved.tool_receipts] show_in_response`. When true,
446    /// `process_channel_message` renders the per-turn collector as a trailing
447    /// `Tool receipts:` block sent after the main reply.
448    show_receipts_in_response: bool,
449    last_applied_config_stamp: Arc<Mutex<Option<ConfigFileStamp>>>,
450    runtime_defaults_override: Arc<Mutex<Option<Arc<ChannelRuntimeOverride>>>>,
451}
452
453#[derive(Clone)]
454struct InFlightSenderTaskState {
455    task_id: u64,
456    cancellation: CancellationToken,
457    completion: Arc<InFlightTaskCompletion>,
458}
459
460struct InFlightTaskCompletion {
461    done: AtomicBool,
462    notify: tokio::sync::Notify,
463}
464
465impl InFlightTaskCompletion {
466    fn new() -> Self {
467        Self {
468            done: AtomicBool::new(false),
469            notify: tokio::sync::Notify::new(),
470        }
471    }
472
473    fn mark_done(&self) {
474        self.done.store(true, Ordering::Release);
475        self.notify.notify_waiters();
476    }
477
478    async fn wait(&self) {
479        if self.done.load(Ordering::Acquire) {
480            return;
481        }
482        self.notify.notified().await;
483    }
484}
485
486fn conversation_memory_key(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
487    // Include thread_ts for per-topic memory isolation in forum groups
488    let raw = match &msg.thread_ts {
489        Some(tid) => format!("{}_{}_{}_{}", msg.channel, tid, msg.sender, msg.id),
490        None => format!("{}_{}_{}", msg.channel, msg.sender, msg.id),
491    };
492    sanitize_session_key(&raw)
493}
494
495pub fn conversation_history_key(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
496    // Channel prefix includes the zeroclaw alias when present so two bots
497    // on the same platform (e.g. `discord.clamps` + `discord.glados`)
498    // compute distinct session_keys and don't share conversation history.
499    let channel_scope = match &msg.channel_alias {
500        Some(alias) => format!("{}.{}", msg.channel, alias),
501        None => msg.channel.clone(),
502    };
503    if msg.channel == "wecom_ws" {
504        return sanitize_session_key(&format!("{channel_scope}_{}", msg.reply_target));
505    }
506    // reply_target gives per-channel isolation (distinct Discord/Slack
507    // channels) and thread_ts gives per-topic isolation in forum groups.
508    // Sanitize so the runtime HashMap key matches `SessionStore::list_sessions`
509    // after a restart; otherwise hydration loads sessions under the on-disk
510    // (sanitized) name while lookup keeps producing the un-sanitized form.
511    let thread_scope = match msg.thread_ts.as_deref() {
512        // Matrix root events can be self-anchored when `reply_in_thread`
513        // is enabled so outbound replies open a thread. That anchor is a
514        // delivery detail, not a conversation-history boundary; otherwise
515        // every top-level Matrix message becomes a fresh session.
516        Some(tid) if is_matrix_channel_name(&msg.channel) && tid == msg.id => None,
517        other => other,
518    };
519    let raw = match thread_scope {
520        Some(tid) => format!("{channel_scope}_{}_{tid}_{}", msg.reply_target, msg.sender),
521        None => format!("{channel_scope}_{}_{}", msg.reply_target, msg.sender),
522    };
523    sanitize_session_key(&raw)
524}
525
526fn followup_thread_id(msg: &zeroclaw_api::channel::ChannelMessage) -> Option<String> {
527    if is_matrix_channel_name(&msg.channel) {
528        msg.thread_ts.clone()
529    } else {
530        msg.thread_ts.clone().or_else(|| Some(msg.id.clone()))
531    }
532}
533
534fn interruption_scope_key(msg: &zeroclaw_api::channel::ChannelMessage) -> String {
535    if msg.channel == "wecom_ws" && msg.reply_target.starts_with("group--") {
536        let channel_scope = match &msg.channel_alias {
537            Some(alias) => format!("{}.{}", msg.channel, alias),
538            None => msg.channel.clone(),
539        };
540        return sanitize_session_key(&format!("{channel_scope}_{}", msg.reply_target));
541    }
542
543    match &msg.interruption_scope_id {
544        Some(scope) => format!(
545            "{}_{}_{}_{}",
546            msg.channel, msg.reply_target, msg.sender, scope
547        ),
548        None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
549    }
550}
551
552/// Returns `true` when `content` is a `/stop` command (with optional `@botname` suffix).
553/// Not gated on channel type — all non-CLI channels support `/stop`.
554fn is_stop_command(content: &str) -> bool {
555    let trimmed = content.trim();
556    if !trimmed.starts_with('/') {
557        return false;
558    }
559    let cmd = trimmed.split_whitespace().next().unwrap_or("");
560    let base = cmd.split('@').next().unwrap_or(cmd);
561    base.eq_ignore_ascii_case("/stop")
562}
563
564/// Strip tool-call XML tags from outgoing messages.
565///
566/// LLM responses may contain `<function_calls>`, `<function_call>`,
567/// `<tool_call>`, `<toolcall>`, `<tool-call>`, `<tool>`, or `<invoke>`
568/// blocks that are internal protocol and must not be forwarded to end
569/// users on any channel.
570pub(crate) fn strip_tool_call_tags(message: &str) -> String {
571    const TOOL_CALL_OPEN_TAGS: [&str; 7] = [
572        "<function_calls>",
573        "<function_call>",
574        "<tool_call>",
575        "<toolcall>",
576        "<tool-call>",
577        "<tool>",
578        "<invoke>",
579    ];
580
581    fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
582        tags.iter()
583            .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
584            .min_by_key(|(idx, _)| *idx)
585    }
586
587    fn matching_close_tag(open_tag: &str) -> Option<&'static str> {
588        match open_tag {
589            "<function_calls>" => Some("</function_calls>"),
590            "<function_call>" => Some("</function_call>"),
591            "<tool_call>" => Some("</tool_call>"),
592            "<toolcall>" => Some("</toolcall>"),
593            "<tool-call>" => Some("</tool-call>"),
594            "<tool>" => Some("</tool>"),
595            "<invoke>" => Some("</invoke>"),
596            _ => None,
597        }
598    }
599
600    fn extract_first_json_end(input: &str) -> Option<usize> {
601        let trimmed = input.trim_start();
602        let trim_offset = input.len().saturating_sub(trimmed.len());
603
604        for (byte_idx, ch) in trimmed.char_indices() {
605            if ch != '{' && ch != '[' {
606                continue;
607            }
608
609            let slice = &trimmed[byte_idx..];
610            let mut stream =
611                serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
612            if let Some(Ok(_value)) = stream.next() {
613                let consumed = stream.byte_offset();
614                if consumed > 0 {
615                    return Some(trim_offset + byte_idx + consumed);
616                }
617            }
618        }
619
620        None
621    }
622
623    fn strip_leading_close_tags(mut input: &str) -> &str {
624        loop {
625            let trimmed = input.trim_start();
626            if !trimmed.starts_with("</") {
627                return trimmed;
628            }
629
630            let Some(close_end) = trimmed.find('>') else {
631                return "";
632            };
633            input = &trimmed[close_end + 1..];
634        }
635    }
636
637    // Does the tag structure run to the end of the message? A *real* truncated
638    // tool call is the model getting cut off, so the unterminated structure is
639    // the last thing in the message. If natural-language prose resumes after the
640    // tags, this is an inline *example* (the model is discussing tool calls), not
641    // a truncation — so we should keep it. Bias toward keeping: a little leaked
642    // XML beats eating the user's text.
643    fn tool_structure_runs_to_end(inner: &str) -> bool {
644        let mut rest = inner.trim_start();
645        while rest.starts_with('<') {
646            match rest.find('>') {
647                Some(gt) => rest = rest[gt + 1..].trim_start(),
648                None => return true,
649            }
650        }
651        let tail = rest.trim();
652        if tail.is_empty() {
653            return true;
654        }
655        !looks_like_prose(tail)
656    }
657
658    // Heuristic: does `text` read like resumed natural-language prose (as opposed
659    // to a cut-off parameter value)? True on an internal sentence boundary
660    // (". " / "! " / "? " + a letter) or a multi-word string that ends like a
661    // sentence. Deliberately lenient so ambiguous tails are kept, not dropped.
662    fn looks_like_prose(text: &str) -> bool {
663        let bytes = text.as_bytes();
664        for i in 0..bytes.len().saturating_sub(1) {
665            if matches!(bytes[i], b'.' | b'!' | b'?')
666                && matches!(bytes[i + 1], b' ' | b'\n' | b'\t')
667                && text[i + 1..]
668                    .trim_start()
669                    .chars()
670                    .next()
671                    .is_some_and(|c| c.is_alphabetic())
672            {
673                return true;
674            }
675        }
676        let trimmed = text.trim_end();
677        let ends_like_sentence = trimmed
678            .chars()
679            .last()
680            .is_some_and(|c| matches!(c, '.' | '!' | '?'))
681            && trimmed
682                .chars()
683                .rev()
684                .nth(1)
685                .is_some_and(|c| c.is_alphabetic());
686        ends_like_sentence && text.trim().contains(' ')
687    }
688
689    let mut kept_segments = Vec::new();
690    let mut remaining = message;
691
692    while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
693        let before = &remaining[..start];
694        if !before.is_empty() {
695            kept_segments.push(before.to_string());
696        }
697
698        let Some(close_tag) = matching_close_tag(open_tag) else {
699            break;
700        };
701        let after_open = &remaining[start + open_tag.len()..];
702
703        if let Some(close_idx) = after_open.find(close_tag) {
704            remaining = &after_open[close_idx + close_tag.len()..];
705            continue;
706        }
707
708        if let Some(consumed_end) = extract_first_json_end(after_open) {
709            remaining = strip_leading_close_tags(&after_open[consumed_end..]);
710            continue;
711        }
712
713        // Unterminated open tag with no parseable JSON body. Drop the broken
714        // tail ONLY when it looks like tool-call structure AND that structure
715        // runs to the end of the message — a real truncation where the model was
716        // cut off mid-call. If prose resumes after the structure, the model is
717        // showing an *example*, not making a call, so keep it verbatim (a little
718        // leaked XML beats eating the reply). Text merely mentioning a tag is
719        // likewise kept.
720        let inner = after_open.trim_start();
721        let inner_lower = inner.to_ascii_lowercase();
722        let looks_like_tool_structure = inner_lower.starts_with("<invoke")
723            || inner_lower.starts_with("<parameter")
724            || inner_lower.starts_with("<tool")
725            || inner_lower.starts_with("<function")
726            || inner.starts_with('{')
727            || inner.starts_with('[');
728        if looks_like_tool_structure && tool_structure_runs_to_end(inner) {
729            remaining = "";
730            break;
731        }
732
733        kept_segments.push(remaining[start..].to_string());
734        remaining = "";
735        break;
736    }
737
738    if !remaining.is_empty() {
739        kept_segments.push(remaining.to_string());
740    }
741
742    let mut result = kept_segments.concat();
743
744    // Clean up any resulting blank lines (but preserve paragraphs)
745    while result.contains("\n\n\n") {
746        result = result.replace("\n\n\n", "\n\n");
747    }
748
749    result.trim().to_string()
750}
751
752fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
753    match channel_name {
754        "matrix" => Some(
755            "When responding on Matrix:\n\
756             - Use Markdown formatting (bold, italic, code blocks)\n\
757             - Be concise and direct\n\
758             - For media attachments use markers: [IMAGE:<absolute-path>], [DOCUMENT:<absolute-path>], [VIDEO:<absolute-path>], [AUDIO:<absolute-path>], or [VOICE:<absolute-path>]\n\
759             - Paths inside markers MUST be absolute (starting with /). Never use relative paths.\n\
760             - Keep normal text outside markers and never wrap markers in code fences.\n\
761             - When you receive a [Voice message], the user spoke to you. Respond naturally as in conversation.\n\
762             - Your text reply will automatically be converted to audio and sent back as a voice message.\n",
763        ),
764        "discord" => Some(
765            "When responding on Discord:\n\
766             - Use Markdown formatting (bold, italic, code blocks)\n\
767             - Be concise and direct\n\
768             - For media attachments use markers: [IMAGE:<absolute-path>], [DOCUMENT:<absolute-path>], [VIDEO:<absolute-path>], [AUDIO:<absolute-path>], or [VOICE:<absolute-path>]\n\
769             - Paths inside markers MUST be absolute (starting with /) and live inside the configured workspace directory. Never use relative paths.\n\
770             - Remote media is also accepted via http:// or https:// URLs in the same marker form.\n\
771             - Keep normal text outside markers and never wrap markers in code fences.\n",
772        ),
773        "telegram" => Some(
774            "When responding on Telegram:\n\
775             - Include media markers for files or URLs that should be sent as attachments\n\
776             - Use **bold** for key terms, section titles, and important info (renders as <b>)\n\
777             - Use *italic* for emphasis (renders as <i>)\n\
778             - Use `backticks` for inline code, commands, or technical terms\n\
779             - Use triple backticks for code blocks\n\
780             - Use emoji naturally to add personality — but don't overdo it\n\
781             - Be concise and direct. Skip filler phrases like 'Great question!' or 'Certainly!'\n\
782             - Structure longer answers with bold headers, not raw markdown ## headers\n\
783             - 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\
784             - Keep normal text outside markers and never wrap markers in code fences.\n\
785             - Use tool results silently: answer the latest user message directly, and do not narrate delayed/internal tool execution bookkeeping.",
786        ),
787        "qq" => Some(
788            "When responding on QQ:\n\
789             - Use Markdown formatting\n\
790             - Be concise and direct\n\
791             - For media attachments use markers: [IMAGE:<path-or-url>], [DOCUMENT:<path-or-url>], \
792               [VIDEO:<path-or-url>], [VOICE:<path-or-url>]\n\
793             - Voice supports .wav, .mp3, .silk formats only. Other audio formats use [DOCUMENT:]\n\
794             - Keep normal text outside markers and never wrap markers in code fences.\n",
795        ),
796        "wechat" => Some(
797            "When responding on WeChat:\n\
798             - Be concise and direct\n\
799             - For media attachments use markers: [IMAGE:<path-or-url>], [DOCUMENT:<path-or-url>], \
800               [VIDEO:<path-or-url>], [AUDIO:<path-or-url>], or [VOICE:<path-or-url>]\n\
801             - Keep normal text outside markers and never wrap markers in code fences.\n\
802             - Use absolute local paths when sending generated files whenever possible.\n",
803        ),
804        "wecom_ws" => Some(
805            "When responding on WeCom AI Bot WebSocket:\n\
806             - Be concise and direct\n\
807             - Use Markdown text; the channel sends progressive draft updates when enabled\n\
808             - Do not use local attachment markers; outbound image payloads are not supported yet.\n",
809        ),
810        _ => None,
811    }
812}
813
814fn build_channel_system_prompt_for_message(
815    base_prompt: &str,
816    msg: &zeroclaw_api::channel::ChannelMessage,
817    target_channel: Option<&Arc<dyn Channel>>,
818) -> String {
819    let bot_mention = target_channel.and_then(|c| c.self_addressed_mention());
820    build_channel_system_prompt(
821        base_prompt,
822        &msg.channel,
823        &msg.reply_target,
824        &msg.sender,
825        &msg.id,
826        bot_mention.as_deref(),
827    )
828}
829
830fn build_channel_system_prompt(
831    base_prompt: &str,
832    channel_name: &str,
833    reply_target: &str,
834    sender: &str,
835    message_id: &str,
836    bot_mention: Option<&str>,
837) -> String {
838    let mut prompt = base_prompt.to_string();
839
840    refresh_channel_prompt_date_section(&mut prompt);
841
842    if let Some(instructions) = channel_delivery_instructions(channel_name) {
843        if prompt.is_empty() {
844            prompt = instructions.to_string();
845        } else {
846            prompt = format!("{prompt}\n\n{instructions}");
847        }
848    }
849
850    if let Some(mention) = bot_mention {
851        let block = format!(
852            "\n\nYour addressable handle on this channel: {mention}. \
853             When you see this exact string anywhere in an inbound message, \
854             it refers to YOU, not another agent or user. This same format \
855             is also what you should emit when you need to tag yourself or \
856             address peers in outbound replies on this channel."
857        );
858        prompt.push_str(&block);
859    }
860
861    if !reply_target.is_empty() {
862        // For most channels, `reply_target` is the address to send to (channel/room
863        // ID for Slack/Discord/Matrix, peer ID for Telegram/Signal). The webhook
864        // channel is the exception: its outbound JSON has both `recipient` and
865        // `thread_id`, and downstream services routing through it expect the
866        // *sender* as the recipient and the *thread/conversation* identifier in
867        // `thread_id`. Reusing `reply_target` as `to` for webhook would strip the
868        // thread context and the receiver would discard the callback.
869        let delivery_hint = if channel_name.eq_ignore_ascii_case("webhook") {
870            format!(
871                "delivery={{\"mode\":\"announce\",\"channel\":\"{channel_name}\",\
872                 \"to\":\"{sender}\",\"thread_id\":\"{reply_target}\"}}"
873            )
874        } else {
875            format!(
876                "delivery={{\"mode\":\"announce\",\"channel\":\"{channel_name}\",\
877                 \"to\":\"{reply_target}\"}}"
878            )
879        };
880        let context = format!(
881            "\n\nChannel context: You are currently responding on channel={channel_name}, \
882             reply_target={reply_target}, sender={sender}, message_id={message_id}. \
883             The sender field is the platform-specific user ID of the person who sent \
884             this message. Use it to distinguish between different users. \
885             The message_id field identifies this incoming message; pass it as the \
886             `message_id` argument when calling the `reaction` tool. \
887             When scheduling delayed messages or reminders \
888             via cron_add for this conversation, use {delivery_hint} so the message \
889             reaches the user.\n\nCalibration note: agents in this system currently err \
890             on the side of silence when a response would be appropriate, which users \
891             find frustrating. Skew toward replying. Memory is supplementary context \
892             that informs how you respond, not a gate on whether you respond."
893        );
894        prompt.push_str(&context);
895    }
896
897    prompt
898}
899
900fn current_date_section() -> String {
901    let now = chrono::Local::now();
902    format!(
903        "{CURRENT_DATE_HEADING}{} ({})",
904        now.format("%Y-%m-%d"),
905        now.format("%:z")
906    )
907}
908
909fn refresh_channel_prompt_date_section(prompt: &mut String) {
910    let runtime_start = prompt
911        .find("\n## Runtime")
912        .map(|i| i + 1)
913        .unwrap_or(prompt.len());
914
915    if let Some((start, heading_len)) = find_latest_date_heading_before(prompt, runtime_start) {
916        let content_start = start + heading_len;
917        let section_end = prompt[content_start..]
918            .find("\n## ")
919            .map(|i| content_start + i)
920            .unwrap_or(prompt.len());
921        prompt.replace_range(start..section_end, &current_date_section());
922    }
923}
924
925fn find_latest_date_heading_before(prompt: &str, before: usize) -> Option<(usize, usize)> {
926    let prefix = &prompt[..before];
927    [CURRENT_DATE_HEADING, LEGACY_CURRENT_DATE_TIME_HEADING]
928        .iter()
929        .filter_map(|heading| prefix.rfind(heading).map(|start| (start, heading.len())))
930        .max_by_key(|(start, _)| *start)
931}
932
933fn timestamp_channel_user_content(content: &str) -> String {
934    let now = chrono::Local::now();
935    format!("[{}] {}", now.format("%Y-%m-%d %H:%M:%S %Z"), content)
936}
937
938fn channel_history_content_for_user_turn(content: &str) -> String {
939    let (cleaned, image_refs) = zeroclaw_providers::multimodal::parse_image_markers(content);
940    if image_refs.is_empty() {
941        return content.to_string();
942    }
943
944    let mut cleaned = cleaned.trim().to_string();
945    while cleaned.contains("\n\n\n") {
946        cleaned = cleaned.replace("\n\n\n", "\n\n");
947    }
948
949    if cleaned.is_empty() {
950        "[Image attachment processed by vision model]".to_string()
951    } else {
952        cleaned
953    }
954}
955
956fn restore_current_user_turn_media_payload(
957    turns: &mut [ChatMessage],
958    history_content: &str,
959    full_content: &str,
960) {
961    if history_content == full_content {
962        return;
963    }
964
965    if let Some(turn) = turns
966        .iter_mut()
967        .rev()
968        .find(|turn| turn.role == "user" && turn.content == history_content)
969    {
970        turn.content = full_content.to_string();
971    }
972}
973
974fn strip_historical_image_payloads(turns: &mut Vec<ChatMessage>) {
975    if turns.len() <= 1 {
976        return;
977    }
978
979    let last_idx = turns.len() - 1;
980    for turn in &mut turns[..last_idx] {
981        if turn.content.contains("[IMAGE:") {
982            turn.content = channel_history_content_for_user_turn(&turn.content);
983        }
984    }
985
986    let current = turns.pop();
987    turns.retain(|turn| !turn.content.trim().is_empty());
988    if let Some(current) = current {
989        turns.push(current);
990    }
991}
992
993fn normalize_cached_channel_turns(turns: Vec<ChatMessage>) -> Vec<ChatMessage> {
994    let mut normalized = Vec::with_capacity(turns.len());
995    let mut expecting_user = true;
996
997    for turn in turns {
998        match (expecting_user, turn.role.as_str()) {
999            // Pass through tool-role messages preserved by
1000            // keep_tool_context_turns.  After a tool result the
1001            // next expected message is an assistant response, same as
1002            // after a user message.
1003            (_, "tool") | (true, "user") => {
1004                normalized.push(turn);
1005                expecting_user = false;
1006            }
1007            (false, "assistant") => {
1008                normalized.push(turn);
1009                expecting_user = true;
1010            }
1011            // Interrupted channel turns can produce consecutive user messages
1012            // (no assistant persisted yet). Merge instead of dropping.
1013            (false, "user") | (true, "assistant") => {
1014                if let Some(last_turn) = normalized.last_mut()
1015                    && !turn.content.is_empty()
1016                {
1017                    if !last_turn.content.is_empty() {
1018                        last_turn.content.push_str("\n\n");
1019                    }
1020                    last_turn.content.push_str(&turn.content);
1021                }
1022            }
1023            _ => {}
1024        }
1025    }
1026
1027    normalized
1028}
1029
1030/// Remove `<tool_result …>…</tool_result>` blocks (and a leading `[Tool results]`
1031/// header, if present) from a conversation-history entry so that stale tool
1032/// output is never presented to the LLM without the corresponding `<tool_call>`.
1033fn strip_tool_result_content(text: &str) -> String {
1034    static TOOL_RESULT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1035        regex::Regex::new(r"(?s)<tool_result[^>]*>.*?</tool_result>").unwrap()
1036    });
1037
1038    let cleaned = TOOL_RESULT_RE.replace_all(text, "");
1039    let cleaned = cleaned.trim();
1040
1041    // If the only remaining content is the header, drop it entirely.
1042    if cleaned == "[Tool results]" || cleaned.is_empty() {
1043        return String::new();
1044    }
1045
1046    cleaned.to_string()
1047}
1048
1049/// Remove a leading `[Used tools: ...]` line from a cached assistant turn.
1050///
1051/// The tool-context summary is prepended to history entries so the LLM retains
1052/// awareness of prior tool usage. However, when these entries are loaded back
1053/// into the LLM context, the bracket-format leaks into generated output and
1054/// gets forwarded to end users as-is (bug #4400). Stripping the prefix on
1055/// reload prevents the model from learning and reproducing this internal format.
1056fn strip_tool_summary_prefix(text: &str) -> String {
1057    if let Some(rest) = text.strip_prefix("[Used tools:") {
1058        // Find the closing bracket, then skip it and any leading newline(s).
1059        if let Some(bracket_end) = rest.find(']') {
1060            let after_bracket = &rest[bracket_end + 1..];
1061            let trimmed = after_bracket.trim_start_matches('\n');
1062            if trimmed.is_empty() {
1063                return String::new();
1064            }
1065            return trimmed.to_string();
1066        }
1067    }
1068    text.to_string()
1069}
1070
1071fn supports_runtime_model_switch(channel_name: &str) -> bool {
1072    matches!(
1073        channel_name,
1074        "telegram" | "discord" | "matrix" | "slack" | "wecom_ws"
1075    )
1076}
1077
1078fn is_explicitly_addressed_channel_message(channel_name: &str, content: &str) -> bool {
1079    channel_name == "wecom_ws"
1080        && content.contains("[WeCom group message addressed to this bot via @")
1081}
1082
1083fn is_matrix_channel_name(channel_name: &str) -> bool {
1084    channel_name == "matrix" || channel_name.starts_with("matrix:")
1085}
1086
1087fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRuntimeCommand> {
1088    let trimmed = content.trim();
1089    if !trimmed.starts_with('/') {
1090        return None;
1091    }
1092
1093    let mut parts = trimmed.split_whitespace();
1094    let command_token = parts.next()?;
1095    let base_command = command_token
1096        .split('@')
1097        .next()
1098        .unwrap_or(command_token)
1099        .to_ascii_lowercase();
1100
1101    match base_command.as_str() {
1102        // `/new` is available on every channel — no model-switch gate.
1103        "/new" => Some(ChannelRuntimeCommand::NewSession),
1104        // Model/model_provider switching is channel-gated.
1105        "/models" if supports_runtime_model_switch(channel_name) => {
1106            if let Some(model_provider) = parts.next() {
1107                Some(ChannelRuntimeCommand::SetProvider(
1108                    model_provider.trim().to_string(),
1109                ))
1110            } else {
1111                Some(ChannelRuntimeCommand::ShowProviders)
1112            }
1113        }
1114        "/model" if supports_runtime_model_switch(channel_name) => {
1115            let model = parts.collect::<Vec<_>>().join(" ").trim().to_string();
1116            if model.is_empty() {
1117                Some(ChannelRuntimeCommand::ShowModel)
1118            } else {
1119                Some(ChannelRuntimeCommand::SetModel(model))
1120            }
1121        }
1122        "/config" if supports_runtime_model_switch(channel_name) => {
1123            Some(ChannelRuntimeCommand::ShowConfig)
1124        }
1125        _ => None,
1126    }
1127}
1128
1129/// Verify `name` matches a canonical model provider family known to the
1130/// runtime registry. Returns the canonical (case-corrected) name, or `None`
1131/// when the input doesn't name a known family. Used by the channel
1132/// `/models` slash command, which accepts only the bare family name; dotted
1133/// aliases (`<family>.<alias>`) are resolved elsewhere through
1134/// `create_resilient_model_provider_from_ref`.
1135fn canonical_model_provider_name(name: &str) -> Option<String> {
1136    let candidate = name.trim();
1137    if candidate.is_empty() {
1138        return None;
1139    }
1140
1141    zeroclaw_providers::list_model_providers()
1142        .into_iter()
1143        .find(|model_provider| model_provider.name.eq_ignore_ascii_case(candidate))
1144        .map(|model_provider| model_provider.name.to_string())
1145}
1146
1147/// Outcome of resolving a `/models <arg>` request to a configured,
1148/// alias-backed provider ref. The bare family path must never construct a
1149/// provider that ignores the configured `[providers.models.<family>.<alias>]`
1150/// key/URI — every accepted route resolves to a real alias entry.
1151#[cfg_attr(test, derive(Debug))]
1152enum ModelsCommandResolution {
1153    /// A dotted `<family>.<alias>` ref backed by a configured entry.
1154    Resolved(String),
1155    /// The family is valid but has more than one configured alias; the user
1156    /// must qualify which one. Carries the canonical family and its aliases.
1157    Ambiguous {
1158        family: String,
1159        aliases: Vec<String>,
1160    },
1161    /// The family is valid but has no configured alias entry, so there is no
1162    /// credentialed provider to switch to.
1163    NoAlias(String),
1164    /// The argument names no known provider family.
1165    Unknown,
1166}
1167
1168/// Resolve a `/models <arg>` argument to a configured, alias-backed provider
1169/// ref. Accepts either a dotted `<family>.<alias>` that resolves to a real
1170/// `[providers.models.<family>.<alias>]` entry, or a bare family name that has
1171/// exactly one configured alias. A bare family with several aliases is
1172/// ambiguous; one with none has no credentialed provider. This keeps `/models`
1173/// inside the v0.8 alias model rather than constructing a bare provider that
1174/// silently ignores the configured key/URI.
1175fn resolve_models_command(
1176    config: &zeroclaw_config::schema::Config,
1177    raw: &str,
1178) -> ModelsCommandResolution {
1179    let candidate = raw.trim();
1180    if let Some((family, alias)) = candidate.split_once('.') {
1181        return match config.providers.models.find(family, alias) {
1182            Some(_) => ModelsCommandResolution::Resolved(format!("{family}.{alias}")),
1183            None => ModelsCommandResolution::NoAlias(candidate.to_string()),
1184        };
1185    }
1186
1187    let Some(family) = canonical_model_provider_name(candidate) else {
1188        return ModelsCommandResolution::Unknown;
1189    };
1190
1191    let mut aliases: Vec<String> = config
1192        .providers
1193        .models
1194        .aliases_of(&family)
1195        .map(ToString::to_string)
1196        .collect();
1197    aliases.sort();
1198    match aliases.len() {
1199        0 => ModelsCommandResolution::NoAlias(family),
1200        1 => ModelsCommandResolution::Resolved(format!("{family}.{}", aliases[0])),
1201        _ => ModelsCommandResolution::Ambiguous { family, aliases },
1202    }
1203}
1204
1205fn resolve_provider_ref_for_runtime_switch(config: &Config, raw: &str) -> anyhow::Result<String> {
1206    match resolve_models_command(config, raw) {
1207        ModelsCommandResolution::Resolved(provider_ref) => Ok(provider_ref),
1208        ModelsCommandResolution::Ambiguous { family, aliases } => {
1209            let list = aliases
1210                .iter()
1211                .map(|alias| format!("{family}.{alias}"))
1212                .collect::<Vec<_>>()
1213                .join(", ");
1214            anyhow::bail!(
1215                "model_provider `{family}` has multiple configured aliases; use one of: {list}"
1216            )
1217        }
1218        ModelsCommandResolution::NoAlias(ref_or_family) => {
1219            anyhow::bail!(
1220                "model_provider `{ref_or_family}` does not resolve to a configured provider"
1221            )
1222        }
1223        ModelsCommandResolution::Unknown => {
1224            anyhow::bail!("unknown model_provider `{raw}`")
1225        }
1226    }
1227}
1228
1229fn resolved_runtime_model_provider_ref(
1230    config: &Config,
1231    agent_alias: &str,
1232) -> anyhow::Result<String> {
1233    let agent = config
1234        .agents
1235        .get(agent_alias)
1236        .with_context(|| format!("agents.{agent_alias} is not configured"))?;
1237    let configured = agent.model_provider.trim();
1238    if configured.is_empty() {
1239        anyhow::bail!(
1240            "agents.{agent_alias}.model_provider is empty; runtime reload requires a dotted `<type>.<alias>` provider reference"
1241        );
1242    }
1243    let (model_provider, _) = model_provider_entry_for_ref(config, configured)?;
1244    Ok(model_provider)
1245}
1246
1247fn model_provider_entry_for_ref<'a>(
1248    config: &'a Config,
1249    model_provider: &str,
1250) -> anyhow::Result<(String, &'a zeroclaw_config::schema::ModelProviderConfig)> {
1251    let trimmed = model_provider.trim();
1252    if trimmed.is_empty() {
1253        anyhow::bail!("model_provider reference must not be empty");
1254    }
1255
1256    let Some((provider_type, provider_alias)) = trimmed.split_once('.') else {
1257        anyhow::bail!("model_provider `{trimmed}` must use `<type>.<alias>` form");
1258    };
1259    let Some(entry) = config.providers.models.find(provider_type, provider_alias) else {
1260        anyhow::bail!("model_provider `{trimmed}` does not resolve to a configured provider");
1261    };
1262    Ok((trimmed.to_string(), entry))
1263}
1264
1265/// Resolve runtime defaults from `config` against a specific dotted
1266/// `model_provider` reference (`"<type>.<alias>"`) — the per-agent
1267/// resolution path.
1268fn runtime_defaults_from_config(
1269    config: &Config,
1270    model_provider: &str,
1271) -> anyhow::Result<ChannelRuntimeDefaults> {
1272    let (default_model_provider, entry) = model_provider_entry_for_ref(config, model_provider)?;
1273    let model = entry
1274        .model
1275        .as_deref()
1276        .map(str::trim)
1277        .filter(|model| !model.is_empty())
1278        .map(ToString::to_string)
1279        .ok_or_else(|| {
1280            ::zeroclaw_log::record!(
1281                ERROR,
1282                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1283                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1284                    .with_attrs(::serde_json::json!({
1285                        "model_provider": model_provider,
1286                        "reason": "no_model_configured",
1287                    })),
1288                "orchestrator: model_provider has no resolvable model"
1289            );
1290            anyhow::Error::msg(format!(
1291                "no model configured: model_provider '{model_provider}' does not resolve to a \
1292                 ModelProviderConfig with a `model` field, and providers.models has no \
1293                 fallback entry."
1294            ))
1295        })?;
1296    Ok(ChannelRuntimeDefaults {
1297        default_model_provider,
1298        model,
1299        temperature: entry.temperature,
1300        api_key: entry.api_key.clone(),
1301        api_url: entry.uri.clone(),
1302        reliability: config.reliability.clone(),
1303    })
1304}
1305
1306fn runtime_config_path(ctx: &ChannelRuntimeContext) -> Option<PathBuf> {
1307    ctx.provider_runtime_options
1308        .zeroclaw_dir
1309        .as_ref()
1310        .map(|dir| dir.join("config.toml"))
1311}
1312
1313fn runtime_defaults_snapshot(ctx: &ChannelRuntimeContext) -> ChannelRuntimeDefaultsSnapshot {
1314    if let Some(runtime_override) = ctx
1315        .runtime_defaults_override
1316        .lock()
1317        .unwrap_or_else(|e| e.into_inner())
1318        .clone()
1319    {
1320        return ChannelRuntimeDefaultsSnapshot {
1321            config: Arc::clone(&runtime_override.config),
1322            defaults: runtime_override.defaults.clone(),
1323            hot: true,
1324            generation: runtime_override.generation,
1325        };
1326    }
1327
1328    ChannelRuntimeDefaultsSnapshot {
1329        config: Arc::clone(&ctx.prompt_config),
1330        defaults: ChannelRuntimeDefaults {
1331            default_model_provider: ctx.model_provider_ref.as_str().to_string(),
1332            model: ctx.model.as_str().to_string(),
1333            temperature: ctx.temperature,
1334            api_key: None,
1335            api_url: None,
1336            reliability: (*ctx.reliability).clone(),
1337        },
1338        hot: false,
1339        generation: 0,
1340    }
1341}
1342
1343async fn config_file_stamp(path: &Path) -> Option<ConfigFileStamp> {
1344    let metadata = tokio::fs::metadata(path).await.ok()?;
1345    let modified = metadata.modified().ok()?;
1346    Some(ConfigFileStamp {
1347        modified,
1348        len: metadata.len(),
1349    })
1350}
1351
1352async fn load_runtime_config_and_defaults(
1353    path: &Path,
1354    agent_alias: &str,
1355) -> Result<(Config, ChannelRuntimeDefaults)> {
1356    let contents = tokio::fs::read_to_string(path)
1357        .await
1358        .with_context(|| format!("Failed to read {}", path.display()))?;
1359    let mut parsed: Config = zeroclaw_config::migration::migrate_to_current(&contents)
1360        .with_context(|| format!("Failed to migrate {}", path.display()))?;
1361    parsed.config_path = path.to_path_buf();
1362
1363    if let Some(zeroclaw_dir) = path.parent() {
1364        let store =
1365            zeroclaw_runtime::security::SecretStore::new(zeroclaw_dir, parsed.secrets.encrypt);
1366        parsed.decrypt_secrets(&store)?;
1367    }
1368    let applied = zeroclaw_config::env_overrides::apply_env_overrides(&mut parsed)?;
1369    parsed.env_overridden_paths = applied.paths;
1370    parsed.pre_override_snapshots = applied.snapshots;
1371
1372    let model_provider = resolved_runtime_model_provider_ref(&parsed, agent_alias)?;
1373    let defaults = runtime_defaults_from_config(&parsed, &model_provider)?;
1374    Ok((parsed, defaults))
1375}
1376
1377async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Result<()> {
1378    let Some(config_path) = runtime_config_path(ctx) else {
1379        return Ok(());
1380    };
1381
1382    let Some(stamp) = config_file_stamp(&config_path).await else {
1383        return Ok(());
1384    };
1385
1386    {
1387        let last = ctx
1388            .last_applied_config_stamp
1389            .lock()
1390            .unwrap_or_else(|e| e.into_inner());
1391        if *last == Some(stamp) {
1392            return Ok(());
1393        }
1394    }
1395
1396    let (next_config, next_defaults) =
1397        load_runtime_config_and_defaults(&config_path, ctx.agent_alias.as_str()).await?;
1398    let next_config = Arc::new(next_config);
1399    let next_options = zeroclaw_providers::options_for_provider_ref(
1400        next_config.as_ref(),
1401        &next_defaults.default_model_provider,
1402        &ctx.provider_runtime_options,
1403    );
1404    let model_provider_instance = zeroclaw_providers::create_resilient_model_provider_from_ref(
1405        next_config.as_ref(),
1406        &next_defaults.default_model_provider,
1407        next_defaults.api_key.as_deref(),
1408        next_defaults.api_url.as_deref(),
1409        &next_defaults.reliability,
1410        &next_options,
1411    )?;
1412    let model_provider_instance: Arc<dyn ModelProvider> = Arc::from(model_provider_instance);
1413
1414    if let Err(err) = model_provider_instance.warmup().await {
1415        if zeroclaw_providers::reliable::is_non_retryable(&err) {
1416            ::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)");
1417            return Ok(());
1418        }
1419        ::zeroclaw_log::record!(
1420            WARN,
1421            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1422                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1423                .with_attrs(
1424                    ::serde_json::json!({"model_provider": next_defaults.default_model_provider, "err": err.to_string()})
1425                ),
1426            "ModelProvider warmup failed after config reload (retryable, applying anyway)"
1427        );
1428    }
1429
1430    {
1431        let mut override_guard = ctx
1432            .runtime_defaults_override
1433            .lock()
1434            .unwrap_or_else(|e| e.into_inner());
1435        let next_generation = override_guard.as_ref().map_or(1, |runtime_override| {
1436            runtime_override.generation.saturating_add(1)
1437        });
1438        let next_override = Arc::new(ChannelRuntimeOverride {
1439            config: Arc::clone(&next_config),
1440            defaults: next_defaults.clone(),
1441            generation: next_generation,
1442        });
1443        let cache_key =
1444            provider_cache_key(&next_defaults.default_model_provider, None, next_generation);
1445
1446        let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
1447        cache.clear();
1448        cache.insert(cache_key, Arc::clone(&model_provider_instance));
1449        *override_guard = Some(next_override);
1450    }
1451
1452    *ctx.last_applied_config_stamp
1453        .lock()
1454        .unwrap_or_else(|e| e.into_inner()) = Some(stamp);
1455
1456    ::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");
1457
1458    Ok(())
1459}
1460
1461fn default_route_selection_from_snapshot(
1462    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
1463) -> ChannelRouteSelection {
1464    let defaults = defaults_snapshot.defaults.clone();
1465    ChannelRouteSelection {
1466        model_provider: defaults.default_model_provider,
1467        model: defaults.model,
1468        api_key: None,
1469    }
1470}
1471
1472fn get_route_selection(
1473    ctx: &ChannelRuntimeContext,
1474    sender_key: &str,
1475    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
1476) -> ChannelRouteSelection {
1477    ctx.route_overrides
1478        .lock()
1479        .unwrap_or_else(|e| e.into_inner())
1480        .get(sender_key)
1481        .cloned()
1482        .unwrap_or_else(|| default_route_selection_from_snapshot(defaults_snapshot))
1483}
1484
1485fn set_route_selection(
1486    ctx: &ChannelRuntimeContext,
1487    sender_key: &str,
1488    next: ChannelRouteSelection,
1489    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
1490) {
1491    let default_route = default_route_selection_from_snapshot(defaults_snapshot);
1492    let mut routes = ctx
1493        .route_overrides
1494        .lock()
1495        .unwrap_or_else(|e| e.into_inner());
1496    if next == default_route {
1497        routes.remove(sender_key);
1498    } else {
1499        routes.insert(sender_key.to_string(), next);
1500    }
1501}
1502
1503fn clear_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) {
1504    ctx.conversation_histories
1505        .lock()
1506        .unwrap_or_else(|e| e.into_inner())
1507        .pop(sender_key);
1508}
1509
1510fn mark_sender_for_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) {
1511    ctx.pending_new_sessions
1512        .lock()
1513        .unwrap_or_else(|e| e.into_inner())
1514        .insert(sender_key.to_string());
1515}
1516
1517fn take_pending_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
1518    ctx.pending_new_sessions
1519        .lock()
1520        .unwrap_or_else(|e| e.into_inner())
1521        .remove(sender_key)
1522}
1523
1524fn replace_available_skills_section(base_prompt: &str, refreshed_skills: &str) -> String {
1525    const SKILLS_HEADER: &str = "## Available Skills\n\n";
1526    const SKILLS_END: &str = "</available_skills>";
1527    const WORKSPACE_HEADER: &str = "## Workspace\n\n";
1528
1529    if let Some(start) = base_prompt.find(SKILLS_HEADER)
1530        && let Some(rel_end) = base_prompt[start..].find(SKILLS_END)
1531    {
1532        let end = start + rel_end + SKILLS_END.len();
1533        let tail = base_prompt[end..]
1534            .strip_prefix("\n\n")
1535            .unwrap_or(&base_prompt[end..]);
1536
1537        let mut refreshed = String::with_capacity(
1538            base_prompt.len().saturating_sub(end.saturating_sub(start))
1539                + refreshed_skills.len()
1540                + 2,
1541        );
1542        refreshed.push_str(&base_prompt[..start]);
1543        if !refreshed_skills.is_empty() {
1544            refreshed.push_str(refreshed_skills);
1545            refreshed.push_str("\n\n");
1546        }
1547        refreshed.push_str(tail);
1548        return refreshed;
1549    }
1550
1551    if refreshed_skills.is_empty() {
1552        return base_prompt.to_string();
1553    }
1554
1555    if let Some(workspace_start) = base_prompt.find(WORKSPACE_HEADER) {
1556        let mut refreshed = String::with_capacity(base_prompt.len() + refreshed_skills.len() + 2);
1557        refreshed.push_str(&base_prompt[..workspace_start]);
1558        refreshed.push_str(refreshed_skills);
1559        refreshed.push_str("\n\n");
1560        refreshed.push_str(&base_prompt[workspace_start..]);
1561        return refreshed;
1562    }
1563
1564    format!("{base_prompt}\n\n{refreshed_skills}")
1565}
1566
1567fn refreshed_new_session_system_prompt(ctx: &ChannelRuntimeContext) -> String {
1568    let refreshed_skills = zeroclaw_runtime::skills::skills_to_prompt_with_mode(
1569        &zeroclaw_runtime::skills::load_skills_with_config(
1570            ctx.workspace_dir.as_ref(),
1571            ctx.prompt_config.as_ref(),
1572        ),
1573        ctx.workspace_dir.as_ref(),
1574        ctx.prompt_config.skills.prompt_injection_mode,
1575    );
1576    replace_available_skills_section(ctx.system_prompt.as_str(), &refreshed_skills)
1577}
1578
1579fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
1580    let mut histories = ctx
1581        .conversation_histories
1582        .lock()
1583        .unwrap_or_else(|e| e.into_inner());
1584
1585    let Some(turns) = histories.get_mut(sender_key) else {
1586        return false;
1587    };
1588
1589    if turns.is_empty() {
1590        return false;
1591    }
1592
1593    let keep_from = turns
1594        .len()
1595        .saturating_sub(CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
1596    let mut compacted = normalize_cached_channel_turns(turns[keep_from..].to_vec());
1597
1598    for turn in &mut compacted {
1599        if turn.content.chars().count() > CHANNEL_HISTORY_COMPACT_CONTENT_CHARS {
1600            turn.content =
1601                truncate_with_ellipsis(&turn.content, CHANNEL_HISTORY_COMPACT_CONTENT_CHARS);
1602        }
1603    }
1604
1605    if compacted.is_empty() {
1606        turns.clear();
1607        return false;
1608    }
1609
1610    *turns = compacted;
1611    true
1612}
1613
1614/// Proactively trim conversation turns so that the total estimated character
1615/// count stays within [`PROACTIVE_CONTEXT_BUDGET_CHARS`].  Drops the oldest
1616/// turns first, but always preserves the most recent turn (the current user
1617/// message).  Returns the number of turns dropped.
1618fn proactive_trim_turns(turns: &mut Vec<ChatMessage>, budget: usize) -> usize {
1619    let total_chars: usize = turns.iter().map(|t| t.content.chars().count()).sum();
1620    if total_chars <= budget || turns.len() <= 1 {
1621        return 0;
1622    }
1623
1624    let mut excess = total_chars.saturating_sub(budget);
1625    let mut drop_count = 0;
1626
1627    // Walk from the oldest turn forward, but never drop the very last turn.
1628    while excess > 0 && drop_count < turns.len().saturating_sub(1) {
1629        excess = excess.saturating_sub(turns[drop_count].content.chars().count());
1630        drop_count += 1;
1631    }
1632
1633    if drop_count > 0 {
1634        turns.drain(..drop_count);
1635    }
1636    drop_count
1637}
1638
1639fn append_sender_turn(ctx: &ChannelRuntimeContext, sender_key: &str, turn: ChatMessage) {
1640    // Persist to JSONL before adding to in-memory history.
1641    if let Some(ref store) = ctx.session_store
1642        && let Err(e) = store.append(sender_key, &turn)
1643    {
1644        ::zeroclaw_log::record!(
1645            WARN,
1646            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1647                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1648                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1649            "Failed to persist session turn"
1650        );
1651    }
1652
1653    // Use the user-configured max_history_messages (fall back to
1654    // MAX_CHANNEL_HISTORY when the config value is 0 or absent).
1655    let max_history = {
1656        let configured = ctx.agent_cfg.resolved.max_history_messages;
1657        if configured > 0 {
1658            configured
1659        } else {
1660            MAX_CHANNEL_HISTORY
1661        }
1662    };
1663
1664    let mut histories = ctx
1665        .conversation_histories
1666        .lock()
1667        .unwrap_or_else(|e| e.into_inner());
1668    let turns = histories.get_or_insert_mut(sender_key.to_string(), Vec::new);
1669    turns.push(turn);
1670    while turns.len() > max_history {
1671        turns.remove(0);
1672    }
1673}
1674
1675/// Extract tool-call (assistant with tool_call content) and tool-result
1676/// messages from the current turn in the LLM history, excluding the final
1677/// assistant text response.  "Current turn" = everything after the last
1678/// user-role message.
1679fn extract_current_turn_tool_messages(history: &[ChatMessage]) -> Vec<ChatMessage> {
1680    // Find the index of the last user message — tool messages for the
1681    // current turn come after it.
1682    let last_user_idx = history.iter().rposition(|m| m.role == "user").unwrap_or(0);
1683
1684    let tail = &history[last_user_idx + 1..];
1685    if tail.is_empty() {
1686        return Vec::new();
1687    }
1688
1689    // Everything except the very last assistant message (which is the
1690    // final text response that gets stored separately).
1691    let end = if tail.last().is_some_and(|m| m.role == "assistant") {
1692        tail.len() - 1
1693    } else {
1694        tail.len()
1695    };
1696
1697    tail[..end]
1698        .iter()
1699        .filter(|m| m.role == "assistant" || m.role == "tool")
1700        .cloned()
1701        .collect()
1702}
1703
1704/// Remove tool-role and intermediate assistant tool-call messages from
1705/// conversation turns older than the most recent `keep_turns` user→assistant
1706/// exchanges.  This prevents unbounded history growth while preserving
1707/// tool context for the N most recent turns.
1708fn strip_old_tool_context(ctx: &ChannelRuntimeContext, sender_key: &str, keep_turns: usize) {
1709    let mut histories = ctx
1710        .conversation_histories
1711        .lock()
1712        .unwrap_or_else(|e| e.into_inner());
1713
1714    let Some(turns) = histories.get_mut(sender_key) else {
1715        return;
1716    };
1717
1718    // Walk backwards to find the oldest user message that still belongs to the
1719    // most recent `keep_turns` exchanges. Everything before that boundary is
1720    // old enough to strip. If the session has fewer than `keep_turns` user
1721    // turns, preserve every message.
1722    let mut user_count = 0;
1723    let mut strip_before = 0;
1724    for (i, turn) in turns.iter().enumerate().rev() {
1725        if turn.role == "user" {
1726            user_count += 1;
1727            if user_count == keep_turns {
1728                strip_before = i;
1729                break;
1730            }
1731        }
1732    }
1733
1734    if user_count < keep_turns {
1735        return;
1736    }
1737
1738    // Remove tool and intermediate assistant messages before the boundary.
1739    // An "intermediate assistant" is one whose content looks like a tool
1740    // call, either in legacy XML / JSON forms or in native `tool_calls` JSON.
1741    let mut i = 0;
1742    while i < strip_before && i < turns.len() {
1743        let dominated = turns[i].role == "tool"
1744            || (turns[i].role == "assistant" && is_tool_call_content(&turns[i].content));
1745        if dominated {
1746            turns.remove(i);
1747            // Adjust boundary since we removed an element.
1748            strip_before = strip_before.saturating_sub(1);
1749        } else {
1750            i += 1;
1751        }
1752    }
1753}
1754
1755/// Heuristic: does this assistant message content represent a tool call
1756/// rather than a final text response?
1757fn is_tool_call_content(content: &str) -> bool {
1758    let trimmed = content.trim();
1759    trimmed.contains("<tool_call>")
1760        || trimmed.starts_with("{\"tool_call\"")
1761        || is_named_tool_call_json(trimmed)
1762        || is_native_tool_call_json(trimmed)
1763}
1764
1765fn is_named_tool_call_json(content: &str) -> bool {
1766    let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
1767        return false;
1768    };
1769
1770    value
1771        .get("name")
1772        .and_then(|name| name.as_str())
1773        .is_some_and(|name| {
1774            !name.is_empty()
1775                && (value.get("args").is_some()
1776                    || value.get("arguments").is_some()
1777                    || value.get("parameters").is_some())
1778        })
1779}
1780
1781fn is_native_tool_call_json(content: &str) -> bool {
1782    let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
1783        return false;
1784    };
1785
1786    let Some(tool_calls) = value.get("tool_calls").and_then(|calls| calls.as_array()) else {
1787        return false;
1788    };
1789
1790    !tool_calls.is_empty()
1791        && tool_calls.iter().all(|call| {
1792            call.get("function")
1793                .and_then(|function| function.get("name"))
1794                .and_then(|name| name.as_str())
1795                .or_else(|| call.get("name").and_then(|name| name.as_str()))
1796                .is_some()
1797        })
1798}
1799
1800fn rollback_orphan_user_turn(
1801    ctx: &ChannelRuntimeContext,
1802    sender_key: &str,
1803    expected_content: &str,
1804) -> bool {
1805    let mut histories = ctx
1806        .conversation_histories
1807        .lock()
1808        .unwrap_or_else(|e| e.into_inner());
1809    let Some(turns) = histories.get_mut(sender_key) else {
1810        return false;
1811    };
1812
1813    let should_pop = turns
1814        .last()
1815        .is_some_and(|turn| turn.role == "user" && turn.content == expected_content);
1816    if !should_pop {
1817        return false;
1818    }
1819
1820    turns.pop();
1821    if turns.is_empty() {
1822        histories.pop(sender_key);
1823    }
1824
1825    // Also remove the orphan turn from the persisted JSONL session store so
1826    // it doesn't resurface after a daemon restart (fixes #3674).
1827    if let Some(ref store) = ctx.session_store
1828        && let Err(e) = store.remove_last(sender_key)
1829    {
1830        ::zeroclaw_log::record!(
1831            WARN,
1832            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1833                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1834                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1835            "Failed to rollback session store entry"
1836        );
1837    }
1838
1839    true
1840}
1841
1842fn should_rollback_failed_user_turn(error: &anyhow::Error) -> bool {
1843    if error
1844        .downcast_ref::<zeroclaw_providers::ProviderCapabilityError>()
1845        .is_some_and(|capability| capability.capability.eq_ignore_ascii_case("vision"))
1846    {
1847        return true;
1848    }
1849
1850    zeroclaw_providers::reliable::is_non_retryable(error)
1851}
1852
1853fn should_skip_memory_context_entry(key: &str, content: &str) -> bool {
1854    if zeroclaw_memory::is_assistant_autosave_key(key) {
1855        return true;
1856    }
1857
1858    // Skip raw per-turn user messages: re-injecting them causes each
1859    // recalled entry to embed all prior generations, growing exponentially.
1860    // Consolidated knowledge is already promoted to Core/Daily entries.
1861    if zeroclaw_memory::is_user_autosave_key(key) {
1862        return true;
1863    }
1864
1865    if zeroclaw_memory::should_skip_autosave_content(content) {
1866        return true;
1867    }
1868
1869    if key.trim().to_ascii_lowercase().ends_with("_history") {
1870        return true;
1871    }
1872
1873    // Skip entries containing image markers to prevent duplication.
1874    // When auto_save stores a photo message to memory, a subsequent
1875    // memory recall on the same turn would surface the marker again,
1876    // causing two identical image blocks in the model_provider request.
1877    if content.contains("[IMAGE:") {
1878        return true;
1879    }
1880
1881    // Skip entries containing tool_result blocks. After a daemon restart
1882    // these can be recalled from SQLite and injected as memory context,
1883    // presenting the LLM with a `<tool_result>` without a preceding
1884    // `<tool_call>` and triggering hallucinated output.
1885    if content.contains("<tool_result") {
1886        return true;
1887    }
1888
1889    content.chars().count() > MEMORY_CONTEXT_MAX_CHARS
1890}
1891
1892fn is_context_window_overflow_error(err: &anyhow::Error) -> bool {
1893    let lower = err.to_string().to_lowercase();
1894    [
1895        "exceeds the context window",
1896        "context window of this model",
1897        "maximum context length",
1898        "context length exceeded",
1899        "too many tokens",
1900        "token limit exceeded",
1901        "prompt is too long",
1902        "input is too long",
1903    ]
1904    .iter()
1905    .any(|hint| lower.contains(hint))
1906}
1907
1908fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec<String> {
1909    let cache_path = workspace_dir.join("state").join(MODEL_CACHE_FILE);
1910    let Ok(raw) = std::fs::read_to_string(cache_path) else {
1911        return Vec::new();
1912    };
1913    let Ok(state) = serde_json::from_str::<ModelCacheState>(&raw) else {
1914        return Vec::new();
1915    };
1916
1917    state
1918        .entries
1919        .into_iter()
1920        .find(|entry| entry.model_provider == provider_name)
1921        .map(|entry| {
1922            entry
1923                .models
1924                .into_iter()
1925                .take(MODEL_CACHE_PREVIEW_LIMIT)
1926                .collect::<Vec<_>>()
1927        })
1928        .unwrap_or_default()
1929}
1930
1931/// Build a cache key that includes the runtime-defaults generation, the
1932/// model_provider name, and, when a route-specific API key is supplied, a hash
1933/// of that key. Generation `0` is the immutable startup config, so its key shape
1934/// stays unchanged; hot-reload generations get isolated cache entries.
1935fn provider_cache_key(provider_name: &str, route_api_key: Option<&str>, generation: u64) -> String {
1936    let base = match route_api_key {
1937        Some(key) => {
1938            use std::hash::{Hash, Hasher};
1939            let mut hasher = std::collections::hash_map::DefaultHasher::new();
1940            key.hash(&mut hasher);
1941            format!("{provider_name}@{:x}", hasher.finish())
1942        }
1943        None => provider_name.to_string(),
1944    };
1945    if generation == 0 {
1946        base
1947    } else {
1948        format!("g{generation}:{base}")
1949    }
1950}
1951
1952/// Resolve a provider ref's own credentials strictly from its
1953/// `[providers.models.<type>.<alias>]` entry. No default/global fallback: a
1954/// provider only ever uses its own `api_key` / `uri`. Returns `(None, None)`
1955/// for a ref that does not parse or does not resolve, so the provider factory
1956/// surfaces the misconfiguration instead of silently borrowing another
1957/// provider's key.
1958fn provider_credentials_for_ref(
1959    config: &zeroclaw_config::schema::Config,
1960    provider_ref: &str,
1961) -> (Option<String>, Option<String>) {
1962    let Some((type_key, alias_key)) = provider_ref.trim().split_once('.') else {
1963        return (None, None);
1964    };
1965    config
1966        .providers
1967        .models
1968        .find(type_key, alias_key)
1969        .map_or((None, None), |entry| {
1970            (entry.api_key.clone(), entry.uri.clone())
1971        })
1972}
1973
1974async fn get_or_create_provider(
1975    ctx: &ChannelRuntimeContext,
1976    provider_name: &str,
1977    route_api_key: Option<&str>,
1978    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
1979) -> anyhow::Result<Arc<dyn ModelProvider>> {
1980    let cache_key = provider_cache_key(provider_name, route_api_key, defaults_snapshot.generation);
1981
1982    if let Some(existing) = ctx
1983        .provider_cache
1984        .lock()
1985        .unwrap_or_else(|e| e.into_inner())
1986        .get(&cache_key)
1987        .cloned()
1988    {
1989        return Ok(existing);
1990    }
1991
1992    let config = Arc::clone(&defaults_snapshot.config);
1993    let defaults = defaults_snapshot.defaults.clone();
1994
1995    // Only return the pre-built startup default model_provider while the
1996    // current runtime defaults still match startup and there is no
1997    // route-specific credential override. Once config reload changes defaults,
1998    // the cache/store path above owns the live default provider.
1999    if route_api_key.is_none()
2000        && provider_name == defaults.default_model_provider.as_str()
2001        && provider_name == ctx.model_provider_ref.as_str()
2002        && !defaults_snapshot.hot
2003    {
2004        return Ok(Arc::clone(&ctx.model_provider));
2005    }
2006    // Resolve credentials and URL strictly from the requested provider's own
2007    // `[providers.models.<type>.<alias>]` entry. There is no global/default
2008    // fallback: a provider never inherits another provider's api_key or
2009    // api_url. An unresolved ref yields no credentials so the factory
2010    // surfaces the misconfiguration.
2011    let (entry_api_key, entry_api_url) =
2012        provider_credentials_for_ref(config.as_ref(), provider_name);
2013    let effective_api_key = route_api_key.map(ToString::to_string).or(entry_api_key);
2014
2015    let model_provider = create_resilient_model_provider_nonblocking(
2016        config,
2017        provider_name,
2018        effective_api_key,
2019        entry_api_url,
2020        defaults.reliability,
2021        ctx.provider_runtime_options.clone(),
2022    )
2023    .await?;
2024    let model_provider: Arc<dyn ModelProvider> = Arc::from(model_provider);
2025
2026    if let Err(err) = model_provider.warmup().await {
2027        ::zeroclaw_log::record!(
2028            WARN,
2029            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2030                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2031                .with_attrs(
2032                    ::serde_json::json!({"model_provider": provider_name, "err": err.to_string()})
2033                ),
2034            "ModelProvider warmup failed"
2035        );
2036    }
2037
2038    let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
2039    let cached = cache
2040        .entry(cache_key)
2041        .or_insert_with(|| Arc::clone(&model_provider));
2042    Ok(Arc::clone(cached))
2043}
2044
2045async fn create_resilient_model_provider_nonblocking(
2046    config: Arc<zeroclaw_config::schema::Config>,
2047    provider_name: &str,
2048    api_key: Option<String>,
2049    api_url: Option<String>,
2050    reliability: zeroclaw_config::schema::ReliabilityConfig,
2051    provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
2052) -> anyhow::Result<Box<dyn ModelProvider>> {
2053    let provider_name = provider_name.to_string();
2054    tokio::task::spawn_blocking(move || {
2055        let options = zeroclaw_providers::options_for_provider_ref(
2056            &config,
2057            &provider_name,
2058            &provider_runtime_options,
2059        );
2060        zeroclaw_providers::create_resilient_model_provider_from_ref(
2061            &config,
2062            &provider_name,
2063            api_key.as_deref(),
2064            api_url.as_deref(),
2065            &reliability,
2066            &options,
2067        )
2068    })
2069    .await
2070    .context("failed to join model_provider initialization task")?
2071}
2072
2073fn build_models_help_response(
2074    current: &ChannelRouteSelection,
2075    workspace_dir: &Path,
2076    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
2077) -> String {
2078    let mut response = String::new();
2079    let _ = writeln!(
2080        response,
2081        "Current model_provider: `{}`\nCurrent model: `{}`",
2082        current.model_provider, current.model
2083    );
2084    response.push_str("\nSwitch model with `/model <model-id>` or `/model <hint>`.\n");
2085
2086    if !model_routes.is_empty() {
2087        response.push_str("\nConfigured model routes:\n");
2088        for route in model_routes {
2089            let _ = writeln!(
2090                response,
2091                "  `{}` → {} ({})",
2092                route.hint, route.model, route.model_provider
2093            );
2094        }
2095    }
2096
2097    let cached_models = load_cached_model_preview(workspace_dir, &current.model_provider);
2098    if cached_models.is_empty() {
2099        let _ = writeln!(
2100            response,
2101            "\nNo cached model list found for `{}`. Ask the operator to run `zeroclaw models refresh --model-provider {}`.",
2102            current.model_provider, current.model_provider
2103        );
2104    } else {
2105        let _ = writeln!(
2106            response,
2107            "\nCached model IDs (top {}):",
2108            cached_models.len()
2109        );
2110        for model in cached_models {
2111            let _ = writeln!(response, "- `{model}`");
2112        }
2113    }
2114
2115    response
2116}
2117
2118fn build_providers_help_response(current: &ChannelRouteSelection) -> String {
2119    let mut response = String::new();
2120    let _ = writeln!(
2121        response,
2122        "Current model_provider: `{}`\nCurrent model: `{}`",
2123        current.model_provider, current.model
2124    );
2125    response.push_str("\nSwitch model_provider with `/models <model_provider>`.\n");
2126    response.push_str("Switch model with `/model <model-id>`.\n\n");
2127    response.push_str("Available model model_providers:\n");
2128    for model_provider in zeroclaw_providers::list_model_providers() {
2129        let _ = writeln!(response, "- {}", model_provider.name);
2130    }
2131    response
2132}
2133
2134/// Build a plain-text `/config` response for non-Slack channels.
2135fn build_config_text_response(
2136    current: &ChannelRouteSelection,
2137    _workspace_dir: &Path,
2138    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
2139) -> String {
2140    let mut resp = String::new();
2141    let _ = writeln!(
2142        resp,
2143        "Current model_provider: `{}`\nCurrent model: `{}`",
2144        current.model_provider, current.model
2145    );
2146    resp.push_str("\nAvailable model_providers:\n");
2147    for p in zeroclaw_providers::list_model_providers() {
2148        let _ = writeln!(resp, "- `{}`", p.name);
2149    }
2150    if !model_routes.is_empty() {
2151        resp.push_str("\nConfigured model routes:\n");
2152        for route in model_routes {
2153            let _ = writeln!(
2154                resp,
2155                "  `{}` -> {} ({})",
2156                route.hint, route.model, route.model_provider
2157            );
2158        }
2159    }
2160    resp.push_str(
2161        "\nUse `/models <model_provider>` to switch model_provider.\nUse `/model <model-id>` to switch model.",
2162    );
2163    resp
2164}
2165
2166/// Build a Slack Block Kit JSON payload for the `/config` interactive UI.
2167fn build_config_block_kit(
2168    current: &ChannelRouteSelection,
2169    workspace_dir: &Path,
2170    model_routes: &[zeroclaw_config::schema::ModelRouteConfig],
2171) -> String {
2172    let provider_options: Vec<serde_json::Value> = zeroclaw_providers::list_model_providers()
2173        .iter()
2174        .map(|p| {
2175            serde_json::json!({
2176                "text": { "type": "plain_text", "text": p.display_name },
2177                "value": p.name
2178            })
2179        })
2180        .collect();
2181
2182    // Build model options from model_routes + cached models.
2183    let mut model_options: Vec<serde_json::Value> = model_routes
2184        .iter()
2185        .map(|r| {
2186            let label = if r.hint.is_empty() {
2187                r.model.clone()
2188            } else {
2189                format!("{} ({})", r.model, r.hint)
2190            };
2191            serde_json::json!({
2192                "text": { "type": "plain_text", "text": label },
2193                "value": r.model
2194            })
2195        })
2196        .collect();
2197
2198    let cached = load_cached_model_preview(workspace_dir, &current.model_provider);
2199    for model_id in cached {
2200        if !model_options.iter().any(|o| {
2201            o.get("value")
2202                .and_then(|v| v.as_str())
2203                .is_some_and(|v| v == model_id)
2204        }) {
2205            model_options.push(serde_json::json!({
2206                "text": { "type": "plain_text", "text": model_id },
2207                "value": model_id
2208            }));
2209        }
2210    }
2211
2212    // If the current model is not in the list, prepend it.
2213    if !model_options.iter().any(|o| {
2214        o.get("value")
2215            .and_then(|v| v.as_str())
2216            .is_some_and(|v| v == current.model)
2217    }) {
2218        model_options.insert(
2219            0,
2220            serde_json::json!({
2221                "text": { "type": "plain_text", "text": &current.model },
2222                "value": &current.model
2223            }),
2224        );
2225    }
2226
2227    // Find initial options matching current selection.
2228    let initial_provider = provider_options
2229        .iter()
2230        .find(|o| {
2231            o.get("value")
2232                .and_then(|v| v.as_str())
2233                .is_some_and(|v| v == current.model_provider)
2234        })
2235        .cloned();
2236
2237    let initial_model = model_options
2238        .iter()
2239        .find(|o| {
2240            o.get("value")
2241                .and_then(|v| v.as_str())
2242                .is_some_and(|v| v == current.model)
2243        })
2244        .cloned();
2245
2246    let mut provider_select = serde_json::json!({
2247        "type": "static_select",
2248        "action_id": "zeroclaw_config_provider",
2249        "placeholder": { "type": "plain_text", "text": "Select model_provider" },
2250        "options": provider_options
2251    });
2252    if let Some(init) = initial_provider {
2253        provider_select["initial_option"] = init;
2254    }
2255
2256    let mut model_select = serde_json::json!({
2257        "type": "static_select",
2258        "action_id": "zeroclaw_config_model",
2259        "placeholder": { "type": "plain_text", "text": "Select model" },
2260        "options": model_options
2261    });
2262    if let Some(init) = initial_model {
2263        model_select["initial_option"] = init;
2264    }
2265
2266    let blocks = serde_json::json!([
2267        {
2268            "type": "section",
2269            "text": {
2270                "type": "mrkdwn",
2271                "text": format!(
2272                    "*Model Configuration*\nCurrent: `{}` / `{}`",
2273                    current.model_provider, current.model
2274                )
2275            }
2276        },
2277        {
2278            "type": "section",
2279            "block_id": "config_provider_block",
2280            "text": { "type": "mrkdwn", "text": "*ModelProvider*" },
2281            "accessory": provider_select
2282        },
2283        {
2284            "type": "section",
2285            "block_id": "config_model_block",
2286            "text": { "type": "mrkdwn", "text": "*Model*" },
2287            "accessory": model_select
2288        }
2289    ]);
2290
2291    blocks.to_string()
2292}
2293
2294async fn handle_runtime_command_if_needed(
2295    ctx: &ChannelRuntimeContext,
2296    msg: &zeroclaw_api::channel::ChannelMessage,
2297    target_channel: Option<&Arc<dyn Channel>>,
2298) -> bool {
2299    let Some(command) = parse_runtime_command(&msg.channel, &msg.content) else {
2300        return false;
2301    };
2302
2303    let Some(channel) = target_channel else {
2304        return true;
2305    };
2306
2307    let sender_key = conversation_history_key(msg);
2308    let defaults_snapshot = runtime_defaults_snapshot(ctx);
2309    let mut current = get_route_selection(ctx, &sender_key, &defaults_snapshot);
2310
2311    let response = match command {
2312        ChannelRuntimeCommand::ShowProviders => build_providers_help_response(&current),
2313        ChannelRuntimeCommand::SetProvider(raw_model_provider) => {
2314            match resolve_models_command(defaults_snapshot.config.as_ref(), &raw_model_provider) {
2315                ModelsCommandResolution::Resolved(provider_ref) => {
2316                    match get_or_create_provider(ctx, &provider_ref, None, &defaults_snapshot).await
2317                    {
2318                        Ok(_) => {
2319                            if provider_ref != current.model_provider {
2320                                current.model_provider = provider_ref.clone();
2321                                set_route_selection(
2322                                    ctx,
2323                                    &sender_key,
2324                                    current.clone(),
2325                                    &defaults_snapshot,
2326                                );
2327                            }
2328
2329                            format!(
2330                                "ModelProvider switched to `{provider_ref}` for this sender session. Current model is `{}`.\nUse `/model <model-id>` to set a provider-compatible model.",
2331                                current.model
2332                            )
2333                        }
2334                        Err(err) => {
2335                            let safe_err = zeroclaw_providers::sanitize_api_error(&err.to_string());
2336                            format!(
2337                                "Failed to initialize model_provider `{provider_ref}`. Route unchanged.\nDetails: {safe_err}"
2338                            )
2339                        }
2340                    }
2341                }
2342                ModelsCommandResolution::Ambiguous { family, aliases } => {
2343                    let list = aliases
2344                        .iter()
2345                        .map(|a| format!("`{family}.{a}`"))
2346                        .collect::<Vec<_>>()
2347                        .join(", ");
2348                    format!(
2349                        "ModelProvider `{family}` has multiple configured aliases. Qualify which one with `/models {family}.<alias>`: {list}"
2350                    )
2351                }
2352                ModelsCommandResolution::NoAlias(ref_or_family) => format!(
2353                    "No configured provider entry for `{ref_or_family}`. Add `[providers.models.{ref_or_family}]` (with its api_key/uri) or select a configured provider — `/models` lists valid ones."
2354                ),
2355                ModelsCommandResolution::Unknown => format!(
2356                    "Unknown model_provider `{raw_model_provider}`. Use `/models` to list valid model_providers."
2357                ),
2358            }
2359        }
2360        ChannelRuntimeCommand::ShowModel => {
2361            build_models_help_response(&current, ctx.workspace_dir.as_path(), &ctx.model_routes)
2362        }
2363        ChannelRuntimeCommand::SetModel(raw_model) => {
2364            let model = raw_model.trim().trim_matches('`').to_string();
2365            if model.is_empty() {
2366                "Model ID cannot be empty. Use `/model <model-id>`.".to_string()
2367            } else {
2368                // Resolve model_provider+model from model_routes (match by model name or hint)
2369                if let Some(route) = ctx.model_routes.iter().find(|r| {
2370                    r.model.eq_ignore_ascii_case(&model) || r.hint.eq_ignore_ascii_case(&model)
2371                }) {
2372                    current.model_provider = route.model_provider.clone();
2373                    current.model = route.model.clone();
2374                    current.api_key = route.api_key.clone();
2375                } else {
2376                    current.model = model.clone();
2377                }
2378                set_route_selection(ctx, &sender_key, current.clone(), &defaults_snapshot);
2379
2380                format!(
2381                    "Model switched to `{}` (model_provider: `{}`). Context preserved.",
2382                    current.model, current.model_provider
2383                )
2384            }
2385        }
2386        ChannelRuntimeCommand::ShowConfig => {
2387            if msg.channel == "slack" {
2388                let blocks_json = build_config_block_kit(
2389                    &current,
2390                    ctx.workspace_dir.as_path(),
2391                    &ctx.model_routes,
2392                );
2393                // Use a magic prefix so SlackChannel::send() can detect Block Kit JSON.
2394                format!("__ZEROCLAW_BLOCK_KIT__{blocks_json}")
2395            } else {
2396                build_config_text_response(&current, ctx.workspace_dir.as_path(), &ctx.model_routes)
2397            }
2398        }
2399        ChannelRuntimeCommand::NewSession => {
2400            clear_sender_history(ctx, &sender_key);
2401            if let Some(ref store) = ctx.session_store
2402                && let Err(e) = store.delete_session(&sender_key)
2403            {
2404                ::zeroclaw_log::record!(
2405                    WARN,
2406                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2407                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2408                        .with_attrs(
2409                            ::serde_json::json!({"error": format!("{}", e), "sender_key": sender_key})
2410                        ),
2411                    "Failed to delete persisted session for"
2412                );
2413            }
2414            mark_sender_for_new_session(ctx, &sender_key);
2415            "Conversation history cleared. Starting fresh.".to_string()
2416        }
2417    };
2418
2419    if let Err(err) = channel
2420        .send(&{
2421            let mut sm = SendMessage::new(response, &msg.reply_target)
2422                .in_thread(msg.thread_ts.clone())
2423                .in_reply_to(Some(msg.id.clone()));
2424            if let Some(ref subj) = msg.subject {
2425                let reply_subject = if subj.to_lowercase().starts_with("re:") {
2426                    subj.clone()
2427                } else {
2428                    format!("Re: {}", subj)
2429                };
2430                sm = sm.subject(reply_subject);
2431            }
2432            sm
2433        })
2434        .await
2435    {
2436        ::zeroclaw_log::record!(
2437            WARN,
2438            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2439                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
2440            &format!(
2441                "Failed to send runtime command response on {}: {err}",
2442                channel.name()
2443            )
2444        );
2445    }
2446
2447    true
2448}
2449
2450async fn build_memory_context(
2451    mem: &dyn Memory,
2452    user_msg: &str,
2453    min_relevance_score: f64,
2454    session_id: Option<&str>,
2455) -> String {
2456    build_memory_context_for_sessions(mem, user_msg, min_relevance_score, &[session_id]).await
2457}
2458
2459async fn build_memory_context_for_sessions(
2460    mem: &dyn Memory,
2461    user_msg: &str,
2462    min_relevance_score: f64,
2463    session_ids: &[Option<&str>],
2464) -> String {
2465    let mut entries = Vec::new();
2466    let mut seen_keys = HashSet::new();
2467
2468    match session_ids {
2469        [] => {}
2470        [session_id] => {
2471            let recalled = mem.recall(user_msg, 5, *session_id, None, None).await;
2472            append_recalled_memory_entries(&mut entries, &mut seen_keys, recalled);
2473        }
2474        [first_session_id, second_session_id] => {
2475            let (first_entries, second_entries) = tokio::join!(
2476                mem.recall(user_msg, 5, *first_session_id, None, None),
2477                mem.recall(user_msg, 5, *second_session_id, None, None)
2478            );
2479            append_recalled_memory_entries(&mut entries, &mut seen_keys, first_entries);
2480            append_recalled_memory_entries(&mut entries, &mut seen_keys, second_entries);
2481        }
2482        _ => {
2483            for session_id in session_ids {
2484                let recalled = mem.recall(user_msg, 5, *session_id, None, None).await;
2485                append_recalled_memory_entries(&mut entries, &mut seen_keys, recalled);
2486            }
2487        }
2488    }
2489
2490    format_memory_context(&entries, min_relevance_score)
2491}
2492
2493fn append_recalled_memory_entries(
2494    entries: &mut Vec<zeroclaw_memory::MemoryEntry>,
2495    seen_keys: &mut HashSet<String>,
2496    recalled: Result<Vec<zeroclaw_memory::MemoryEntry>>,
2497) {
2498    if let Ok(recalled) = recalled {
2499        for entry in recalled {
2500            if seen_keys.insert(entry.key.clone()) {
2501                entries.push(entry);
2502            }
2503        }
2504    }
2505}
2506
2507fn format_memory_context(
2508    entries: &[zeroclaw_memory::MemoryEntry],
2509    min_relevance_score: f64,
2510) -> String {
2511    let mut context = String::new();
2512
2513    let mut included = 0usize;
2514    let mut used_chars = 0usize;
2515
2516    for entry in entries.iter().filter(|e| match e.score {
2517        Some(score) => score >= min_relevance_score,
2518        None => true, // keep entries without a score (e.g. non-vector backends)
2519    }) {
2520        if included >= MEMORY_CONTEXT_MAX_ENTRIES {
2521            break;
2522        }
2523
2524        if should_skip_memory_context_entry(&entry.key, &entry.content) {
2525            continue;
2526        }
2527
2528        let content = if entry.content.chars().count() > MEMORY_CONTEXT_ENTRY_MAX_CHARS {
2529            truncate_with_ellipsis(&entry.content, MEMORY_CONTEXT_ENTRY_MAX_CHARS)
2530        } else {
2531            entry.content.clone()
2532        };
2533
2534        let line = format!("- {}: {}\n", entry.key, content);
2535        let line_chars = line.chars().count();
2536        if used_chars + line_chars > MEMORY_CONTEXT_MAX_CHARS {
2537            break;
2538        }
2539
2540        if included == 0 {
2541            context.push_str(MEMORY_CONTEXT_OPEN);
2542            context.push('\n');
2543        }
2544
2545        context.push_str(&line);
2546        used_chars += line_chars;
2547        included += 1;
2548    }
2549
2550    if included > 0 {
2551        context.push_str(MEMORY_CONTEXT_CLOSE);
2552        context.push_str("\n\n");
2553    }
2554
2555    context
2556}
2557
2558fn is_group_reply_target(reply_target: &str) -> bool {
2559    reply_target.contains("@g.us") || reply_target.starts_with("group:")
2560}
2561
2562fn sender_memory_session_ids(
2563    msg: &zeroclaw_api::channel::ChannelMessage,
2564    history_key: &str,
2565) -> Vec<String> {
2566    // Match the sanitized form persisted by memory backend migrations.
2567    let sanitized_sender = sanitize_session_key(&msg.sender);
2568    if is_group_reply_target(&msg.reply_target) {
2569        vec![sanitized_sender]
2570    } else {
2571        vec![history_key.to_string(), sanitized_sender]
2572    }
2573}
2574
2575/// Extract a compact summary of tool interactions from history messages added
2576/// during `run_tool_call_loop`. Scans assistant messages for `<tool_call>` tags
2577/// or native tool-call JSON to collect tool names used.
2578/// Returns an empty string when no tools were invoked.
2579#[cfg(test)]
2580fn extract_tool_context_summary(history: &[ChatMessage], start_index: usize) -> String {
2581    fn push_unique_tool_name(tool_names: &mut Vec<String>, name: &str) {
2582        let candidate = name.trim();
2583        if candidate.is_empty() {
2584            return;
2585        }
2586        if !tool_names.iter().any(|existing| existing == candidate) {
2587            tool_names.push(candidate.to_string());
2588        }
2589    }
2590
2591    fn collect_tool_names_from_tool_call_tags(content: &str, tool_names: &mut Vec<String>) {
2592        const TAG_PAIRS: [(&str, &str); 4] = [
2593            ("<tool_call>", "</tool_call>"),
2594            ("<toolcall>", "</toolcall>"),
2595            ("<tool-call>", "</tool-call>"),
2596            ("<invoke>", "</invoke>"),
2597        ];
2598
2599        for (open_tag, close_tag) in TAG_PAIRS {
2600            for segment in content.split(open_tag) {
2601                if let Some(json_end) = segment.find(close_tag) {
2602                    let json_str = segment[..json_end].trim();
2603                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(json_str)
2604                        && let Some(name) = val.get("name").and_then(|n| n.as_str())
2605                    {
2606                        push_unique_tool_name(tool_names, name);
2607                    }
2608                }
2609            }
2610        }
2611    }
2612
2613    fn collect_tool_names_from_native_json(content: &str, tool_names: &mut Vec<String>) {
2614        if let Ok(val) = serde_json::from_str::<serde_json::Value>(content)
2615            && let Some(calls) = val.get("tool_calls").and_then(|c| c.as_array())
2616        {
2617            for call in calls {
2618                let name = call
2619                    .get("function")
2620                    .and_then(|f| f.get("name"))
2621                    .and_then(|n| n.as_str())
2622                    .or_else(|| call.get("name").and_then(|n| n.as_str()));
2623                if let Some(name) = name {
2624                    push_unique_tool_name(tool_names, name);
2625                }
2626            }
2627        }
2628    }
2629
2630    fn collect_tool_names_from_tool_results(content: &str, tool_names: &mut Vec<String>) {
2631        let marker = "<tool_result name=\"";
2632        let mut remaining = content;
2633        while let Some(start) = remaining.find(marker) {
2634            let name_start = start + marker.len();
2635            let after_name_start = &remaining[name_start..];
2636            if let Some(name_end) = after_name_start.find('"') {
2637                let name = &after_name_start[..name_end];
2638                push_unique_tool_name(tool_names, name);
2639                remaining = &after_name_start[name_end + 1..];
2640            } else {
2641                break;
2642            }
2643        }
2644    }
2645
2646    let mut tool_names: Vec<String> = Vec::new();
2647
2648    for msg in history.iter().skip(start_index) {
2649        match msg.role.as_str() {
2650            "assistant" => {
2651                collect_tool_names_from_tool_call_tags(&msg.content, &mut tool_names);
2652                collect_tool_names_from_native_json(&msg.content, &mut tool_names);
2653            }
2654            "user" => {
2655                // Prompt-mode tool calls are always followed by [Tool results] entries
2656                // containing `<tool_result name="...">` tags with canonical tool names.
2657                collect_tool_names_from_tool_results(&msg.content, &mut tool_names);
2658            }
2659            _ => {}
2660        }
2661    }
2662
2663    if tool_names.is_empty() {
2664        return String::new();
2665    }
2666
2667    format!("[Used tools: {}]", tool_names.join(", "))
2668}
2669
2670/// Why the assistant chose not to reply. Drives the chat-surface reaction
2671/// (👍/🚫/⚠️) on the user's inbound message via `Channel::add_reaction` so a
2672/// no-reply outcome isn't silent. The LLM classifier emits the kind via a
2673/// `NO_REPLY[KIND]:` prefix; `Informational` is the default when absent.
2674/// Channels that don't implement `add_reaction` are silently skipped (the
2675/// trait default is a no-op `Ok(())`).
2676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2677enum NoReplyKind {
2678    /// "Got it, no action needed" — informational, social, or
2679    /// non-addressed messages. Reaction: 👍.
2680    Informational,
2681    /// "I will not do this" — safety / policy refusals (prompt injection,
2682    /// blocked tool, disallowed request). Reaction: 🚫.
2683    Refused,
2684    /// "I tried but couldn't fulfil" — external failures, missing
2685    /// resources, timeouts where the assistant gave up. Reaction: ⚠️.
2686    Failed,
2687}
2688
2689impl NoReplyKind {
2690    fn emoji(self) -> &'static str {
2691        match self {
2692            NoReplyKind::Informational => "👍",
2693            NoReplyKind::Refused => "🚫",
2694            NoReplyKind::Failed => "⚠️",
2695        }
2696    }
2697}
2698
2699#[derive(Debug, Clone, PartialEq, Eq)]
2700enum AssistantChannelOutcome {
2701    Reply(String),
2702    NoReply {
2703        kind: NoReplyKind,
2704        reason: Option<String>,
2705    },
2706}
2707
2708impl AssistantChannelOutcome {
2709    fn history_marker(&self) -> String {
2710        match self {
2711            Self::Reply(text) => text.clone(),
2712            Self::NoReply {
2713                reason: Some(reason),
2714                ..
2715            } if !reason.trim().is_empty() => {
2716                format!("[No reply sent: {}]", reason.trim())
2717            }
2718            Self::NoReply { .. } => "[No reply sent]".to_string(),
2719        }
2720    }
2721}
2722
2723async fn classify_channel_reply_intent(
2724    model_provider: &dyn ModelProvider,
2725    system_prompt: &str,
2726    history: &[ChatMessage],
2727    model: &str,
2728    temperature: Option<f64>,
2729) -> anyhow::Result<AssistantChannelOutcome> {
2730    let mut convo = String::from(
2731        "Decide whether the assistant should send any visible reply to the latest inbound \
2732         channel message, and if not, which kind of non-reply it is.\n\nReturn exactly one of:\n\
2733         - `REPLY`\n\
2734         - `NO_REPLY[INFO]: <short reason>`   (informational/social, no action needed)\n\
2735         - `NO_REPLY[REFUSE]: <short reason>` (refused for safety, policy, or prompt injection)\n\
2736         - `NO_REPLY[FAIL]: <short reason>`   (tried but couldn't fulfil — bad URL, missing file, timeout)\n\
2737         - `NO_REPLY: <short reason>`         (legacy form; treated as INFO)\n\n\
2738         Rules:\n\
2739         - Any call to action from the user MUST be actioned — return `REPLY`. A call to action \
2740         is a question, request, command, or ask: a message that requires the assistant to do \
2741         or say something. Being merely named, addressed, or referenced is NOT a call to action \
2742         on its own (e.g. \"stand by\", \"hold on\", \"thanks bot\" — those are not asks). \
2743         There is no exception when a real ask is present: memory or prior history showing a \
2744         similar earlier exchange is NOT grounds to skip the response — the user asked now and \
2745         is owed a reply now.\n\
2746         - For everything that is not a call to action, default to `REPLY`. Only emit \
2747         `NO_REPLY[*]` when one of the categories below clearly applies; when in doubt, `REPLY`.\n\
2748         - `NO_REPLY[INFO]` is reserved for messages plainly not for the assistant: chatter \
2749         between other humans in a group channel, system broadcasts, or content the embedded \
2750         system prompt explicitly tells the assistant to ignore.\n\
2751         - Output exactly one of the tokens above; emit no other text. The `<short reason>` \
2752         describes the inbound message — it MUST NOT restate or paraphrase these classifier \
2753         instructions.\n\nConversation:\n",
2754    );
2755
2756    for msg in history.iter().filter(|m| m.role != "system") {
2757        let role = match msg.role.as_str() {
2758            "assistant" => "assistant",
2759            _ => "user",
2760        };
2761        // Strip media markers — auxiliary classifier does not need image
2762        // content, and forwarding `[IMAGE:/local/path]` would reach the
2763        // provider as a malformed `image_url.url` and trigger 400 errors.
2764        let safe_content = zeroclaw_providers::multimodal::strip_media_markers(&msg.content);
2765        let _ = writeln!(convo, "[{role}] {safe_content}");
2766    }
2767
2768    let response = model_provider
2769        .chat_with_system(Some(system_prompt), &convo, model, temperature)
2770        .await?;
2771    Ok(parse_reply_intent(&response))
2772}
2773
2774/// Parse the classifier's raw output into an `AssistantChannelOutcome`. Pure
2775/// helper extracted so the LLM-call wrapper has no parsing logic and the
2776/// kinded `NO_REPLY[...]` forms can be unit-tested without a model_provider.
2777fn parse_reply_intent(response: &str) -> AssistantChannelOutcome {
2778    let trimmed = response.trim();
2779    if trimmed.is_empty() {
2780        return AssistantChannelOutcome::NoReply {
2781            kind: NoReplyKind::Informational,
2782            reason: None,
2783        };
2784    }
2785    if trimmed.eq_ignore_ascii_case("REPLY") {
2786        return AssistantChannelOutcome::Reply(String::new());
2787    }
2788
2789    for (tag, kind) in &[
2790        ("NO_REPLY[INFO]:", NoReplyKind::Informational),
2791        ("NO_REPLY[REFUSE]:", NoReplyKind::Refused),
2792        ("NO_REPLY[FAIL]:", NoReplyKind::Failed),
2793    ] {
2794        if let Some(reason) = trimmed.strip_prefix(tag) {
2795            return outcome_for_no_reply(reason.trim(), *kind);
2796        }
2797    }
2798
2799    if let Some(reason) = trimmed.strip_prefix("NO_REPLY:") {
2800        return outcome_for_no_reply(reason.trim(), NoReplyKind::Informational);
2801    }
2802    if trimmed.eq_ignore_ascii_case("NO_REPLY") {
2803        return AssistantChannelOutcome::NoReply {
2804            kind: NoReplyKind::Informational,
2805            reason: None,
2806        };
2807    }
2808
2809    AssistantChannelOutcome::Reply(String::new())
2810}
2811
2812/// Resolve a per-agent `classifier_provider` ref to a (provider, model, temperature)
2813/// triple for `classify_channel_reply_intent`. Returns `None` when the
2814/// ref is empty or unresolvable; the caller MUST then fall back to the
2815/// main agent's `active_model_provider` plus the active route/defaults snapshot.
2816///
2817/// Per AGENTS.md SINGLE SOURCE OF TRUTH: this function reads the
2818/// referenced `[providers.models.<type>.<alias>]` entry on every call
2819/// (no field cache on `ChannelRuntimeContext`). The provider instance
2820/// itself is deduped through the existing `provider_cache` LRU.
2821async fn resolve_classifier_route(
2822    ctx: &ChannelRuntimeContext,
2823    provider_ref: &zeroclaw_config::providers::ModelProviderRef,
2824    defaults_snapshot: &ChannelRuntimeDefaultsSnapshot,
2825) -> Option<(Arc<dyn ModelProvider>, String, Option<f64>)> {
2826    let provider_str = provider_ref.as_str().trim();
2827    if provider_str.is_empty() {
2828        return None;
2829    }
2830
2831    let (type_key, alias_key) = match provider_str.split_once('.') {
2832        Some(parts) => parts,
2833        None => {
2834            ::zeroclaw_log::record!(
2835                WARN,
2836                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2837                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2838                    .with_attrs(::serde_json::json!({"provider": provider_str})),
2839                "classifier_provider must be dotted `<type>.<alias>`; falling back to main agent"
2840            );
2841            return None;
2842        }
2843    };
2844
2845    let model_cfg = match defaults_snapshot
2846        .config
2847        .providers
2848        .models
2849        .find(type_key, alias_key)
2850    {
2851        Some(cfg) => cfg,
2852        None => {
2853            ::zeroclaw_log::record!(
2854                WARN,
2855                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2856                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2857                    .with_attrs(::serde_json::json!({"provider": provider_str})),
2858                "classifier_provider references an unknown [providers.models.<type>.<alias>] entry; falling back to main agent"
2859            );
2860            return None;
2861        }
2862    };
2863
2864    let model = model_cfg.model.clone().unwrap_or_default();
2865    let temperature = model_cfg.temperature;
2866    if model.is_empty() {
2867        ::zeroclaw_log::record!(
2868            WARN,
2869            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2870                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2871                .with_attrs(::serde_json::json!({"provider": provider_str})),
2872            "classifier_provider points to a [providers.models] entry without a `model` field; falling back to main agent"
2873        );
2874        return None;
2875    }
2876
2877    let provider = match get_or_create_provider(
2878        ctx,
2879        provider_str,
2880        model_cfg.api_key.as_deref(),
2881        defaults_snapshot,
2882    )
2883    .await
2884    {
2885        Ok(p) => p,
2886        Err(e) => {
2887            let safe_err = zeroclaw_providers::sanitize_api_error(&e.to_string());
2888            ::zeroclaw_log::record!(
2889                WARN,
2890                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2891                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2892                    .with_attrs(::serde_json::json!({"provider": provider_str, "error": safe_err})),
2893                "Failed to initialize classifier_provider; falling back to main agent provider"
2894            );
2895            return None;
2896        }
2897    };
2898
2899    ::zeroclaw_log::record!(
2900        INFO,
2901        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2902            .with_attrs(::serde_json::json!({"provider": provider_str, "model": model.as_str()})),
2903        "classifier_provider override active"
2904    );
2905
2906    Some((provider, model, temperature))
2907}
2908
2909/// Build the `NoReply` outcome, with a narrow rubric-echo failsafe scoped to
2910/// the `Informational` kind only. When the classifier emits `NO_REPLY[INFO]`
2911/// with a reason that restates its own rubric (the only failure mode observed
2912/// in production after PR #6112), it has failed to actually classify the
2913/// inbound message — falling through to `Reply` is the safe asymmetry there,
2914/// since the alternative is silently swallowing a legitimate user message.
2915///
2916/// `Refused` and `Failed` are explicit safety routing decisions (e.g. the
2917/// classifier flagged a prompt-injection attempt or a hard failure), so we
2918/// respect them verbatim even when the reason text happens to quote
2919/// rubric-like phrases — converting those to `Reply` would re-enter the
2920/// tool-capable agent path and skip the refusal/failure recording surface.
2921fn outcome_for_no_reply(reason: &str, kind: NoReplyKind) -> AssistantChannelOutcome {
2922    if matches!(kind, NoReplyKind::Informational) && looks_like_meta_instruction_echo(reason) {
2923        return AssistantChannelOutcome::Reply(String::new());
2924    }
2925    AssistantChannelOutcome::NoReply {
2926        kind,
2927        reason: (!reason.is_empty()).then(|| reason.to_string()),
2928    }
2929}
2930
2931/// True when the no-reply reason restates the classifier's own instructions
2932/// rather than describing the inbound message. Observed failure mode after
2933/// the classifier prompt rewrite in PR #6112: outputs like `NO_REPLY[INFO]:
2934/// classification task only — must not answer the user.` where the "reason"
2935/// is verbatim rubric text. Substring match is intentionally narrow — these
2936/// phrases almost never appear in genuine descriptions of an inbound
2937/// message, while the false-negative cost (suppressing a real user reply)
2938/// is high.
2939fn looks_like_meta_instruction_echo(reason: &str) -> bool {
2940    if reason.is_empty() {
2941        return false;
2942    }
2943    let lower = reason.to_ascii_lowercase();
2944    const MARKERS: &[&str] = &[
2945        "classification task",
2946        "only classify",
2947        "must not answer",
2948        "not answering the user",
2949        "do not answer the user",
2950        "do not reply to the user",
2951        "classifier instruction",
2952    ];
2953    MARKERS.iter().any(|m| lower.contains(m))
2954}
2955
2956/// Strip `<think>...</think>` blocks from streaming draft text so reasoning
2957/// tokens are never shown to the user in partial updates.
2958fn strip_think_tags_inline(s: &str) -> String {
2959    let mut result = String::with_capacity(s.len());
2960    let mut rest = s;
2961    loop {
2962        if let Some(start) = rest.find("<think>") {
2963            result.push_str(&rest[..start]);
2964            if let Some(end) = rest[start..].find("</think>") {
2965                rest = &rest[start + end + "</think>".len()..];
2966            } else {
2967                // Unclosed tag: drop the tail to avoid leaking partial reasoning.
2968                break;
2969            }
2970        } else {
2971            result.push_str(rest);
2972            break;
2973        }
2974    }
2975    result.trim().to_string()
2976}
2977
2978fn starts_with_visible_tool_call_tag_example(response: &str) -> bool {
2979    let lower = response.trim_start().to_ascii_lowercase();
2980    let starts_with_tool_tag = lower.starts_with("<tool_call")
2981        || lower.starts_with("<toolcall")
2982        || lower.starts_with("<tool-call")
2983        || lower.starts_with("<invoke");
2984
2985    starts_with_tool_tag && zeroclaw_tool_call_parser::looks_like_tool_protocol_example(response)
2986}
2987
2988fn should_suppress_top_level_tool_protocol_response(
2989    response: &str,
2990    known_tool_names: &HashSet<String>,
2991) -> bool {
2992    if zeroclaw_tool_call_parser::looks_like_tool_protocol_example(response) {
2993        return false;
2994    }
2995
2996    if zeroclaw_tool_call_parser::looks_like_malformed_tool_protocol_envelope_for_known_tools(
2997        response,
2998        known_tool_names,
2999    ) {
3000        return true;
3001    }
3002
3003    if let Some(kind) = zeroclaw_tool_call_parser::classify_tool_protocol_envelope(response) {
3004        return matches!(
3005            kind,
3006            zeroclaw_tool_call_parser::ToolProtocolEnvelopeKind::TaggedToolCall
3007        ) || (!known_tool_names.is_empty()
3008            && (matches!(
3009                kind,
3010                zeroclaw_tool_call_parser::ToolProtocolEnvelopeKind::ToolResult
3011            ) || zeroclaw_tool_call_parser::tool_protocol_envelope_mentions_known_tool(
3012                response,
3013                known_tool_names,
3014            )));
3015    }
3016
3017    // If the broad envelope detector still matches after classification failed,
3018    // this is malformed internal protocol JSON rather than ordinary content.
3019    zeroclaw_tool_call_parser::looks_like_tool_protocol_envelope(response)
3020}
3021
3022fn sanitize_channel_response(response: &str, tools: &[Box<dyn Tool>]) -> String {
3023    let known_tool_names: HashSet<String> = tools
3024        .iter()
3025        .map(|tool| tool.name().to_ascii_lowercase())
3026        .collect();
3027    // Strip any [Used tools: ...] prefix that the LLM may have echoed from
3028    // history context. Trim first to handle leading/trailing whitespace.
3029    let trimmed_response = response.trim();
3030    let trimmed_response = strip_think_tags_inline(trimmed_response).trim().to_string();
3031    let trimmed_response = trimmed_response.as_str();
3032    // Final channel guardrail: reuse the parser classifier so channel cleanup
3033    // cannot drift from runtime tool-protocol detection.
3034    if should_suppress_top_level_tool_protocol_response(trimmed_response, &known_tool_names) {
3035        return String::new();
3036    }
3037    let stripped_summary = strip_tool_summary_prefix(trimmed_response);
3038    let stripped_xml = if starts_with_visible_tool_call_tag_example(&stripped_summary) {
3039        stripped_summary
3040    } else {
3041        strip_tool_call_tags(&stripped_summary)
3042    };
3043    let stripped_results = strip_tool_result_content(&stripped_xml);
3044    let stripped_fenced_json =
3045        strip_fenced_tool_protocol_artifacts(&stripped_results, &known_tool_names);
3046    let stripped_json =
3047        strip_isolated_tool_json_artifacts(&stripped_fenced_json, &known_tool_names);
3048    // Strip leading narration lines that announce tool usage
3049    let sanitized = strip_tool_narration(&stripped_json);
3050
3051    // Scan for credential leaks before returning to caller
3052    match zeroclaw_runtime::security::LeakDetector::new().scan(&sanitized) {
3053        zeroclaw_runtime::security::LeakResult::Clean => sanitized,
3054        zeroclaw_runtime::security::LeakResult::Detected { patterns, redacted } => {
3055            ::zeroclaw_log::record!(
3056                WARN,
3057                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3058                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3059                    .with_attrs(::serde_json::json!({"patterns": patterns})),
3060                "output guardrail: credential leak detected in outbound channel response"
3061            );
3062            redacted
3063        }
3064    }
3065}
3066
3067/// Shown when the agent turn completes but no visible text remains after sanitization.
3068const EMPTY_CHANNEL_REPLY_FALLBACK: &str =
3069    "I couldn't produce a visible reply for that message. Please try again.";
3070
3071/// Ensure channel outbound text is never empty so users don't see typing with no message.
3072fn ensure_nonempty_channel_reply(
3073    delivered_response: String,
3074    outbound_response: &str,
3075    channel: &str,
3076    reply_target: &str,
3077) -> String {
3078    if !delivered_response.trim().is_empty() {
3079        return delivered_response;
3080    }
3081    ::zeroclaw_log::record!(
3082        WARN,
3083        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3084            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3085            .with_attrs(::serde_json::json!({
3086                "channel": channel,
3087                "reply_target": reply_target,
3088                "outbound_len": outbound_response.len(),
3089            })),
3090        "channel_reply_empty; substituting fallback"
3091    );
3092    EMPTY_CHANNEL_REPLY_FALLBACK.to_string()
3093}
3094
3095/// Remove leading lines that narrate tool usage (e.g. "Let me check the weather for you.").
3096///
3097/// Only strips lines from the very beginning of the message that match common
3098/// narration patterns, so genuine content is preserved.
3099fn strip_tool_narration(message: &str) -> String {
3100    let narration_prefixes: &[&str] = &[
3101        "let me ",
3102        "i'll ",
3103        "i will ",
3104        "i am going to ",
3105        "i'm going to ",
3106        "searching ",
3107        "looking up ",
3108        "fetching ",
3109        "checking ",
3110        "using the ",
3111        "using my ",
3112        "one moment",
3113        "hold on",
3114        "just a moment",
3115        "give me a moment",
3116        "allow me to ",
3117    ];
3118
3119    let mut result_lines: Vec<&str> = Vec::new();
3120    let mut past_narration = false;
3121
3122    for line in message.lines() {
3123        if past_narration {
3124            result_lines.push(line);
3125            continue;
3126        }
3127        let trimmed = line.trim();
3128        if trimmed.is_empty() {
3129            continue;
3130        }
3131        let lower = trimmed.to_lowercase();
3132        if narration_prefixes.iter().any(|p| lower.starts_with(p)) {
3133            // Skip this narration line
3134            continue;
3135        }
3136        // First non-narration, non-empty line — keep everything from here
3137        past_narration = true;
3138        result_lines.push(line);
3139    }
3140
3141    let joined = result_lines.join("\n");
3142    let trimmed = joined.trim();
3143    if trimmed.is_empty() && !message.trim().is_empty() {
3144        // If stripping removed everything, return original to avoid empty reply
3145        message.to_string()
3146    } else {
3147        trimmed.to_string()
3148    }
3149}
3150
3151fn is_tool_call_payload(value: &serde_json::Value, known_tool_names: &HashSet<String>) -> bool {
3152    let Some(object) = value.as_object() else {
3153        return false;
3154    };
3155
3156    let (name, has_args) =
3157        if let Some(function) = object.get("function").and_then(|f| f.as_object()) {
3158            (
3159                function
3160                    .get("name")
3161                    .and_then(|v| v.as_str())
3162                    .or_else(|| object.get("name").and_then(|v| v.as_str())),
3163                function.contains_key("arguments")
3164                    || function.contains_key("parameters")
3165                    || object.contains_key("arguments")
3166                    || object.contains_key("parameters"),
3167            )
3168        } else {
3169            (
3170                object.get("name").and_then(|v| v.as_str()),
3171                object.contains_key("arguments") || object.contains_key("parameters"),
3172            )
3173        };
3174
3175    let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
3176        return false;
3177    };
3178
3179    has_args && known_tool_names.contains(&name.to_ascii_lowercase())
3180}
3181
3182fn is_tool_result_payload(
3183    object: &serde_json::Map<String, serde_json::Value>,
3184    saw_tool_call_payload: bool,
3185) -> bool {
3186    if !saw_tool_call_payload || !object.contains_key("result") {
3187        return false;
3188    }
3189
3190    object.keys().all(|key| {
3191        matches!(
3192            key.as_str(),
3193            "result" | "id" | "tool_call_id" | "name" | "tool"
3194        )
3195    })
3196}
3197
3198fn sanitize_tool_json_value(
3199    value: &serde_json::Value,
3200    known_tool_names: &HashSet<String>,
3201    saw_tool_call_payload: bool,
3202) -> Option<(String, bool)> {
3203    if let Some(kind) =
3204        zeroclaw_tool_call_parser::classify_tool_protocol_envelope(&value.to_string())
3205    {
3206        if known_tool_names.is_empty() {
3207            return None;
3208        }
3209
3210        if matches!(
3211            kind,
3212            zeroclaw_tool_call_parser::ToolProtocolEnvelopeKind::ToolResult
3213        ) {
3214            return Some((String::new(), true));
3215        }
3216
3217        if !zeroclaw_tool_call_parser::tool_protocol_envelope_mentions_known_tool(
3218            &value.to_string(),
3219            known_tool_names,
3220        ) {
3221            return None;
3222        }
3223
3224        let content = safe_protocol_envelope_content(value);
3225        return Some((content, true));
3226    }
3227
3228    if is_tool_call_payload(value, known_tool_names) {
3229        return Some((String::new(), true));
3230    }
3231
3232    if let Some(array) = value.as_array() {
3233        if !array.is_empty()
3234            && array
3235                .iter()
3236                .all(|item| is_tool_call_payload(item, known_tool_names))
3237        {
3238            return Some((String::new(), true));
3239        }
3240        return None;
3241    }
3242
3243    let object = value.as_object()?;
3244
3245    if let Some(tool_calls) = object.get("tool_calls").and_then(|value| value.as_array())
3246        && !tool_calls.is_empty()
3247        && tool_calls
3248            .iter()
3249            .all(|call| is_tool_call_payload(call, known_tool_names))
3250    {
3251        let content = object
3252            .get("content")
3253            .and_then(|value| value.as_str())
3254            .unwrap_or("")
3255            .trim()
3256            .to_string();
3257        return Some((content, true));
3258    }
3259
3260    if is_tool_result_payload(object, saw_tool_call_payload) {
3261        return Some((String::new(), false));
3262    }
3263
3264    None
3265}
3266
3267fn safe_protocol_envelope_content(value: &serde_json::Value) -> String {
3268    let content = value
3269        .get("content")
3270        .and_then(|value| value.as_str())
3271        .unwrap_or("")
3272        .trim();
3273
3274    if content.is_empty()
3275        || zeroclaw_tool_call_parser::looks_like_tool_protocol_envelope(content)
3276        || zeroclaw_tool_call_parser::looks_like_malformed_tool_protocol_envelope(content)
3277    {
3278        return String::new();
3279    }
3280
3281    content.to_string()
3282}
3283
3284fn is_line_isolated_json_segment(message: &str, start: usize, end: usize) -> bool {
3285    let line_start = message[..start].rfind('\n').map_or(0, |idx| idx + 1);
3286    let line_end = message[end..]
3287        .find('\n')
3288        .map_or(message.len(), |idx| end + idx);
3289
3290    message[line_start..start].trim().is_empty() && message[end..line_end].trim().is_empty()
3291}
3292
3293fn is_inside_markdown_code_fence(message: &str, index: usize) -> bool {
3294    // This intentionally uses a lightweight fence parity check. The sanitizer only
3295    // needs to avoid re-processing JSON in ordinary triple-backtick fences that
3296    // `strip_fenced_tool_protocol_artifacts` already handles; it is not a full
3297    // Markdown parser for inline code spans or longer fence runs.
3298    let mut in_fence = false;
3299    let mut cursor = 0usize;
3300    while let Some(rel_pos) = message[cursor..index].find("```") {
3301        in_fence = !in_fence;
3302        cursor += rel_pos + 3;
3303    }
3304    in_fence
3305}
3306
3307fn isolated_malformed_tool_protocol_segment_end(
3308    message: &str,
3309    start: usize,
3310    known_tool_names: &HashSet<String>,
3311) -> Option<usize> {
3312    let line_start = message[..start].rfind('\n').map_or(0, |idx| idx + 1);
3313    if !message[line_start..start].trim().is_empty() {
3314        return None;
3315    }
3316
3317    let mut end = start;
3318    // Malformed JSON has no serde byte offset. Scan forward from an isolated
3319    // JSON candidate start, but stop before ordinary prose resumes.
3320    for line in message[start..].split_inclusive('\n') {
3321        let trimmed = line.trim();
3322        if end > start
3323            && !trimmed.is_empty()
3324            && !trimmed.starts_with(['{', '[', ']', '}'])
3325            && !trimmed.starts_with('"')
3326        {
3327            break;
3328        }
3329        end += line.len();
3330        let candidate = &message[start..end];
3331        if zeroclaw_tool_call_parser::looks_like_malformed_tool_protocol_envelope_for_known_tools(
3332            candidate,
3333            known_tool_names,
3334        ) {
3335            return Some(end);
3336        }
3337    }
3338
3339    None
3340}
3341
3342fn is_tool_protocol_fence_language(language: &str) -> bool {
3343    let lower = language.trim().to_ascii_lowercase();
3344    lower == "tool_call"
3345        || lower == "toolcall"
3346        || lower == "tool-call"
3347        || lower == "invoke"
3348        || lower
3349            .strip_prefix("tool")
3350            .is_some_and(|rest| rest.starts_with(char::is_whitespace) && !rest.trim().is_empty())
3351}
3352
3353fn strip_fenced_tool_protocol_artifacts(
3354    message: &str,
3355    known_tool_names: &HashSet<String>,
3356) -> String {
3357    if zeroclaw_tool_call_parser::looks_like_tool_protocol_example(message) {
3358        return message.to_string();
3359    }
3360
3361    let mut cleaned = String::with_capacity(message.len());
3362    let mut cursor = 0usize;
3363
3364    while let Some(rel_open) = message[cursor..].find("```") {
3365        let open_start = cursor + rel_open;
3366        let language_start = open_start + 3;
3367        let Some(line_end_rel) = message[language_start..].find('\n') else {
3368            break;
3369        };
3370        let line_end = language_start + line_end_rel;
3371        let language = message[language_start..line_end]
3372            .trim()
3373            .trim_end_matches('\r');
3374        let body_start = line_end + 1;
3375        let Some(close_rel) = message[body_start..].find("```") else {
3376            break;
3377        };
3378        let close_start = body_start + close_rel;
3379        let close_end = close_start + 3;
3380
3381        let fence_block = &message[open_start..close_end];
3382        let should_strip = if language.eq_ignore_ascii_case("json") {
3383            should_suppress_top_level_tool_protocol_response(
3384                message[body_start..close_start].trim(),
3385                known_tool_names,
3386            )
3387        } else {
3388            is_tool_protocol_fence_language(language)
3389                && zeroclaw_tool_call_parser::contains_tool_protocol_tag_call(fence_block)
3390        };
3391
3392        if should_strip {
3393            cleaned.push_str(&message[cursor..open_start]);
3394            cursor = close_end;
3395            continue;
3396        }
3397
3398        cleaned.push_str(&message[cursor..close_end]);
3399        cursor = close_end;
3400    }
3401
3402    cleaned.push_str(&message[cursor..]);
3403    cleaned
3404}
3405
3406fn strip_isolated_tool_json_artifacts(message: &str, known_tool_names: &HashSet<String>) -> String {
3407    let mut cleaned = String::with_capacity(message.len());
3408    let mut cursor = 0usize;
3409    let mut saw_tool_call_payload = false;
3410
3411    while cursor < message.len() {
3412        let Some(rel_start) = message[cursor..].find(['{', '[']) else {
3413            cleaned.push_str(&message[cursor..]);
3414            break;
3415        };
3416
3417        let start = cursor + rel_start;
3418        cleaned.push_str(&message[cursor..start]);
3419        if is_inside_markdown_code_fence(message, start) {
3420            let Some(ch) = message[start..].chars().next() else {
3421                break;
3422            };
3423            cleaned.push(ch);
3424            cursor = start + ch.len_utf8();
3425            continue;
3426        }
3427
3428        let candidate = &message[start..];
3429        let mut stream =
3430            serde_json::Deserializer::from_str(candidate).into_iter::<serde_json::Value>();
3431
3432        if let Some(Ok(value)) = stream.next() {
3433            let consumed = stream.byte_offset();
3434            if consumed > 0 {
3435                let end = start + consumed;
3436                if is_line_isolated_json_segment(message, start, end)
3437                    && let Some((replacement, marks_tool_call)) =
3438                        sanitize_tool_json_value(&value, known_tool_names, saw_tool_call_payload)
3439                {
3440                    if marks_tool_call {
3441                        saw_tool_call_payload = true;
3442                    }
3443                    if !replacement.trim().is_empty() {
3444                        cleaned.push_str(replacement.trim());
3445                    }
3446                    cursor = end;
3447                    continue;
3448                }
3449            }
3450        }
3451
3452        if let Some(end) =
3453            isolated_malformed_tool_protocol_segment_end(message, start, known_tool_names)
3454        {
3455            cursor = end;
3456            continue;
3457        }
3458
3459        let Some(ch) = message[start..].chars().next() else {
3460            break;
3461        };
3462        cleaned.push(ch);
3463        cursor = start + ch.len_utf8();
3464    }
3465
3466    let mut result = cleaned.replace("\r\n", "\n");
3467    while result.contains("\n\n\n") {
3468        result = result.replace("\n\n\n", "\n\n");
3469    }
3470    result.trim().to_string()
3471}
3472
3473fn spawn_supervised_listener(
3474    ch: Arc<dyn Channel>,
3475    alias: Option<String>,
3476    tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
3477    initial_backoff_secs: u64,
3478    max_backoff_secs: u64,
3479    cancel: tokio_util::sync::CancellationToken,
3480) -> tokio::task::JoinHandle<()> {
3481    spawn_supervised_listener_with_health_interval(
3482        ch,
3483        alias,
3484        tx,
3485        initial_backoff_secs,
3486        max_backoff_secs,
3487        Duration::from_secs(CHANNEL_HEALTH_HEARTBEAT_SECS),
3488        cancel,
3489    )
3490}
3491
3492fn spawn_supervised_listener_with_health_interval(
3493    ch: Arc<dyn Channel>,
3494    alias: Option<String>,
3495    tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
3496    initial_backoff_secs: u64,
3497    max_backoff_secs: u64,
3498    health_interval: Duration,
3499    cancel: tokio_util::sync::CancellationToken,
3500) -> tokio::task::JoinHandle<()> {
3501    let health_interval = if health_interval.is_zero() {
3502        Duration::from_secs(1)
3503    } else {
3504        health_interval
3505    };
3506
3507    let composite = match alias.as_deref() {
3508        Some(a) if !a.is_empty() => format!("{}.{}", ch.name(), a),
3509        _ => ch.name().to_string(),
3510    };
3511    let span = zeroclaw_log::attribution_span!(&*ch);
3512    zeroclaw_spawn::spawn!(
3513        async move {
3514            let component = format!("channel:{composite}");
3515            let mut backoff = initial_backoff_secs.max(1);
3516            let max_backoff = max_backoff_secs.max(backoff);
3517
3518            loop {
3519                zeroclaw_runtime::health::mark_component_ok(&component);
3520                let mut health = tokio::time::interval(health_interval);
3521                health.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
3522                let result = {
3523                    let listen_future = ch.listen(tx.clone());
3524                    tokio::pin!(listen_future);
3525
3526                    loop {
3527                        tokio::select! {
3528                            () = cancel.cancelled() => return,
3529                            _ = health.tick() => {
3530                                zeroclaw_runtime::health::mark_component_ok(&component);
3531                            }
3532                            result = &mut listen_future => break result,
3533                        }
3534                    }
3535                };
3536
3537                match result {
3538                    Ok(()) => {
3539                        ::zeroclaw_log::record!(
3540                            WARN,
3541                            ::zeroclaw_log::Event::new(
3542                                module_path!(),
3543                                ::zeroclaw_log::Action::Note
3544                            )
3545                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
3546                            &format!("Channel {} exited unexpectedly; restarting", ch.name())
3547                        );
3548                        zeroclaw_runtime::health::mark_component_error(
3549                            &component,
3550                            "listener exited unexpectedly",
3551                        );
3552                        backoff = initial_backoff_secs.max(1);
3553                    }
3554                    Err(e) => {
3555                        if is_non_retryable_channel_listener_error(ch.name(), &e) {
3556                            ::zeroclaw_log::record!(
3557                                ERROR,
3558                                ::zeroclaw_log::Event::new(
3559                                    module_path!(),
3560                                    ::zeroclaw_log::Action::Reject
3561                                )
3562                                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3563                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3564                                "channel listener hit non-retryable error; waiting for config change or shutdown"
3565                            );
3566                            zeroclaw_runtime::health::mark_component_error(&component, e.to_string());
3567                            tokio::select! {
3568                                () = cancel.cancelled() => return,
3569                                () = std::future::pending::<()>() => unreachable!(),
3570                            }
3571                        }
3572                        ::zeroclaw_log::record!(
3573                            ERROR,
3574                            ::zeroclaw_log::Event::new(
3575                                module_path!(),
3576                                ::zeroclaw_log::Action::Fail
3577                            )
3578                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3579                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3580                            "channel listener error; restarting"
3581                        );
3582                        zeroclaw_runtime::health::mark_component_error(&component, e.to_string());
3583                    }
3584                }
3585
3586                zeroclaw_runtime::health::bump_component_restart(&component);
3587                tokio::select! {
3588                    () = cancel.cancelled() => return,
3589                    () = tokio::time::sleep(Duration::from_secs(backoff)) => {}
3590                }
3591                backoff = backoff.saturating_mul(2).min(max_backoff);
3592            }
3593        }
3594        .instrument(span)
3595    )
3596}
3597
3598fn is_non_retryable_channel_listener_error(channel_name: &str, error: &anyhow::Error) -> bool {
3599    match channel_name {
3600        name if name == "discord" || name.starts_with("discord-") => {
3601            #[cfg(feature = "channel-discord")]
3602            if error
3603                .downcast_ref::<crate::discord::DiscordListenerFatalError>()
3604                .is_some()
3605            {
3606                return true;
3607            }
3608            zeroclaw_providers::reliable::is_non_retryable(error)
3609        }
3610        _ => false,
3611    }
3612}
3613
3614fn compute_max_in_flight_messages(
3615    channel_count: usize,
3616    max_concurrent_per_channel: usize,
3617) -> usize {
3618    channel_count
3619        .saturating_mul(max_concurrent_per_channel)
3620        .clamp(
3621            CHANNEL_MIN_IN_FLIGHT_MESSAGES,
3622            CHANNEL_MAX_IN_FLIGHT_MESSAGES,
3623        )
3624}
3625
3626fn max_in_flight_messages_for_config(
3627    channel_count: usize,
3628    config: &zeroclaw_config::schema::ChannelsConfig,
3629) -> usize {
3630    compute_max_in_flight_messages(channel_count, config.max_concurrent_per_channel)
3631}
3632
3633fn log_worker_join_result(result: Result<(), tokio::task::JoinError>) {
3634    if let Err(error) = result {
3635        ::zeroclaw_log::record!(
3636            ERROR,
3637            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
3638                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
3639                .with_attrs(::serde_json::json!({"error": format!("{}", error)})),
3640            "Channel message worker crashed"
3641        );
3642    }
3643}
3644
3645fn spawn_scoped_typing_task(
3646    channel: Arc<dyn Channel>,
3647    recipient: String,
3648    cancellation_token: CancellationToken,
3649) -> tokio::task::JoinHandle<()> {
3650    let stop_signal = cancellation_token;
3651    let refresh_interval = Duration::from_secs(CHANNEL_TYPING_REFRESH_INTERVAL_SECS);
3652    zeroclaw_spawn::spawn!(async move {
3653        let mut interval = tokio::time::interval(refresh_interval);
3654        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
3655
3656        loop {
3657            tokio::select! {
3658                () = stop_signal.cancelled() => break,
3659                _ = interval.tick() => {
3660                    if let Err(e) = channel.start_typing(&recipient).await {
3661                        ::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");
3662                    }
3663                }
3664            }
3665        }
3666
3667        if let Err(e) = channel.stop_typing(&recipient).await {
3668            ::zeroclaw_log::record!(
3669                DEBUG,
3670                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3671                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3672                "failed to stop typing"
3673            );
3674        }
3675    })
3676}
3677
3678async fn process_channel_message(
3679    ctx: Arc<ChannelRuntimeContext>,
3680    msg: zeroclaw_api::channel::ChannelMessage,
3681    cancellation_token: CancellationToken,
3682) {
3683    if cancellation_token.is_cancelled() {
3684        return;
3685    }
3686
3687    let channel_composite = match &msg.channel_alias {
3688        Some(alias) => format!("{}.{}", msg.channel, alias),
3689        None => msg.channel.clone(),
3690    };
3691    let agent_alias = Arc::clone(&ctx.agent_alias);
3692    let sender = msg.sender.clone();
3693    let message_id = msg.id.clone();
3694    let composite_for_body = channel_composite.clone();
3695    zeroclaw_log::scope!(
3696        category: "channel",
3697        agent_alias: agent_alias.as_str(),
3698        channel: channel_composite.as_str(),
3699        sender: sender.as_str(),
3700        message_id: message_id.as_str(),
3701        => async move {
3702            process_channel_message_body(ctx, msg, cancellation_token, composite_for_body).await;
3703        }
3704    )
3705    .await;
3706}
3707
3708async fn process_channel_message_body(
3709    ctx: Arc<ChannelRuntimeContext>,
3710    msg: zeroclaw_api::channel::ChannelMessage,
3711    cancellation_token: CancellationToken,
3712    channel_composite: String,
3713) {
3714    ::zeroclaw_log::record!(
3715        INFO,
3716        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Inbound).with_attrs(
3717            ::serde_json::json!({
3718                "sender": msg.sender,
3719                "message_id": msg.id,
3720                "reply_target": msg.reply_target,
3721                "thread_ts": msg.thread_ts,
3722                "content": msg.content,
3723                "attachments_count": msg.attachments.len(),
3724            })
3725        ),
3726        "channel inbound message"
3727    );
3728
3729    // ── Hook: on_message_received (modifying) ────────────
3730    let mut msg = if let Some(hooks) = &ctx.hooks {
3731        match hooks.run_on_message_received(msg).await {
3732            zeroclaw_runtime::hooks::HookResult::Cancel(reason) => {
3733                ::zeroclaw_log::record!(
3734                    INFO,
3735                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3736                        .with_attrs(::serde_json::json!({"reason": reason.to_string()})),
3737                    "incoming message dropped by hook"
3738                );
3739                return;
3740            }
3741            zeroclaw_runtime::hooks::HookResult::Continue(modified) => modified,
3742        }
3743    } else {
3744        msg
3745    };
3746
3747    // ── Media pipeline: enrich inbound message with media annotations ──
3748    if ctx.media_pipeline.enabled && !msg.attachments.is_empty() {
3749        let vision = ctx.model_provider.supports_vision();
3750        let transcription_manager =
3751            crate::transcription::TranscriptionManager::new(&ctx.transcription_config)
3752                .ok()
3753                .map(|m| {
3754                    m.with_agent_transcription_provider(ctx.agent_transcription_provider.clone())
3755                });
3756        let pipeline = media_pipeline::MediaPipeline::new(
3757            &ctx.media_pipeline,
3758            transcription_manager.as_ref(),
3759            vision,
3760        );
3761        msg.content = Box::pin(pipeline.process(&msg.content, &msg.attachments)).await;
3762    }
3763
3764    // ── Link enricher: prepend URL summaries before agent sees the message ──
3765    let le_config = &ctx.prompt_config.link_enricher;
3766    if le_config.enabled {
3767        let enricher_cfg = link_enricher::LinkEnricherConfig {
3768            enabled: le_config.enabled,
3769            max_links: le_config.max_links,
3770            timeout_secs: le_config.timeout_secs,
3771        };
3772        let enriched = link_enricher::enrich_message(&msg.content, &enricher_cfg).await;
3773        if enriched != msg.content {
3774            ::zeroclaw_log::record!(
3775                INFO,
3776                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3777                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
3778                "Link enricher: prepended URL summaries to message"
3779            );
3780            msg.content = enriched;
3781        }
3782    }
3783
3784    let target_channel = find_channel_for_message(&ctx.channels_by_name, &msg).cloned();
3785
3786    // Self-loop guard, two-layer.
3787    //
3788    // Layer 1 — SDK side: channels that expose `Channel::self_handle()`
3789    // get caught here.
3790    //
3791    // Layer 2 — agent-loop fallback: even when the channel returned a
3792    // handle and Layer 1 ran, re-check via the shared
3793    // `peers::should_drop_self_loop` helper using the same handle. The
3794    // fallback exists so a channel impl that gains its
3795    // self-identity later in its lifecycle (after Layer 1's check
3796    // fired with `None`) still has a guard available; both layers use
3797    // identical normalization so they agree on what "self" means.
3798    if let Some(channel) = target_channel.as_ref() {
3799        if channel.drop_self_messages(&msg) {
3800            ::zeroclaw_log::record!(
3801                DEBUG,
3802                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3803                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
3804                "dropping self-authored inbound message (self-loop guard, sdk layer)"
3805            );
3806            return;
3807        }
3808        if zeroclaw_runtime::peers::should_drop_self_loop(
3809            &msg.sender,
3810            channel.self_handle().as_deref(),
3811        ) {
3812            ::zeroclaw_log::record!(
3813                DEBUG,
3814                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3815                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
3816                "dropping self-authored inbound message (self-loop guard, agent-loop fallback)"
3817            );
3818            return;
3819        }
3820    }
3821
3822    if let Err(err) = maybe_apply_runtime_config_update(ctx.as_ref()).await {
3823        ::zeroclaw_log::record!(
3824            WARN,
3825            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3826                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3827                .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
3828            "Failed to apply runtime config update"
3829        );
3830    }
3831    if handle_runtime_command_if_needed(ctx.as_ref(), &msg, target_channel.as_ref()).await {
3832        return;
3833    }
3834
3835    let history_key = conversation_history_key(&msg);
3836    if let Some(ref store) = ctx.session_store {
3837        let channel_id = msg
3838            .channel_alias
3839            .as_deref()
3840            .map(|alias| format!("{}.{alias}", msg.channel));
3841        let room_id = msg
3842            .thread_ts
3843            .as_deref()
3844            .filter(|s| !s.is_empty())
3845            .or_else(|| {
3846                let target = msg.reply_target.trim();
3847                if target.is_empty() {
3848                    None
3849                } else {
3850                    Some(target)
3851                }
3852            });
3853        let context = zeroclaw_infra::session_backend::SessionContext {
3854            channel_id: channel_id.as_deref(),
3855            room_id,
3856            sender_id: Some(msg.sender.as_str()).filter(|s| !s.is_empty()),
3857        };
3858        if let Err(e) = store.set_session_context(&history_key, context) {
3859            ::zeroclaw_log::record!(
3860                WARN,
3861                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3862                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3863                    .with_attrs(
3864                        ::serde_json::json!({"history_key": history_key, "e": e.to_string()})
3865                    ),
3866                "Failed to stamp session routing context"
3867            );
3868        }
3869    }
3870    let runtime_defaults = runtime_defaults_snapshot(ctx.as_ref());
3871    let mut route = get_route_selection(ctx.as_ref(), &history_key, &runtime_defaults);
3872
3873    // ── Query classification: override route when a rule matches ──
3874    if let Some(hint) =
3875        zeroclaw_runtime::agent::classifier::classify(&ctx.query_classification, &msg.content)
3876        && let Some(matched_route) = ctx
3877            .model_routes
3878            .iter()
3879            .find(|r| r.hint.eq_ignore_ascii_case(&hint))
3880    {
3881        ::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");
3882        route = ChannelRouteSelection {
3883            model_provider: matched_route.model_provider.clone(),
3884            model: matched_route.model.clone(),
3885            api_key: matched_route.api_key.clone(),
3886        };
3887    }
3888
3889    let mut active_model_provider = match get_or_create_provider(
3890        ctx.as_ref(),
3891        &route.model_provider,
3892        route.api_key.as_deref(),
3893        &runtime_defaults,
3894    )
3895    .await
3896    {
3897        Ok(model_provider) => model_provider,
3898        Err(err) => {
3899            let safe_err = zeroclaw_providers::sanitize_api_error(&err.to_string());
3900            let message = format!(
3901                "⚠️ Failed to initialize model_provider `{}`. Please run `/models` to choose another model_provider.\nDetails: {safe_err}",
3902                route.model_provider
3903            );
3904            if let Some(channel) = target_channel.as_ref() {
3905                let _ = channel.send(&SendMessage::reply_to(&msg, message)).await;
3906            }
3907            return;
3908        }
3909    };
3910    let history_user_content = channel_history_content_for_user_turn(&msg.content);
3911    if ctx.auto_save_memory
3912        && history_user_content.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
3913        && !zeroclaw_memory::should_skip_autosave_content(&history_user_content)
3914    {
3915        let autosave_key = conversation_memory_key(&msg);
3916        let _ = ctx
3917            .memory
3918            .store(
3919                &autosave_key,
3920                &history_user_content,
3921                zeroclaw_memory::MemoryCategory::Conversation,
3922                Some(&history_key),
3923            )
3924            .await;
3925    }
3926
3927    ::zeroclaw_log::record!(
3928        INFO,
3929        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3930            .with_attrs(::serde_json::json!({"message_id": msg.id})),
3931        "processing inbound message"
3932    );
3933    let started_at = Instant::now();
3934
3935    let force_fresh_session = take_pending_new_session(ctx.as_ref(), &history_key);
3936    if force_fresh_session {
3937        // `/new` should make the next user turn completely fresh even if
3938        // older cached turns reappear before this message starts.
3939        clear_sender_history(ctx.as_ref(), &history_key);
3940    }
3941
3942    let had_prior_history = if force_fresh_session {
3943        false
3944    } else {
3945        ctx.conversation_histories
3946            .lock()
3947            .unwrap_or_else(|e| e.into_inner())
3948            .peek(&history_key)
3949            .is_some_and(|turns| !turns.is_empty())
3950    };
3951
3952    // Preserve the dated user turn before the LLM call so interrupted requests
3953    // keep the same temporal context as CLI turns. Keep history compact so
3954    // inline image payloads are not reloaded into later requests.
3955    let timestamped_content = timestamp_channel_user_content(&msg.content);
3956    let timestamped_history_content = timestamp_channel_user_content(&history_user_content);
3957    append_sender_turn(
3958        ctx.as_ref(),
3959        &history_key,
3960        ChatMessage::user(&timestamped_history_content),
3961    );
3962
3963    // Build history from per-sender conversation cache.
3964    let mut prior_turns_raw = if force_fresh_session {
3965        vec![ChatMessage::user(&timestamped_content)]
3966    } else {
3967        ctx.conversation_histories
3968            .lock()
3969            .unwrap_or_else(|e| e.into_inner())
3970            .get(&history_key)
3971            .cloned()
3972            .unwrap_or_default()
3973    };
3974    if !force_fresh_session {
3975        restore_current_user_turn_media_payload(
3976            &mut prior_turns_raw,
3977            &timestamped_history_content,
3978            &timestamped_content,
3979        );
3980    }
3981    let mut prior_turns = normalize_cached_channel_turns(prior_turns_raw);
3982
3983    // Strip stale tool_result blocks from cached turns so the LLM never
3984    // sees a `<tool_result>` without a preceding `<tool_call>`, which
3985    // causes hallucinated output on subsequent heartbeat ticks or sessions.
3986    for turn in &mut prior_turns {
3987        if turn.content.contains("<tool_result") {
3988            turn.content = strip_tool_result_content(&turn.content);
3989        }
3990    }
3991
3992    // Strip [Used tools: ...] prefixes from cached assistant turns so the
3993    // LLM never sees (and reproduces) this internal summary format.
3994    for turn in &mut prior_turns {
3995        if turn.role == "assistant" && turn.content.starts_with("[Used tools:") {
3996            turn.content = strip_tool_summary_prefix(&turn.content);
3997        }
3998    }
3999
4000    // Strip [IMAGE:] markers from older cached turns before context
4001    // compression. The current message keeps its full payload so vision still
4002    // works for the active turn; historical turns keep compact annotations.
4003    strip_historical_image_payloads(&mut prior_turns);
4004
4005    // Proactively trim conversation history before sending to the model_provider
4006    // to prevent context-window-exceeded errors (bug #3460).
4007    let dropped = proactive_trim_turns(&mut prior_turns, PROACTIVE_CONTEXT_BUDGET_CHARS);
4008    if dropped > 0 {
4009        ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"sender": msg.sender, "dropped_turns": dropped, "remaining_turns": prior_turns.len()})), "Proactively trimmed conversation history to fit context budget");
4010    }
4011
4012    // ── Dual-scope memory recall ──────────────────────────────────
4013    // Always recall before each LLM call (not just first turn).
4014    // For group chats: merge sender-scope + group-scope memories.
4015    // For DMs: recall from the current conversation scope plus sender scope.
4016    let is_group_chat = is_group_reply_target(&msg.reply_target);
4017
4018    let mem_recall_start = Instant::now();
4019    let sender_session_ids = sender_memory_session_ids(&msg, &history_key);
4020    let sender_session_id_refs: Vec<Option<&str>> = sender_session_ids
4021        .iter()
4022        .map(|s| Some(s.as_str()))
4023        .collect();
4024    let sender_memory_fut = build_memory_context_for_sessions(
4025        ctx.memory.as_ref(),
4026        &msg.content,
4027        ctx.min_relevance_score,
4028        sender_session_id_refs.as_slice(),
4029    );
4030
4031    let (sender_memory, group_memory) = if is_group_chat {
4032        let group_memory_fut = build_memory_context(
4033            ctx.memory.as_ref(),
4034            &msg.content,
4035            ctx.min_relevance_score,
4036            Some(&history_key),
4037        );
4038        tokio::join!(sender_memory_fut, group_memory_fut)
4039    } else {
4040        (sender_memory_fut.await, String::new())
4041    };
4042    #[allow(clippy::cast_possible_truncation)]
4043    let mem_recall_ms = mem_recall_start.elapsed().as_millis() as u64;
4044    ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"mem_recall_ms": mem_recall_ms, "sender_empty": sender_memory.is_empty(), "group_empty": group_memory.is_empty()})), "memory recall completed");
4045
4046    // Merge sender and group memory context blocks.
4047    let memory_context = if group_memory.is_empty() {
4048        sender_memory
4049    } else if sender_memory.is_empty() {
4050        group_memory
4051    } else {
4052        format!("{sender_memory}\n{group_memory}")
4053    };
4054
4055    // Use refreshed system prompt for new sessions (master's /new support),
4056    // and inject memory into system prompt (not user message) so it
4057    // doesn't pollute session history and is re-fetched each turn.
4058    let base_system_prompt = if had_prior_history {
4059        ctx.system_prompt.as_str().to_string()
4060    } else {
4061        refreshed_new_session_system_prompt(ctx.as_ref())
4062    };
4063    let mut system_prompt =
4064        build_channel_system_prompt_for_message(&base_system_prompt, &msg, target_channel.as_ref());
4065    if !memory_context.is_empty() {
4066        let _ = write!(system_prompt, "\n\n{memory_context}");
4067    }
4068    let mut history = vec![ChatMessage::system(system_prompt)];
4069    history.extend(prior_turns);
4070
4071    // ── Proactive context compression ────────────────────────────
4072    // Use the existing ContextCompressor to summarize older history
4073    // before the LLM call, preventing context-window-exceeded errors
4074    // and preserving key decisions through LLM-driven summarization.
4075    {
4076        let cc_config = ctx.agent_cfg.resolved.context_compression.clone();
4077        let compressor = zeroclaw_runtime::agent::context_compressor::ContextCompressor::new(
4078            cc_config,
4079            ctx.context_token_budget,
4080        )
4081        .with_memory(Arc::clone(&ctx.memory));
4082        match compressor
4083            .compress_if_needed(
4084                &mut history,
4085                active_model_provider.as_ref(),
4086                route.model.as_str(),
4087                runtime_defaults.defaults.temperature,
4088            )
4089            .await
4090        {
4091            Ok(result) if result.compressed => {
4092                ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"sender": msg.sender, "tokens_before": result.tokens_before, "tokens_after": result.tokens_after, "passes": result.passes_used})), "Proactive context compression applied before LLM call");
4093            }
4094            Err(e) => {
4095                ::zeroclaw_log::record!(
4096                    WARN,
4097                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4098                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
4099                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4100                    "Context compression failed, proceeding without"
4101                );
4102            }
4103            _ => {}
4104        }
4105    }
4106
4107    // ── Reply-intent precheck ────────────────────────────────────────
4108    let explicit_channel_address =
4109        is_explicitly_addressed_channel_message(&msg.channel, &msg.content);
4110    let classifier_intent = if explicit_channel_address {
4111        AssistantChannelOutcome::Reply(String::new())
4112    } else {
4113        let (classifier_provider_arc, classifier_model_owned, classifier_temperature): (
4114            Arc<dyn ModelProvider>,
4115            String,
4116            Option<f64>,
4117        ) = resolve_classifier_route(
4118            ctx.as_ref(),
4119            &ctx.agent_cfg.classifier_provider,
4120            &runtime_defaults,
4121        )
4122        .await
4123        .unwrap_or_else(|| {
4124            (
4125                Arc::clone(&active_model_provider),
4126                route.model.clone(),
4127                None,
4128            )
4129        });
4130
4131        classify_channel_reply_intent(
4132            classifier_provider_arc.as_ref(),
4133            history[0].content.as_str(),
4134            &history,
4135            classifier_model_owned.as_str(),
4136            classifier_temperature.or(runtime_defaults.defaults.temperature),
4137        )
4138        .await
4139        .unwrap_or(AssistantChannelOutcome::Reply(String::new()))
4140    };
4141
4142    // ACP sessions are direct user requests — there is no broadcast,
4143    // no peer context, no spam concern. The no-reply classifier is a
4144    // multi-agent / chatroom heuristic; on ACP, every inbound is a
4145    // call to action and must produce a reply. Override the verdict
4146    // before the no-reply gate so the agent loop generates a response.
4147    let is_acp_channel = target_channel
4148        .as_ref()
4149        .map(|c| {
4150            matches!(
4151                ::zeroclaw_api::attribution::Attributable::role(c.as_ref()),
4152                ::zeroclaw_api::attribution::Role::Channel(
4153                    ::zeroclaw_api::attribution::ChannelKind::AcpChannel
4154                )
4155            )
4156        })
4157        .unwrap_or(false);
4158    let reply_intent = if is_acp_channel
4159        && let AssistantChannelOutcome::NoReply {
4160            ref kind,
4161            ref reason,
4162        } = classifier_intent
4163    {
4164        ::zeroclaw_log::record!(
4165            DEBUG,
4166            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
4167                ::serde_json::json!({
4168                    "kind": format!("{kind:?}"),
4169                    "reason": reason.as_deref().unwrap_or(""),
4170                })
4171            ),
4172            "ACP channel: classifier voted no_reply, overriding to reply (ACP must always respond)"
4173        );
4174        AssistantChannelOutcome::Reply(String::new())
4175    } else {
4176        classifier_intent
4177    };
4178
4179    if let AssistantChannelOutcome::NoReply { kind, reason } = reply_intent {
4180        let history_response = AssistantChannelOutcome::NoReply {
4181            kind,
4182            reason: reason.clone(),
4183        }
4184        .history_marker();
4185        append_sender_turn(
4186            ctx.as_ref(),
4187            &history_key,
4188            ChatMessage::assistant(&history_response),
4189        );
4190        // Surface the no-reply decision in chat with an emoji on the user's
4191        // message so the chatter isn't left wondering whether the bot saw
4192        // the message. Same `ack_reactions` gate as the 👀 → ✅/⚠️ ack/done
4193        // pattern so operators with reactions disabled don't suddenly see
4194        // them. Best-effort: log on failure, never propagate. Channels that
4195        // don't implement add_reaction get the trait's no-op default.
4196        if ctx.ack_reactions
4197            && let Some(channel) = target_channel.as_ref()
4198        {
4199            let emoji = kind.emoji();
4200            if let Err(e) = channel
4201                .add_reaction(&msg.reply_target, &msg.id, emoji)
4202                .await
4203            {
4204                ::zeroclaw_log::record!(
4205                    DEBUG,
4206                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
4207                    &format!(
4208                        "Failed to add {emoji} no-reply reaction on {}: {e}",
4209                        channel.name()
4210                    )
4211                );
4212            }
4213        }
4214        ::zeroclaw_log::record!(
4215            INFO,
4216            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Skip)
4217                .with_duration(u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),)
4218                .with_attrs(::serde_json::json!({
4219                    "model_provider": route.model_provider,
4220                    "model": route.model,
4221                    "sender": msg.sender,
4222                    "phase": "precheck",
4223                    "kind": format!("{kind:?}"),
4224                    "reason": reason.as_deref().unwrap_or("no reason provided"),
4225                })),
4226            "channel_message_no_reply"
4227        );
4228        return;
4229    }
4230
4231    let use_draft_streaming = target_channel
4232        .as_ref()
4233        .is_some_and(|ch| ch.supports_draft_updates());
4234
4235    ::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");
4236
4237    // Partial mode: delta channel for draft updates (progress + text).
4238    let (delta_tx, delta_rx) = if use_draft_streaming {
4239        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_runtime::agent::loop_::DraftEvent>(64);
4240        (Some(tx), Some(rx))
4241    } else {
4242        (None, None)
4243    };
4244
4245    // Partial mode: send an initial draft message for progressive editing.
4246    let draft_message_id = if use_draft_streaming {
4247        if let Some(channel) = target_channel.as_ref() {
4248            match channel
4249                .send_draft(
4250                    &SendMessage::new("...", &msg.reply_target).in_thread(msg.thread_ts.clone()),
4251                )
4252                .await
4253            {
4254                Ok(id) => id,
4255                Err(e) => {
4256                    ::zeroclaw_log::record!(
4257                        DEBUG,
4258                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4259                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4260                        &format!("Failed to send draft on {}", channel.name())
4261                    );
4262                    None
4263                }
4264            }
4265        } else {
4266            None
4267        }
4268    } else {
4269        None
4270    };
4271
4272    // Spawn the appropriate handler for the delta channel.
4273    let draft_updater = if use_draft_streaming {
4274        // Partial: accumulate text and edit a single draft message.
4275        if let (Some(mut rx), Some(draft_id_ref), Some(channel_ref)) = (
4276            delta_rx,
4277            draft_message_id.as_deref(),
4278            target_channel.as_ref(),
4279        ) {
4280            let channel = Arc::clone(channel_ref);
4281            let reply_target = msg.reply_target.clone();
4282            let draft_id = draft_id_ref.to_string();
4283            Some(zeroclaw_spawn::spawn!(async move {
4284                use zeroclaw_runtime::agent::loop_::StreamDelta;
4285                let mut accumulated = String::new();
4286                while let Some(event) = rx.recv().await {
4287                    match event {
4288                        StreamDelta::Status(text) => {
4289                            let visible = strip_think_tags_inline(&text);
4290                            if let Err(e) = channel
4291                                .update_draft_progress(&reply_target, &draft_id, &visible)
4292                                .await
4293                            {
4294                                ::zeroclaw_log::record!(
4295                                    DEBUG,
4296                                    ::zeroclaw_log::Event::new(
4297                                        module_path!(),
4298                                        ::zeroclaw_log::Action::Note
4299                                    )
4300                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4301                                    "Draft progress update failed"
4302                                );
4303                            }
4304                        }
4305                        StreamDelta::Text(text) => {
4306                            accumulated.push_str(&text);
4307                            let visible = strip_think_tags_inline(&accumulated);
4308                            if let Err(e) = channel
4309                                .update_draft(&reply_target, &draft_id, &visible)
4310                                .await
4311                            {
4312                                ::zeroclaw_log::record!(
4313                                    DEBUG,
4314                                    ::zeroclaw_log::Event::new(
4315                                        module_path!(),
4316                                        ::zeroclaw_log::Action::Note
4317                                    )
4318                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4319                                    "Draft update failed"
4320                                );
4321                            }
4322                        }
4323                    }
4324                }
4325            }))
4326        } else {
4327            None
4328        }
4329    } else {
4330        None
4331    };
4332
4333    // React with 👀 to acknowledge the incoming message
4334    if ctx.ack_reactions
4335        && let Some(channel) = target_channel.as_ref()
4336        && let Err(e) = channel
4337            .add_reaction(&msg.reply_target, &msg.id, "\u{1F440}")
4338            .await
4339    {
4340        ::zeroclaw_log::record!(
4341            DEBUG,
4342            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4343                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4344            "Failed to add reaction"
4345        );
4346    }
4347
4348    // Skip typing only for Partial mode — the draft message itself provides
4349    // visual feedback. MultiMessage and Off both keep typing active.
4350    let is_partial_draft = target_channel
4351        .as_ref()
4352        .is_some_and(|ch| ch.supports_draft_updates() && !ch.supports_multi_message_streaming());
4353    let typing_cancellation = if is_partial_draft {
4354        None
4355    } else {
4356        target_channel.as_ref().map(|_| CancellationToken::new())
4357    };
4358    let typing_task = match (target_channel.as_ref(), typing_cancellation.as_ref()) {
4359        (Some(channel), Some(token)) => Some(spawn_scoped_typing_task(
4360            Arc::clone(channel),
4361            msg.reply_target.clone(),
4362            token.clone(),
4363        )),
4364        _ => None,
4365    };
4366
4367    // Wrap observer to forward tool events as live thread messages
4368    let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
4369    let notify_observer: Arc<ChannelNotifyObserver> = Arc::new(ChannelNotifyObserver {
4370        inner: Arc::clone(&ctx.observer),
4371        tx: notify_tx,
4372        tools_used: AtomicBool::new(false),
4373    });
4374    let notify_observer_flag = Arc::clone(&notify_observer);
4375    let notify_channel = target_channel.clone();
4376    let notify_reply_target = msg.reply_target.clone();
4377    let notify_thread_root = followup_thread_id(&msg);
4378    let notify_task = if msg.channel == "cli" || !ctx.show_tool_calls {
4379        Some(zeroclaw_spawn::spawn!(async move {
4380            while notify_rx.recv().await.is_some() {}
4381        }))
4382    } else {
4383        Some(zeroclaw_spawn::spawn!(async move {
4384            let thread_ts = notify_thread_root;
4385            while let Some(text) = notify_rx.recv().await {
4386                if let Some(ref ch) = notify_channel {
4387                    let _ = ch
4388                        .send(
4389                            &SendMessage::new(&text, &notify_reply_target)
4390                                .in_thread(thread_ts.clone()),
4391                        )
4392                        .await;
4393                }
4394            }
4395        }))
4396    };
4397
4398    enum LlmExecutionResult {
4399        Completed(Result<Result<String, anyhow::Error>, tokio::time::error::Elapsed>),
4400        Cancelled,
4401    }
4402
4403    let model_switch_callback = get_model_switch_state();
4404    let scale_cap = ctx
4405        .pacing
4406        .message_timeout_scale_max
4407        .unwrap_or(CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP);
4408    let timeout_budget_secs = channel_message_timeout_budget_secs_with_cap(
4409        ctx.message_timeout_secs,
4410        ctx.max_tool_iterations,
4411        scale_cap,
4412    );
4413    let cost_tracking_context = ctx.cost_tracking.clone().map(|state| {
4414        zeroclaw_runtime::agent::loop_::ToolLoopCostTrackingContext::new(
4415            state.tracker,
4416            state.model_provider_pricing,
4417        )
4418        .with_agent_alias(state.agent_alias.as_str())
4419    });
4420    let llm_call_start = Instant::now();
4421    #[allow(clippy::cast_possible_truncation)]
4422    let elapsed_before_llm_ms = started_at.elapsed().as_millis() as u64;
4423    ::zeroclaw_log::record!(
4424        INFO,
4425        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4426            .with_attrs(::serde_json::json!({"elapsed_before_llm_ms": elapsed_before_llm_ms})),
4427        "starting LLM call"
4428    );
4429    // Per-turn collector. `tool_execution::execute_one_tool` pushes
4430    // `<tool_name>: <receipt>` here whenever a receipt is generated, so the
4431    // orchestrator can render the trailing `Tool receipts:` block after the
4432    // loop returns. Wrapped in `Arc` so the same handle can be shared into
4433    // `TOOL_LOOP_RECEIPT_CONTEXT` for subagent forwarding. Inert when
4434    // `receipt_generator` is `None`.
4435    let tool_receipts_collector: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
4436        std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4437    let receipt_scope = ctx.receipt_generator.as_ref().map(|generator| {
4438        zeroclaw_runtime::agent::tool_receipts::ReceiptScope {
4439            generator: generator.clone(),
4440            collector: std::sync::Arc::clone(&tool_receipts_collector),
4441        }
4442    });
4443    let (llm_result, fallback_info) = scope_provider_fallback(async {
4444        let llm_result = loop {
4445            let loop_result = tokio::select! {
4446                () = cancellation_token.cancelled() => LlmExecutionResult::Cancelled,
4447                result = tokio::time::timeout(
4448                    Duration::from_secs(timeout_budget_secs),
4449                    scope_thread_id(
4450                        msg.interruption_scope_id.clone()
4451                            .or_else(|| msg.thread_ts.clone())
4452                            .or_else(|| Some(msg.id.clone())),
4453                    scope_session_key(
4454                        Some(history_key.clone()),
4455                        zeroclaw_runtime::agent::loop_::TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
4456                            cost_tracking_context.clone(),
4457                        zeroclaw_runtime::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT.scope(
4458                            receipt_scope.clone(),
4459                        run_tool_call_loop(
4460                        active_model_provider.as_ref(),
4461                        &mut history,
4462                        ctx.tools_registry.as_ref(),
4463                        notify_observer.as_ref() as &dyn Observer,
4464                        route.model_provider.as_str(),
4465                        route.model.as_str(),
4466                        runtime_defaults.defaults.temperature,
4467                        true,
4468                        Some(&*ctx.approval_manager),
4469                        msg.channel.as_str(),
4470                        Some(msg.reply_target.as_str()),
4471                        &ctx.multimodal,
4472                        ctx.max_tool_iterations,
4473                        Some(cancellation_token.clone()),
4474                        delta_tx.clone(),
4475                        ctx.hooks.as_deref(),
4476                        if msg.channel == "cli"
4477                            || ctx.autonomy_level == AutonomyLevel::Full
4478                        {
4479                            &[]
4480                        } else {
4481                            ctx.non_cli_excluded_tools.as_ref()
4482                        },
4483                        ctx.tool_call_dedup_exempt.as_ref(),
4484                        ctx.activated_tools.as_ref(),
4485                        Some(model_switch_callback.clone()),
4486                        &ctx.pacing,
4487                        ctx.prompt_config
4488                            .agent(ctx.agent_alias.as_str())
4489                            .is_some_and(|agent| agent.resolved.strict_tool_parsing),
4490                        ctx.prompt_config
4491                            .agent(ctx.agent_alias.as_str())
4492                            .is_some_and(|agent| agent.resolved.parallel_tools),
4493                        ctx.max_tool_result_chars,
4494                        ctx.context_token_budget,
4495                        None, // shared_budget
4496                        target_channel.as_deref(),
4497                        ctx.receipt_generator.as_ref(),
4498                        // Collector is meaningful only when the generator is
4499                        // active. Pass None when receipts are disabled so the
4500                        // call site reflects that coupling explicitly.
4501                        ctx.receipt_generator
4502                            .as_ref()
4503                            .map(|_| tool_receipts_collector.as_ref()),
4504                    ),
4505                    ),
4506                    ),
4507                    ),
4508                    ),
4509                ) => LlmExecutionResult::Completed(result),
4510            };
4511
4512            // Handle model switch: re-create the model_provider and retry
4513            if let LlmExecutionResult::Completed(Ok(Err(ref e))) = loop_result
4514                && let Some((new_model_provider, new_model)) = is_model_switch_requested(e)
4515            {
4516                ::zeroclaw_log::record!(
4517                    INFO,
4518                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
4519                    &format!(
4520                        "Model switch requested, switching from {} {} to {} {}",
4521                        route.model_provider, route.model, new_model_provider, new_model
4522                    )
4523                );
4524
4525                let resolved_model_provider = match resolve_provider_ref_for_runtime_switch(
4526                    runtime_defaults.config.as_ref(),
4527                    &new_model_provider,
4528                ) {
4529                    Ok(provider_ref) => provider_ref,
4530                    Err(err) => {
4531                        ::zeroclaw_log::record!(
4532                            ERROR,
4533                            ::zeroclaw_log::Event::new(
4534                                module_path!(),
4535                                ::zeroclaw_log::Action::Fail
4536                            )
4537                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
4538                            .with_attrs(::serde_json::json!({"err": err.to_string()})),
4539                            "Failed to resolve model_provider after model switch"
4540                        );
4541                        clear_model_switch_request();
4542                        break loop_result;
4543                    }
4544                };
4545
4546                match get_or_create_provider(
4547                    ctx.as_ref(),
4548                    &resolved_model_provider,
4549                    None,
4550                    &runtime_defaults,
4551                )
4552                .await
4553                {
4554                    Ok(new_prov) => {
4555                        active_model_provider = new_prov;
4556                        route.model_provider = resolved_model_provider;
4557                        route.model = new_model;
4558                        clear_model_switch_request();
4559
4560                        ctx.observer.record_event(&ObserverEvent::AgentStart {
4561                            model_provider: route.model_provider.clone(),
4562                            model: route.model.clone(),
4563                        });
4564
4565                        continue;
4566                    }
4567                    Err(err) => {
4568                        ::zeroclaw_log::record!(
4569                            ERROR,
4570                            ::zeroclaw_log::Event::new(
4571                                module_path!(),
4572                                ::zeroclaw_log::Action::Fail
4573                            )
4574                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
4575                            .with_attrs(::serde_json::json!({"err": err.to_string()})),
4576                            "Failed to create model_provider after model switch"
4577                        );
4578                        clear_model_switch_request();
4579                        // Fall through with the original error
4580                    }
4581                }
4582            }
4583
4584            break loop_result;
4585        };
4586        let fb = take_last_provider_fallback();
4587        (llm_result, fb)
4588    })
4589    .await;
4590
4591    // Drop all senders so updater tasks can exit (rx.recv() returns None).
4592    ::zeroclaw_log::record!(
4593        DEBUG,
4594        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
4595        "Post-loop: dropping delta_tx and awaiting draft updater"
4596    );
4597    drop(delta_tx);
4598    if let Some(handle) = draft_updater {
4599        let _ = handle.await;
4600    }
4601    ::zeroclaw_log::record!(
4602        DEBUG,
4603        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
4604        "Post-loop: draft updater completed"
4605    );
4606
4607    // Thread the final reply only if tools were used (multi-message response)
4608    if notify_observer_flag.tools_used.load(Ordering::Relaxed) && msg.channel != "cli" {
4609        msg.thread_ts = followup_thread_id(&msg);
4610    }
4611    // Drop the notify sender so the forwarder task finishes
4612    drop(notify_observer);
4613    drop(notify_observer_flag);
4614    if let Some(handle) = notify_task {
4615        let _ = handle.await;
4616    }
4617
4618    #[allow(clippy::cast_possible_truncation)]
4619    let llm_call_ms = llm_call_start.elapsed().as_millis() as u64;
4620    #[allow(clippy::cast_possible_truncation)]
4621    let total_ms = started_at.elapsed().as_millis() as u64;
4622    ::zeroclaw_log::record!(
4623        INFO,
4624        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4625            .with_attrs(::serde_json::json!({"llm_call_ms": llm_call_ms, "total_ms": total_ms})),
4626        "LLM call completed"
4627    );
4628
4629    if let Some(token) = typing_cancellation.as_ref() {
4630        token.cancel();
4631    }
4632    if let Some(handle) = typing_task {
4633        log_worker_join_result(handle.await);
4634    }
4635
4636    let reaction_done_emoji = match &llm_result {
4637        LlmExecutionResult::Completed(Ok(Ok(_))) => "\u{2705}", // ✅
4638        _ => "\u{26A0}\u{FE0F}",                                // ⚠️
4639    };
4640
4641    match llm_result {
4642        LlmExecutionResult::Cancelled => {
4643            ::zeroclaw_log::record!(
4644                INFO,
4645                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4646                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
4647                "Cancelled in-flight channel request due to newer message"
4648            );
4649            ::zeroclaw_log::record!(
4650                INFO,
4651                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Cancel)
4652                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
4653                    .with_duration(
4654                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
4655                    )
4656                    .with_attrs(::serde_json::json!({
4657                        "model_provider": route.model_provider,
4658                        "model": route.model,
4659                        "sender": msg.sender,
4660                        "reason": "cancelled due to newer inbound message",
4661                    })),
4662                "channel_message_cancelled"
4663            );
4664            if let (Some(channel), Some(draft_id)) =
4665                (target_channel.as_ref(), draft_message_id.as_deref())
4666                && let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await
4667            {
4668                ::zeroclaw_log::record!(
4669                    DEBUG,
4670                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4671                        .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
4672                    &format!("Failed to cancel draft on {}", channel.name())
4673                );
4674            }
4675        }
4676        LlmExecutionResult::Completed(Ok(Ok(response))) => {
4677            // ── Hook: on_message_sending (modifying) ─────────
4678            let mut outbound_response = response;
4679            if let Some(hooks) = &ctx.hooks {
4680                match hooks
4681                    .run_on_message_sending(
4682                        msg.channel.clone(),
4683                        msg.reply_target.clone(),
4684                        outbound_response.clone(),
4685                    )
4686                    .await
4687                {
4688                    zeroclaw_runtime::hooks::HookResult::Cancel(reason) => {
4689                        ::zeroclaw_log::record!(
4690                            INFO,
4691                            ::zeroclaw_log::Event::new(
4692                                module_path!(),
4693                                ::zeroclaw_log::Action::Note
4694                            )
4695                            .with_attrs(::serde_json::json!({"reason": reason.to_string()})),
4696                            "outgoing message suppressed by hook"
4697                        );
4698                        if let (Some(channel), Some(draft_id)) =
4699                            (target_channel.as_ref(), draft_message_id.as_deref())
4700                        {
4701                            let _ = channel.cancel_draft(&msg.reply_target, draft_id).await;
4702                        }
4703                        return;
4704                    }
4705                    zeroclaw_runtime::hooks::HookResult::Continue((
4706                        hook_channel,
4707                        hook_recipient,
4708                        mut modified_content,
4709                    )) => {
4710                        if hook_channel != msg.channel || hook_recipient != msg.reply_target {
4711                            ::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");
4712                        }
4713
4714                        let modified_len = modified_content.chars().count();
4715                        if modified_len > CHANNEL_HOOK_MAX_OUTBOUND_CHARS {
4716                            ::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");
4717                            modified_content = truncate_with_ellipsis(
4718                                &modified_content,
4719                                CHANNEL_HOOK_MAX_OUTBOUND_CHARS,
4720                            );
4721                        }
4722
4723                        if modified_content != outbound_response {
4724                            ::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");
4725                        }
4726
4727                        outbound_response = modified_content;
4728                    }
4729                }
4730            }
4731
4732            let sanitized_response =
4733                sanitize_channel_response(&outbound_response, ctx.tools_registry.as_ref());
4734            let mut delivered_response = if sanitized_response.is_empty()
4735                && !outbound_response.trim().is_empty()
4736            {
4737                "I encountered malformed tool-call output and could not produce a safe reply. Please try again.".to_string()
4738            } else {
4739                sanitized_response
4740            };
4741            delivered_response = ensure_nonempty_channel_reply(
4742                delivered_response,
4743                &outbound_response,
4744                &msg.channel,
4745                &msg.reply_target,
4746            );
4747
4748            // Append a footer when the response was served by a different model_provider family.
4749            // Intra-family fallbacks (e.g. minimax → minimax-cn) are suppressed.
4750            if let Some(fb) = fallback_info.as_ref() {
4751                let req_base = fb.requested_provider.split(':').next().unwrap_or("");
4752                let act_base = fb.actual_provider.split(':').next().unwrap_or("");
4753                let same_family = req_base == act_base
4754                    || req_base.starts_with(act_base)
4755                    || act_base.starts_with(req_base);
4756                if !same_family {
4757                    use std::fmt::Write as _;
4758                    write!(
4759                        delivered_response,
4760                        "\n\n---\n\u{26A1} `{}` unavailable \u{2014} response from **{}** (`{}`)\nSwitch model: /models",
4761                        fb.requested_provider, fb.actual_provider, fb.actual_model,
4762                    )
4763                    .ok();
4764                }
4765            }
4766
4767            ::zeroclaw_log::record!(
4768                INFO,
4769                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Outbound)
4770                    .with_outcome(::zeroclaw_log::EventOutcome::Success)
4771                    .with_duration(
4772                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
4773                    )
4774                    .with_attrs(::serde_json::json!({
4775                        "model_provider": route.model_provider,
4776                        "model": route.model,
4777                        "sender": msg.sender,
4778                        "response": scrub_credentials(&delivered_response),
4779                    })),
4780                "channel_message_outbound"
4781            );
4782
4783            // Persist intermediate tool-call/result messages from this turn
4784            // so the model retains concrete "I used tools" examples in
4785            // context, preventing drift toward tool-less responses.
4786            let keep_tool_turns = ctx.agent_cfg.resolved.keep_tool_context_turns;
4787            if keep_tool_turns > 0 {
4788                // Find tool messages for the current turn: everything after
4789                // the last user message up to (but not including) the final
4790                // assistant response that matches our delivered text.
4791                let tool_messages: Vec<ChatMessage> = extract_current_turn_tool_messages(&history);
4792                for tool_msg in tool_messages {
4793                    append_sender_turn(ctx.as_ref(), &history_key, tool_msg);
4794                }
4795            }
4796
4797            let history_response = delivered_response.clone();
4798            append_sender_turn(
4799                ctx.as_ref(),
4800                &history_key,
4801                ChatMessage::assistant(&history_response),
4802            );
4803
4804            // Strip tool-call messages from turns older than
4805            // keep_tool_context_turns to prevent unbounded growth.
4806            if keep_tool_turns > 0 {
4807                strip_old_tool_context(ctx.as_ref(), &history_key, keep_tool_turns);
4808            }
4809
4810            // Fire-and-forget LLM-driven memory consolidation. Passes the
4811            // agent's resolved temperature through unchanged — `None`
4812            // means the provider sends no `temperature` field (necessary
4813            // for models that reject it, e.g. claude-opus-4-7).
4814            if ctx.auto_save_memory && msg.content.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS {
4815                let memory_strategy = Arc::clone(&ctx.memory_strategy);
4816                let model_provider = Arc::clone(&ctx.model_provider);
4817                let model = ctx.model.to_string();
4818                let temperature = ctx.temperature;
4819                let user_msg = msg.content.clone();
4820                let assistant_resp = delivered_response.clone();
4821                zeroclaw_spawn::spawn!(async move {
4822                    if let Err(e) = memory_strategy
4823                        .consolidate_turn(
4824                            &user_msg,
4825                            &assistant_resp,
4826                            model_provider.as_ref(),
4827                            &model,
4828                            temperature,
4829                        )
4830                        .await
4831                    {
4832                        ::zeroclaw_log::record!(
4833                            DEBUG,
4834                            ::zeroclaw_log::Event::new(
4835                                module_path!(),
4836                                ::zeroclaw_log::Action::Note
4837                            )
4838                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4839                            "Memory consolidation skipped"
4840                        );
4841                    }
4842                });
4843            }
4844
4845            ::zeroclaw_log::record!(
4846                INFO,
4847                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Outbound)
4848                    .with_outcome(::zeroclaw_log::EventOutcome::Success)
4849                    .with_duration(
4850                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
4851                    )
4852                    .with_attrs(::serde_json::json!({
4853                        "sender": msg.sender,
4854                        "message_id": msg.id,
4855                        "reply_target": msg.reply_target,
4856                        "thread_ts": msg.thread_ts,
4857                        "content": delivered_response,
4858                    })),
4859                "reply delivered"
4860            );
4861            // Build the trailing `Tool receipts:` block from the per-turn
4862            // collector. Empty when receipts are disabled or no tool ran.
4863            // Includes receipts from delegate sub-agents because the same
4864            // `Arc<Mutex<Vec<String>>>` is forwarded via
4865            // `TOOL_LOOP_RECEIPT_CONTEXT` into sub-loops.
4866            let receipts_block = if ctx.show_receipts_in_response {
4867                let receipts = tool_receipts_collector
4868                    .lock()
4869                    .unwrap_or_else(|e| e.into_inner());
4870                if receipts.is_empty() {
4871                    None
4872                } else {
4873                    use std::fmt::Write as _;
4874                    let mut block = String::from("---\nTool receipts:");
4875                    for r in receipts.iter() {
4876                        write!(block, "\n  {r}").ok();
4877                    }
4878                    Some(block)
4879                }
4880            } else {
4881                None
4882            };
4883
4884            if let Some(channel) = target_channel.as_ref() {
4885                if let Some(ref draft_id) = draft_message_id {
4886                    if let Err(e) = channel
4887                        .finalize_draft(&msg.reply_target, draft_id, &delivered_response)
4888                        .await
4889                    {
4890                        ::zeroclaw_log::record!(
4891                            WARN,
4892                            ::zeroclaw_log::Event::new(
4893                                module_path!(),
4894                                ::zeroclaw_log::Action::Note
4895                            )
4896                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
4897                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4898                            "Failed to finalize draft; sending as new message"
4899                        );
4900                        let _ = channel
4901                            .send(&SendMessage::reply_to(&msg, &delivered_response))
4902                            .await;
4903                    }
4904                } else if let Err(e) = channel
4905                    .send(
4906                        &SendMessage::reply_to(&msg, &delivered_response)
4907                            .with_cancellation(cancellation_token.clone()),
4908                    )
4909                    .await
4910                {
4911                    ::zeroclaw_log::record!(
4912                        ERROR,
4913                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
4914                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
4915                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4916                        "failed to reply"
4917                    );
4918                }
4919                // Send tool receipts as a separate message in the same thread.
4920                // The block is the operator-facing audit surface for the feature,
4921                // so a dropped send must leave a log signal rather than silently
4922                // disappear.
4923                if let Some(ref block) = receipts_block
4924                    && let Err(e) = channel
4925                        .send(
4926                            &SendMessage::new(block, &msg.reply_target)
4927                                .in_thread(msg.thread_ts.clone()),
4928                        )
4929                        .await
4930                {
4931                    ::zeroclaw_log::record!(
4932                        WARN,
4933                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4934                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
4935                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
4936                        "failed to send tool receipts block"
4937                    );
4938                }
4939            }
4940        }
4941        LlmExecutionResult::Completed(Ok(Err(e))) => {
4942            if zeroclaw_runtime::agent::loop_::is_tool_loop_cancelled(&e)
4943                || cancellation_token.is_cancelled()
4944            {
4945                ::zeroclaw_log::record!(
4946                    INFO,
4947                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4948                        .with_attrs(::serde_json::json!({"sender": msg.sender})),
4949                    "Cancelled in-flight channel request due to newer message"
4950                );
4951                ::zeroclaw_log::record!(
4952                    INFO,
4953                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Cancel)
4954                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
4955                        .with_duration(
4956                            u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
4957                        )
4958                        .with_attrs(::serde_json::json!({
4959                            "model_provider": route.model_provider,
4960                            "model": route.model,
4961                            "sender": msg.sender,
4962                            "reason": "cancelled during tool-call loop",
4963                        })),
4964                    "channel_message_cancelled"
4965                );
4966                if let (Some(channel), Some(draft_id)) =
4967                    (target_channel.as_ref(), draft_message_id.as_deref())
4968                    && let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await
4969                {
4970                    ::zeroclaw_log::record!(
4971                        DEBUG,
4972                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4973                            .with_attrs(::serde_json::json!({"error": format!("{}", err)})),
4974                        &format!("Failed to cancel draft on {}", channel.name())
4975                    );
4976                }
4977            } else if is_context_window_overflow_error(&e) {
4978                let compacted = compact_sender_history(ctx.as_ref(), &history_key);
4979                let error_text = if compacted {
4980                    "⚠️ Context window exceeded for this conversation. I compacted recent history and kept the latest context. Please resend your last message."
4981                } else {
4982                    "⚠️ Context window exceeded for this conversation. Please resend your last message."
4983                };
4984                eprintln!(
4985                    "  ⚠️ Context window exceeded after {}ms; sender history compacted={}",
4986                    started_at.elapsed().as_millis(),
4987                    compacted
4988                );
4989                ::zeroclaw_log::record!(
4990                    WARN,
4991                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
4992                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
4993                        .with_duration(
4994                            u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
4995                        )
4996                        .with_attrs(::serde_json::json!({
4997                            "model_provider": route.model_provider,
4998                            "model": route.model,
4999                            "sender": msg.sender,
5000                            "reason": "context window exceeded",
5001                            "history_compacted": compacted,
5002                        })),
5003                    "channel_message_error"
5004                );
5005                if let Some(channel) = target_channel.as_ref() {
5006                    if let Some(ref draft_id) = draft_message_id {
5007                        let _ = channel
5008                            .finalize_draft(&msg.reply_target, draft_id, error_text)
5009                            .await;
5010                    } else {
5011                        let _ = channel
5012                            .send(
5013                                &SendMessage::new(error_text, &msg.reply_target)
5014                                    .in_thread(msg.thread_ts.clone()),
5015                            )
5016                            .await;
5017                    }
5018                }
5019            } else {
5020                eprintln!(
5021                    "  ❌ LLM error after {}ms: {e}",
5022                    started_at.elapsed().as_millis()
5023                );
5024
5025                // Evict cached model_provider on auth errors so the next request
5026                // re-creates it with fresh OAuth credentials.
5027                if zeroclaw_providers::reliable::is_auth_error(&e) {
5028                    let cache_key = provider_cache_key(
5029                        &route.model_provider,
5030                        route.api_key.as_deref(),
5031                        runtime_defaults.generation,
5032                    );
5033                    let mut cache = ctx.provider_cache.lock().unwrap_or_else(|p| p.into_inner());
5034                    if cache.remove(&cache_key).is_some() {
5035                        ::zeroclaw_log::record!(
5036                            INFO,
5037                            ::zeroclaw_log::Event::new(
5038                                module_path!(),
5039                                ::zeroclaw_log::Action::Note
5040                            )
5041                            .with_attrs(
5042                                ::serde_json::json!({"model_provider": route.model_provider})
5043                            ),
5044                            "Evicted cached model_provider after auth error; next request will re-create with fresh credentials"
5045                        );
5046                    }
5047                }
5048                let safe_error = zeroclaw_providers::sanitize_api_error(&e.to_string());
5049                ::zeroclaw_log::record!(
5050                    WARN,
5051                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
5052                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
5053                        .with_duration(
5054                            u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
5055                        )
5056                        .with_attrs(::serde_json::json!({
5057                            "model_provider": route.model_provider,
5058                            "model": route.model,
5059                            "sender": msg.sender,
5060                            "error": safe_error,
5061                        })),
5062                    "channel_message_error"
5063                );
5064                let should_rollback_user_turn = should_rollback_failed_user_turn(&e);
5065                let rolled_back = should_rollback_user_turn
5066                    && rollback_orphan_user_turn(
5067                        ctx.as_ref(),
5068                        &history_key,
5069                        &timestamped_history_content,
5070                    );
5071
5072                if !rolled_back {
5073                    // Close the orphan user turn so subsequent messages don't
5074                    // inherit this failed request as unfinished context.
5075                    append_sender_turn(
5076                        ctx.as_ref(),
5077                        &history_key,
5078                        ChatMessage::assistant("[Task failed — not continuing this request]"),
5079                    );
5080                }
5081                if let Some(channel) = target_channel.as_ref() {
5082                    if let Some(ref draft_id) = draft_message_id {
5083                        let _ = channel
5084                            .finalize_draft(&msg.reply_target, draft_id, &format!("⚠️ Error: {e}"))
5085                            .await;
5086                    } else {
5087                        let _ = channel
5088                            .send(
5089                                &SendMessage::new(format!("⚠️ Error: {e}"), &msg.reply_target)
5090                                    .in_thread(msg.thread_ts.clone()),
5091                            )
5092                            .await;
5093                    }
5094                }
5095            }
5096        }
5097        LlmExecutionResult::Completed(Err(_)) => {
5098            let timeout_msg = format!(
5099                "LLM response timed out after {}s (base={}s, max_tool_iterations={})",
5100                timeout_budget_secs, ctx.message_timeout_secs, ctx.max_tool_iterations
5101            );
5102            ::zeroclaw_log::record!(
5103                WARN,
5104                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Timeout)
5105                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
5106                    .with_duration(
5107                        u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
5108                    )
5109                    .with_attrs(::serde_json::json!({
5110                        "model_provider": route.model_provider,
5111                        "model": route.model,
5112                        "sender": msg.sender,
5113                        "reason": timeout_msg,
5114                    })),
5115                "channel_message_timeout"
5116            );
5117            eprintln!(
5118                "  ❌ {} (elapsed: {}ms)",
5119                timeout_msg,
5120                started_at.elapsed().as_millis()
5121            );
5122            // Close the orphan user turn so subsequent messages don't
5123            // inherit this timed-out request as unfinished context.
5124            append_sender_turn(
5125                ctx.as_ref(),
5126                &history_key,
5127                ChatMessage::assistant("[Task timed out — not continuing this request]"),
5128            );
5129            if let Some(channel) = target_channel.as_ref() {
5130                let error_text =
5131                    "⚠️ Request timed out while waiting for the model. Please try again.";
5132                if let Some(ref draft_id) = draft_message_id {
5133                    let _ = channel
5134                        .finalize_draft(&msg.reply_target, draft_id, error_text)
5135                        .await;
5136                } else {
5137                    let _ = channel
5138                        .send(
5139                            &SendMessage::new(error_text, &msg.reply_target)
5140                                .in_thread(msg.thread_ts.clone()),
5141                        )
5142                        .await;
5143                }
5144            }
5145        }
5146    }
5147
5148    // Swap 👀 → ✅ (or ⚠️ on error) to signal processing is complete
5149    if ctx.ack_reactions
5150        && let Some(channel) = target_channel.as_ref()
5151    {
5152        let _ = channel
5153            .remove_reaction(&msg.reply_target, &msg.id, "\u{1F440}")
5154            .await;
5155        let _ = channel
5156            .add_reaction(&msg.reply_target, &msg.id, reaction_done_emoji)
5157            .await;
5158    }
5159}
5160
5161/// Shared worker body extracted so both the normal path and the debounce path
5162/// can reuse the same in-flight tracking / cancellation / process logic.
5163async fn dispatch_worker(
5164    ctx: Arc<ChannelRuntimeContext>,
5165    msg: zeroclaw_api::channel::ChannelMessage,
5166    in_flight: Arc<tokio::sync::Mutex<HashMap<String, InFlightSenderTaskState>>>,
5167    task_sequence: Arc<AtomicU64>,
5168    permit: tokio::sync::OwnedSemaphorePermit,
5169) {
5170    let _permit = permit;
5171    let interrupt_enabled = ctx
5172        .interrupt_on_new_message
5173        .enabled_for_channel(msg.channel.as_str());
5174    let sender_scope_key = interruption_scope_key(&msg);
5175    let cancellation_token = CancellationToken::new();
5176    let completion = Arc::new(InFlightTaskCompletion::new());
5177    let task_id = task_sequence.fetch_add(1, Ordering::Relaxed);
5178
5179    let register_in_flight = msg.channel != "cli";
5180
5181    if register_in_flight {
5182        let previous = {
5183            let mut active = in_flight.lock().await;
5184            active.insert(
5185                sender_scope_key.clone(),
5186                InFlightSenderTaskState {
5187                    task_id,
5188                    cancellation: cancellation_token.clone(),
5189                    completion: Arc::clone(&completion),
5190                },
5191            )
5192        };
5193
5194        if interrupt_enabled && let Some(previous) = previous {
5195            ::zeroclaw_log::record!(
5196                INFO,
5197                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
5198                    .with_attrs(::serde_json::json!({"sender": msg.sender})),
5199                "interrupting previous in-flight request for sender"
5200            );
5201            previous.cancellation.cancel();
5202            previous.completion.wait().await;
5203        }
5204    }
5205
5206    process_channel_message(ctx, msg, cancellation_token).await;
5207
5208    if register_in_flight {
5209        let mut active = in_flight.lock().await;
5210        if active
5211            .get(&sender_scope_key)
5212            .is_some_and(|state| state.task_id == task_id)
5213        {
5214            active.remove(&sender_scope_key);
5215        }
5216    }
5217
5218    completion.mark_done();
5219}
5220
5221/// Maps each inbound `ChannelMessage` to the owning agent's `ChannelRuntimeContext`.
5222///
5223/// Lookup mirrors `find_channel_for_message`: composite `<type>.<alias>` first,
5224/// bare `<type>` second. Returns `None` when no agent owns the channel — the
5225/// dispatch loop drops the message rather than picking a default.
5226#[derive(Clone)]
5227struct AgentRouter {
5228    by_agent: Arc<HashMap<String, Arc<ChannelRuntimeContext>>>,
5229    owner_by_channel_key: Arc<HashMap<String, String>>,
5230    single_ctx: Option<Arc<ChannelRuntimeContext>>,
5231}
5232
5233impl AgentRouter {
5234    #[cfg(test)]
5235    fn single(ctx: Arc<ChannelRuntimeContext>) -> Self {
5236        Self {
5237            by_agent: Arc::new(HashMap::new()),
5238            owner_by_channel_key: Arc::new(HashMap::new()),
5239            single_ctx: Some(ctx),
5240        }
5241    }
5242
5243    fn multi(
5244        by_agent: HashMap<String, Arc<ChannelRuntimeContext>>,
5245        owner_by_channel_key: HashMap<String, String>,
5246    ) -> Self {
5247        Self {
5248            by_agent: Arc::new(by_agent),
5249            owner_by_channel_key: Arc::new(owner_by_channel_key),
5250            single_ctx: None,
5251        }
5252    }
5253
5254    fn resolve(
5255        &self,
5256        msg: &zeroclaw_api::channel::ChannelMessage,
5257    ) -> Option<Arc<ChannelRuntimeContext>> {
5258        if let Some(ctx) = &self.single_ctx {
5259            return Some(Arc::clone(ctx));
5260        }
5261        if let Some(alias) = msg.channel_alias.as_deref().filter(|s| !s.is_empty()) {
5262            let composite = format!("{}.{alias}", msg.channel);
5263            if let Some(agent) = self.owner_by_channel_key.get(&composite)
5264                && let Some(ctx) = self.by_agent.get(agent)
5265            {
5266                return Some(Arc::clone(ctx));
5267            }
5268        }
5269        if let Some(agent) = self.owner_by_channel_key.get(&msg.channel)
5270            && let Some(ctx) = self.by_agent.get(agent)
5271        {
5272            return Some(Arc::clone(ctx));
5273        }
5274        None
5275    }
5276}
5277
5278async fn run_message_dispatch_loop(
5279    mut rx: tokio::sync::mpsc::Receiver<zeroclaw_api::channel::ChannelMessage>,
5280    router: AgentRouter,
5281    max_in_flight_messages: usize,
5282) {
5283    let semaphore = Arc::new(tokio::sync::Semaphore::new(max_in_flight_messages));
5284    let mut workers = tokio::task::JoinSet::new();
5285    let in_flight_by_sender = Arc::new(tokio::sync::Mutex::new(HashMap::<
5286        String,
5287        InFlightSenderTaskState,
5288    >::new()));
5289    let task_sequence = Arc::new(AtomicU64::new(1));
5290
5291    while let Some(msg) = rx.recv().await {
5292        let Some(ctx) = router.resolve(&msg) else {
5293            ::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");
5294            continue;
5295        };
5296        // Fast path: /stop cancels the in-flight task for this sender scope without
5297        // spawning a worker or registering a new task. Handled here — before semaphore
5298        // acquisition — so the target task is still in the store and is never replaced.
5299        if msg.channel != "cli" && is_stop_command(&msg.content) {
5300            let scope_key = interruption_scope_key(&msg);
5301            let previous = {
5302                let mut active = in_flight_by_sender.lock().await;
5303                active.remove(&scope_key)
5304            };
5305            let reply = if let Some(state) = previous {
5306                state.cancellation.cancel();
5307                "Stop signal sent.".to_string()
5308            } else {
5309                "No in-flight task for this sender scope.".to_string()
5310            };
5311            let channel = find_channel_for_message(&ctx.channels_by_name, &msg).cloned();
5312            if let Some(channel) = channel {
5313                let reply_target = msg.reply_target.clone();
5314                let thread_ts = msg.thread_ts.clone();
5315                zeroclaw_spawn::spawn!(async move {
5316                    let _ = channel
5317                        .send(&SendMessage::new(reply, &reply_target).in_thread(thread_ts))
5318                        .await;
5319                });
5320            } else {
5321                ::zeroclaw_log::record!(
5322                    WARN,
5323                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
5324                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
5325                    "stop command: no registered channel found for reply"
5326                );
5327            }
5328            continue;
5329        }
5330
5331        // ── Debounce: accumulate rapid messages per sender ──────────
5332        // CLI messages bypass debouncing so the interactive loop stays responsive.
5333        let msg = if msg.channel != "cli" && ctx.debouncer.enabled() {
5334            let debounce_key = conversation_history_key(&msg);
5335            match ctx.debouncer.debounce(&debounce_key, &msg.content).await {
5336                zeroclaw_infra::debounce::DebounceResult::Pending(rx) => {
5337                    // Spawn a lightweight task that waits for the debounce window
5338                    // to expire, then feeds the combined message through the normal
5339                    // worker path below.
5340                    let debounce_ctx = Arc::clone(&ctx);
5341                    let debounce_in_flight = Arc::clone(&in_flight_by_sender);
5342                    let debounce_semaphore = Arc::clone(&semaphore);
5343                    let debounce_task_seq = Arc::clone(&task_sequence);
5344                    let mut debounce_msg = msg;
5345                    workers.spawn(async move {
5346                        let combined = match rx.await {
5347                            Ok(combined) => combined,
5348                            Err(_) => {
5349                                // Receiver dropped — a newer message superseded this one.
5350                                return;
5351                            }
5352                        };
5353                        debounce_msg.content = combined;
5354                        ::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");
5355
5356                        let permit = match debounce_semaphore.acquire_owned().await {
5357                            Ok(permit) => permit,
5358                            Err(_) => return,
5359                        };
5360
5361                        dispatch_worker(
5362                            debounce_ctx,
5363                            debounce_msg,
5364                            debounce_in_flight,
5365                            debounce_task_seq,
5366                            permit,
5367                        )
5368                        .await;
5369                    });
5370                    continue;
5371                }
5372                zeroclaw_infra::debounce::DebounceResult::Passthrough(content) => {
5373                    let mut m = msg;
5374                    m.content = content;
5375                    m
5376                }
5377            }
5378        } else {
5379            msg
5380        };
5381
5382        let permit = match Arc::clone(&semaphore).acquire_owned().await {
5383            Ok(permit) => permit,
5384            Err(_) => break,
5385        };
5386
5387        let worker_ctx = Arc::clone(&ctx);
5388        let in_flight = Arc::clone(&in_flight_by_sender);
5389        let task_sequence = Arc::clone(&task_sequence);
5390        workers.spawn(async move {
5391            dispatch_worker(worker_ctx, msg, in_flight, task_sequence, permit).await;
5392        });
5393
5394        while let Some(result) = workers.try_join_next() {
5395            log_worker_join_result(result);
5396        }
5397    }
5398
5399    while let Some(result) = workers.join_next().await {
5400        log_worker_join_result(result);
5401    }
5402}
5403
5404fn normalize_telegram_identity(value: &str) -> String {
5405    value.trim().trim_start_matches('@').to_string()
5406}
5407
5408pub async fn bind_telegram_identity(config: &Config, identity: &str) -> Result<()> {
5409    use zeroclaw_config::multi_agent::{PeerGroupConfig, PeerUsername};
5410
5411    let normalized = normalize_telegram_identity(identity);
5412    if normalized.is_empty() {
5413        anyhow::bail!("Telegram identity cannot be empty");
5414    }
5415
5416    let mut updated = config.clone();
5417    if !updated.channels.telegram.contains_key("default") {
5418        anyhow::bail!(
5419            "Telegram channel is not configured. Run \
5420             `zeroclaw config set channels.telegram.<alias>.bot-token=<token>` \
5421             (see docs/book/src/channels/overview.md for the full field list)."
5422        );
5423    }
5424
5425    // Locate (or create) the peer group bound to telegram.default. The
5426    // V3 surface puts inbound peer authorization in `peer_groups`,
5427    // not on the channel block. Convention: the synthesized group
5428    // name is `<type>_<alias>` (matching what the V2→V3 fold uses)
5429    // so a hand-bound identity lands in the same group an operator
5430    // would inspect after an upgrade. The `channel` field is the
5431    // dotted alias ref so authorization stays scoped to the bound
5432    // alias; a bare type would broaden the peer across every
5433    // telegram alias on the install.
5434    let group_name = "telegram_default".to_string();
5435    let group = updated
5436        .peer_groups
5437        .entry(group_name.clone())
5438        .or_insert_with(|| PeerGroupConfig {
5439            channel: "telegram.default".to_string(),
5440            ..PeerGroupConfig::default()
5441        });
5442
5443    if group
5444        .external_peers
5445        .iter()
5446        .any(|p| normalize_telegram_identity(p.as_str()) == normalized)
5447    {
5448        println!("✅ Telegram identity already bound: {normalized}");
5449        return Ok(());
5450    }
5451
5452    group
5453        .external_peers
5454        .push(PeerUsername::new(normalized.clone()));
5455    updated.save().await?;
5456    println!("✅ Bound Telegram identity: {normalized}");
5457    println!("   Saved to {}", updated.config_path.display());
5458    match maybe_restart_managed_daemon_service() {
5459        Ok(true) => {
5460            println!("🔄 Detected running managed daemon service; reloaded automatically.");
5461        }
5462        Ok(false) => {
5463            println!(
5464                "ℹ️ No managed daemon service detected. If `zeroclaw daemon`/`channel start` is already running, restart it to load the updated allowlist."
5465            );
5466        }
5467        Err(e) => {
5468            eprintln!(
5469                "⚠️ Allowlist saved, but failed to reload daemon service automatically: {e}\n\
5470                 Restart service manually with `zeroclaw service stop && zeroclaw service start`."
5471            );
5472        }
5473    }
5474    Ok(())
5475}
5476
5477fn maybe_restart_managed_daemon_service() -> Result<bool> {
5478    if cfg!(target_os = "macos") {
5479        let home = directories::UserDirs::new()
5480            .map(|u| u.home_dir().to_path_buf())
5481            .context("Could not find home directory")?;
5482        let plist = home
5483            .join("Library")
5484            .join("LaunchAgents")
5485            .join("com.zeroclaw.daemon.plist");
5486        if !plist.exists() {
5487            return Ok(false);
5488        }
5489
5490        let list_output = Command::new("launchctl")
5491            .arg("list")
5492            .output()
5493            .context("Failed to query launchctl list")?;
5494        let listed = String::from_utf8_lossy(&list_output.stdout);
5495        if !listed.contains("com.zeroclaw.daemon") {
5496            return Ok(false);
5497        }
5498
5499        let _ = Command::new("launchctl")
5500            .args(["stop", "com.zeroclaw.daemon"])
5501            .output();
5502        let start_output = Command::new("launchctl")
5503            .args(["start", "com.zeroclaw.daemon"])
5504            .output()
5505            .context("Failed to start launchd daemon service")?;
5506        if !start_output.status.success() {
5507            let stderr = String::from_utf8_lossy(&start_output.stderr);
5508            anyhow::bail!("launchctl start failed: {}", stderr.trim());
5509        }
5510
5511        return Ok(true);
5512    }
5513
5514    if cfg!(target_os = "linux") {
5515        // OpenRC (system-wide) takes precedence over systemd (user-level)
5516        let openrc_init_script = PathBuf::from("/etc/init.d/zeroclaw");
5517        if openrc_init_script.exists()
5518            && let Ok(status_output) = Command::new("rc-service").args(OPENRC_STATUS_ARGS).output()
5519        {
5520            // rc-service exits 0 if running, non-zero otherwise
5521            if status_output.status.success() {
5522                let restart_output = Command::new("rc-service")
5523                    .args(OPENRC_RESTART_ARGS)
5524                    .output()
5525                    .context("Failed to restart OpenRC daemon service")?;
5526                if !restart_output.status.success() {
5527                    let stderr = String::from_utf8_lossy(&restart_output.stderr);
5528                    anyhow::bail!("rc-service restart failed: {}", stderr.trim());
5529                }
5530                return Ok(true);
5531            }
5532        }
5533
5534        // Systemd (user-level)
5535        let home = directories::UserDirs::new()
5536            .map(|u| u.home_dir().to_path_buf())
5537            .context("Could not find home directory")?;
5538        let unit_path: PathBuf = home
5539            .join(".config")
5540            .join("systemd")
5541            .join("user")
5542            .join("zeroclaw.service");
5543        if !unit_path.exists() {
5544            return Ok(false);
5545        }
5546
5547        let active_output = Command::new("systemctl")
5548            .args(SYSTEMD_STATUS_ARGS)
5549            .output()
5550            .context("Failed to query systemd service state")?;
5551        let state = String::from_utf8_lossy(&active_output.stdout);
5552        if !state.trim().eq_ignore_ascii_case("active") {
5553            return Ok(false);
5554        }
5555
5556        let restart_output = Command::new("systemctl")
5557            .args(SYSTEMD_RESTART_ARGS)
5558            .output()
5559            .context("Failed to restart systemd daemon service")?;
5560        if !restart_output.status.success() {
5561            let stderr = String::from_utf8_lossy(&restart_output.stderr);
5562            anyhow::bail!("systemctl restart failed: {}", stderr.trim());
5563        }
5564
5565        return Ok(true);
5566    }
5567
5568    Ok(false)
5569}
5570
5571/// Build a single channel instance by config section name (e.g. "telegram").
5572fn build_channel_by_id(
5573    config_arc: &Arc<RwLock<Config>>,
5574    channel_id: &str,
5575) -> Result<Arc<dyn Channel>> {
5576    #[allow(unused_variables)]
5577    let config = config_arc.read();
5578    match channel_id {
5579        #[cfg(feature = "channel-telegram")]
5580        "telegram" => {
5581            let tg = config
5582                .channels
5583                .telegram
5584                .get("default")
5585                .context("Telegram channel is not configured")?;
5586            let ack = tg.ack_reactions.unwrap_or(config.channels.ack_reactions);
5587            let alias = "default".to_string();
5588            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5589                let cfg_arc = config_arc.clone();
5590                let alias = alias.clone();
5591                Arc::new(move || cfg_arc.read().channel_external_peers("telegram", &alias))
5592            };
5593            Ok(Arc::new(
5594                TelegramChannel::new(
5595                    tg.bot_token.clone(),
5596                    alias.clone(),
5597                    peer_resolver,
5598                    tg.mention_only,
5599                )
5600                .with_persistence(config_arc.clone())
5601                .with_ack_reactions(ack)
5602                .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
5603                .with_transcription(config.transcription.clone())
5604                .with_tts(&config)
5605                .with_voice_peer_prefs(&config, "telegram", alias)
5606                .with_workspace_dir(config.data_dir.clone())
5607                .with_approval_timeout_secs(tg.approval_timeout_secs),
5608            ))
5609        }
5610        #[cfg(not(feature = "channel-telegram"))]
5611        "telegram" => {
5612            anyhow::bail!("Telegram channel requires the `channel-telegram` feature");
5613        }
5614        #[cfg(feature = "channel-discord")]
5615        "discord" => {
5616            let dc = config
5617                .channels
5618                .discord
5619                .get("default")
5620                .context("Discord channel is not configured")?;
5621            let alias = "default".to_string();
5622            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5623                let cfg_arc = config_arc.clone();
5624                let alias = alias.clone();
5625                Arc::new(move || cfg_arc.read().channel_external_peers("discord", &alias))
5626            };
5627            Ok(Arc::new(
5628                DiscordChannel::new(
5629                    dc.bot_token.clone(),
5630                    dc.guild_ids.clone(),
5631                    alias,
5632                    peer_resolver,
5633                    dc.listen_to_bots,
5634                    dc.mention_only,
5635                )
5636                .with_channel_ids(dc.channel_ids.clone())
5637                .with_workspace_dir(config.data_dir.clone())
5638                .with_streaming(
5639                    dc.stream_mode,
5640                    dc.draft_update_interval_ms,
5641                    dc.multi_message_delay_ms,
5642                )
5643                .with_transcription(config.transcription.clone())
5644                .with_stall_timeout(dc.stall_timeout_secs)
5645                .with_approval_timeout_secs(dc.approval_timeout_secs),
5646            ))
5647        }
5648        #[cfg(not(feature = "channel-discord"))]
5649        "discord" => {
5650            anyhow::bail!("Discord channel requires the `channel-discord` feature");
5651        }
5652        #[cfg(feature = "channel-slack")]
5653        "slack" => {
5654            let sl = config
5655                .channels
5656                .slack
5657                .get("default")
5658                .context("Slack channel is not configured")?;
5659            let alias = "default".to_string();
5660            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5661                let cfg_arc = config_arc.clone();
5662                let alias = alias.clone();
5663                Arc::new(move || cfg_arc.read().channel_external_peers("slack", &alias))
5664            };
5665            Ok(Arc::new(
5666                SlackChannel::new(
5667                    sl.bot_token.clone(),
5668                    sl.app_token.clone(),
5669                    sl.channel_ids.clone(),
5670                    alias,
5671                    peer_resolver,
5672                )
5673                .with_workspace_dir(config.data_dir.clone())
5674                .with_markdown_blocks(sl.use_markdown_blocks)
5675                .with_transcription(config.transcription.clone())
5676                .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
5677                .with_cancel_reaction(sl.cancel_reaction.clone())
5678                .with_approval_timeout_secs(sl.approval_timeout_secs),
5679            ))
5680        }
5681        #[cfg(not(feature = "channel-slack"))]
5682        "slack" => {
5683            anyhow::bail!("Slack channel requires the `channel-slack` feature");
5684        }
5685        #[cfg(feature = "channel-mattermost")]
5686        "mattermost" => {
5687            let mm = config
5688                .channels
5689                .mattermost
5690                .get("default")
5691                .context("Mattermost channel is not configured")?;
5692            let alias = "default".to_string();
5693            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5694                let cfg_arc = config_arc.clone();
5695                let alias = alias.clone();
5696                Arc::new(move || cfg_arc.read().channel_external_peers("mattermost", &alias))
5697            };
5698            Ok(Arc::new(
5699                MattermostChannel::new(
5700                    mm.url.clone(),
5701                    mm.bot_token.clone(),
5702                    mm.login_id.clone(),
5703                    mm.password.clone(),
5704                    mm.channel_ids.clone(),
5705                    alias,
5706                    peer_resolver,
5707                    mm.thread_replies.unwrap_or(true),
5708                    mm.mention_only.unwrap_or(false),
5709                )
5710                .with_team_ids(mm.team_ids.clone())
5711                .with_discover_dms(mm.discover_dms.unwrap_or(true)),
5712            ))
5713        }
5714        #[cfg(not(feature = "channel-mattermost"))]
5715        "mattermost" => {
5716            anyhow::bail!("Mattermost channel requires the `channel-mattermost` feature");
5717        }
5718        #[cfg(feature = "channel-signal")]
5719        "signal" => {
5720            let sg = config
5721                .channels
5722                .signal
5723                .get("default")
5724                .context("Signal channel is not configured")?;
5725            let alias = "default".to_string();
5726            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5727                let cfg_arc = config_arc.clone();
5728                let alias = alias.clone();
5729                Arc::new(move || cfg_arc.read().channel_external_peers("signal", &alias))
5730            };
5731            Ok(Arc::new(
5732                SignalChannel::new(
5733                    sg.http_url.clone(),
5734                    sg.account.clone(),
5735                    sg.group_ids.clone(),
5736                    sg.dm_only,
5737                    alias,
5738                    peer_resolver,
5739                    sg.ignore_attachments,
5740                    sg.ignore_stories,
5741                )
5742                .with_approval_timeout_secs(sg.approval_timeout_secs),
5743            ))
5744        }
5745        #[cfg(not(feature = "channel-signal"))]
5746        "signal" => {
5747            anyhow::bail!("Signal channel requires the `channel-signal` feature");
5748        }
5749        "matrix" => {
5750            #[cfg(feature = "channel-matrix")]
5751            {
5752                let mx = config
5753                    .channels
5754                    .matrix
5755                    .get("default")
5756                    .context("Matrix channel is not configured")?;
5757                let alias = "default".to_string();
5758                let state_dir = matrix_state_dir(&config.config_path, &alias);
5759                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5760                    let cfg_arc = config_arc.clone();
5761                    let alias = alias.clone();
5762                    Arc::new(move || cfg_arc.read().channel_external_peers("matrix", &alias))
5763                };
5764                let ack = mx.ack_reactions.unwrap_or(config.channels.ack_reactions);
5765                Ok(Arc::new(
5766                    MatrixChannel::new(mx.clone(), alias, peer_resolver, state_dir)?
5767                        .with_transcription(config.transcription.clone())
5768                        .with_workspace_dir(config.data_dir.clone())
5769                        .with_ack_reactions(ack),
5770                ))
5771            }
5772            #[cfg(not(feature = "channel-matrix"))]
5773            {
5774                anyhow::bail!("Matrix channel requires the `channel-matrix` feature");
5775            }
5776        }
5777        "whatsapp" | "whatsapp-web" | "whatsapp_web" => {
5778            #[cfg(feature = "whatsapp-web")]
5779            {
5780                let wa = config
5781                    .channels
5782                    .whatsapp
5783                    .get("default")
5784                    .context("WhatsApp channel is not configured")?;
5785                if !wa.is_web_config() {
5786                    anyhow::bail!(
5787                        "WhatsApp channel send requires Web mode (session_path must be set)"
5788                    );
5789                }
5790                let alias = "default".to_string();
5791                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5792                    let cfg_arc = config_arc.clone();
5793                    let alias = alias.clone();
5794                    Arc::new(move || cfg_arc.read().channel_external_peers("whatsapp", &alias))
5795                };
5796                Ok(Arc::new(WhatsAppWebChannel::new(wa, alias, peer_resolver)))
5797            }
5798            #[cfg(not(feature = "whatsapp-web"))]
5799            {
5800                anyhow::bail!("WhatsApp channel requires the `whatsapp-web` feature");
5801            }
5802        }
5803        #[cfg(feature = "channel-qq")]
5804        "qq" => {
5805            let qq = config
5806                .channels
5807                .qq
5808                .get("default")
5809                .context("QQ channel is not configured")?;
5810            let alias = "default".to_string();
5811            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5812                let cfg_arc = config_arc.clone();
5813                let alias = alias.clone();
5814                Arc::new(move || cfg_arc.read().channel_external_peers("qq", &alias))
5815            };
5816            Ok(Arc::new(QQChannel::new(
5817                qq.app_id.clone(),
5818                qq.app_secret.clone(),
5819                alias,
5820                peer_resolver,
5821            )))
5822        }
5823        #[cfg(not(feature = "channel-qq"))]
5824        "qq" => {
5825            anyhow::bail!("QQ channel requires the `channel-qq` feature");
5826        }
5827        "lark" => {
5828            #[cfg(feature = "channel-lark")]
5829            {
5830                let lk = config
5831                    .channels
5832                    .lark
5833                    .get("default")
5834                    .context("Lark channel is not configured")?;
5835                let alias = "default".to_string();
5836                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5837                    let cfg_arc = config_arc.clone();
5838                    let alias = alias.clone();
5839                    Arc::new(move || cfg_arc.read().channel_external_peers("lark", &alias))
5840                };
5841                Ok(Arc::new(
5842                    LarkChannel::from_config(lk, alias, peer_resolver)
5843                        .with_approval_timeout_secs(lk.approval_timeout_secs)
5844                        .with_per_user_session(lk.per_user_session)
5845                        .with_streaming(lk.stream_mode, lk.draft_update_interval_ms),
5846                ))
5847            }
5848            #[cfg(not(feature = "channel-lark"))]
5849            {
5850                anyhow::bail!("Lark channel requires the `channel-lark` feature");
5851            }
5852        }
5853        #[cfg(feature = "channel-dingtalk")]
5854        "dingtalk" => {
5855            let dt = config
5856                .channels
5857                .dingtalk
5858                .get("default")
5859                .context("DingTalk channel is not configured")?;
5860            let alias = "default".to_string();
5861            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5862                let cfg_arc = config_arc.clone();
5863                let alias = alias.clone();
5864                Arc::new(move || cfg_arc.read().channel_external_peers("dingtalk", &alias))
5865            };
5866            Ok(Arc::new(
5867                DingTalkChannel::new(
5868                    dt.client_id.clone(),
5869                    dt.client_secret.clone(),
5870                    alias,
5871                    peer_resolver,
5872                )
5873                .with_proxy_url(dt.proxy_url.clone()),
5874            ))
5875        }
5876        #[cfg(not(feature = "channel-dingtalk"))]
5877        "dingtalk" => {
5878            anyhow::bail!("DingTalk channel requires the `channel-dingtalk` feature");
5879        }
5880        #[cfg(feature = "channel-wecom")]
5881        "wecom" => {
5882            let wc = config
5883                .channels
5884                .wecom
5885                .get("default")
5886                .context("WeCom channel is not configured")?;
5887            let alias = "default".to_string();
5888            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5889                let cfg_arc = config_arc.clone();
5890                let alias = alias.clone();
5891                Arc::new(move || cfg_arc.read().channel_external_peers("wecom", &alias))
5892            };
5893            Ok(Arc::new(WeComChannel::new(
5894                wc.webhook_key.clone(),
5895                alias,
5896                peer_resolver,
5897            )))
5898        }
5899        #[cfg(not(feature = "channel-wecom"))]
5900        "wecom" => {
5901            anyhow::bail!("WeCom channel requires the `channel-wecom` feature");
5902        }
5903        #[cfg(feature = "channel-wecom-ws")]
5904        channel_id
5905            if channel_id == "wecom_ws"
5906                || channel_id == "wecom-ws"
5907                || channel_id.starts_with("wecom_ws.")
5908                || channel_id.starts_with("wecom-ws.") =>
5909        {
5910            let alias = channel_id
5911                .split_once('.')
5912                .map(|(_, alias)| alias)
5913                .unwrap_or("default")
5914                .to_string();
5915            let wc =
5916                config.channels.wecom_ws.get(&alias).with_context(|| {
5917                    format!("WeCom WebSocket channel '{alias}' is not configured")
5918                })?;
5919            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5920                let cfg_arc = config_arc.clone();
5921                let alias = alias.clone();
5922                let configured_allowed_users = wc.allowed_users.clone();
5923                Arc::new(move || {
5924                    let config = cfg_arc.read();
5925                    let mut peers = configured_allowed_users.clone();
5926                    for peer in config.channel_external_peers("wecom-ws", &alias) {
5927                        if !peers.contains(&peer) {
5928                            peers.push(peer);
5929                        }
5930                    }
5931                    for peer in config.channel_external_peers("wecom_ws", &alias) {
5932                        if !peers.contains(&peer) {
5933                            peers.push(peer);
5934                        }
5935                    }
5936                    peers
5937                })
5938            };
5939            Ok(Arc::new(WeComWsChannel::new_with_alias(
5940                wc,
5941                alias.clone(),
5942                peer_resolver,
5943                &config.channel_workspace_dir(&format!("wecom_ws.{alias}")),
5944            )?))
5945        }
5946        #[cfg(not(feature = "channel-wecom-ws"))]
5947        channel_id
5948            if channel_id == "wecom_ws"
5949                || channel_id == "wecom-ws"
5950                || channel_id.starts_with("wecom_ws.")
5951                || channel_id.starts_with("wecom-ws.") =>
5952        {
5953            anyhow::bail!("WeCom WebSocket channel requires the `channel-wecom-ws` feature");
5954        }
5955        #[cfg(feature = "channel-wechat")]
5956        "wechat" => {
5957            let wc = config
5958                .channels
5959                .wechat
5960                .get("default")
5961                .context("WeChat channel is not configured")?;
5962            let alias = "default".to_string();
5963            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5964                let cfg_arc = config_arc.clone();
5965                let alias = alias.clone();
5966                Arc::new(move || cfg_arc.read().channel_external_peers("wechat", &alias))
5967            };
5968            Ok(Arc::new(
5969                WeChatChannel::new(
5970                    alias,
5971                    peer_resolver,
5972                    wc.api_base_url.clone(),
5973                    wc.cdn_base_url.clone(),
5974                    wc.state_dir.as_ref().map(|s| expand_tilde_in_path(s)),
5975                )?
5976                .with_persistence(config_arc.clone())
5977                .with_workspace_dir(config.data_dir.clone()),
5978            ))
5979        }
5980        #[cfg(not(feature = "channel-wechat"))]
5981        "wechat" => {
5982            anyhow::bail!("WeChat channel requires the `channel-wechat` feature");
5983        }
5984        #[cfg(feature = "channel-nextcloud")]
5985        "nextcloud_talk" | "nextcloud-talk" => {
5986            let nc = config
5987                .channels
5988                .nextcloud_talk
5989                .get("default")
5990                .context("Nextcloud Talk channel is not configured")?;
5991            let alias = "default".to_string();
5992            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
5993                let cfg_arc = config_arc.clone();
5994                let alias = alias.clone();
5995                Arc::new(move || {
5996                    cfg_arc
5997                        .read()
5998                        .channel_external_peers("nextcloud_talk", &alias)
5999                })
6000            };
6001            Ok(Arc::new(
6002                NextcloudTalkChannel::new_with_proxy(
6003                    nc.base_url.clone(),
6004                    nc.app_token.clone(),
6005                    nc.bot_name.clone().unwrap_or_default(),
6006                    alias,
6007                    peer_resolver,
6008                    nc.proxy_url.clone(),
6009                )
6010                .with_streaming(nc.stream_mode, nc.draft_update_interval_ms),
6011            ))
6012        }
6013        #[cfg(not(feature = "channel-nextcloud"))]
6014        "nextcloud_talk" | "nextcloud-talk" => {
6015            anyhow::bail!("Nextcloud Talk channel requires the `channel-nextcloud` feature");
6016        }
6017        #[cfg(feature = "channel-wati")]
6018        "wati" => {
6019            let wati_cfg = config
6020                .channels
6021                .wati
6022                .get("default")
6023                .context("WATI channel is not configured")?;
6024            let alias = "default".to_string();
6025            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6026                let cfg_arc = config_arc.clone();
6027                let alias = alias.clone();
6028                Arc::new(move || cfg_arc.read().channel_external_peers("wati", &alias))
6029            };
6030            Ok(Arc::new(WatiChannel::new_with_proxy(
6031                wati_cfg.api_token.clone(),
6032                wati_cfg.api_url.clone(),
6033                wati_cfg.tenant_id.clone(),
6034                alias,
6035                peer_resolver,
6036                wati_cfg.proxy_url.clone(),
6037            )))
6038        }
6039        #[cfg(not(feature = "channel-wati"))]
6040        "wati" => {
6041            anyhow::bail!("WATI channel requires the `channel-wati` feature");
6042        }
6043        #[cfg(feature = "channel-linq")]
6044        "linq" => {
6045            let lq = config
6046                .channels
6047                .linq
6048                .get("default")
6049                .context("Linq channel is not configured")?;
6050            let alias = "default".to_string();
6051            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6052                let cfg_arc = config_arc.clone();
6053                let alias = alias.clone();
6054                Arc::new(move || cfg_arc.read().channel_external_peers("linq", &alias))
6055            };
6056            Ok(Arc::new(LinqChannel::new(
6057                lq.api_token.clone(),
6058                lq.from_phone.clone(),
6059                alias,
6060                peer_resolver,
6061            )))
6062        }
6063        #[cfg(feature = "channel-linq")]
6064        x if x.starts_with("linq.") => {
6065            let alias = x.strip_prefix("linq.").context("invalid linq channel id")?;
6066            let lq = config
6067                .channels
6068                .linq
6069                .get(alias)
6070                .with_context(|| format!("Linq alias '{alias}' not configured"))?;
6071            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6072                let cfg_arc = config_arc.clone();
6073                let alias = alias.to_string();
6074                Arc::new(move || cfg_arc.read().channel_external_peers("linq", &alias))
6075            };
6076            Ok(Arc::new(LinqChannel::new(
6077                lq.api_token.clone(),
6078                lq.from_phone.clone(),
6079                alias.to_string(),
6080                peer_resolver,
6081            )))
6082        }
6083        #[cfg(not(feature = "channel-linq"))]
6084        x if x.starts_with("linq") => {
6085            anyhow::bail!("Linq channel requires the `channel-linq` feature");
6086        }
6087        #[cfg(feature = "channel-email")]
6088        "email" => {
6089            let em = config
6090                .channels
6091                .email
6092                .get("default")
6093                .context("Email channel is not configured")?;
6094            let alias = "default".to_string();
6095            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6096                let cfg_arc = config_arc.clone();
6097                let alias = alias.clone();
6098                Arc::new(move || cfg_arc.read().channel_external_peers("email", &alias))
6099            };
6100            Ok(Arc::new(EmailChannel::new(
6101                em.clone(),
6102                alias,
6103                peer_resolver,
6104            )))
6105        }
6106        #[cfg(not(feature = "channel-email"))]
6107        "email" => {
6108            anyhow::bail!("Email channel requires the `channel-email` feature");
6109        }
6110        #[cfg(feature = "channel-email")]
6111        "gmail_push" | "gmail-push" => {
6112            let gp = config
6113                .channels
6114                .gmail_push
6115                .get("default")
6116                .context("Gmail Push channel is not configured")?;
6117            let alias = "default".to_string();
6118            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6119                let cfg_arc = config_arc.clone();
6120                let alias = alias.clone();
6121                Arc::new(move || cfg_arc.read().channel_external_peers("gmail_push", &alias))
6122            };
6123            Ok(Arc::new(GmailPushChannel::new(
6124                gp.clone(),
6125                alias,
6126                peer_resolver,
6127            )))
6128        }
6129        #[cfg(not(feature = "channel-email"))]
6130        "gmail_push" | "gmail-push" => {
6131            anyhow::bail!("Gmail Push channel requires the `channel-email` feature");
6132        }
6133        #[cfg(feature = "channel-irc")]
6134        "irc" => {
6135            let irc_cfg = config
6136                .channels
6137                .irc
6138                .get("default")
6139                .context("IRC channel is not configured")?;
6140            let alias = "default".to_string();
6141            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6142                let cfg_arc = config_arc.clone();
6143                let alias = alias.clone();
6144                Arc::new(move || cfg_arc.read().channel_external_peers("irc", &alias))
6145            };
6146            Ok(Arc::new(IrcChannel::new(crate::irc::IrcChannelConfig {
6147                server: irc_cfg.server.clone(),
6148                port: irc_cfg.port,
6149                nickname: irc_cfg.nickname.clone(),
6150                username: irc_cfg.username.clone(),
6151                channels: irc_cfg.channels.clone(),
6152                alias,
6153                peer_resolver,
6154                server_password: irc_cfg.server_password.clone(),
6155                nickserv_password: irc_cfg.nickserv_password.clone(),
6156                sasl_password: irc_cfg.sasl_password.clone(),
6157                verify_tls: irc_cfg.verify_tls.unwrap_or(true),
6158                mention_only: irc_cfg.mention_only,
6159            })))
6160        }
6161        #[cfg(not(feature = "channel-irc"))]
6162        "irc" => {
6163            anyhow::bail!("IRC channel requires the `channel-irc` feature");
6164        }
6165        #[cfg(feature = "channel-twitch")]
6166        "twitch" => {
6167            let tw_cfg = config
6168                .channels
6169                .twitch
6170                .get("default")
6171                .context("Twitch channel is not configured")?;
6172            let alias = "default".to_string();
6173            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6174                let cfg_arc = config_arc.clone();
6175                let alias = alias.clone();
6176                Arc::new(move || cfg_arc.read().channel_external_peers("twitch", &alias))
6177            };
6178            Ok(Arc::new(TwitchChannel::new(
6179                tw_cfg.bot_username.clone(),
6180                tw_cfg.oauth_token.clone(),
6181                tw_cfg.channels.clone(),
6182                tw_cfg.mention_only,
6183                alias,
6184                peer_resolver,
6185            )))
6186        }
6187        #[cfg(not(feature = "channel-twitch"))]
6188        "twitch" => {
6189            anyhow::bail!("Twitch channel requires the `channel-twitch` feature");
6190        }
6191        #[cfg(feature = "channel-twitter")]
6192        "twitter" => {
6193            let tw = config
6194                .channels
6195                .twitter
6196                .get("default")
6197                .context("X/Twitter channel is not configured")?;
6198            let alias = "default".to_string();
6199            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6200                let cfg_arc = config_arc.clone();
6201                let alias = alias.clone();
6202                Arc::new(move || cfg_arc.read().channel_external_peers("twitter", &alias))
6203            };
6204            Ok(Arc::new(TwitterChannel::new(
6205                tw.bearer_token.clone(),
6206                alias,
6207                peer_resolver,
6208            )))
6209        }
6210        #[cfg(not(feature = "channel-twitter"))]
6211        "twitter" => {
6212            anyhow::bail!("X/Twitter channel requires the `channel-twitter` feature");
6213        }
6214        #[cfg(feature = "channel-mochat")]
6215        "mochat" => {
6216            let mc = config
6217                .channels
6218                .mochat
6219                .get("default")
6220                .context("Mochat channel is not configured")?;
6221            let alias = "default".to_string();
6222            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6223                let cfg_arc = config_arc.clone();
6224                let alias = alias.clone();
6225                Arc::new(move || cfg_arc.read().channel_external_peers("mochat", &alias))
6226            };
6227            Ok(Arc::new(MochatChannel::new(
6228                mc.api_url.clone(),
6229                mc.api_token.clone(),
6230                alias,
6231                peer_resolver,
6232                mc.poll_interval_secs,
6233            )))
6234        }
6235        #[cfg(not(feature = "channel-mochat"))]
6236        "mochat" => {
6237            anyhow::bail!("Mochat channel requires the `channel-mochat` feature");
6238        }
6239        #[cfg(feature = "channel-imessage")]
6240        "imessage" => {
6241            if !config.channels.imessage.contains_key("default") {
6242                anyhow::bail!("iMessage channel is not configured");
6243            }
6244            let alias = "default".to_string();
6245            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6246                let cfg_arc = config_arc.clone();
6247                let alias = alias.clone();
6248                Arc::new(move || cfg_arc.read().channel_external_peers("imessage", &alias))
6249            };
6250            Ok(Arc::new(IMessageChannel::new(alias, peer_resolver)))
6251        }
6252        #[cfg(not(feature = "channel-imessage"))]
6253        "imessage" => {
6254            anyhow::bail!("iMessage channel requires the `channel-imessage` feature");
6255        }
6256        "line" => {
6257            #[cfg(feature = "channel-line")]
6258            {
6259                let ln = config
6260                    .channels
6261                    .line
6262                    .get("default")
6263                    .context("LINE channel is not configured")?;
6264                let alias = "default".to_string();
6265                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6266                    let cfg_arc = config_arc.clone();
6267                    let alias = alias.clone();
6268                    Arc::new(move || cfg_arc.read().channel_external_peers("line", &alias))
6269                };
6270                Ok(Arc::new(
6271                    LineChannel::from_config(ln, alias, peer_resolver)
6272                        .with_persistence(config_arc.clone()),
6273                ))
6274            }
6275            #[cfg(not(feature = "channel-line"))]
6276            {
6277                anyhow::bail!("LINE channel requires the `channel-line` feature");
6278            }
6279        }
6280        "voice-call" => {
6281            #[cfg(feature = "channel-voice-call")]
6282            {
6283                let (alias, vc) = config
6284                    .channels
6285                    .voice_call
6286                    .iter()
6287                    .next()
6288                    .context("Voice Call channel is not configured")?;
6289                Ok(Arc::new(VoiceCallChannel::new(alias.clone(), vc.clone())))
6290            }
6291            #[cfg(not(feature = "channel-voice-call"))]
6292            {
6293                anyhow::bail!("Voice Call channel requires the `channel-voice-call` feature");
6294            }
6295        }
6296        other => anyhow::bail!(
6297            "Unknown channel '{other}'. Supported: telegram, discord, slack, mattermost, signal, \
6298            matrix, whatsapp, qq, lark, feishu, dingtalk, wecom, wecom_ws, nextcloud_talk, wati, linq, \
6299            email, gmail_push, irc, twitter, mochat, imessage, line, voice-call"
6300        ),
6301    }
6302}
6303
6304/// Send a one-off message to a configured channel.
6305pub async fn send_channel_message(
6306    config: &Config,
6307    channel_id: &str,
6308    recipient: &str,
6309    message: &str,
6310) -> Result<()> {
6311    // Wrap into the canonical shared handle for the builder; this is a
6312    // one-shot path so the snapshot is dropped immediately after send.
6313    let config_arc = Arc::new(RwLock::new(config.clone()));
6314    let channel = build_channel_by_id(&config_arc, channel_id)?;
6315    let msg = SendMessage::new(message, recipient);
6316    channel
6317        .send(&msg)
6318        .await
6319        .with_context(|| format!("Failed to send message via {channel_id}"))?;
6320    println!("Message sent via {channel_id}.");
6321    Ok(())
6322}
6323
6324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6325enum ChannelHealthState {
6326    Healthy,
6327    Unhealthy,
6328    Timeout,
6329}
6330
6331fn classify_health_result(
6332    result: &std::result::Result<bool, tokio::time::error::Elapsed>,
6333) -> ChannelHealthState {
6334    match result {
6335        Ok(true) => ChannelHealthState::Healthy,
6336        Ok(false) => ChannelHealthState::Unhealthy,
6337        Err(_) => ChannelHealthState::Timeout,
6338    }
6339}
6340
6341struct ConfiguredChannel {
6342    display_name: &'static str,
6343    /// ZeroClaw channel alias (the `<alias>` half of `[channels.<type>.<alias>]`).
6344    /// `Some` for every aliased channel built in `collect_configured_channels`;
6345    /// `None` for singleton channels with no alias concept (e.g. Notion).
6346    /// Used by `composite_channel_key` to give each `(type, alias)` pair a
6347    /// distinct slot in the runtime `channels_by_name` registry so two bots
6348    /// on the same platform (e.g. `discord.clamps` + `discord.glados`) don't
6349    /// collide and silently overwrite each other.
6350    alias: Option<String>,
6351    channel: Arc<dyn Channel>,
6352}
6353
6354/// Compose the registry key for a channel given its `name()` and configured alias.
6355/// Aliased channels live at `<name>.<alias>`; un-aliased singletons keep the bare name.
6356pub(crate) fn composite_channel_key(name: &str, alias: Option<&str>) -> String {
6357    match alias.filter(|s| !s.is_empty()) {
6358        Some(alias) => format!("{name}.{alias}"),
6359        None => name.to_string(),
6360    }
6361}
6362
6363/// Look up the live channel handle that should send a reply to `msg`.
6364///
6365/// Resolution order:
6366/// 1. Composite key `<channel>.<channel_alias>` — fires for multi-alias platforms
6367///    (Discord/Telegram/Slack/etc. with multiple `[channels.<type>.<alias>]` blocks).
6368/// 2. Bare `msg.channel` — singleton channels and legacy callers that didn't
6369///    supply an alias.
6370/// 3. `<base>:<qualifier>` split (e.g. Matrix `matrix:!roomId`) falls back to
6371///    the base channel name.
6372fn find_channel_for_message<'a>(
6373    channels: &'a HashMap<String, Arc<dyn Channel>>,
6374    msg: &zeroclaw_api::channel::ChannelMessage,
6375) -> Option<&'a Arc<dyn Channel>> {
6376    if let Some(alias) = msg.channel_alias.as_deref().filter(|s| !s.is_empty()) {
6377        let composite = format!("{}.{alias}", msg.channel);
6378        if let Some(ch) = channels.get(&composite) {
6379            return Some(ch);
6380        }
6381    }
6382    if let Some(ch) = channels.get(&msg.channel) {
6383        return Some(ch);
6384    }
6385    msg.channel
6386        .split_once(':')
6387        .and_then(|(base, _)| channels.get(base))
6388}
6389
6390/// Active `<type>.<alias>` channel references from enabled agents.
6391///
6392/// An empty set means no enabled agent declared channel bindings, so
6393/// collection falls back to legacy behavior and accepts all enabled channels.
6394struct ActiveChannelAliases {
6395    /// Set of `<type>.<alias>` channel references from enabled agents.
6396    aliases: HashSet<String>,
6397}
6398
6399impl ActiveChannelAliases {
6400    /// Returns true when `channel_ref` is explicitly bound, or when there are
6401    /// no explicit bindings and legacy "accept all enabled channels" mode applies.
6402    fn contains(&self, channel_ref: &str) -> bool {
6403        self.aliases.is_empty() || self.aliases.contains(channel_ref)
6404    }
6405}
6406
6407/// Build `channel_key → Arc<dyn Channel>` map from config.
6408///
6409/// Constructs channel instances without starting listen loops.
6410/// Called by CLI and other callers that need a channel map
6411/// for late-bound tool handle population.
6412pub fn build_channel_map(
6413    config: &Config,
6414) -> HashMap<String, Arc<dyn zeroclaw_api::channel::Channel>> {
6415    let config_arc = Arc::new(RwLock::new(config.clone()));
6416    collect_configured_channels(&config_arc, "", &[])
6417        .into_iter()
6418        .map(|ch| {
6419            let key = composite_channel_key(ch.channel.name(), ch.alias.as_deref());
6420            (key, ch.channel)
6421        })
6422        .collect()
6423}
6424
6425/// Build configured channels and register them into late-bound tool handles.
6426///
6427/// Constructs channel instances from config (without starting listen loops)
6428/// and inserts each into the provided handles under their composite key
6429/// (`<channel>.<alias>` or bare `<channel>` for singletons).
6430///
6431/// Returns the list of registered channel names for logging.
6432pub fn register_channels_for_tools(
6433    config: &Config,
6434    ask_user_handle: &Option<tools::PerToolChannelHandle>,
6435    reaction_handle: &Option<tools::PerToolChannelHandle>,
6436    poll_handle: &Option<tools::PerToolChannelHandle>,
6437    escalate_handle: &Option<tools::PerToolChannelHandle>,
6438) -> Vec<String> {
6439    let config_arc = Arc::new(RwLock::new(config.clone()));
6440    let configured = collect_configured_channels(&config_arc, "", &[]);
6441
6442    let handles = [
6443        ask_user_handle.as_ref(),
6444        reaction_handle.as_ref(),
6445        poll_handle.as_ref(),
6446        escalate_handle.as_ref(),
6447    ];
6448
6449    let mut names = Vec::new();
6450    for ch in &configured {
6451        let key = composite_channel_key(ch.channel.name(), ch.alias.as_deref());
6452        for handle in handles.iter().flatten() {
6453            handle.write().insert(key.clone(), Arc::clone(&ch.channel));
6454        }
6455        names.push(key);
6456    }
6457    names
6458}
6459
6460/// Per-alias Matrix state directory. Each `[channels.matrix.<alias>]` block
6461/// must own its own session/crypto store so two bots under one daemon don't
6462/// restore each other's `session.json` and run as the wrong account. The
6463/// alias component is what keeps them distinct.
6464#[cfg(feature = "channel-matrix")]
6465fn matrix_state_dir(config_path: &std::path::Path, alias: &str) -> std::path::PathBuf {
6466    config_path
6467        .parent()
6468        .map(|p| p.join("state").join("matrix").join(alias))
6469        .unwrap_or_else(|| std::path::PathBuf::from(".zeroclaw/state/matrix").join(alias))
6470}
6471
6472fn collect_configured_channels(
6473    config_arc: &Arc<RwLock<Config>>,
6474    matrix_skip_context: &str,
6475    tool_specs: &[(String, String)],
6476) -> Vec<ConfiguredChannel> {
6477    let _ = matrix_skip_context;
6478    let _ = tool_specs;
6479    #[allow(unused_mut)]
6480    let mut channels = Vec::new();
6481
6482    // Shadow `config` with a read guard so the existing body keeps
6483    // working via `Deref<Target = Config>`. Resolver closures that
6484    // outlive the function capture `config_arc.clone()`.
6485    let config = config_arc.read();
6486
6487    let active_channel_aliases = ActiveChannelAliases {
6488        aliases: config
6489            .agents
6490            .values()
6491            .filter(|a| a.enabled)
6492            .flat_map(|a| a.channels.iter().map(|c| c.as_str().to_string()))
6493            .collect(),
6494    };
6495
6496    #[cfg(feature = "channel-telegram")]
6497    for (alias, tg) in &config.channels.telegram {
6498        if !active_channel_aliases.contains(&format!("telegram.{alias}")) {
6499            continue;
6500        }
6501        if !tg.enabled {
6502            continue;
6503        }
6504        let ack = tg.ack_reactions.unwrap_or(config.channels.ack_reactions);
6505        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6506            let cfg_arc = config_arc.clone();
6507            let alias = alias.clone();
6508            Arc::new(move || cfg_arc.read().channel_external_peers("telegram", &alias))
6509        };
6510        channels.push(ConfiguredChannel {
6511            display_name: "Telegram",
6512            alias: Some(alias.clone()),
6513            channel: crate::paced_channel::PacedChannel::wrap(
6514                Arc::new(
6515                    TelegramChannel::new(
6516                        tg.bot_token.clone(),
6517                        alias.clone(),
6518                        peer_resolver,
6519                        tg.mention_only,
6520                    )
6521                    .with_persistence(config_arc.clone())
6522                    .with_ack_reactions(ack)
6523                    .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
6524                    .with_transcription(config.transcription.clone())
6525                    .with_tts(&config)
6526                    .with_voice_peer_prefs(&config, "telegram", alias)
6527                    .with_workspace_dir(config.channel_workspace_dir(&format!("telegram.{alias}")))
6528                    .with_proxy_url(tg.proxy_url.clone())
6529                    .with_tool_command_specs(tool_specs.to_vec())
6530                    .with_approval_timeout_secs(tg.approval_timeout_secs),
6531                ),
6532                tg,
6533            ),
6534        });
6535    }
6536
6537    #[cfg(not(feature = "channel-telegram"))]
6538    if !config.channels.telegram.is_empty() {
6539        ::zeroclaw_log::record!(
6540            WARN,
6541            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6542                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6543            "Telegram channel is configured but this build was compiled without \
6544             `channel-telegram`; skipping Telegram."
6545        );
6546    }
6547
6548    #[cfg(feature = "channel-discord")]
6549    for (alias, dc) in &config.channels.discord {
6550        if !active_channel_aliases.contains(&format!("discord.{alias}")) {
6551            continue;
6552        }
6553        if !dc.enabled {
6554            continue;
6555        }
6556        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6557            let cfg_arc = config_arc.clone();
6558            let alias = alias.clone();
6559            Arc::new(move || cfg_arc.read().channel_external_peers("discord", &alias))
6560        };
6561        let mut discord_ch = DiscordChannel::new(
6562            dc.bot_token.clone(),
6563            dc.guild_ids.clone(),
6564            alias.clone(),
6565            peer_resolver,
6566            dc.listen_to_bots,
6567            dc.mention_only,
6568        )
6569        .with_channel_ids(dc.channel_ids.clone())
6570        .with_workspace_dir(config.channel_workspace_dir(&format!("discord.{alias}")))
6571        .with_streaming(
6572            dc.stream_mode,
6573            dc.draft_update_interval_ms,
6574            dc.multi_message_delay_ms,
6575        )
6576        .with_proxy_url(dc.proxy_url.clone())
6577        .with_transcription(config.transcription.clone())
6578        .with_stall_timeout(dc.stall_timeout_secs)
6579        .with_approval_timeout_secs(dc.approval_timeout_secs);
6580        if dc.archive {
6581            match zeroclaw_memory::SqliteMemory::new_named("sqlite", &config.data_dir, "discord") {
6582                Ok(mem) => {
6583                    discord_ch = discord_ch.with_archive_memory(std::sync::Arc::new(mem));
6584                }
6585                Err(e) => {
6586                    ::zeroclaw_log::record!(
6587                        WARN,
6588                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6589                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
6590                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
6591                        "discord: archive enabled but failed to open discord.db"
6592                    );
6593                }
6594            }
6595        }
6596        channels.push(ConfiguredChannel {
6597            display_name: "Discord",
6598            alias: Some(alias.clone()),
6599            channel: crate::paced_channel::PacedChannel::wrap(Arc::new(discord_ch), dc),
6600        });
6601    }
6602
6603    #[cfg(not(feature = "channel-discord"))]
6604    if !config.channels.discord.is_empty() {
6605        ::zeroclaw_log::record!(
6606            WARN,
6607            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6608                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6609            "Discord channel is configured but this build was compiled without \
6610             `channel-discord`; skipping Discord."
6611        );
6612    }
6613
6614    #[cfg(feature = "channel-slack")]
6615    for (alias, sl) in &config.channels.slack {
6616        if !active_channel_aliases.contains(&format!("slack.{alias}")) {
6617            continue;
6618        }
6619        if !sl.enabled {
6620            continue;
6621        }
6622        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6623            let cfg_arc = config_arc.clone();
6624            let alias = alias.clone();
6625            Arc::new(move || cfg_arc.read().channel_external_peers("slack", &alias))
6626        };
6627        channels.push(ConfiguredChannel {
6628            display_name: "Slack",
6629            alias: Some(alias.clone()),
6630            channel: crate::paced_channel::PacedChannel::wrap(
6631                Arc::new(
6632                    SlackChannel::new(
6633                        sl.bot_token.clone(),
6634                        sl.app_token.clone(),
6635                        sl.channel_ids.clone(),
6636                        alias.clone(),
6637                        peer_resolver,
6638                    )
6639                    .with_thread_replies(sl.thread_replies.unwrap_or(true))
6640                    .with_group_reply_policy(sl.mention_only, Vec::new())
6641                    .with_strict_mention_in_thread(sl.strict_mention_in_thread)
6642                    .with_workspace_dir(config.channel_workspace_dir(&format!("slack.{alias}")))
6643                    .with_markdown_blocks(sl.use_markdown_blocks)
6644                    .with_proxy_url(sl.proxy_url.clone())
6645                    .with_transcription(config.transcription.clone())
6646                    .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
6647                    .with_cancel_reaction(sl.cancel_reaction.clone())
6648                    .with_approval_timeout_secs(sl.approval_timeout_secs),
6649                ),
6650                sl,
6651            ),
6652        });
6653    }
6654
6655    #[cfg(not(feature = "channel-slack"))]
6656    if !config.channels.slack.is_empty() {
6657        ::zeroclaw_log::record!(
6658            WARN,
6659            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6660                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6661            "Slack channel is configured but this build was compiled without \
6662             `channel-slack`; skipping Slack."
6663        );
6664    }
6665
6666    #[cfg(feature = "channel-mattermost")]
6667    for (alias, mm) in &config.channels.mattermost {
6668        if !active_channel_aliases.contains(&format!("mattermost.{alias}")) {
6669            continue;
6670        }
6671        if !mm.enabled {
6672            continue;
6673        }
6674        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6675            let cfg_arc = config_arc.clone();
6676            let alias = alias.clone();
6677            Arc::new(move || cfg_arc.read().channel_external_peers("mattermost", &alias))
6678        };
6679        channels.push(ConfiguredChannel {
6680            display_name: "Mattermost",
6681            alias: Some(alias.clone()),
6682            channel: crate::paced_channel::PacedChannel::wrap(
6683                Arc::new(
6684                    MattermostChannel::new(
6685                        mm.url.clone(),
6686                        mm.bot_token.clone(),
6687                        mm.login_id.clone(),
6688                        mm.password.clone(),
6689                        mm.channel_ids.clone(),
6690                        alias.clone(),
6691                        peer_resolver,
6692                        mm.thread_replies.unwrap_or(true),
6693                        mm.mention_only.unwrap_or(false),
6694                    )
6695                    .with_team_ids(mm.team_ids.clone())
6696                    .with_discover_dms(mm.discover_dms.unwrap_or(true))
6697                    .with_proxy_url(mm.proxy_url.clone())
6698                    .with_transcription(config.transcription.clone()),
6699                ),
6700                mm,
6701            ),
6702        });
6703    }
6704
6705    #[cfg(not(feature = "channel-mattermost"))]
6706    if !config.channels.mattermost.is_empty() {
6707        ::zeroclaw_log::record!(
6708            WARN,
6709            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6710                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6711            "Mattermost channel is configured but this build was compiled without \
6712             `channel-mattermost`; skipping Mattermost."
6713        );
6714    }
6715
6716    #[cfg(feature = "channel-imessage")]
6717    for (alias, im) in &config.channels.imessage {
6718        if !active_channel_aliases.contains(&format!("imessage.{alias}")) {
6719            continue;
6720        }
6721        if !im.enabled {
6722            continue;
6723        }
6724        let _ = im;
6725        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6726            let cfg_arc = config_arc.clone();
6727            let alias = alias.clone();
6728            Arc::new(move || cfg_arc.read().channel_external_peers("imessage", &alias))
6729        };
6730        channels.push(ConfiguredChannel {
6731            display_name: "iMessage",
6732            alias: Some(alias.clone()),
6733            channel: crate::paced_channel::PacedChannel::wrap(
6734                Arc::new(IMessageChannel::new(alias.clone(), peer_resolver)),
6735                im,
6736            ),
6737        });
6738    }
6739
6740    #[cfg(not(feature = "channel-imessage"))]
6741    if !config.channels.imessage.is_empty() {
6742        ::zeroclaw_log::record!(
6743            WARN,
6744            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6745                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6746            "iMessage channel is configured but this build was compiled without \
6747             `channel-imessage`; skipping iMessage."
6748        );
6749    }
6750
6751    #[cfg(feature = "channel-matrix")]
6752    for (alias, mx) in &config.channels.matrix {
6753        if !active_channel_aliases.contains(&format!("matrix.{alias}")) {
6754            continue;
6755        }
6756        if !mx.enabled {
6757            continue;
6758        }
6759        let state_dir = matrix_state_dir(&config.config_path, alias);
6760        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6761            let cfg_arc = config_arc.clone();
6762            let alias = alias.clone();
6763            Arc::new(move || cfg_arc.read().channel_external_peers("matrix", &alias))
6764        };
6765        let ack = mx.ack_reactions.unwrap_or(config.channels.ack_reactions);
6766        match MatrixChannel::new(mx.clone(), alias.clone(), peer_resolver, state_dir) {
6767            Ok(channel) => {
6768                let channel = channel
6769                    .with_transcription(config.transcription.clone())
6770                    .with_workspace_dir(config.channel_workspace_dir(&format!("matrix.{alias}")))
6771                    .with_ack_reactions(ack);
6772                channels.push(ConfiguredChannel {
6773                    display_name: "Matrix",
6774                    alias: Some(alias.clone()),
6775                    channel: crate::paced_channel::PacedChannel::wrap(Arc::new(channel), mx),
6776                });
6777            }
6778            Err(e) => {
6779                ::zeroclaw_log::record!(
6780                    ERROR,
6781                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
6782                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
6783                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
6784                    "Matrix channel construction failed"
6785                );
6786            }
6787        }
6788    }
6789
6790    #[cfg(not(feature = "channel-matrix"))]
6791    if !config.channels.matrix.is_empty() {
6792        ::zeroclaw_log::record!(
6793            WARN,
6794            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6795                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6796            &format!(
6797                "Matrix channel is configured but this build was compiled without `channel-matrix`; skipping Matrix {}.",
6798                matrix_skip_context
6799            )
6800        );
6801    }
6802
6803    #[cfg(feature = "channel-signal")]
6804    for (alias, sig) in &config.channels.signal {
6805        if !active_channel_aliases.contains(&format!("signal.{alias}")) {
6806            continue;
6807        }
6808        if !sig.enabled {
6809            continue;
6810        }
6811        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6812            let cfg_arc = config_arc.clone();
6813            let alias = alias.clone();
6814            Arc::new(move || cfg_arc.read().channel_external_peers("signal", &alias))
6815        };
6816        channels.push(ConfiguredChannel {
6817            display_name: "Signal",
6818            alias: Some(alias.clone()),
6819            channel: crate::paced_channel::PacedChannel::wrap(
6820                Arc::new(
6821                    SignalChannel::new(
6822                        sig.http_url.clone(),
6823                        sig.account.clone(),
6824                        sig.group_ids.clone(),
6825                        sig.dm_only,
6826                        alias.clone(),
6827                        peer_resolver,
6828                        sig.ignore_attachments,
6829                        sig.ignore_stories,
6830                    )
6831                    .with_proxy_url(sig.proxy_url.clone())
6832                    .with_approval_timeout_secs(sig.approval_timeout_secs),
6833                ),
6834                sig,
6835            ),
6836        });
6837    }
6838
6839    #[cfg(not(feature = "channel-signal"))]
6840    if !config.channels.signal.is_empty() {
6841        ::zeroclaw_log::record!(
6842            WARN,
6843            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6844                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6845            "Signal channel is configured but this build was compiled without \
6846             `channel-signal`; skipping Signal."
6847        );
6848    }
6849
6850    #[cfg(any(feature = "channel-whatsapp-cloud", feature = "whatsapp-web"))]
6851    for (alias, wa) in &config.channels.whatsapp {
6852        if !active_channel_aliases.contains(&format!("whatsapp.{alias}")) {
6853            continue;
6854        }
6855        if !wa.enabled {
6856            continue;
6857        }
6858        if wa.is_ambiguous_config() {
6859            ::zeroclaw_log::record!(
6860                WARN,
6861                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6862                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6863                "WhatsApp config has both phone_number_id and session_path set; preferring Cloud API mode. Remove one selector to avoid ambiguity."
6864            );
6865        }
6866        // Runtime negotiation: detect backend type from config
6867        match wa.backend_type() {
6868            #[cfg(feature = "channel-whatsapp-cloud")]
6869            "cloud" => {
6870                // Cloud API mode: requires phone_number_id, access_token, verify_token
6871                if wa.is_cloud_config() {
6872                    let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6873                        let cfg_arc = config_arc.clone();
6874                        let alias = alias.clone();
6875                        Arc::new(move || cfg_arc.read().channel_external_peers("whatsapp", &alias))
6876                    };
6877                    channels.push(ConfiguredChannel {
6878                        display_name: "WhatsApp",
6879                        alias: Some(alias.clone()),
6880                        channel: crate::paced_channel::PacedChannel::wrap(
6881                            Arc::new(
6882                                WhatsAppChannel::new(
6883                                    wa.access_token.clone().unwrap_or_default(),
6884                                    wa.phone_number_id.clone().unwrap_or_default(),
6885                                    wa.verify_token.clone().unwrap_or_default(),
6886                                    alias.clone(),
6887                                    peer_resolver,
6888                                )
6889                                .with_proxy_url(wa.proxy_url.clone())
6890                                .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
6891                                .with_group_mention_patterns(wa.group_mention_patterns.clone())
6892                                .with_approval_timeout_secs(wa.approval_timeout_secs),
6893                            ),
6894                            wa,
6895                        ),
6896                    });
6897                } else {
6898                    ::zeroclaw_log::record!(
6899                        WARN,
6900                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6901                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6902                        "WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)"
6903                    );
6904                }
6905                #[cfg(not(feature = "channel-whatsapp-cloud"))]
6906                {
6907                    ::zeroclaw_log::record!(
6908                        WARN,
6909                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6910                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6911                        "WhatsApp Cloud API backend requires 'channel-whatsapp-cloud' feature. Build/run with --features channel-whatsapp-cloud"
6912                    );
6913                }
6914            }
6915            #[cfg(not(feature = "channel-whatsapp-cloud"))]
6916            "cloud" => {
6917                ::zeroclaw_log::record!(
6918                    WARN,
6919                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6920                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6921                    "WhatsApp Cloud API is configured but this build was compiled without `channel-whatsapp-cloud`; skipping WhatsApp Cloud."
6922                );
6923            }
6924            "web" => {
6925                // Web mode: requires session_path
6926                #[cfg(feature = "whatsapp-web")]
6927                if wa.is_web_config() {
6928                    let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6929                        let cfg_arc = config_arc.clone();
6930                        let alias = alias.clone();
6931                        Arc::new(move || cfg_arc.read().channel_external_peers("whatsapp", &alias))
6932                    };
6933                    channels.push(ConfiguredChannel {
6934                        display_name: "WhatsApp",
6935                        alias: Some(alias.clone()),
6936                        channel: crate::paced_channel::PacedChannel::wrap(
6937                            Arc::new(
6938                                WhatsAppWebChannel::new(wa, alias.clone(), peer_resolver)
6939                                    .with_transcription(config.transcription.clone())
6940                                    .with_tts(&config)
6941                                    .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
6942                                    .with_group_mention_patterns(wa.group_mention_patterns.clone()),
6943                            ),
6944                            wa,
6945                        ),
6946                    });
6947                } else {
6948                    ::zeroclaw_log::record!(
6949                        WARN,
6950                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6951                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6952                        "WhatsApp Web configured but session_path not set"
6953                    );
6954                }
6955                #[cfg(not(feature = "whatsapp-web"))]
6956                {
6957                    ::zeroclaw_log::record!(
6958                        WARN,
6959                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6960                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6961                        "WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web"
6962                    );
6963                    eprintln!(
6964                        "  ⚠ WhatsApp Web is configured but the 'whatsapp-web' feature is not compiled in."
6965                    );
6966                    eprintln!("    Rebuild with: cargo build --features whatsapp-web");
6967                }
6968            }
6969            _ => {
6970                ::zeroclaw_log::record!(
6971                    WARN,
6972                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
6973                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
6974                    "WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set"
6975                );
6976            }
6977        }
6978    }
6979
6980    #[cfg(feature = "channel-linq")]
6981    for (alias, lq) in &config.channels.linq {
6982        if !active_channel_aliases.contains(&format!("linq.{alias}")) {
6983            continue;
6984        }
6985        if !lq.enabled {
6986            continue;
6987        }
6988        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
6989            let cfg_arc = config_arc.clone();
6990            let alias = alias.clone();
6991            Arc::new(move || cfg_arc.read().channel_external_peers("linq", &alias))
6992        };
6993        channels.push(ConfiguredChannel {
6994            display_name: "Linq",
6995            alias: Some(alias.clone()),
6996            channel: Arc::new(LinqChannel::new(
6997                lq.api_token.clone(),
6998                lq.from_phone.clone(),
6999                alias.clone(),
7000                peer_resolver,
7001            )),
7002        });
7003    }
7004
7005    #[cfg(not(feature = "channel-linq"))]
7006    if !config.channels.linq.is_empty() {
7007        ::zeroclaw_log::record!(
7008            WARN,
7009            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7010                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7011            "Linq channel is configured but this build was compiled without \
7012             `channel-linq`; skipping Linq."
7013        );
7014    }
7015
7016    #[cfg(feature = "channel-wati")]
7017    for (alias, wati_cfg) in &config.channels.wati {
7018        if !active_channel_aliases.contains(&format!("wati.{alias}")) {
7019            continue;
7020        }
7021        if !wati_cfg.enabled {
7022            continue;
7023        }
7024        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7025            let cfg_arc = config_arc.clone();
7026            let alias = alias.clone();
7027            Arc::new(move || cfg_arc.read().channel_external_peers("wati", &alias))
7028        };
7029        let wati_channel = WatiChannel::new_with_proxy(
7030            wati_cfg.api_token.clone(),
7031            wati_cfg.api_url.clone(),
7032            wati_cfg.tenant_id.clone(),
7033            alias.clone(),
7034            peer_resolver,
7035            wati_cfg.proxy_url.clone(),
7036        )
7037        .with_transcription(config.transcription.clone());
7038        channels.push(ConfiguredChannel {
7039            display_name: "WATI",
7040            alias: Some(alias.clone()),
7041            channel: Arc::new(wati_channel),
7042        });
7043    }
7044
7045    #[cfg(not(feature = "channel-wati"))]
7046    if !config.channels.wati.is_empty() {
7047        ::zeroclaw_log::record!(
7048            WARN,
7049            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7050                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7051            "WATI channel is configured but this build was compiled without \
7052             `channel-wati`; skipping WATI."
7053        );
7054    }
7055
7056    #[cfg(feature = "channel-nextcloud")]
7057    for (alias, nc) in &config.channels.nextcloud_talk {
7058        if !active_channel_aliases.contains(&format!("nextcloud_talk.{alias}")) {
7059            continue;
7060        }
7061        if !nc.enabled {
7062            continue;
7063        }
7064        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7065            let cfg_arc = config_arc.clone();
7066            let alias = alias.clone();
7067            Arc::new(move || {
7068                cfg_arc
7069                    .read()
7070                    .channel_external_peers("nextcloud_talk", &alias)
7071            })
7072        };
7073        channels.push(ConfiguredChannel {
7074            display_name: "Nextcloud Talk",
7075            alias: Some(alias.clone()),
7076            channel: Arc::new(NextcloudTalkChannel::new_with_proxy(
7077                nc.base_url.clone(),
7078                nc.app_token.clone(),
7079                nc.bot_name.clone().unwrap_or_default(),
7080                alias.clone(),
7081                peer_resolver,
7082                nc.proxy_url.clone(),
7083            )),
7084        });
7085    }
7086
7087    #[cfg(not(feature = "channel-nextcloud"))]
7088    if !config.channels.nextcloud_talk.is_empty() {
7089        ::zeroclaw_log::record!(
7090            WARN,
7091            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7092                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7093            "Nextcloud Talk channel is configured but this build was compiled without \
7094             `channel-nextcloud`; skipping Nextcloud Talk."
7095        );
7096    }
7097
7098    #[cfg(feature = "channel-email")]
7099    for (alias, email_cfg) in &config.channels.email {
7100        if !active_channel_aliases.contains(&format!("email.{alias}")) {
7101            continue;
7102        }
7103        if !email_cfg.enabled {
7104            continue;
7105        }
7106        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7107            let cfg_arc = config_arc.clone();
7108            let alias = alias.clone();
7109            Arc::new(move || cfg_arc.read().channel_external_peers("email", &alias))
7110        };
7111        channels.push(ConfiguredChannel {
7112            display_name: "Email",
7113            alias: Some(alias.clone()),
7114            channel: Arc::new(EmailChannel::new(
7115                email_cfg.clone(),
7116                alias.clone(),
7117                peer_resolver,
7118            )),
7119        });
7120    }
7121
7122    #[cfg(feature = "channel-email")]
7123    for (alias, gp_cfg) in &config.channels.gmail_push {
7124        if !active_channel_aliases.contains(&format!("gmail_push.{alias}")) {
7125            continue;
7126        }
7127        if !gp_cfg.enabled {
7128            continue;
7129        }
7130        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7131            let cfg_arc = config_arc.clone();
7132            let alias = alias.clone();
7133            Arc::new(move || cfg_arc.read().channel_external_peers("gmail_push", &alias))
7134        };
7135        channels.push(ConfiguredChannel {
7136            display_name: "Gmail Push",
7137            alias: Some(alias.clone()),
7138            channel: Arc::new(GmailPushChannel::new(
7139                gp_cfg.clone(),
7140                alias.clone(),
7141                peer_resolver,
7142            )),
7143        });
7144    }
7145
7146    #[cfg(not(feature = "channel-email"))]
7147    if !config.channels.email.is_empty() || !config.channels.gmail_push.is_empty() {
7148        ::zeroclaw_log::record!(
7149            WARN,
7150            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7151                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7152            "Email/Gmail Push channel is configured but this build was compiled without \
7153             `channel-email`; skipping Email and Gmail Push."
7154        );
7155    }
7156
7157    #[cfg(feature = "channel-irc")]
7158    for (alias, irc) in &config.channels.irc {
7159        if !active_channel_aliases.contains(&format!("irc.{alias}")) {
7160            continue;
7161        }
7162        if !irc.enabled {
7163            continue;
7164        }
7165        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7166            let cfg_arc = config_arc.clone();
7167            let alias = alias.clone();
7168            Arc::new(move || cfg_arc.read().channel_external_peers("irc", &alias))
7169        };
7170        channels.push(ConfiguredChannel {
7171            display_name: "IRC",
7172            alias: Some(alias.clone()),
7173            channel: Arc::new(IrcChannel::new(crate::irc::IrcChannelConfig {
7174                server: irc.server.clone(),
7175                port: irc.port,
7176                nickname: irc.nickname.clone(),
7177                username: irc.username.clone(),
7178                channels: irc.channels.clone(),
7179                alias: alias.clone(),
7180                peer_resolver,
7181                server_password: irc.server_password.clone(),
7182                nickserv_password: irc.nickserv_password.clone(),
7183                sasl_password: irc.sasl_password.clone(),
7184                verify_tls: irc.verify_tls.unwrap_or(true),
7185                mention_only: irc.mention_only,
7186            })),
7187        });
7188    }
7189
7190    #[cfg(not(feature = "channel-irc"))]
7191    if !config.channels.irc.is_empty() {
7192        ::zeroclaw_log::record!(
7193            WARN,
7194            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7195                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7196            "IRC channel is configured but this build was compiled without \
7197             `channel-irc`; skipping IRC."
7198        );
7199    }
7200
7201    #[cfg(feature = "channel-amqp")]
7202    for (alias, amqp) in &config.channels.amqp {
7203        if !active_channel_aliases.contains(&format!("amqp.{alias}")) {
7204            continue;
7205        }
7206        if !amqp.enabled {
7207            continue;
7208        }
7209        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7210            let cfg_arc = config_arc.clone();
7211            let alias = alias.clone();
7212            Arc::new(move || cfg_arc.read().channel_external_peers("amqp", &alias))
7213        };
7214        channels.push(ConfiguredChannel {
7215            display_name: "AMQP",
7216            alias: Some(alias.clone()),
7217            channel: Arc::new(AmqpChannel::new(crate::amqp::AmqpChannelConfig {
7218                amqp_url: amqp.amqp_url.clone(),
7219                exchange: amqp.exchange.clone(),
7220                routing_keys: amqp.routing_keys.clone(),
7221                queue: amqp.queue.clone(),
7222                ca_cert: amqp.ca_cert.clone(),
7223                client_cert: amqp.client_cert.clone(),
7224                client_key: amqp.client_key.clone(),
7225                sender_label: amqp.sender_label.clone(),
7226                content_template: amqp.content_template.clone(),
7227                thread_id_field: amqp.thread_id_field.clone(),
7228                durable_ack: amqp.durable_ack,
7229                alias: alias.clone(),
7230                peer_resolver,
7231            })),
7232        });
7233    }
7234
7235    #[cfg(not(feature = "channel-amqp"))]
7236    if !config.channels.amqp.is_empty() {
7237        ::zeroclaw_log::record!(
7238            WARN,
7239            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7240                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7241            "AMQP channel is configured but this build was compiled without \
7242             `channel-amqp`; skipping AMQP."
7243        );
7244    }
7245
7246    #[cfg(feature = "channel-twitch")]
7247    for (alias, tw) in &config.channels.twitch {
7248        if !active_channel_aliases.contains(&format!("twitch.{alias}")) {
7249            continue;
7250        }
7251        if !tw.enabled {
7252            continue;
7253        }
7254        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7255            let cfg_arc = config_arc.clone();
7256            let alias = alias.clone();
7257            Arc::new(move || cfg_arc.read().channel_external_peers("twitch", &alias))
7258        };
7259        channels.push(ConfiguredChannel {
7260            display_name: "Twitch",
7261            alias: Some(alias.clone()),
7262            channel: Arc::new(TwitchChannel::new(
7263                tw.bot_username.clone(),
7264                tw.oauth_token.clone(),
7265                tw.channels.clone(),
7266                tw.mention_only,
7267                alias.clone(),
7268                peer_resolver,
7269            )),
7270        });
7271    }
7272
7273    #[cfg(not(feature = "channel-twitch"))]
7274    if !config.channels.twitch.is_empty() {
7275        ::zeroclaw_log::record!(
7276            WARN,
7277            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7278                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7279            "Twitch channel is configured but this build was compiled without \
7280             `channel-twitch`; skipping Twitch."
7281        );
7282    }
7283
7284    #[cfg(feature = "channel-lark")]
7285    for (alias, lk) in &config.channels.lark {
7286        if !active_channel_aliases.contains(&format!("lark.{alias}")) {
7287            continue;
7288        }
7289        if !lk.enabled {
7290            continue;
7291        }
7292        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7293            let cfg_arc = config_arc.clone();
7294            let alias = alias.clone();
7295            Arc::new(move || cfg_arc.read().channel_external_peers("lark", &alias))
7296        };
7297        let display_name = if lk.use_feishu { "Feishu" } else { "Lark" };
7298        channels.push(ConfiguredChannel {
7299            display_name,
7300            alias: Some(alias.clone()),
7301            channel: Arc::new(
7302                LarkChannel::from_config(lk, alias.clone(), peer_resolver)
7303                    .with_approval_timeout_secs(lk.approval_timeout_secs)
7304                    .with_per_user_session(lk.per_user_session)
7305                    .with_streaming(lk.stream_mode, lk.draft_update_interval_ms)
7306                    .with_transcription(config.transcription.clone()),
7307            ),
7308        });
7309    }
7310
7311    #[cfg(not(feature = "channel-lark"))]
7312    if !config.channels.lark.is_empty() {
7313        ::zeroclaw_log::record!(
7314            WARN,
7315            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7316                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7317            "Lark/Feishu channel is configured but this build was compiled without `channel-lark`; skipping Lark/Feishu health check."
7318        );
7319    }
7320
7321    #[cfg(feature = "channel-line")]
7322    for (alias, ln) in &config.channels.line {
7323        if !active_channel_aliases.contains(&format!("line.{alias}")) {
7324            continue;
7325        }
7326        if !ln.enabled {
7327            continue;
7328        }
7329        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7330            let cfg_arc = config_arc.clone();
7331            let alias = alias.clone();
7332            Arc::new(move || cfg_arc.read().channel_external_peers("line", &alias))
7333        };
7334        channels.push(ConfiguredChannel {
7335            display_name: "LINE",
7336            alias: Some(alias.clone()),
7337            channel: Arc::new(
7338                LineChannel::from_config(ln, alias.clone(), peer_resolver)
7339                    .with_persistence(config_arc.clone())
7340                    .with_transcription(config.transcription.clone()),
7341            ),
7342        });
7343    }
7344
7345    #[cfg(not(feature = "channel-line"))]
7346    if !config.channels.line.is_empty() {
7347        ::zeroclaw_log::record!(
7348            WARN,
7349            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7350                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7351            "LINE channel is configured but this build was compiled without `channel-line`; skipping LINE health check."
7352        );
7353    }
7354
7355    #[cfg(feature = "channel-dingtalk")]
7356    for (alias, dt) in &config.channels.dingtalk {
7357        if !active_channel_aliases.contains(&format!("dingtalk.{alias}")) {
7358            continue;
7359        }
7360        if !dt.enabled {
7361            continue;
7362        }
7363        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7364            let cfg_arc = config_arc.clone();
7365            let alias = alias.clone();
7366            Arc::new(move || cfg_arc.read().channel_external_peers("dingtalk", &alias))
7367        };
7368        channels.push(ConfiguredChannel {
7369            display_name: "DingTalk",
7370            alias: Some(alias.clone()),
7371            channel: Arc::new(
7372                DingTalkChannel::new(
7373                    dt.client_id.clone(),
7374                    dt.client_secret.clone(),
7375                    alias.clone(),
7376                    peer_resolver,
7377                )
7378                .with_proxy_url(dt.proxy_url.clone()),
7379            ),
7380        });
7381    }
7382
7383    #[cfg(not(feature = "channel-dingtalk"))]
7384    if !config.channels.dingtalk.is_empty() {
7385        ::zeroclaw_log::record!(
7386            WARN,
7387            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7388                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7389            "DingTalk channel is configured but this build was compiled without \
7390             `channel-dingtalk`; skipping DingTalk."
7391        );
7392    }
7393
7394    #[cfg(feature = "channel-qq")]
7395    for (alias, qq) in &config.channels.qq {
7396        if !active_channel_aliases.contains(&format!("qq.{alias}")) {
7397            continue;
7398        }
7399        if !qq.enabled {
7400            continue;
7401        }
7402        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7403            let cfg_arc = config_arc.clone();
7404            let alias = alias.clone();
7405            Arc::new(move || cfg_arc.read().channel_external_peers("qq", &alias))
7406        };
7407        channels.push(ConfiguredChannel {
7408            display_name: "QQ",
7409            alias: Some(alias.clone()),
7410            channel: Arc::new(
7411                QQChannel::new(
7412                    qq.app_id.clone(),
7413                    qq.app_secret.clone(),
7414                    alias.clone(),
7415                    peer_resolver,
7416                )
7417                .with_workspace_dir(config.channel_workspace_dir(&format!("qq.{alias}")))
7418                .with_proxy_url(qq.proxy_url.clone())
7419                .with_transcription(config.transcription.clone()),
7420            ),
7421        });
7422    }
7423
7424    #[cfg(not(feature = "channel-qq"))]
7425    if !config.channels.qq.is_empty() {
7426        ::zeroclaw_log::record!(
7427            WARN,
7428            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7429                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7430            "QQ channel is configured but this build was compiled without \
7431             `channel-qq`; skipping QQ."
7432        );
7433    }
7434
7435    #[cfg(feature = "channel-twitter")]
7436    for (alias, tw) in &config.channels.twitter {
7437        if !active_channel_aliases.contains(&format!("twitter.{alias}")) {
7438            continue;
7439        }
7440        if !tw.enabled {
7441            continue;
7442        }
7443        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7444            let cfg_arc = config_arc.clone();
7445            let alias = alias.clone();
7446            Arc::new(move || cfg_arc.read().channel_external_peers("twitter", &alias))
7447        };
7448        channels.push(ConfiguredChannel {
7449            display_name: "X/Twitter",
7450            alias: Some(alias.clone()),
7451            channel: Arc::new(TwitterChannel::new(
7452                tw.bearer_token.clone(),
7453                alias.clone(),
7454                peer_resolver,
7455            )),
7456        });
7457    }
7458
7459    #[cfg(not(feature = "channel-twitter"))]
7460    if !config.channels.twitter.is_empty() {
7461        ::zeroclaw_log::record!(
7462            WARN,
7463            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7464                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7465            "X/Twitter channel is configured but this build was compiled without \
7466             `channel-twitter`; skipping X/Twitter."
7467        );
7468    }
7469
7470    #[cfg(feature = "channel-mochat")]
7471    for (alias, mc) in &config.channels.mochat {
7472        if !active_channel_aliases.contains(&format!("mochat.{alias}")) {
7473            continue;
7474        }
7475        if !mc.enabled {
7476            continue;
7477        }
7478        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7479            let cfg_arc = config_arc.clone();
7480            let alias = alias.clone();
7481            Arc::new(move || cfg_arc.read().channel_external_peers("mochat", &alias))
7482        };
7483        channels.push(ConfiguredChannel {
7484            display_name: "Mochat",
7485            alias: Some(alias.clone()),
7486            channel: Arc::new(MochatChannel::new(
7487                mc.api_url.clone(),
7488                mc.api_token.clone(),
7489                alias.clone(),
7490                peer_resolver,
7491                mc.poll_interval_secs,
7492            )),
7493        });
7494    }
7495
7496    #[cfg(not(feature = "channel-mochat"))]
7497    if !config.channels.mochat.is_empty() {
7498        ::zeroclaw_log::record!(
7499            WARN,
7500            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7501                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7502            "Mochat channel is configured but this build was compiled without \
7503             `channel-mochat`; skipping Mochat."
7504        );
7505    }
7506
7507    #[cfg(feature = "channel-wecom")]
7508    for (alias, wc) in &config.channels.wecom {
7509        if !active_channel_aliases.contains(&format!("wecom.{alias}")) {
7510            continue;
7511        }
7512        if !wc.enabled {
7513            continue;
7514        }
7515        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7516            let cfg_arc = config_arc.clone();
7517            let alias = alias.clone();
7518            Arc::new(move || cfg_arc.read().channel_external_peers("wecom", &alias))
7519        };
7520        channels.push(ConfiguredChannel {
7521            display_name: "WeCom",
7522            alias: Some(alias.clone()),
7523            channel: Arc::new(WeComChannel::new(
7524                wc.webhook_key.clone(),
7525                alias.clone(),
7526                peer_resolver,
7527            )),
7528        });
7529    }
7530
7531    #[cfg(not(feature = "channel-wecom"))]
7532    if !config.channels.wecom.is_empty() {
7533        ::zeroclaw_log::record!(
7534            WARN,
7535            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7536                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7537            "WeCom channel is configured but this build was compiled without \
7538             `channel-wecom`; skipping WeCom."
7539        );
7540    }
7541
7542    #[cfg(feature = "channel-wecom-ws")]
7543    for (alias, wc_ws) in &config.channels.wecom_ws {
7544        if !active_channel_aliases.contains(&format!("wecom_ws.{alias}"))
7545            && !active_channel_aliases.contains(&format!("wecom-ws.{alias}"))
7546        {
7547            continue;
7548        }
7549        if !wc_ws.enabled {
7550            continue;
7551        }
7552        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7553            let cfg_arc = config_arc.clone();
7554            let alias = alias.clone();
7555            let configured_allowed_users = wc_ws.allowed_users.clone();
7556            Arc::new(move || {
7557                let config = cfg_arc.read();
7558                let mut peers = configured_allowed_users.clone();
7559                for peer in config.channel_external_peers("wecom-ws", &alias) {
7560                    if !peers.contains(&peer) {
7561                        peers.push(peer);
7562                    }
7563                }
7564                for peer in config.channel_external_peers("wecom_ws", &alias) {
7565                    if !peers.contains(&peer) {
7566                        peers.push(peer);
7567                    }
7568                }
7569                peers
7570            })
7571        };
7572        match WeComWsChannel::new_with_alias(
7573            wc_ws,
7574            alias.clone(),
7575            peer_resolver,
7576            &config.channel_workspace_dir(&format!("wecom_ws.{alias}")),
7577        ) {
7578            Ok(channel) => channels.push(ConfiguredChannel {
7579                display_name: "WeCom WebSocket",
7580                alias: Some(alias.clone()),
7581                channel: Arc::new(channel),
7582            }),
7583            Err(err) => {
7584                ::zeroclaw_log::record!(
7585                    WARN,
7586                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7587                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
7588                        .with_attrs(::serde_json::json!({"error": format!("{err:#}")})),
7589                    format!(
7590                        "WeCom WebSocket channel configuration is invalid; skipping WeCom WebSocket {matrix_skip_context}"
7591                    ),
7592                );
7593            }
7594        }
7595    }
7596
7597    #[cfg(not(feature = "channel-wecom-ws"))]
7598    if !config.channels.wecom_ws.is_empty() {
7599        ::zeroclaw_log::record!(
7600            WARN,
7601            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7602                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7603            format!(
7604                "WeCom WebSocket channel is configured but this build was compiled without `channel-wecom-ws`; skipping WeCom WebSocket {matrix_skip_context}."
7605            ),
7606        );
7607    }
7608
7609    #[cfg(feature = "channel-wechat")]
7610    for (alias, wechat) in &config.channels.wechat {
7611        if !active_channel_aliases.contains(&format!("wechat.{alias}")) {
7612            continue;
7613        }
7614        if !wechat.enabled {
7615            continue;
7616        }
7617        let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7618            let cfg_arc = config_arc.clone();
7619            let alias = alias.clone();
7620            Arc::new(move || cfg_arc.read().channel_external_peers("wechat", &alias))
7621        };
7622        match WeChatChannel::new(
7623            alias.clone(),
7624            peer_resolver,
7625            wechat.api_base_url.clone(),
7626            wechat.cdn_base_url.clone(),
7627            wechat.state_dir.as_ref().map(|s| expand_tilde_in_path(s)),
7628        ) {
7629            Ok(channel) => {
7630                channels.push(ConfiguredChannel {
7631                    display_name: "WeChat",
7632                    alias: Some(alias.clone()),
7633                    channel: Arc::new(
7634                        channel
7635                            .with_persistence(config_arc.clone())
7636                            .with_workspace_dir(
7637                                config.channel_workspace_dir(&format!("wechat.{alias}")),
7638                            ),
7639                    ),
7640                });
7641            }
7642            Err(err) => {
7643                ::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");
7644            }
7645        }
7646    }
7647
7648    #[cfg(not(feature = "channel-wechat"))]
7649    for alias in config.channels.wechat.keys() {
7650        if active_channel_aliases.contains(&format!("wechat.{alias}")) {
7651            ::zeroclaw_log::record!(
7652                WARN,
7653                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7654                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
7655                    .with_attrs(::serde_json::json!({"matrix_skip_context": matrix_skip_context})),
7656                "WeChat channel is configured but this build was compiled without `channel-wechat`; skipping WeChat ."
7657            );
7658        }
7659    }
7660
7661    #[cfg(feature = "channel-clawdtalk")]
7662    for (alias, ct) in &config.channels.clawdtalk {
7663        if !active_channel_aliases.contains(&format!("clawdtalk.{alias}")) {
7664            continue;
7665        }
7666        if !ct.enabled {
7667            continue;
7668        }
7669        channels.push(ConfiguredChannel {
7670            display_name: "ClawdTalk",
7671            alias: Some(alias.clone()),
7672            channel: Arc::new(ClawdTalkChannel::new(alias.clone(), ct.clone())),
7673        });
7674    }
7675
7676    #[cfg(not(feature = "channel-clawdtalk"))]
7677    if !config.channels.clawdtalk.is_empty() {
7678        ::zeroclaw_log::record!(
7679            WARN,
7680            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7681                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7682            "ClawdTalk channel is configured but this build was compiled without \
7683             `channel-clawdtalk`; skipping ClawdTalk."
7684        );
7685    }
7686
7687    // Notion database poller channel
7688    #[cfg(feature = "channel-notion")]
7689    if config.notion.enabled && !config.notion.database_id.trim().is_empty() {
7690        let notion_api_key = config.notion.api_key.trim().to_string();
7691        if notion_api_key.is_empty() {
7692            ::zeroclaw_log::record!(
7693                WARN,
7694                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7695                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7696                "Notion channel enabled but `notion.api_key` is unset. Set it via the schema-mirror grammar: \
7697                 `ZEROCLAW_notion__api_key=...`."
7698            );
7699        } else {
7700            channels.push(ConfiguredChannel {
7701                display_name: "Notion",
7702                alias: None,
7703                channel: Arc::new(NotionChannel::new(
7704                    "notion",
7705                    notion_api_key,
7706                    config.notion.database_id.clone(),
7707                    config.notion.poll_interval_secs,
7708                    config.notion.status_property.clone(),
7709                    config.notion.input_property.clone(),
7710                    config.notion.result_property.clone(),
7711                    config.notion.max_concurrent,
7712                    config.notion.recover_stale,
7713                )),
7714            });
7715        }
7716    }
7717
7718    #[cfg(not(feature = "channel-notion"))]
7719    if config.notion.enabled {
7720        ::zeroclaw_log::record!(
7721            WARN,
7722            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7723                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7724            "Notion channel is enabled but this build was compiled without \
7725             `channel-notion`; skipping Notion."
7726        );
7727    }
7728
7729    #[cfg(feature = "channel-reddit")]
7730    for (alias, rd) in &config.channels.reddit {
7731        if !active_channel_aliases.contains(&format!("reddit.{alias}")) {
7732            continue;
7733        }
7734        if !rd.enabled {
7735            continue;
7736        }
7737        channels.push(ConfiguredChannel {
7738            display_name: "Reddit",
7739            alias: Some(alias.clone()),
7740            channel: Arc::new(RedditChannel::new(
7741                alias.clone(),
7742                rd.client_id.clone(),
7743                rd.client_secret.clone(),
7744                rd.refresh_token.clone(),
7745                rd.username.clone(),
7746                rd.subreddits.clone(),
7747            )),
7748        });
7749    }
7750
7751    #[cfg(not(feature = "channel-reddit"))]
7752    if !config.channels.reddit.is_empty() {
7753        ::zeroclaw_log::record!(
7754            WARN,
7755            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7756                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7757            "Reddit channel is configured but this build was compiled without \
7758             `channel-reddit`; skipping Reddit."
7759        );
7760    }
7761
7762    #[cfg(feature = "channel-bluesky")]
7763    for (alias, bs) in &config.channels.bluesky {
7764        if !active_channel_aliases.contains(&format!("bluesky.{alias}")) {
7765            continue;
7766        }
7767        if !bs.enabled {
7768            continue;
7769        }
7770        channels.push(ConfiguredChannel {
7771            display_name: "Bluesky",
7772            alias: Some(alias.clone()),
7773            channel: Arc::new(BlueskyChannel::new(
7774                alias.clone(),
7775                bs.handle.clone(),
7776                bs.app_password.clone(),
7777            )),
7778        });
7779    }
7780
7781    #[cfg(not(feature = "channel-bluesky"))]
7782    if !config.channels.bluesky.is_empty() {
7783        ::zeroclaw_log::record!(
7784            WARN,
7785            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7786                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7787            "Bluesky channel is configured but this build was compiled without \
7788             `channel-bluesky`; skipping Bluesky."
7789        );
7790    }
7791
7792    #[cfg(feature = "voice-wake")]
7793    for (alias, vw) in &config.channels.voice_wake {
7794        if !active_channel_aliases.contains(&format!("voice_wake.{alias}")) {
7795            continue;
7796        }
7797        if !vw.enabled {
7798            continue;
7799        }
7800        channels.push(ConfiguredChannel {
7801            display_name: "VoiceWake",
7802            alias: Some(alias.clone()),
7803            channel: Arc::new(VoiceWakeChannel::new(
7804                alias.clone(),
7805                vw.clone(),
7806                config.transcription.clone(),
7807            )),
7808        });
7809    }
7810
7811    #[cfg(not(feature = "voice-wake"))]
7812    if !config.channels.voice_wake.is_empty() {
7813        ::zeroclaw_log::record!(
7814            WARN,
7815            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7816                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7817            "VoiceWake channel is configured but this build was compiled without \
7818             `voice-wake`; skipping VoiceWake."
7819        );
7820    }
7821
7822    #[cfg(feature = "channel-voice-call")]
7823    for (alias, vc) in &config.channels.voice_call {
7824        if !active_channel_aliases.contains(&format!("voice_call.{alias}")) {
7825            continue;
7826        }
7827        if !vc.enabled {
7828            continue;
7829        }
7830        channels.push(ConfiguredChannel {
7831            display_name: "Voice Call",
7832            alias: Some(alias.clone()),
7833            channel: Arc::new(VoiceCallChannel::new(alias.clone(), vc.clone())),
7834        });
7835    }
7836
7837    #[cfg(not(feature = "channel-voice-call"))]
7838    if !config.channels.voice_call.is_empty() {
7839        ::zeroclaw_log::record!(
7840            WARN,
7841            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7842                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7843            "Voice Call channel is configured but this build was compiled without \
7844             `channel-voice-call`; skipping Voice Call."
7845        );
7846    }
7847
7848    #[cfg(feature = "channel-webhook")]
7849    for (alias, wh) in &config.channels.webhook {
7850        if !active_channel_aliases.contains(&format!("webhook.{alias}")) {
7851            continue;
7852        }
7853        if !wh.enabled {
7854            continue;
7855        }
7856        channels.push(ConfiguredChannel {
7857            display_name: "Webhook",
7858            alias: Some(alias.clone()),
7859            channel: crate::paced_channel::PacedChannel::wrap(
7860                Arc::new(WebhookChannel::new(
7861                    alias.clone(),
7862                    wh.port,
7863                    wh.listen_path.clone(),
7864                    wh.send_url.clone(),
7865                    wh.send_method.clone(),
7866                    wh.auth_header.clone(),
7867                    wh.secret.clone(),
7868                    wh.max_retries,
7869                    wh.retry_base_delay_ms,
7870                    wh.retry_max_delay_ms,
7871                )),
7872                wh,
7873            ),
7874        });
7875    }
7876
7877    #[cfg(not(feature = "channel-webhook"))]
7878    if !config.channels.webhook.is_empty() {
7879        ::zeroclaw_log::record!(
7880            WARN,
7881            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7882                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7883            "Webhook channel is configured but this build was compiled without \
7884             `channel-webhook`; skipping Webhook."
7885        );
7886    }
7887
7888    channels
7889}
7890
7891fn no_real_time_channels_message() -> &'static str {
7892    "No real-time channels configured. Run `zeroclaw quickstart` to set one up."
7893}
7894
7895/// Run health checks for configured channels.
7896pub async fn doctor_channels(config: Config) -> Result<()> {
7897    let config_arc = Arc::new(RwLock::new(config));
7898    #[allow(unused_mut)]
7899    let mut channels = collect_configured_channels(&config_arc, "health check", &[]);
7900
7901    #[cfg(feature = "channel-nostr")]
7902    {
7903        // Materialize the work list into owned values BEFORE any `.await`
7904        // so the RwLockReadGuard is dropped before the async constructor
7905        // runs (parking_lot guards are not Send).
7906        let nostr_jobs: Vec<(String, String, Vec<String>)> = {
7907            let config = config_arc.read();
7908            let active_nostr: std::collections::HashSet<String> = config
7909                .agents
7910                .values()
7911                .filter(|a| a.enabled)
7912                .flat_map(|a| a.channels.iter().map(|c| c.as_str().to_string()))
7913                .collect();
7914            config
7915                .channels
7916                .nostr
7917                .iter()
7918                .filter(|(alias, _)| active_nostr.contains(&format!("nostr.{alias}")))
7919                .map(|(alias, ns)| (alias.clone(), ns.private_key.clone(), ns.relays.clone()))
7920                .collect()
7921        };
7922        for (alias, private_key, relays) in nostr_jobs {
7923            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
7924                let cfg_arc = config_arc.clone();
7925                let alias = alias.clone();
7926                Arc::new(move || cfg_arc.read().channel_external_peers("nostr", &alias))
7927            };
7928            channels.push(ConfiguredChannel {
7929                display_name: "Nostr",
7930                alias: Some(alias.clone()),
7931                channel: Arc::new(
7932                    NostrChannel::new(&private_key, relays, alias, peer_resolver).await?,
7933                ),
7934            });
7935        }
7936    }
7937
7938    #[cfg(not(feature = "channel-nostr"))]
7939    {
7940        let config = config_arc.read();
7941        if !config.channels.nostr.is_empty() {
7942            ::zeroclaw_log::record!(
7943                WARN,
7944                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7945                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
7946                "Nostr channel is configured but this build was compiled without \
7947                 `channel-nostr`; skipping Nostr health check."
7948            );
7949        }
7950    }
7951
7952    if channels.is_empty() {
7953        println!("{}", no_real_time_channels_message());
7954        return Ok(());
7955    }
7956
7957    println!("🩺 ZeroClaw Channel Doctor");
7958    println!();
7959
7960    let mut healthy = 0_u32;
7961    let mut unhealthy = 0_u32;
7962    let mut timeout = 0_u32;
7963
7964    for configured in channels {
7965        let result =
7966            tokio::time::timeout(Duration::from_secs(10), configured.channel.health_check()).await;
7967        let state = classify_health_result(&result);
7968
7969        match state {
7970            ChannelHealthState::Healthy => {
7971                healthy += 1;
7972                println!("  ✅ {:<9} healthy", configured.display_name);
7973            }
7974            ChannelHealthState::Unhealthy => {
7975                unhealthy += 1;
7976                println!(
7977                    "  ❌ {:<9} unhealthy (auth/config/network)",
7978                    configured.display_name
7979                );
7980            }
7981            ChannelHealthState::Timeout => {
7982                timeout += 1;
7983                println!("  ⏱️  {:<9} timed out (>10s)", configured.display_name);
7984            }
7985        }
7986    }
7987
7988    if !config_arc.read().channels.webhook.is_empty() {
7989        println!("  ℹ️  Webhook   check via `zeroclaw gateway` then GET /health");
7990    }
7991
7992    println!();
7993    println!("Summary: {healthy} healthy, {unhealthy} unhealthy, {timeout} timed out");
7994    Ok(())
7995}
7996
7997fn build_owner_by_channel_key(
7998    config: &Config,
7999    enabled_agents: &[String],
8000    collected_channel_keys: &[String],
8001) -> HashMap<String, String> {
8002    // Owner map: `<channel_type>.<alias>` (and bare `<channel_type>` for
8003    // backward-compat with cron callers / singleton channels) → agent_alias.
8004    // Built from each enabled agent's `agents.<alias>.channels` list — the
8005    // schema treats this as the source of truth for channel ownership.
8006    let mut owner_by_channel_key: HashMap<String, String> = HashMap::new();
8007    for alias_str in enabled_agents {
8008        let Some(agent_cfg) = config.agents.get(alias_str) else {
8009            debug_assert!(
8010                false,
8011                "enabled agent alias missing from config.agents: {}",
8012                alias_str
8013            );
8014            continue;
8015        };
8016        for ch in &agent_cfg.channels {
8017            let ch_str: &str = ch.as_ref();
8018            owner_by_channel_key.insert(ch_str.to_string(), alias_str.clone());
8019            if let Some((bare, _)) = ch_str.split_once('.') {
8020                owner_by_channel_key
8021                    .entry(bare.to_string())
8022                    .or_insert_with(|| alias_str.clone());
8023            }
8024        }
8025    }
8026
8027    // Legacy fallback mode: when no enabled agent declares channel bindings,
8028    // channel collection accepts all enabled channels. Those channels must
8029    // also be routable, so bind collected channel keys to the runtime-active
8030    // agent selection (explicit `"default"` alias when present, else
8031    // lexicographically-smallest enabled alias).
8032    // `owner_by_channel_key.is_empty()` means every enabled agent had an
8033    // empty `agents.<alias>.channels` list; this is the same "legacy mode"
8034    // signal used by `collect_configured_channels` to accept all enabled
8035    // channel blocks.
8036    if owner_by_channel_key.is_empty() && !collected_channel_keys.is_empty() {
8037        let fallback_owner = config
8038            .resolved_runtime_agent_alias()
8039            .filter(|alias| enabled_agents.iter().any(|enabled| enabled == *alias))
8040            .map(ToString::to_string)
8041            .or_else(|| enabled_agents.first().cloned());
8042
8043        if let Some(owner_alias) = fallback_owner {
8044            for channel_key in collected_channel_keys {
8045                owner_by_channel_key.insert(channel_key.clone(), owner_alias.clone());
8046                if let Some((bare, _)) = channel_key.split_once('.') {
8047                    owner_by_channel_key
8048                        .entry(bare.to_string())
8049                        .or_insert_with(|| owner_alias.clone());
8050                }
8051            }
8052        }
8053    }
8054
8055    owner_by_channel_key
8056}
8057
8058/// Start all configured channels and route messages to the agent
8059#[allow(clippy::too_many_lines)]
8060pub async fn start_channels(
8061    config: Config,
8062    canvas_store: Option<zeroclaw_runtime::tools::CanvasStore>,
8063    cancel: tokio_util::sync::CancellationToken,
8064) -> Result<()> {
8065    // Wrap into the canonical shared handle so channels and persistence
8066    // paths share one source of truth. The local `config` shadowing
8067    // keeps this function's body (which threads `config` through dozens
8068    // of sync reads and awaits) compatible with the old `Config` shape
8069    // via a one-time clone; channels themselves consult `config_arc`.
8070    let config_arc = Arc::new(RwLock::new(config));
8071    let config: Config = config_arc.read().clone();
8072    // No agent's model provider resolves yet — the user has channels
8073    // configured but hasn't finished onboarding their model_provider.
8074    // Returning Ok() here lets the daemon supervisor mark the channels
8075    // component "done" instead of restart-looping. The user completes
8076    // onboarding at /onboard and reloads via /admin/reload to bring channels
8077    // up. Resolution is strict: an enabled agent counts only if its mandatory
8078    // `<type>.<alias>` ref resolves to a configured entry with a `model`.
8079    let any_agent_provider_resolves = config
8080        .agents
8081        .iter()
8082        .filter(|(_, a)| a.enabled)
8083        .any(|(_, a)| runtime_defaults_from_config(&config, a.model_provider.as_str()).is_ok());
8084    if !any_agent_provider_resolves {
8085        ::zeroclaw_log::record!(
8086            WARN,
8087            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8088                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
8089            "Channels supervisor: no model configured. Waiting for reload \
8090             (complete onboarding at /onboard or set \
8091             [providers.models.<type>.<alias>] model = \"...\" and reload)."
8092        );
8093        cancel.cancelled().await;
8094        return Ok(());
8095    }
8096
8097    // Every `[channels.<type>.<alias>]` block is owned by exactly one agent
8098    // (declared via `agents.<alias>.channels = [...]`). One
8099    // `ChannelRuntimeContext` per enabled agent; `AgentRouter::multi` resolves
8100    // each inbound message to the owning agent. Discord/Telegram/Slack/etc.
8101    // sockets stay shared at the channel layer.
8102    let enabled_agents: Vec<String> = {
8103        let mut v: Vec<String> = config
8104            .agents
8105            .iter()
8106            .filter(|(_, a)| a.enabled)
8107            .map(|(alias, _)| alias.clone())
8108            .collect();
8109        if v.is_empty() {
8110            anyhow::bail!("start_channels requires at least one enabled [agents.<alias>] entry");
8111        }
8112        v.sort();
8113        v
8114    };
8115
8116    let observer: Arc<dyn Observer> =
8117        Arc::from(observability::create_observer(&config.observability));
8118    let runtime: Arc<dyn platform::RuntimeAdapter> =
8119        Arc::from(platform::create_runtime(&config.runtime)?);
8120
8121    // i18n is process-global; initialize once before the per-agent loop
8122    // touches tool descriptions.
8123    let i18n_locale = config
8124        .locale
8125        .as_deref()
8126        .filter(|s| !s.is_empty())
8127        .map(ToString::to_string)
8128        .unwrap_or_else(zeroclaw_runtime::i18n::detect_locale);
8129    zeroclaw_runtime::i18n::init(&i18n_locale);
8130
8131    // Single session backend shared across agents — they're scoped by
8132    // `session_key` (which already encodes `<channel_type>.<alias>`), so
8133    // multiple agent ctxs reading the same backend never overlap.
8134    let shared_session_store: Option<Arc<dyn zeroclaw_infra::session_backend::SessionBackend>> =
8135        if config.channels.session_persistence {
8136            match zeroclaw_infra::make_session_backend(
8137                &config.data_dir,
8138                &config.channels.session_backend,
8139            ) {
8140                Ok(backend) => {
8141                    ::zeroclaw_log::record!(
8142                        INFO,
8143                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
8144                        &format!(
8145                            "📂 Session persistence enabled (backend: {})",
8146                            config.channels.session_backend
8147                        )
8148                    );
8149                    Some(backend)
8150                }
8151                Err(e) => {
8152                    ::zeroclaw_log::record!(
8153                        WARN,
8154                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8155                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
8156                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
8157                        "Session persistence disabled"
8158                    );
8159                    None
8160                }
8161            }
8162        } else {
8163            None
8164        };
8165
8166    // Channel infrastructure (listeners, `channels_by_name`, the mpsc bus)
8167    // is built once inside the loop on the first iteration — the primary
8168    // agent's `tool_specs` are used to wire Telegram slash commands.
8169    // Subsequent iterations reuse `channels_by_name_shared` to populate
8170    // their tool handles and to seed their `ChannelRuntimeContext`.
8171    let mut channels_by_name_shared: Option<Arc<HashMap<String, Arc<dyn Channel>>>> = None;
8172    let mut collected_channel_keys: Vec<String> = Vec::new();
8173    let mut max_in_flight_messages: Option<usize> = None;
8174    let mut listener_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
8175    let mut rx_holder: Option<tokio::sync::mpsc::Receiver<zeroclaw_api::channel::ChannelMessage>> =
8176        None;
8177
8178    let mut agent_ctxs: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
8179
8180    for agent_alias in &enabled_agents {
8181        let agent = config
8182            .resolved_agent_config(agent_alias)
8183            .with_context(|| format!("agents.{agent_alias} is not configured"))?;
8184        let risk_profile = config
8185            .risk_profile_for_agent(agent_alias)
8186            .with_context(|| {
8187                format!(
8188                    "agents.{agent_alias}.risk_profile does not name a configured risk_profiles entry"
8189                )
8190            })?
8191            .clone();
8192
8193        // Resolve the agent's model provider strictly from its mandatory
8194        // `<type>.<alias>` reference. No fallback to a first/default provider:
8195        // an agent whose ref does not resolve to a configured entry with a
8196        // `model` is rejected here.
8197        let runtime_defaults = runtime_defaults_from_config(&config, agent.model_provider.as_str())
8198            .with_context(|| format!("agents.{agent_alias}.model_provider"))?;
8199        let provider_name = runtime_defaults.default_model_provider.clone();
8200        let model = runtime_defaults.model.clone();
8201        let temperature = runtime_defaults.temperature;
8202        let provider_api_key = runtime_defaults.api_key.clone();
8203        let provider_api_url = runtime_defaults.api_url.clone();
8204        let provider_reliability = runtime_defaults.reliability.clone();
8205        let provider_runtime_options =
8206            zeroclaw_providers::provider_runtime_options_for_agent(&config, agent_alias);
8207        let model_provider: Arc<dyn ModelProvider> = Arc::from(
8208            create_resilient_model_provider_nonblocking(
8209                Arc::new(config.clone()),
8210                &provider_name,
8211                provider_api_key.clone(),
8212                provider_api_url.clone(),
8213                provider_reliability.clone(),
8214                provider_runtime_options.clone(),
8215            )
8216            .await?,
8217        );
8218
8219        if let Err(e) = model_provider.warmup().await {
8220            ::zeroclaw_log::record!(
8221                WARN,
8222                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8223                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
8224                    .with_attrs(
8225                        ::serde_json::json!({"error": format!("{}", e), "agent": agent_alias})
8226                    ),
8227                "ModelProvider warmup failed (non-fatal)"
8228            );
8229        }
8230
8231        let security = Arc::new(SecurityPolicy::for_agent(&config, agent_alias)?);
8232        let mem: Arc<dyn Memory> = zeroclaw_memory::create_memory_for_agent(
8233            &config,
8234            agent_alias,
8235            provider_api_key.as_deref(),
8236        )
8237        .await?;
8238        let (composio_key, composio_entity_id) = if config.composio.enabled {
8239            (
8240                config.composio.api_key.as_deref(),
8241                Some(config.composio.entity_id.as_str()),
8242            )
8243        } else {
8244            (None, None)
8245        };
8246
8247        // Per-agent workspace: `<install>/agents/<alias>/workspace/`. Holds
8248        // this agent's IDENTITY.md / SOUL.md / USER.md / TOOLS.md /
8249        // AGENTS.md / MEMORY.md — the personality files the gateway UI
8250        // edits via /config/agents/<alias>?tab=personality. The system
8251        // prompt builder below reads these to render the agent's voice;
8252        // file_read / file_write tools scope path access to this root.
8253        let workspace = config.agent_workspace_dir(agent_alias);
8254        // Per-agent skills: install-wide workspace + open_skills set,
8255        // unioned with this agent's declared `skill_bundles`.
8256        let skills =
8257            zeroclaw_runtime::skills::load_skills_for_agent(&workspace, &config, agent_alias);
8258
8259        let all_tools_result_ch = tools::all_tools_with_runtime(
8260            Arc::new(config.clone()),
8261            &security,
8262            &risk_profile,
8263            agent_alias,
8264            Arc::clone(&runtime),
8265            Arc::clone(&mem),
8266            composio_key,
8267            composio_entity_id,
8268            &config.browser,
8269            &config.http_request,
8270            &config.web_fetch,
8271            &workspace,
8272            &config.agents,
8273            provider_api_key.as_deref(),
8274            &config,
8275            canvas_store.clone(),
8276            false,
8277            None,
8278        );
8279        let mut built_tools = all_tools_result_ch.tools;
8280        let delegate_handle_ch = all_tools_result_ch.delegate_handle;
8281
8282        // Wire peripheral tools (gpio_read/gpio_write etc.) so channel-driven
8283        // sessions (Telegram, Discord, etc.) can actuate hardware when
8284        // [peripherals] is configured. Mirrors the agent loop wiring.
8285        // The helper is safe to call unconditionally (returns empty when
8286        // no peripherals are wired).
8287        let peripheral_tools =
8288            zeroclaw_runtime::agent::loop_::load_peripheral_tools(config.peripherals.clone()).await;
8289        if !peripheral_tools.is_empty() {
8290            ::zeroclaw_log::record!(
8291                INFO,
8292                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8293                    .with_attrs(::serde_json::json!({"count": peripheral_tools.len()})),
8294                "Peripheral tools added (channels orchestrator)"
8295            );
8296            built_tools.extend(peripheral_tools);
8297        }
8298        let reaction_handle_ch = all_tools_result_ch.reaction_handle;
8299        let ask_user_handle_ch = all_tools_result_ch.ask_user_handle;
8300        let poll_handle_ch = all_tools_result_ch.poll_handle;
8301        let escalate_handle_ch = all_tools_result_ch.escalate_handle;
8302
8303        // ── Built-in SecurityPolicy tool gate (parity with agent::run /
8304        // process_message / from_config) ────────────────────────────────────
8305        // Apply the agent's allowlist (`allowed_tools`) AND denylist
8306        // (`excluded_tools`) to the eager built-in registry, BEFORE MCP and
8307        // skill tools are added. `start_channels` previously enforced only the
8308        // risk-profile denylist on the prompt catalog here — never the
8309        // per-agent allowlist on the registry sent to the model — so an agent
8310        // allowlisted to e.g. `file_read` still received raw `shell` /
8311        // `file_write` in its native tool specs. Filtering before skill
8312        // registration is also what lets a scoped elevation wrapper survive:
8313        // the raw target is removed while the distinct prefixed
8314        // `{skill}__{tool}` wrapper is appended later. MCP tools are injected
8315        // after this gate and are intentionally exempt (a restrictive allowlist
8316        // must not silently drop a server's tools); the risk-profile denylist
8317        // still applies to them.
8318        let before_policy_filter_ch = built_tools.len();
8319        apply_policy_tool_filter(&mut built_tools, Some(security.as_ref()), None);
8320        if built_tools.len() != before_policy_filter_ch {
8321            ::zeroclaw_log::record!(
8322                INFO,
8323                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8324                    .with_attrs(::serde_json::json!({
8325                        "agent": agent_alias,
8326                        "before": before_policy_filter_ch,
8327                        "retained": built_tools.len(),
8328                        "policy_allowed": security.allowed_tools.as_ref().map(|v| v.len()),
8329                        "policy_excluded": security.excluded_tools.as_ref().map(|v| v.len()),
8330                    })),
8331                "Applied SecurityPolicy built-in tool filter (channel path)"
8332            );
8333        }
8334
8335        // Wire MCP tools into the per-agent registry before freezing —
8336        // non-fatal. When `mcp.deferred_loading` is enabled, MCP tools are
8337        // exposed via a `tool_search` built-in rather than added eagerly.
8338        let mut deferred_section = String::new();
8339        let mut ch_activated_handle: Option<
8340            std::sync::Arc<std::sync::Mutex<zeroclaw_runtime::tools::ActivatedToolSet>>,
8341        > = None;
8342        // Resolution-only MCP wrappers for skill MCP elevation (kind = "mcp").
8343        let mut ch_mcp_elevation_arcs: Vec<std::sync::Arc<dyn zeroclaw_runtime::tools::Tool>> =
8344            Vec::new();
8345        if config.mcp.enabled && !config.mcp.servers.is_empty() {
8346            ::zeroclaw_log::record!(
8347                INFO,
8348                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8349                    .with_attrs(::serde_json::json!({"agent": agent_alias})),
8350                &format!(
8351                    "Initializing MCP client — {} server(s) configured",
8352                    config.mcp.servers.len()
8353                )
8354            );
8355            match zeroclaw_runtime::tools::McpRegistry::connect_all(&config.mcp.servers).await {
8356                Ok(registry) => {
8357                    let registry = std::sync::Arc::new(registry);
8358                    ch_mcp_elevation_arcs =
8359                        zeroclaw_runtime::tools::collect_mcp_elevation_arcs(&registry).await;
8360                    if config.mcp.deferred_loading {
8361                        let deferred_set =
8362                            zeroclaw_runtime::tools::DeferredMcpToolSet::from_registry(
8363                                std::sync::Arc::clone(&registry),
8364                            )
8365                            .await;
8366                        ::zeroclaw_log::record!(
8367                            INFO,
8368                            ::zeroclaw_log::Event::new(
8369                                module_path!(),
8370                                ::zeroclaw_log::Action::Note
8371                            )
8372                            .with_attrs(::serde_json::json!({"agent": agent_alias})),
8373                            &format!(
8374                                "MCP deferred: {} tool stub(s) from {} server(s)",
8375                                deferred_set.len(),
8376                                registry.server_count()
8377                            )
8378                        );
8379                        deferred_section =
8380                            zeroclaw_runtime::tools::build_deferred_tools_section(&deferred_set);
8381                        let activated = std::sync::Arc::new(std::sync::Mutex::new(
8382                            zeroclaw_runtime::tools::ActivatedToolSet::new(),
8383                        ));
8384                        ch_activated_handle = Some(std::sync::Arc::clone(&activated));
8385                        built_tools.push(Box::new(zeroclaw_runtime::tools::ToolSearchTool::new(
8386                            deferred_set,
8387                            activated,
8388                        )));
8389                    } else {
8390                        let names = registry.tool_names();
8391                        let mut registered = 0usize;
8392                        for name in names {
8393                            if let Some(def) = registry.get_tool_def(&name).await {
8394                                let wrapper: std::sync::Arc<dyn Tool> = std::sync::Arc::new(
8395                                    zeroclaw_runtime::tools::McpToolWrapper::new(
8396                                        name,
8397                                        def,
8398                                        std::sync::Arc::clone(&registry),
8399                                    ),
8400                                );
8401                                if let Some(ref handle) = delegate_handle_ch {
8402                                    handle.write().push(std::sync::Arc::clone(&wrapper));
8403                                }
8404                                built_tools
8405                                    .push(Box::new(zeroclaw_runtime::tools::ArcToolRef(wrapper)));
8406                                registered += 1;
8407                            }
8408                        }
8409                        ::zeroclaw_log::record!(
8410                            INFO,
8411                            ::zeroclaw_log::Event::new(
8412                                module_path!(),
8413                                ::zeroclaw_log::Action::Note
8414                            )
8415                            .with_attrs(::serde_json::json!({"agent": agent_alias})),
8416                            &format!(
8417                                "MCP: {} tool(s) registered from {} server(s)",
8418                                registered,
8419                                registry.server_count()
8420                            )
8421                        );
8422                    }
8423                }
8424                Err(e) => {
8425                    ::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!({"agent_alias": agent_alias, "error": format!("{}", e)})), "MCP registry failed to initialize");
8426                }
8427            }
8428        }
8429
8430        // Skill tools share the workspace-loaded `skills` Vec but each
8431        // agent gets its own `ToolBox` so per-agent security policies
8432        // gate execution.
8433        // Resolution registry = built-in arcs + resolution-only MCP wrappers.
8434        let skill_resolution_registry: Vec<std::sync::Arc<dyn zeroclaw_runtime::tools::Tool>> =
8435            all_tools_result_ch
8436                .unfiltered_tool_arcs
8437                .iter()
8438                .cloned()
8439                .chain(ch_mcp_elevation_arcs.iter().cloned())
8440                .collect();
8441        zeroclaw_runtime::tools::register_skill_tools_with_context(
8442            &mut built_tools,
8443            &skills,
8444            security.clone(),
8445            &skill_resolution_registry,
8446        );
8447
8448        let tool_specs: Vec<(String, String)> = built_tools
8449            .iter()
8450            .map(|t| (t.name().to_string(), t.description().to_string()))
8451            .collect();
8452
8453        let tools_registry = Arc::new(built_tools);
8454
8455        let mut tool_descs: Vec<(&str, &str)> = vec![
8456            (
8457                "shell",
8458                "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.",
8459            ),
8460            (
8461                "file_read",
8462                "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
8463            ),
8464            (
8465                "file_write",
8466                "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.",
8467            ),
8468            (
8469                "memory_store",
8470                "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
8471            ),
8472            (
8473                "memory_recall",
8474                "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
8475            ),
8476            (
8477                "memory_forget",
8478                "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
8479            ),
8480        ];
8481
8482        if matches!(
8483            config.skills.prompt_injection_mode,
8484            zeroclaw_config::schema::SkillsPromptInjectionMode::Compact
8485        ) {
8486            tool_descs.push((
8487                "read_skill",
8488                "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.",
8489            ));
8490        }
8491        if config.browser.enabled {
8492            tool_descs.push((
8493                "browser_open",
8494                "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
8495            ));
8496        }
8497        if config.composio.enabled {
8498            tool_descs.push((
8499                "composio",
8500                "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.",
8501            ));
8502        }
8503        tool_descs.push((
8504            "schedule",
8505            "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
8506        ));
8507        tool_descs.push((
8508            "pushover",
8509            "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.",
8510        ));
8511        if !config.agents.is_empty() {
8512            tool_descs.push((
8513                "delegate",
8514                "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.",
8515            ));
8516        }
8517
8518        // Filter out tools excluded for non-CLI channels so this agent's
8519        // system prompt does not advertise them for channel-driven runs.
8520        {
8521            let active_profile = &risk_profile;
8522            let excluded = &active_profile.excluded_tools;
8523            if !excluded.is_empty() && active_profile.level != AutonomyLevel::Full {
8524                tool_descs.retain(|(name, _)| !excluded.iter().any(|ex| ex == name));
8525            }
8526        }
8527        let effective_tool_names: HashSet<&str> =
8528            tools_registry.iter().map(|tool| tool.name()).collect();
8529        tool_descs.retain(|(name, _)| effective_tool_names.contains(name));
8530
8531        let bootstrap_max_chars = if agent.resolved.compact_context {
8532            Some(6000)
8533        } else {
8534            None
8535        };
8536        let native_tools = model_provider.supports_native_tools();
8537        let expose_text_tool_protocol = apply_text_tool_prompt_policy(
8538            native_tools,
8539            agent.resolved.strict_tool_parsing,
8540            &mut tool_descs,
8541            &mut deferred_section,
8542        );
8543        let mut system_prompt = build_system_prompt_with_mode_and_autonomy(
8544            &workspace,
8545            &model,
8546            &tool_descs,
8547            &skills,
8548            Some(&agent.identity),
8549            bootstrap_max_chars,
8550            Some(&risk_profile),
8551            native_tools,
8552            config.skills.prompt_injection_mode,
8553            agent.resolved.compact_context,
8554            agent.resolved.max_system_prompt_chars,
8555            true,
8556        );
8557        if expose_text_tool_protocol {
8558            system_prompt.push_str(&build_tool_instructions_for_names(
8559                tools_registry.as_ref(),
8560                &effective_tool_names,
8561            ));
8562        }
8563        if !deferred_section.is_empty() {
8564            system_prompt.push('\n');
8565            system_prompt.push_str(&deferred_section);
8566        }
8567        if agent.resolved.tool_receipts.enabled && agent.resolved.tool_receipts.inject_system_prompt
8568        {
8569            system_prompt.push_str(
8570                "\n## Tool Execution Receipts\n\n\
8571                 Every tool result includes a `[receipt: ...]` field. This is a cryptographic \
8572                 signature proving the tool actually executed. You must include the receipt \
8573                 verbatim when referencing tool results. Do not modify, omit, or fabricate receipts. \
8574                 A missing or invalid receipt indicates a fabricated tool call.\n\n",
8575            );
8576        }
8577
8578        // === First iteration only: set up shared channel infrastructure ===
8579        //
8580        // We collect channels here (using *this* agent's `tool_specs`, since
8581        // the loop puts the primary agent first) and stash the
8582        // `channels_by_name` registry so subsequent iterations can populate
8583        // their tool handles without re-building Discord/Telegram/etc.
8584        // sockets. The first agent's `tool_specs` wire Telegram-style slash
8585        // commands; multi-agent installs that want per-bot command sets
8586        // require a future per-channel `tool_specs` lookup (tracked
8587        // alongside the per-channel ChannelRuntimeContext follow-up).
8588        if channels_by_name_shared.is_none() {
8589            if !skills.is_empty() {
8590                println!(
8591                    "  🧩 Skills:   {}",
8592                    skills
8593                        .iter()
8594                        .map(|s| s.name.as_str())
8595                        .collect::<Vec<_>>()
8596                        .join(", ")
8597                );
8598            }
8599
8600            #[allow(unused_mut)]
8601            let mut configured_channels: Vec<ConfiguredChannel> =
8602                collect_configured_channels(&config_arc, "runtime startup", &tool_specs);
8603
8604            #[cfg(feature = "channel-nostr")]
8605            if let Some((alias, ns)) = config.channels.nostr.iter().next() {
8606                let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> = {
8607                    let cfg_arc = config_arc.clone();
8608                    let alias = alias.clone();
8609                    Arc::new(move || cfg_arc.read().channel_external_peers("nostr", &alias))
8610                };
8611                configured_channels.push(ConfiguredChannel {
8612                    display_name: "Nostr",
8613                    alias: Some(alias.clone()),
8614                    channel: Arc::new(
8615                        NostrChannel::new(
8616                            &ns.private_key,
8617                            ns.relays.clone(),
8618                            alias.clone(),
8619                            peer_resolver,
8620                        )
8621                        .await?,
8622                    ),
8623                });
8624            }
8625            #[cfg(not(feature = "channel-nostr"))]
8626            if !config.channels.nostr.is_empty() {
8627                ::zeroclaw_log::record!(
8628                    WARN,
8629                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8630                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
8631                    "Nostr channel is configured but this build was compiled without \
8632                     `channel-nostr`; skipping Nostr."
8633                );
8634            }
8635            let channels: Vec<Arc<dyn Channel>> = configured_channels
8636                .iter()
8637                .map(|cc| Arc::clone(&cc.channel))
8638                .collect();
8639            if channels.is_empty() {
8640                ::zeroclaw_log::record!(
8641                    INFO,
8642                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
8643                    "No active channels to supervise (none configured or all disabled). \
8644                     Waiting for reload signal."
8645                );
8646                cancel.cancelled().await;
8647                return Ok(());
8648            }
8649
8650            println!("🦀 ZeroClaw Channel Server");
8651            println!("  🤖 Model:    {model} (agent: {agent_alias})");
8652            let effective_backend = config.resolve_active_storage().kind();
8653            println!(
8654                "  🧠 Memory:   {} (auto-save: {})",
8655                effective_backend,
8656                if config.memory.auto_save { "on" } else { "off" }
8657            );
8658            let channel_labels: Vec<String> = configured_channels
8659                .iter()
8660                .map(|cc| composite_channel_key(cc.channel.name(), cc.alias.as_deref()))
8661                .collect();
8662            collected_channel_keys = channel_labels.clone();
8663            println!("  📡 Channels: {}", channel_labels.join(", "));
8664            println!("  🤖 Agents:   {}", enabled_agents.join(", "));
8665            println!();
8666            println!("  Listening for messages... (Ctrl+C to stop)");
8667            println!();
8668
8669            zeroclaw_runtime::health::mark_component_ok("channels");
8670
8671            let initial_backoff_secs = config
8672                .reliability
8673                .channel_initial_backoff_secs
8674                .max(DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS);
8675            let max_backoff_secs = config
8676                .reliability
8677                .channel_max_backoff_secs
8678                .max(DEFAULT_CHANNEL_MAX_BACKOFF_SECS);
8679
8680            let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(100);
8681
8682            for cc in &configured_channels {
8683                listener_handles.push(spawn_supervised_listener(
8684                    cc.channel.clone(),
8685                    cc.alias.clone(),
8686                    tx.clone(),
8687                    initial_backoff_secs,
8688                    max_backoff_secs,
8689                    cancel.clone(),
8690                ));
8691            }
8692            drop(tx);
8693
8694            // Composite-key registry (see `composite_channel_key`).
8695            let cbn = Arc::new({
8696                let mut map: HashMap<String, Arc<dyn Channel>> = HashMap::new();
8697                let mut name_counts: HashMap<&str, usize> = HashMap::new();
8698                for cc in &configured_channels {
8699                    *name_counts.entry(cc.channel.name()).or_insert(0) += 1;
8700                }
8701                for cc in &configured_channels {
8702                    let name = cc.channel.name();
8703                    let composite = composite_channel_key(name, cc.alias.as_deref());
8704                    map.insert(composite, Arc::clone(&cc.channel));
8705                    if name_counts.get(name).copied().unwrap_or(0) == 1 {
8706                        map.entry(name.to_string())
8707                            .or_insert_with(|| Arc::clone(&cc.channel));
8708                    }
8709                }
8710                map
8711            });
8712            *CRON_CHANNEL_REGISTRY
8713                .write()
8714                .unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&cbn));
8715
8716            let in_flight = max_in_flight_messages_for_config(channels.len(), &config.channels);
8717            println!("  🚦 In-flight message limit: {in_flight}");
8718
8719            max_in_flight_messages = Some(in_flight);
8720            channels_by_name_shared = Some(cbn);
8721            rx_holder = Some(rx);
8722        }
8723
8724        let channels_by_name = Arc::clone(
8725            channels_by_name_shared
8726                .as_ref()
8727                .expect("channels_by_name initialized on first iteration"),
8728        );
8729
8730        // Wire this agent's reaction / ask_user / escalate tool handles
8731        // into the shared `channels_by_name` map.
8732        {
8733            let mut map = reaction_handle_ch.write();
8734            for (name, ch) in channels_by_name.as_ref() {
8735                map.insert(name.clone(), Arc::clone(ch));
8736            }
8737        }
8738        if let Some(ref handle) = ask_user_handle_ch {
8739            let mut map = handle.write();
8740            for (name, ch) in channels_by_name.as_ref() {
8741                map.insert(name.clone(), Arc::clone(ch));
8742            }
8743        }
8744        if let Some(ref handle) = poll_handle_ch {
8745            let mut map = handle.write();
8746            for (name, ch) in channels_by_name.as_ref() {
8747                map.insert(name.clone(), Arc::clone(ch));
8748            }
8749        }
8750        if let Some(ref handle) = escalate_handle_ch {
8751            let mut map = handle.write();
8752            for (name, ch) in channels_by_name.as_ref() {
8753                map.insert(name.clone(), Arc::clone(ch));
8754            }
8755        }
8756
8757        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
8758        provider_cache_seed.insert(provider_name.clone(), Arc::clone(&model_provider));
8759        let message_timeout_secs =
8760            effective_channel_message_timeout_secs(config.channels.message_timeout_secs);
8761        let interrupt_on_new_message = config
8762            .channels
8763            .telegram
8764            .get("default")
8765            .is_some_and(|tg| tg.interrupt_on_new_message);
8766        let interrupt_on_new_message_slack = config
8767            .channels
8768            .slack
8769            .get("default")
8770            .is_some_and(|sl| sl.interrupt_on_new_message);
8771        let interrupt_on_new_message_discord = config
8772            .channels
8773            .discord
8774            .get("default")
8775            .is_some_and(|dc| dc.interrupt_on_new_message);
8776        let interrupt_on_new_message_mattermost = config
8777            .channels
8778            .mattermost
8779            .get("default")
8780            .is_some_and(|mm| mm.interrupt_on_new_message);
8781        let interrupt_on_new_message_matrix = config
8782            .channels
8783            .matrix
8784            .get("default")
8785            .is_some_and(|mx| mx.interrupt_on_new_message);
8786
8787        let memory_strategy: Arc<dyn MemoryStrategy> = Arc::new(
8788            zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
8789                Arc::clone(&mem),
8790                config.memory.clone(),
8791                config.data_dir.clone(),
8792            ),
8793        );
8794
8795        let runtime_ctx = Arc::new(ChannelRuntimeContext {
8796            channels_by_name: Arc::clone(&channels_by_name),
8797            model_provider: Arc::clone(&model_provider),
8798            model_provider_ref: Arc::new(provider_name.clone()),
8799            agent_alias: Arc::new(agent_alias.clone()),
8800            agent_cfg: Arc::new(agent.clone()),
8801            prompt_config: Arc::new(config.clone()),
8802            memory: Arc::clone(&mem),
8803            memory_strategy,
8804            tools_registry: Arc::clone(&tools_registry),
8805            observer: Arc::clone(&observer),
8806            system_prompt: Arc::new(system_prompt),
8807            model: Arc::new(model.clone()),
8808            temperature,
8809            auto_save_memory: config.memory.auto_save,
8810            max_tool_iterations: config.effective_max_tool_iterations(agent_alias.as_str()),
8811            min_relevance_score: config.memory.min_relevance_score,
8812            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
8813                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
8814            ))),
8815            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
8816            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
8817            route_overrides: Arc::new(Mutex::new(HashMap::new())),
8818            reliability: Arc::new(config.reliability.clone()),
8819            provider_runtime_options,
8820            // Use this agent's workspace (not the install-wide data dir): the
8821            // channel runtime context drives per-message skill reloads, prompt
8822            // refresh, and file-access scoping, all of which must resolve to the
8823            // same agent workspace that boot-time registration loads from.
8824            // Pointing at `config.data_dir` silently breaks per-message skill
8825            // activation (candidates load from `<data_dir>/skills`, which is
8826            // empty) and mis-scopes file tools.
8827            workspace_dir: Arc::new(workspace.clone()),
8828            message_timeout_secs,
8829            interrupt_on_new_message: InterruptOnNewMessageConfig {
8830                telegram: interrupt_on_new_message,
8831                slack: interrupt_on_new_message_slack,
8832                discord: interrupt_on_new_message_discord,
8833                mattermost: interrupt_on_new_message_mattermost,
8834                matrix: interrupt_on_new_message_matrix,
8835            },
8836            multimodal: config.multimodal.clone(),
8837            media_pipeline: config.media_pipeline.clone(),
8838            transcription_config: config.transcription.clone(),
8839            agent_transcription_provider: agent.transcription_provider.as_str().to_string(),
8840            hooks: if config.hooks.enabled {
8841                let mut runner = zeroclaw_runtime::hooks::HookRunner::new();
8842                if config.hooks.builtin.command_logger {
8843                    runner.register(Box::new(
8844                        zeroclaw_runtime::hooks::builtin::CommandLoggerHook::new(),
8845                    ));
8846                }
8847                if config.hooks.builtin.webhook_audit.enabled {
8848                    runner.register(Box::new(
8849                        zeroclaw_runtime::hooks::builtin::WebhookAuditHook::new(
8850                            config.hooks.builtin.webhook_audit.clone(),
8851                        ),
8852                    ));
8853                }
8854                Some(Arc::new(runner))
8855            } else {
8856                None
8857            },
8858            non_cli_excluded_tools: Arc::new(risk_profile.excluded_tools.clone()),
8859            autonomy_level: risk_profile.level,
8860            tool_call_dedup_exempt: Arc::new(agent.resolved.tool_call_dedup_exempt.clone()),
8861            model_routes: Arc::new(config.model_routes.clone()),
8862            query_classification: config.query_classification.clone(),
8863            ack_reactions: config.channels.ack_reactions,
8864            show_tool_calls: config.channels.show_tool_calls,
8865            session_store: shared_session_store.clone(),
8866            approval_manager: Arc::new(ApprovalManager::for_non_interactive(&risk_profile)),
8867            activated_tools: ch_activated_handle,
8868            cost_tracking: zeroclaw_runtime::cost::CostTracker::get_or_init_global(
8869                config.cost.clone(),
8870                &config.data_dir,
8871            )
8872            .map(|tracker| {
8873                // The cost tracker's lookup site (`record_tool_loop_cost_usage`
8874                // in zeroclaw-runtime) receives the bare provider type — the
8875                // composite alias isn't threaded through the agent loop. Build
8876                // the pricing map keyed by `<type>` and merge each alias's
8877                // `pricing` table into the type-level slot. Rates are per
8878                // (provider type, model); they don't differ between an
8879                // operator's `anthropic.work` and `anthropic.personal` keys.
8880                let mut by_type: std::collections::HashMap<
8881                    String,
8882                    std::collections::HashMap<String, f64>,
8883                > = std::collections::HashMap::new();
8884                for (type_k, _alias_k, profile) in config.providers.models.iter_entries() {
8885                    if profile.pricing.is_empty() {
8886                        continue;
8887                    }
8888                    let slot = by_type.entry(type_k.to_string()).or_default();
8889                    for (key, value) in &profile.pricing {
8890                        slot.insert(key.clone(), *value);
8891                    }
8892                }
8893                // Merge the `[cost.rates.providers.models.<type>.<model>]`
8894                // section. Keys land as `"<model>.input"` / `"<model>.output"`
8895                // / `"<model>.cached_input"` so the existing lookup
8896                // (`resolve_rates`) finds them with no further changes. The
8897                // rate sheet wins on conflict — it's the forward-looking
8898                // surface, the legacy per-alias `pricing` table is the
8899                // fallback for installs that haven't migrated.
8900                for (provider_type, model_id, rates) in
8901                    config.cost.rates.providers.models.iter_entries()
8902                {
8903                    let slot = by_type.entry(provider_type.to_string()).or_default();
8904                    if let Some(input) = rates.input_per_mtok {
8905                        slot.insert(format!("{model_id}.input"), input);
8906                    }
8907                    if let Some(output) = rates.output_per_mtok {
8908                        slot.insert(format!("{model_id}.output"), output);
8909                    }
8910                    if let Some(cached) = rates.cached_input_per_mtok {
8911                        slot.insert(format!("{model_id}.cached_input"), cached);
8912                    }
8913                }
8914                ChannelCostTrackingState {
8915                    tracker,
8916                    model_provider_pricing: Arc::new(by_type),
8917                    agent_alias: Arc::new(agent_alias.clone()),
8918                }
8919            }),
8920            pacing: config.pacing.clone(),
8921            max_tool_result_chars: agent.resolved.max_tool_result_chars,
8922            context_token_budget: agent.resolved.max_context_tokens,
8923            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
8924                Duration::from_millis(config.channels.debounce_ms),
8925            )),
8926            receipt_generator: if agent.resolved.tool_receipts.enabled {
8927                Some(zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator::new())
8928            } else {
8929                None
8930            },
8931            show_receipts_in_response: agent.resolved.tool_receipts.show_in_response,
8932            last_applied_config_stamp: Arc::new(Mutex::new(None)),
8933            runtime_defaults_override: Arc::new(Mutex::new(None)),
8934        });
8935
8936        agent_ctxs.insert(agent_alias.clone(), runtime_ctx);
8937    }
8938
8939    let owner_by_channel_key =
8940        build_owner_by_channel_key(&config, &enabled_agents, &collected_channel_keys);
8941
8942    // Hydrate persisted session histories into the owning agent's
8943    // `conversation_histories` LRU. Sessions whose channel has no enabled
8944    // owner are skipped so their history doesn't end up loaded into the
8945    // fallback agent (which wouldn't reply on that channel anyway).
8946    if let Some(ref store) = shared_session_store {
8947        let mut metadata = store.list_sessions_with_metadata();
8948        metadata.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
8949        // Budget proportional to the number of agents — each gets up to
8950        // `MAX_CONVERSATION_SENDERS` slots, so a multi-agent install
8951        // hydrates strictly more total sessions than a single-agent one.
8952        let cap = MAX_CONVERSATION_SENDERS.saturating_mul(enabled_agents.len().max(1));
8953        if metadata.len() > cap {
8954            metadata.truncate(cap);
8955        }
8956
8957        let mut hydrated = 0usize;
8958        let mut orphans_closed = 0usize;
8959        for m in metadata {
8960            let owner_agent = m
8961                .channel_id
8962                .as_deref()
8963                .and_then(|cid| owner_by_channel_key.get(cid).cloned())
8964                .or_else(|| {
8965                    m.channel_id
8966                        .as_deref()
8967                        .and_then(|cid| cid.split_once('.').map(|(b, _)| b.to_string()))
8968                        .and_then(|b| owner_by_channel_key.get(&b).cloned())
8969                });
8970            let target_ctx = match owner_agent.as_ref().and_then(|a| agent_ctxs.get(a)) {
8971                Some(ctx) => ctx,
8972                None => continue,
8973            };
8974            let mut msgs = store.load(&m.key);
8975            if msgs.is_empty() {
8976                continue;
8977            }
8978            if msgs.len() > MAX_CHANNEL_HISTORY {
8979                msgs.drain(..msgs.len() - MAX_CHANNEL_HISTORY);
8980            }
8981            if msgs.last().is_some_and(|msg| msg.role == "user") {
8982                let closure =
8983                    ChatMessage::assistant("[Session interrupted — not continuing this request]");
8984                if let Err(e) = store.append(&m.key, &closure) {
8985                    ::zeroclaw_log::record!(
8986                        DEBUG,
8987                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8988                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
8989                        &format!("Failed to persist orphan closure for {}", m.key)
8990                    );
8991                }
8992                msgs.push(closure);
8993                orphans_closed += 1;
8994            }
8995            let pruned =
8996                zeroclaw_runtime::agent::history_pruner::remove_orphaned_tool_messages(&mut msgs);
8997            if !pruned.is_empty() {
8998                ::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)");
8999            }
9000
9001            let mut histories = target_ctx
9002                .conversation_histories
9003                .lock()
9004                .unwrap_or_else(|e| e.into_inner());
9005            histories.push(m.key.clone(), msgs);
9006            drop(histories);
9007            hydrated += 1;
9008        }
9009        if hydrated > 0 {
9010            ::zeroclaw_log::record!(
9011                INFO,
9012                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
9013                    .with_attrs(::serde_json::json!({"hydrated": hydrated})),
9014                "restored sessions from disk"
9015            );
9016        }
9017        if orphans_closed > 0 {
9018            ::zeroclaw_log::record!(
9019                INFO,
9020                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
9021                    .with_attrs(::serde_json::json!({"orphans_closed": orphans_closed})),
9022                "closed orphaned session turns from previous crash"
9023            );
9024        }
9025    }
9026
9027    let router = AgentRouter::multi(agent_ctxs, owner_by_channel_key);
9028
9029    let rx = rx_holder.expect("rx initialized by first agent's channel setup");
9030    let max_in_flight =
9031        max_in_flight_messages.expect("max_in_flight initialized by first agent's channel setup");
9032    run_message_dispatch_loop(rx, router, max_in_flight).await;
9033
9034    for h in listener_handles {
9035        let _ = h.await;
9036    }
9037
9038    Ok(())
9039}
9040
9041/// Deliver a cron job announcement to a configured channel.
9042/// Scans for credential leaks before delivery.
9043///
9044/// `thread_id` is forwarded to channels whose outbound `thread_id` is distinct
9045/// from the recipient (notably the webhook channel, which serialises both into
9046/// the JSON callback). For channels that do not honour `thread_ts` it is a
9047/// harmless no-op.
9048pub async fn deliver_announcement(
9049    config: &zeroclaw_config::schema::Config,
9050    channel: &str,
9051    target: &str,
9052    thread_id: Option<String>,
9053    output: &str,
9054) -> anyhow::Result<()> {
9055    use zeroclaw_api::channel::SendMessage;
9056    let _ = config;
9057
9058    // Scan for credential leaks before delivering
9059    let leak_detector = zeroclaw_runtime::security::LeakDetector::new();
9060    let safe_output = match leak_detector.scan(output) {
9061        zeroclaw_runtime::security::LeakResult::Detected { redacted, .. } => redacted,
9062        zeroclaw_runtime::security::LeakResult::Clean => output.to_string(),
9063    };
9064
9065    let make_msg = |s: &str| SendMessage::new(s, target).in_thread(thread_id.clone());
9066
9067    // Snapshot out of the sync RwLock before awaiting. Use the live
9068    // channel instance when available — critical for Matrix E2EE which
9069    // must reuse the authenticated client rather than re-running session
9070    // restore per delivery.
9071    let registry_snapshot = CRON_CHANNEL_REGISTRY
9072        .read()
9073        .unwrap_or_else(|e| e.into_inner())
9074        .clone();
9075    if let Some(registry) = registry_snapshot
9076        && let Some(ch) = registry.get(channel.to_ascii_lowercase().as_str())
9077    {
9078        return ch.send(&make_msg(&safe_output)).await;
9079    }
9080
9081    let (raw_type, alias) = channel.split_once('.').ok_or_else(|| {
9082        anyhow::Error::msg(format!(
9083            "delivery channel {channel:?} must be a dotted <type>.<alias> ref (e.g. telegram.work)"
9084        ))
9085    })?;
9086    let channel_type = raw_type.to_ascii_lowercase();
9087    #[allow(unused_variables)]
9088    let not_configured = || {
9089        ::zeroclaw_log::record!(
9090            ERROR,
9091            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
9092                .with_outcome(::zeroclaw_log::EventOutcome::Failure),
9093            &format!("[channels.{channel_type}.{alias}] not configured")
9094        );
9095        anyhow::Error::msg(format!("[channels.{channel_type}.{alias}] not configured"))
9096    };
9097    match channel_type.as_str() {
9098        #[cfg(feature = "channel-telegram")]
9099        "telegram" => {
9100            let tg = config
9101                .channels
9102                .telegram
9103                .get(alias)
9104                .ok_or_else(not_configured)?;
9105            let peers = config.channel_external_peers("telegram", alias);
9106            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
9107                Arc::new(move || peers.clone());
9108            let ch =
9109                TelegramChannel::new(tg.bot_token.clone(), alias, peer_resolver, tg.mention_only);
9110            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
9111        }
9112        #[cfg(not(feature = "channel-telegram"))]
9113        "telegram" => {
9114            anyhow::bail!("Telegram channel requires the `channel-telegram` feature");
9115        }
9116        #[cfg(feature = "channel-discord")]
9117        "discord" => {
9118            let dc = config
9119                .channels
9120                .discord
9121                .get(alias)
9122                .ok_or_else(not_configured)?;
9123            let peers = config.channel_external_peers("discord", alias);
9124            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
9125                Arc::new(move || peers.clone());
9126            let ch = DiscordChannel::new(
9127                dc.bot_token.clone(),
9128                dc.guild_ids.clone(),
9129                alias,
9130                peer_resolver,
9131                dc.listen_to_bots,
9132                dc.mention_only,
9133            )
9134            .with_channel_ids(dc.channel_ids.clone())
9135            .with_workspace_dir(config.channel_workspace_dir(channel));
9136            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
9137        }
9138        #[cfg(not(feature = "channel-discord"))]
9139        "discord" => {
9140            anyhow::bail!("Discord channel requires the `channel-discord` feature");
9141        }
9142        #[cfg(feature = "channel-slack")]
9143        "slack" => {
9144            let sl = config
9145                .channels
9146                .slack
9147                .get(alias)
9148                .ok_or_else(not_configured)?;
9149            let peers = config.channel_external_peers("slack", alias);
9150            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
9151                Arc::new(move || peers.clone());
9152            let ch = SlackChannel::new(
9153                sl.bot_token.clone(),
9154                sl.app_token.clone(),
9155                sl.channel_ids.clone(),
9156                alias,
9157                peer_resolver,
9158            )
9159            .with_workspace_dir(config.channel_workspace_dir(channel));
9160            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
9161        }
9162        #[cfg(not(feature = "channel-slack"))]
9163        "slack" => {
9164            anyhow::bail!("Slack channel requires the `channel-slack` feature");
9165        }
9166        #[cfg(feature = "channel-signal")]
9167        "signal" => {
9168            let sg = config
9169                .channels
9170                .signal
9171                .get(alias)
9172                .ok_or_else(not_configured)?;
9173            let peers = config.channel_external_peers("signal", alias);
9174            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
9175                Arc::new(move || peers.clone());
9176            let ch = SignalChannel::new(
9177                sg.http_url.clone(),
9178                sg.account.clone(),
9179                sg.group_ids.clone(),
9180                sg.dm_only,
9181                alias,
9182                peer_resolver,
9183                sg.ignore_attachments,
9184                sg.ignore_stories,
9185            );
9186            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
9187        }
9188        #[cfg(not(feature = "channel-signal"))]
9189        "signal" => {
9190            anyhow::bail!("Signal channel requires the `channel-signal` feature");
9191        }
9192        #[cfg(feature = "channel-wechat")]
9193        "wechat" => {
9194            let wc = config
9195                .channels
9196                .wechat
9197                .get(alias)
9198                .ok_or_else(not_configured)?;
9199            let peers = config.channel_external_peers("wechat", alias);
9200            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
9201                Arc::new(move || peers.clone());
9202            let ch = WeChatChannel::new(
9203                alias,
9204                peer_resolver,
9205                wc.api_base_url.clone(),
9206                wc.cdn_base_url.clone(),
9207                wc.state_dir.as_ref().map(std::path::PathBuf::from),
9208            )?
9209            .with_workspace_dir(config.channel_workspace_dir(channel));
9210            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
9211        }
9212        #[cfg(not(feature = "channel-wechat"))]
9213        "wechat" => {
9214            anyhow::bail!("WeChat channel requires the `channel-wechat` feature");
9215        }
9216        #[cfg(feature = "channel-lark")]
9217        "lark" | "feishu" => {
9218            // [channels.lark.<alias>] is the single source of truth for both
9219            // names (AGENTS.md). from_config selects the endpoint via
9220            // use_feishu. Error text names the real config table, not the
9221            // cron alias the user wrote.
9222            let lk = config.channels.lark.get(alias).ok_or_else(|| {
9223                ::zeroclaw_log::record!(
9224                    ERROR,
9225                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
9226                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
9227                    &format!(
9228                        "[channels.lark.{alias}] not configured (cron channel \"{channel_type}.{alias}\")"
9229                    )
9230                );
9231                anyhow::Error::msg(format!(
9232                    "[channels.lark.{alias}] not configured (cron channel \"{channel_type}.{alias}\")"
9233                ))
9234            })?;
9235            // Asymmetric by design: "feishu"+use_feishu=false is a typo
9236            // (hard fail). "lark"+use_feishu=true is a soft compat path
9237            // (warn but still deliver via fallback construction).
9238            if channel_type == "feishu" && !lk.use_feishu {
9239                anyhow::bail!(
9240                    "[channels.lark.{alias}] has use_feishu=false but cron channel=\"feishu.{alias}\"; \
9241                     use channel=\"lark.{alias}\" or set use_feishu=true"
9242                );
9243            }
9244            if channel_type == "lark" && lk.use_feishu {
9245                ::zeroclaw_log::record!(
9246                    WARN,
9247                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
9248                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
9249                    &format!(
9250                        "cron channel=\"lark.{alias}\" with [channels.lark.{alias}] use_feishu=true \
9251                         falls back to one-shot channel construction; prefer channel=\"feishu.{alias}\" \
9252                         to reuse the live Feishu handle from start_channels"
9253                    )
9254                );
9255            }
9256            let peers = config.channel_external_peers("lark", alias);
9257            let peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync> =
9258                Arc::new(move || peers.clone());
9259            let ch = LarkChannel::from_config(lk, alias, peer_resolver)
9260                .with_approval_timeout_secs(lk.approval_timeout_secs)
9261                .with_per_user_session(lk.per_user_session)
9262                .with_streaming(lk.stream_mode, lk.draft_update_interval_ms);
9263            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
9264        }
9265        #[cfg(not(feature = "channel-lark"))]
9266        "lark" | "feishu" => {
9267            anyhow::bail!("Lark channel requires the `channel-lark` feature");
9268        }
9269        #[cfg(feature = "channel-webhook")]
9270        "webhook" => {
9271            let wh = config
9272                .channels
9273                .webhook
9274                .get(alias)
9275                .ok_or_else(not_configured)?;
9276            let ch = WebhookChannel::new(
9277                alias.to_string(),
9278                wh.port,
9279                wh.listen_path.clone(),
9280                wh.send_url.clone(),
9281                wh.send_method.clone(),
9282                wh.auth_header.clone(),
9283                wh.secret.clone(),
9284                wh.max_retries,
9285                wh.retry_base_delay_ms,
9286                wh.retry_max_delay_ms,
9287            );
9288            zeroclaw_api::channel::Channel::send(&ch, &make_msg(&safe_output)).await?;
9289        }
9290        #[cfg(not(feature = "channel-webhook"))]
9291        "webhook" => {
9292            anyhow::bail!("Webhook channel requires the `channel-webhook` feature");
9293        }
9294        "wecom_ws" | "wecom-ws" => {
9295            let _ = config
9296                .channels
9297                .wecom_ws
9298                .get(alias)
9299                .ok_or_else(not_configured)?;
9300            anyhow::bail!("wecom_ws channel is not connected");
9301        }
9302        other => anyhow::bail!("unsupported delivery channel: {other}"),
9303    }
9304    #[allow(unreachable_code)]
9305    Ok(())
9306}
9307
9308#[cfg(feature = "channel-wechat")]
9309fn expand_tilde_in_path(path: &str) -> PathBuf {
9310    PathBuf::from(shellexpand::tilde(path).as_ref())
9311}
9312
9313#[cfg(test)]
9314mod tests {
9315    use super::*;
9316    use std::collections::{HashMap, HashSet};
9317    use std::sync::Arc;
9318    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9319    use tempfile::TempDir;
9320    use zeroclaw_memory::{Memory, MemoryCategory, SqliteMemory};
9321    use zeroclaw_providers::{ChatMessage, ModelProvider};
9322    use zeroclaw_runtime::agent::loop_::build_tool_instructions;
9323
9324    #[test]
9325    fn no_real_time_channels_message_points_at_quickstart_not_onboard() {
9326        // The "no channels configured" message must point operators at the
9327        // current command (zeroclaw quickstart), not the deleted `zeroclaw onboard`.
9328        // Source of truth: the string at orchestrator/mod.rs:~7376.
9329        let msg = super::no_real_time_channels_message();
9330        assert!(
9331            !msg.contains("zeroclaw onboard"),
9332            "stale `zeroclaw onboard` reference in message: {msg}"
9333        );
9334        assert!(
9335            msg.contains("zeroclaw quickstart"),
9336            "expected `zeroclaw quickstart` reference, got: {msg}"
9337        );
9338    }
9339
9340    #[tokio::test]
9341    async fn channel_runtime_reload_applies_env_overrides_after_migration() {
9342        let tmp = TempDir::new().unwrap();
9343        let config_path = tmp.path().join("config.toml");
9344        std::fs::write(
9345            &config_path,
9346            r#"
9347default_provider = "openrouter"
9348
9349[model_providers.openrouter]
9350name = "openrouter"
9351
9352[agents.demo]
9353provider = "openrouter"
9354model = "meta-llama/llama-3.1-8b-instruct"
9355temperature = 0.3
9356"#,
9357        )
9358        .unwrap();
9359
9360        let env_name = "ZEROCLAW_providers__models__openrouter__agent_demo__api_key";
9361        // SAFETY: this test owns this specific env-var key and restores it
9362        // before returning. The value is synthetic and not a real credential.
9363        unsafe { std::env::set_var(env_name, "sk-or-v1-test-channel-reload") };
9364
9365        let result = load_runtime_config_and_defaults(&config_path, "demo").await;
9366
9367        // SAFETY: undo the test-only process env mutation above.
9368        unsafe { std::env::remove_var(env_name) };
9369
9370        let (config, defaults) = result.unwrap();
9371        assert_eq!(
9372            defaults.api_key.as_deref(),
9373            Some("sk-or-v1-test-channel-reload")
9374        );
9375        assert!(
9376            config
9377                .env_overridden_paths
9378                .contains("providers.models.openrouter.agent_demo.api_key")
9379        );
9380    }
9381
9382    use zeroclaw_runtime::observability::NoopObserver;
9383    use zeroclaw_runtime::tools::{Tool, ToolResult};
9384
9385    fn make_workspace() -> TempDir {
9386        let tmp = TempDir::new().unwrap();
9387        // Create minimal workspace files
9388        std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap();
9389        std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: ZeroClaw").unwrap();
9390        std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap();
9391        std::fs::write(
9392            tmp.path().join("AGENTS.md"),
9393            "# Agents\nFollow instructions.",
9394        )
9395        .unwrap();
9396        std::fs::write(tmp.path().join("TOOLS.md"), "# Tools\nUse shell carefully.").unwrap();
9397        std::fs::write(
9398            tmp.path().join("HEARTBEAT.md"),
9399            "# Heartbeat\nCheck status.",
9400        )
9401        .unwrap();
9402        std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap();
9403        tmp
9404    }
9405
9406    /// Minimal mock Channel returning a configurable `name()` so the
9407    /// channel-registry routing tests can simulate two aliases of the
9408    /// same channel type without pulling in real platform SDKs.
9409    /// Identity is checked via `Arc::ptr_eq`, not by inspecting fields.
9410    struct NamedMockChannel {
9411        name: &'static str,
9412    }
9413
9414    impl ::zeroclaw_api::attribution::Attributable for NamedMockChannel {
9415        fn role(&self) -> ::zeroclaw_api::attribution::Role {
9416            ::zeroclaw_api::attribution::Role::Channel(
9417                ::zeroclaw_api::attribution::ChannelKind::Webhook,
9418            )
9419        }
9420        fn alias(&self) -> &str {
9421            "test"
9422        }
9423    }
9424
9425    #[async_trait::async_trait]
9426    impl Channel for NamedMockChannel {
9427        fn name(&self) -> &str {
9428            self.name
9429        }
9430        async fn send(&self, _message: &zeroclaw_api::channel::SendMessage) -> anyhow::Result<()> {
9431            Ok(())
9432        }
9433        async fn listen(
9434            &self,
9435            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
9436        ) -> anyhow::Result<()> {
9437            Ok(())
9438        }
9439    }
9440
9441    fn mock_channel(name: &'static str) -> Arc<dyn Channel> {
9442        Arc::new(NamedMockChannel { name })
9443    }
9444
9445    struct MentionMockChannel {
9446        name: &'static str,
9447        mention: &'static str,
9448    }
9449
9450    impl ::zeroclaw_api::attribution::Attributable for MentionMockChannel {
9451        fn role(&self) -> ::zeroclaw_api::attribution::Role {
9452            ::zeroclaw_api::attribution::Role::Channel(
9453                ::zeroclaw_api::attribution::ChannelKind::Discord,
9454            )
9455        }
9456        fn alias(&self) -> &str {
9457            "test"
9458        }
9459    }
9460
9461    #[async_trait::async_trait]
9462    impl Channel for MentionMockChannel {
9463        fn name(&self) -> &str {
9464            self.name
9465        }
9466        fn self_addressed_mention(&self) -> Option<String> {
9467            Some(self.mention.to_string())
9468        }
9469        async fn send(&self, _message: &zeroclaw_api::channel::SendMessage) -> anyhow::Result<()> {
9470            Ok(())
9471        }
9472        async fn listen(
9473            &self,
9474            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
9475        ) -> anyhow::Result<()> {
9476            Ok(())
9477        }
9478    }
9479
9480    fn mention_mock(name: &'static str, mention: &'static str) -> Arc<dyn Channel> {
9481        Arc::new(MentionMockChannel { name, mention })
9482    }
9483
9484    fn channel_message(
9485        channel: &str,
9486        alias: Option<&str>,
9487    ) -> zeroclaw_api::channel::ChannelMessage {
9488        zeroclaw_api::channel::ChannelMessage {
9489            id: "m1".into(),
9490            sender: "u1".into(),
9491            reply_target: "r1".into(),
9492            content: "hi".into(),
9493            channel: channel.into(),
9494            channel_alias: alias.map(|s| s.to_string()),
9495            timestamp: 0,
9496            thread_ts: None,
9497            interruption_scope_id: None,
9498            attachments: vec![],
9499            subject: None,
9500        }
9501    }
9502
9503    #[test]
9504    fn composite_channel_key_aliased_uses_dotted_form() {
9505        assert_eq!(
9506            composite_channel_key("discord", Some("clamps")),
9507            "discord.clamps"
9508        );
9509        assert_eq!(
9510            composite_channel_key("telegram", Some("default")),
9511            "telegram.default"
9512        );
9513    }
9514
9515    #[test]
9516    fn composite_channel_key_unaliased_uses_bare_name() {
9517        assert_eq!(composite_channel_key("notion", None), "notion");
9518        // Empty-string alias collapses to bare name so we never produce a
9519        // `discord.` key that no message would ever match.
9520        assert_eq!(composite_channel_key("discord", Some("")), "discord");
9521    }
9522
9523    #[test]
9524    fn find_channel_for_message_resolves_by_composite_key_for_multi_alias() {
9525        // Two Discord bots in the registry: only the composite key
9526        // distinguishes them. Without this, the second insertion silently
9527        // overwrites the first via `name()` collision — the bug that left
9528        // one Discord agent unresponsive on multi-bot configs.
9529        let clamps = mock_channel("discord");
9530        let glados = mock_channel("discord");
9531        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
9532        channels.insert("discord.clamps".to_string(), Arc::clone(&clamps));
9533        channels.insert("discord.glados".to_string(), Arc::clone(&glados));
9534
9535        let msg_clamps = channel_message("discord", Some("clamps"));
9536        let msg_glados = channel_message("discord", Some("glados"));
9537
9538        let resolved_clamps = find_channel_for_message(&channels, &msg_clamps).expect("clamps");
9539        let resolved_glados = find_channel_for_message(&channels, &msg_glados).expect("glados");
9540
9541        assert!(Arc::ptr_eq(resolved_clamps, &clamps), "clamps lookup");
9542        assert!(Arc::ptr_eq(resolved_glados, &glados), "glados lookup");
9543        // Sanity: the two pointers are actually different.
9544        assert!(!Arc::ptr_eq(&clamps, &glados));
9545    }
9546
9547    #[test]
9548    fn aliased_inbound_emits_per_alias_mention_in_prompt() {
9549        let clamps = mention_mock("discord", "<@111>");
9550        let glados = mention_mock("discord", "<@222>");
9551        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
9552        channels.insert("discord.clamps".into(), Arc::clone(&clamps));
9553        channels.insert("discord.glados".into(), Arc::clone(&glados));
9554
9555        let msg_glados = channel_message("discord", Some("glados"));
9556        let target_glados = find_channel_for_message(&channels, &msg_glados).cloned();
9557        let prompt_glados =
9558            build_channel_system_prompt_for_message("Base.", &msg_glados, target_glados.as_ref());
9559        assert!(
9560            prompt_glados.contains("<@222>"),
9561            "glados prompt missing its own mention: {prompt_glados}"
9562        );
9563        assert!(
9564            !prompt_glados.contains("<@111>"),
9565            "glados prompt leaked the peer's mention: {prompt_glados}"
9566        );
9567
9568        let msg_clamps = channel_message("discord", Some("clamps"));
9569        let target_clamps = find_channel_for_message(&channels, &msg_clamps).cloned();
9570        let prompt_clamps =
9571            build_channel_system_prompt_for_message("Base.", &msg_clamps, target_clamps.as_ref());
9572        assert!(
9573            prompt_clamps.contains("<@111>"),
9574            "clamps prompt missing its own mention: {prompt_clamps}"
9575        );
9576        assert!(
9577            !prompt_clamps.contains("<@222>"),
9578            "clamps prompt leaked the peer's mention: {prompt_clamps}"
9579        );
9580    }
9581
9582    #[test]
9583    fn unaliased_inbound_with_no_self_handle_omits_mention_block() {
9584        let webhook = mock_channel("webhook");
9585        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
9586        channels.insert("webhook".into(), Arc::clone(&webhook));
9587
9588        let msg = channel_message("webhook", None);
9589        let target = find_channel_for_message(&channels, &msg).cloned();
9590        let prompt = build_channel_system_prompt_for_message("Base.", &msg, target.as_ref());
9591
9592        assert!(
9593            target.is_some(),
9594            "registry must resolve the webhook channel"
9595        );
9596        assert!(
9597            !prompt.contains("addressable handle on this channel"),
9598            "channels without self_addressed_mention must not emit the block: {prompt}"
9599        );
9600    }
9601
9602    #[test]
9603    fn unresolved_channel_omits_mention_block() {
9604        let channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
9605        let msg = channel_message("discord", Some("ghost"));
9606        let target = find_channel_for_message(&channels, &msg).cloned();
9607        let prompt = build_channel_system_prompt_for_message("Base.", &msg, target.as_ref());
9608
9609        assert!(target.is_none());
9610        assert!(!prompt.contains("addressable handle on this channel"));
9611    }
9612
9613    #[test]
9614    fn find_channel_for_message_falls_back_to_bare_name_when_no_alias_supplied() {
9615        // Legacy inbound (or singleton channel) with `channel_alias = None`
9616        // still resolves via the bare-name slot — the registry builder
9617        // populates it for single-alias platforms so cron callers and
9618        // outbound-only channels keep working.
9619        let webhook = mock_channel("webhook");
9620        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
9621        channels.insert("webhook".to_string(), Arc::clone(&webhook));
9622
9623        let msg = channel_message("webhook", None);
9624        let resolved = find_channel_for_message(&channels, &msg).expect("webhook");
9625        assert!(Arc::ptr_eq(resolved, &webhook));
9626    }
9627
9628    #[test]
9629    fn find_channel_for_message_falls_back_to_base_for_room_qualifier() {
9630        // Multi-room channels (Matrix) deliver inbound messages with
9631        // `channel = "matrix:!roomId"`. The registry key is bare `matrix`;
9632        // the helper splits on `:` and resolves the base channel.
9633        let matrix = mock_channel("matrix");
9634        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
9635        channels.insert("matrix".to_string(), Arc::clone(&matrix));
9636
9637        let msg = channel_message("matrix:!room1:example.org", None);
9638        let resolved = find_channel_for_message(&channels, &msg).expect("matrix");
9639        assert!(Arc::ptr_eq(resolved, &matrix));
9640    }
9641
9642    /// Build a minimal `ChannelRuntimeContext` suitable only for identity
9643    /// checks (`Arc::ptr_eq`). Every dependency is a no-op default — these
9644    /// ctxs aren't usable for actually running the dispatch loop.
9645    fn router_test_ctx() -> Arc<ChannelRuntimeContext> {
9646        Arc::new(ChannelRuntimeContext {
9647            channels_by_name: Arc::new(HashMap::new()),
9648            model_provider: Arc::new(DummyModelProvider),
9649            model_provider_ref: Arc::new("test-provider".to_string()),
9650            agent_alias: Arc::new("test-agent".to_string()),
9651            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
9652            memory: Arc::new(NoopMemory),
9653            memory_strategy: Arc::new(
9654                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
9655                    Arc::new(NoopMemory),
9656                    zeroclaw_config::schema::MemoryConfig::default(),
9657                    std::path::PathBuf::new(),
9658                ),
9659            ),
9660            tools_registry: Arc::new(vec![]),
9661            observer: Arc::new(NoopObserver),
9662            system_prompt: Arc::new(String::new()),
9663            model: Arc::new("test-model".to_string()),
9664            temperature: Some(0.0),
9665            auto_save_memory: false,
9666            max_tool_iterations: 0,
9667            min_relevance_score: 0.0,
9668            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
9669                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
9670            ))),
9671            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
9672            provider_cache: Arc::new(Mutex::new(HashMap::new())),
9673            route_overrides: Arc::new(Mutex::new(HashMap::new())),
9674            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
9675            interrupt_on_new_message: InterruptOnNewMessageConfig {
9676                telegram: false,
9677                slack: false,
9678                discord: false,
9679                mattermost: false,
9680                matrix: false,
9681            },
9682            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
9683            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
9684            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
9685            agent_transcription_provider: String::new(),
9686            hooks: None,
9687            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
9688            workspace_dir: Arc::new(std::env::temp_dir()),
9689            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
9690            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
9691            non_cli_excluded_tools: Arc::new(Vec::new()),
9692            autonomy_level: AutonomyLevel::default(),
9693            tool_call_dedup_exempt: Arc::new(Vec::new()),
9694            model_routes: Arc::new(Vec::new()),
9695            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
9696            ack_reactions: true,
9697            show_tool_calls: true,
9698            session_store: None,
9699            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
9700                &zeroclaw_config::schema::RiskProfileConfig::default(),
9701            )),
9702            activated_tools: None,
9703            cost_tracking: None,
9704            pacing: zeroclaw_config::schema::PacingConfig::default(),
9705            max_tool_result_chars: 0,
9706            context_token_budget: 0,
9707            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
9708                Duration::ZERO,
9709            )),
9710            receipt_generator: None,
9711            show_receipts_in_response: false,
9712            last_applied_config_stamp: Arc::new(Mutex::new(None)),
9713            runtime_defaults_override: Arc::new(Mutex::new(None)),
9714        })
9715    }
9716
9717    #[tokio::test]
9718    async fn resolve_classifier_route_returns_none_for_empty_ref() {
9719        let ctx = router_test_ctx();
9720        let empty = zeroclaw_config::providers::ModelProviderRef::default();
9721        let result = resolve_classifier_route(
9722            ctx.as_ref(),
9723            &empty,
9724            &runtime_defaults_snapshot(ctx.as_ref()),
9725        )
9726        .await;
9727        assert!(result.is_none(), "empty ref must fall back to main agent");
9728    }
9729
9730    #[tokio::test]
9731    async fn resolve_classifier_route_returns_none_for_unresolvable_ref() {
9732        let ctx = router_test_ctx();
9733        let bogus = zeroclaw_config::providers::ModelProviderRef::from("custom.does-not-exist");
9734        let result = resolve_classifier_route(
9735            ctx.as_ref(),
9736            &bogus,
9737            &runtime_defaults_snapshot(ctx.as_ref()),
9738        )
9739        .await;
9740        assert!(result.is_none(), "unresolvable ref must soft-fail to None");
9741    }
9742
9743    #[tokio::test]
9744    async fn resolve_classifier_route_returns_alias_temperature() {
9745        // Build a config where `openai.my-classifier` has `temperature = 0.0`.
9746        let mut cfg = zeroclaw_config::schema::Config::default();
9747        cfg.providers.models.openai.insert(
9748            "my-classifier".to_string(),
9749            zeroclaw_config::schema::OpenAIModelProviderConfig {
9750                base: zeroclaw_config::schema::ModelProviderConfig {
9751                    model: Some("gpt-4o-mini".to_string()),
9752                    temperature: Some(0.0),
9753                    ..Default::default()
9754                },
9755            },
9756        );
9757
9758        let base_ctx = (*router_test_ctx()).clone();
9759        let ctx = Arc::new(ChannelRuntimeContext {
9760            prompt_config: Arc::new(cfg),
9761            ..base_ctx
9762        });
9763
9764        let alias_ref = zeroclaw_config::providers::ModelProviderRef::from("openai.my-classifier");
9765        let result = resolve_classifier_route(
9766            ctx.as_ref(),
9767            &alias_ref,
9768            &runtime_defaults_snapshot(ctx.as_ref()),
9769        )
9770        .await;
9771
9772        let (_, _, temp) = result.expect("must resolve to alias");
9773        assert_eq!(
9774            temp,
9775            Some(0.0),
9776            "alias temperature must be returned, not runtime_defaults.temperature"
9777        );
9778    }
9779
9780    fn seed_sender_history(ctx: &ChannelRuntimeContext, sender: &str, turns: Vec<ChatMessage>) {
9781        let mut histories = ctx
9782            .conversation_histories
9783            .lock()
9784            .unwrap_or_else(|e| e.into_inner());
9785        histories.push(sender.to_string(), turns);
9786    }
9787
9788    fn cloned_sender_history(ctx: &ChannelRuntimeContext, sender: &str) -> Vec<ChatMessage> {
9789        let histories = ctx
9790            .conversation_histories
9791            .lock()
9792            .unwrap_or_else(|e| e.into_inner());
9793        histories.peek(sender).cloned().unwrap_or_default()
9794    }
9795
9796    fn history_signature(turns: &[ChatMessage]) -> Vec<(String, String)> {
9797        turns
9798            .iter()
9799            .map(|turn| (turn.role.clone(), turn.content.clone()))
9800            .collect()
9801    }
9802
9803    #[test]
9804    fn agent_router_multi_routes_each_alias_to_its_owning_agent() {
9805        // Two enabled agents, each owning one Discord bot. A message tagged
9806        // with `channel_alias = "clamps"` must resolve to clamps' ctx; the
9807        // same channel name with `"glados"` must resolve to glados' ctx.
9808        // This is the exact behavior that was broken before per-agent ctxs:
9809        // both bots' inbound messages used to land in one shared agent's
9810        // pipeline and reply with that agent's identity/model.
9811        let clamps_ctx = router_test_ctx();
9812        let glados_ctx = router_test_ctx();
9813        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
9814        by_agent.insert("clamps".to_string(), Arc::clone(&clamps_ctx));
9815        by_agent.insert("glados".to_string(), Arc::clone(&glados_ctx));
9816        let mut owners: HashMap<String, String> = HashMap::new();
9817        owners.insert("discord.clamps".to_string(), "clamps".to_string());
9818        owners.insert("discord.glados".to_string(), "glados".to_string());
9819        let router = AgentRouter::multi(by_agent, owners);
9820
9821        let msg_clamps = channel_message("discord", Some("clamps"));
9822        let msg_glados = channel_message("discord", Some("glados"));
9823
9824        let resolved_clamps = router.resolve(&msg_clamps).expect("clamps resolves");
9825        let resolved_glados = router.resolve(&msg_glados).expect("glados resolves");
9826
9827        assert!(Arc::ptr_eq(&resolved_clamps, &clamps_ctx), "clamps routing");
9828        assert!(Arc::ptr_eq(&resolved_glados, &glados_ctx), "glados routing");
9829        assert!(
9830            !Arc::ptr_eq(&resolved_clamps, &resolved_glados),
9831            "ctxs distinct"
9832        );
9833    }
9834
9835    #[test]
9836    fn agent_router_multi_returns_none_for_unowned_channels() {
9837        let agent_a_ctx = router_test_ctx();
9838        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
9839        by_agent.insert("agent_a".to_string(), Arc::clone(&agent_a_ctx));
9840        let mut owners: HashMap<String, String> = HashMap::new();
9841        owners.insert("discord.bot_a".to_string(), "agent_a".to_string());
9842        let router = AgentRouter::multi(by_agent, owners);
9843
9844        let cli_msg = channel_message("cli", None);
9845        assert!(router.resolve(&cli_msg).is_none(), "cli has no owner");
9846    }
9847
9848    #[test]
9849    fn agent_router_multi_resolves_bare_channel_for_singleton_owners() {
9850        let notion_agent_ctx = router_test_ctx();
9851        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
9852        by_agent.insert("ops".to_string(), Arc::clone(&notion_agent_ctx));
9853        let mut owners: HashMap<String, String> = HashMap::new();
9854        owners.insert("notion".to_string(), "ops".to_string());
9855        let router = AgentRouter::multi(by_agent, owners);
9856
9857        let msg = channel_message("notion", None);
9858        let resolved = router.resolve(&msg).expect("notion resolves");
9859        assert!(Arc::ptr_eq(&resolved, &notion_agent_ctx));
9860    }
9861
9862    #[test]
9863    fn agent_router_multi_resolves_fallback_loaded_channel_to_legacy_agent() {
9864        let mut config = Config::default();
9865        config.agents.clear();
9866        config.agents.insert(
9867            "legacy".to_string(),
9868            zeroclaw_config::schema::AliasedAgentConfig {
9869                enabled: true,
9870                channels: vec![],
9871                ..Default::default()
9872            },
9873        );
9874        let enabled_agents = vec!["legacy".to_string()];
9875        let collected_channel_keys = vec!["mattermost.default".to_string()];
9876        let owners = build_owner_by_channel_key(&config, &enabled_agents, &collected_channel_keys);
9877
9878        let legacy_ctx = router_test_ctx();
9879        let mut by_agent: HashMap<String, Arc<ChannelRuntimeContext>> = HashMap::new();
9880        by_agent.insert("legacy".to_string(), Arc::clone(&legacy_ctx));
9881        let router = AgentRouter::multi(by_agent, owners);
9882
9883        let msg = channel_message("mattermost", Some("default"));
9884        let resolved = router.resolve(&msg).expect("fallback owner resolves");
9885        assert!(Arc::ptr_eq(&resolved, &legacy_ctx));
9886    }
9887
9888    #[test]
9889    fn build_owner_by_channel_key_legacy_fallback_is_deterministic_without_default() {
9890        let mut config = Config::default();
9891        config.agents.clear();
9892        config.agents.insert(
9893            "zeta".to_string(),
9894            zeroclaw_config::schema::AliasedAgentConfig {
9895                enabled: true,
9896                channels: vec![],
9897                ..Default::default()
9898            },
9899        );
9900        config.agents.insert(
9901            "alpha".to_string(),
9902            zeroclaw_config::schema::AliasedAgentConfig {
9903                enabled: true,
9904                channels: vec![],
9905                ..Default::default()
9906            },
9907        );
9908
9909        let enabled_agents = vec!["alpha".to_string(), "zeta".to_string()];
9910        let collected_channel_keys = vec!["mattermost.default".to_string()];
9911        let owners = build_owner_by_channel_key(&config, &enabled_agents, &collected_channel_keys);
9912
9913        assert_eq!(
9914            owners.get("mattermost.default").map(String::as_str),
9915            Some("alpha")
9916        );
9917        assert_eq!(owners.get("mattermost").map(String::as_str), Some("alpha"));
9918    }
9919
9920    #[test]
9921    fn find_channel_for_message_returns_none_when_alias_unknown() {
9922        // A message tagged with an alias that isn't registered must not
9923        // accidentally fall through to a different bot's handle — silent
9924        // misrouting is exactly what the original collision bug caused.
9925        let clamps = mock_channel("discord");
9926        let mut channels: HashMap<String, Arc<dyn Channel>> = HashMap::new();
9927        channels.insert("discord.clamps".to_string(), Arc::clone(&clamps));
9928
9929        // No bare `discord` key and no `discord.ghost` key — lookup must fail.
9930        let msg = channel_message("discord", Some("ghost"));
9931        assert!(find_channel_for_message(&channels, &msg).is_none());
9932    }
9933
9934    #[test]
9935    fn effective_channel_message_timeout_secs_clamps_to_minimum() {
9936        assert_eq!(
9937            effective_channel_message_timeout_secs(0),
9938            MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
9939        );
9940        assert_eq!(
9941            effective_channel_message_timeout_secs(15),
9942            MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
9943        );
9944        assert_eq!(effective_channel_message_timeout_secs(300), 300);
9945    }
9946
9947    #[test]
9948    fn compute_max_in_flight_messages_uses_configured_per_channel_budget() {
9949        assert_eq!(compute_max_in_flight_messages(3, 4), 12);
9950        assert_eq!(compute_max_in_flight_messages(3, 8), 24);
9951    }
9952
9953    #[test]
9954    fn max_in_flight_messages_for_config_uses_channel_budget() {
9955        let config = zeroclaw_config::schema::ChannelsConfig {
9956            max_concurrent_per_channel: 8,
9957            ..Default::default()
9958        };
9959
9960        assert_eq!(max_in_flight_messages_for_config(3, &config), 24);
9961    }
9962
9963    #[test]
9964    fn compute_max_in_flight_messages_preserves_global_bounds() {
9965        assert_eq!(
9966            compute_max_in_flight_messages(1, 1),
9967            CHANNEL_MIN_IN_FLIGHT_MESSAGES
9968        );
9969        assert_eq!(
9970            compute_max_in_flight_messages(100, 4),
9971            CHANNEL_MAX_IN_FLIGHT_MESSAGES
9972        );
9973    }
9974
9975    #[test]
9976    fn channel_message_timeout_budget_scales_with_tool_iterations() {
9977        assert_eq!(channel_message_timeout_budget_secs(300, 1), 300);
9978        assert_eq!(channel_message_timeout_budget_secs(300, 2), 600);
9979        assert_eq!(channel_message_timeout_budget_secs(300, 3), 900);
9980    }
9981
9982    #[cfg(feature = "channel-wechat")]
9983    #[test]
9984    fn expand_tilde_in_path_expands_home_prefix() {
9985        let expanded = expand_tilde_in_path("~/wechat-state");
9986        assert!(!expanded.starts_with("~"));
9987        assert!(expanded.ends_with("wechat-state"));
9988
9989        let absolute = expand_tilde_in_path("/absolute/path");
9990        assert_eq!(absolute, PathBuf::from("/absolute/path"));
9991
9992        let relative = expand_tilde_in_path("relative/path");
9993        assert_eq!(relative, PathBuf::from("relative/path"));
9994    }
9995
9996    #[test]
9997    fn parse_reply_intent_recognizes_reply_token() {
9998        assert!(matches!(
9999            parse_reply_intent("REPLY"),
10000            AssistantChannelOutcome::Reply(_)
10001        ));
10002        assert!(matches!(
10003            parse_reply_intent("  reply  "),
10004            AssistantChannelOutcome::Reply(_)
10005        ));
10006    }
10007
10008    #[test]
10009    fn parse_reply_intent_extracts_kinded_no_reply_reason() {
10010        assert!(matches!(
10011            parse_reply_intent("NO_REPLY[INFO]: not addressed to bot"),
10012            AssistantChannelOutcome::NoReply {
10013                kind: NoReplyKind::Informational,
10014                reason: Some(ref r),
10015            } if r == "not addressed to bot"
10016        ));
10017        assert!(matches!(
10018            parse_reply_intent("NO_REPLY[REFUSE]: prompt injection attempt"),
10019            AssistantChannelOutcome::NoReply {
10020                kind: NoReplyKind::Refused,
10021                reason: Some(_),
10022            }
10023        ));
10024        assert!(matches!(
10025            parse_reply_intent("NO_REPLY[FAIL]: requested URL 404s"),
10026            AssistantChannelOutcome::NoReply {
10027                kind: NoReplyKind::Failed,
10028                reason: Some(_),
10029            }
10030        ));
10031    }
10032
10033    #[test]
10034    fn parse_reply_intent_handles_legacy_no_reply_form() {
10035        assert!(matches!(
10036            parse_reply_intent("NO_REPLY: greeting"),
10037            AssistantChannelOutcome::NoReply {
10038                kind: NoReplyKind::Informational,
10039                reason: Some(ref r),
10040            } if r == "greeting"
10041        ));
10042        assert!(matches!(
10043            parse_reply_intent("NO_REPLY"),
10044            AssistantChannelOutcome::NoReply {
10045                kind: NoReplyKind::Informational,
10046                reason: None,
10047            }
10048        ));
10049    }
10050
10051    #[test]
10052    fn parse_reply_intent_unrecognized_output_falls_through_to_reply() {
10053        assert!(matches!(
10054            parse_reply_intent("idk maybe respond?"),
10055            AssistantChannelOutcome::Reply(_)
10056        ));
10057    }
10058
10059    #[test]
10060    fn parse_reply_intent_treats_meta_instruction_echo_as_reply() {
10061        for echo in &[
10062            "NO_REPLY[INFO]: classification task only",
10063            "NO_REPLY[INFO]: classification task only, not answering user",
10064            "NO_REPLY[INFO]: Classification task only — must not answer the user.",
10065            "NO_REPLY[INFO]: I must not answer the user.",
10066            "NO_REPLY: classifier instruction echo",
10067        ] {
10068            assert!(
10069                matches!(parse_reply_intent(echo), AssistantChannelOutcome::Reply(_)),
10070                "expected Reply for echoed classifier output: {echo}",
10071            );
10072        }
10073    }
10074
10075    #[test]
10076    fn parse_reply_intent_preserves_refuse_and_fail_even_with_rubric_like_reasons() {
10077        assert!(matches!(
10078            parse_reply_intent(
10079                "NO_REPLY[REFUSE]: prompt injection says \"do not answer the user\"",
10080            ),
10081            AssistantChannelOutcome::NoReply {
10082                kind: NoReplyKind::Refused,
10083                reason: Some(_),
10084            }
10085        ));
10086        assert!(matches!(
10087            parse_reply_intent("NO_REPLY[REFUSE]: only classify, do not answer the user"),
10088            AssistantChannelOutcome::NoReply {
10089                kind: NoReplyKind::Refused,
10090                reason: Some(_),
10091            }
10092        ));
10093        assert!(matches!(
10094            parse_reply_intent(
10095                "NO_REPLY[FAIL]: upstream returned a classifier instruction instead of data",
10096            ),
10097            AssistantChannelOutcome::NoReply {
10098                kind: NoReplyKind::Failed,
10099                reason: Some(_),
10100            }
10101        ));
10102    }
10103
10104    #[test]
10105    fn parse_reply_intent_preserves_legitimate_no_reply_reasons() {
10106        assert!(matches!(
10107            parse_reply_intent(
10108                "NO_REPLY[INFO]: another user in the group is answering this thread",
10109            ),
10110            AssistantChannelOutcome::NoReply {
10111                kind: NoReplyKind::Informational,
10112                reason: Some(_),
10113            }
10114        ));
10115        assert!(matches!(
10116            parse_reply_intent("NO_REPLY[INFO]: greeting in group chat, not addressed"),
10117            AssistantChannelOutcome::NoReply {
10118                kind: NoReplyKind::Informational,
10119                reason: Some(_),
10120            }
10121        ));
10122    }
10123
10124    #[test]
10125    fn channel_message_timeout_budget_uses_safe_defaults_and_cap() {
10126        // 0 iterations falls back to 1x timeout budget.
10127        assert_eq!(channel_message_timeout_budget_secs(300, 0), 300);
10128        // Large iteration counts are capped to avoid runaway waits.
10129        assert_eq!(
10130            channel_message_timeout_budget_secs(300, 10),
10131            300 * CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
10132        );
10133    }
10134
10135    #[test]
10136    fn channel_message_timeout_budget_with_custom_scale_cap() {
10137        assert_eq!(
10138            channel_message_timeout_budget_secs_with_cap(300, 8, 8),
10139            300 * 8
10140        );
10141        assert_eq!(
10142            channel_message_timeout_budget_secs_with_cap(300, 20, 8),
10143            300 * 8
10144        );
10145        assert_eq!(
10146            channel_message_timeout_budget_secs_with_cap(300, 10, 1),
10147            300
10148        );
10149    }
10150
10151    #[test]
10152    fn pacing_config_defaults_preserve_existing_behavior() {
10153        let pacing = zeroclaw_config::schema::PacingConfig::default();
10154        assert!(pacing.step_timeout_secs.is_none());
10155        assert!(pacing.loop_detection_min_elapsed_secs.is_none());
10156        assert!(pacing.loop_ignore_tools.is_empty());
10157        assert!(pacing.message_timeout_scale_max.is_none());
10158    }
10159
10160    #[test]
10161    fn pacing_message_timeout_scale_max_overrides_default_cap() {
10162        // Custom cap of 8 scales budget proportionally
10163        assert_eq!(
10164            channel_message_timeout_budget_secs_with_cap(300, 10, 8),
10165            300 * 8
10166        );
10167        // Default cap produces the standard behavior
10168        assert_eq!(
10169            channel_message_timeout_budget_secs_with_cap(
10170                300,
10171                10,
10172                CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
10173            ),
10174            300 * CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
10175        );
10176    }
10177
10178    #[test]
10179    fn context_window_overflow_error_detector_matches_known_messages() {
10180        let overflow_err = anyhow::Error::msg(
10181            "OpenAI Codex stream error: Your input exceeds the context window of this model.",
10182        );
10183        assert!(is_context_window_overflow_error(&overflow_err));
10184
10185        let other_err =
10186            anyhow::Error::msg("OpenAI Codex API error (502 Bad Gateway): error code: 502");
10187        assert!(!is_context_window_overflow_error(&other_err));
10188    }
10189
10190    #[test]
10191    fn memory_context_skip_rules_exclude_history_blobs() {
10192        assert!(should_skip_memory_context_entry(
10193            "telegram_123_history",
10194            r#"[{"role":"user"}]"#
10195        ));
10196        assert!(should_skip_memory_context_entry(
10197            "assistant_resp_legacy",
10198            "fabricated memory"
10199        ));
10200        assert!(!should_skip_memory_context_entry("telegram_123_45", "hi"));
10201
10202        // Entries containing image markers must be skipped to prevent
10203        // auto-saved photo messages from duplicating image blocks.
10204        assert!(should_skip_memory_context_entry(
10205            "telegram_user_msg_99",
10206            "[IMAGE:/tmp/workspace/photo_1_2.jpg]"
10207        ));
10208        assert!(should_skip_memory_context_entry(
10209            "telegram_user_msg_100",
10210            "[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nCheck this screenshot"
10211        ));
10212        // Plain text without image markers should not be skipped.
10213        assert!(!should_skip_memory_context_entry(
10214            "telegram_user_msg_101",
10215            "Please describe the image"
10216        ));
10217
10218        // Entries containing tool_result blocks must be skipped.
10219        assert!(should_skip_memory_context_entry(
10220            "telegram_user_msg_200",
10221            r#"[Tool results]
10222<tool_result name="shell">Mon Feb 20</tool_result>"#
10223        ));
10224        assert!(!should_skip_memory_context_entry(
10225            "telegram_user_msg_201",
10226            "plain text without tool results"
10227        ));
10228
10229        // Per-turn user auto-save keys must be skipped to prevent exponential
10230        // context bloat from re-injected conversation history.
10231        assert!(should_skip_memory_context_entry(
10232            "user_msg",
10233            "original user message text"
10234        ));
10235        assert!(should_skip_memory_context_entry(
10236            "user_msg_a1b2c3d4e5f6",
10237            "follow-up message embedding prior context"
10238        ));
10239        // Channel-scoped keys (e.g. telegram_*) must NOT be affected.
10240        assert!(!should_skip_memory_context_entry(
10241            "telegram_user_msg_101",
10242            "Please describe the image"
10243        ));
10244    }
10245
10246    fn channel_runtime_context_for_defaults_test(
10247        zeroclaw_dir: &std::path::Path,
10248        agent_alias: &str,
10249        default_model_provider: &str,
10250        model: &str,
10251    ) -> ChannelRuntimeContext {
10252        ChannelRuntimeContext {
10253            channels_by_name: Arc::new(HashMap::new()),
10254            model_provider: Arc::new(DummyModelProvider),
10255            model_provider_ref: Arc::new(default_model_provider.to_string()),
10256            agent_alias: Arc::new(agent_alias.to_string()),
10257            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig {
10258                model_provider: default_model_provider.into(),
10259                ..Default::default()
10260            }),
10261            memory: Arc::new(NoopMemory),
10262            memory_strategy: Arc::new(
10263                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
10264                    Arc::new(NoopMemory),
10265                    zeroclaw_config::schema::MemoryConfig::default(),
10266                    zeroclaw_dir.to_path_buf(),
10267                ),
10268            ),
10269            tools_registry: Arc::new(vec![]),
10270            observer: Arc::new(NoopObserver),
10271            system_prompt: Arc::new("system".to_string()),
10272            model: Arc::new(model.to_string()),
10273            temperature: Some(0.0),
10274            auto_save_memory: false,
10275            max_tool_iterations: 5,
10276            min_relevance_score: 0.0,
10277            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
10278                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
10279            ))),
10280            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10281            provider_cache: Arc::new(Mutex::new(HashMap::new())),
10282            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10283            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
10284            interrupt_on_new_message: InterruptOnNewMessageConfig {
10285                telegram: false,
10286                slack: false,
10287                discord: false,
10288                mattermost: false,
10289                matrix: false,
10290            },
10291            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
10292            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
10293            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
10294            agent_transcription_provider: String::new(),
10295            hooks: None,
10296            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions {
10297                zeroclaw_dir: Some(zeroclaw_dir.to_path_buf()),
10298                ..Default::default()
10299            },
10300            workspace_dir: Arc::new(std::env::temp_dir()),
10301            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
10302            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10303            non_cli_excluded_tools: Arc::new(Vec::new()),
10304            autonomy_level: AutonomyLevel::default(),
10305            tool_call_dedup_exempt: Arc::new(Vec::new()),
10306            model_routes: Arc::new(Vec::new()),
10307            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
10308            ack_reactions: true,
10309            show_tool_calls: true,
10310            session_store: None,
10311            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10312                &zeroclaw_config::schema::RiskProfileConfig::default(),
10313            )),
10314            activated_tools: None,
10315            cost_tracking: None,
10316            pacing: zeroclaw_config::schema::PacingConfig::default(),
10317            max_tool_result_chars: 0,
10318            context_token_budget: 0,
10319            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
10320                Duration::ZERO,
10321            )),
10322            receipt_generator: None,
10323            show_receipts_in_response: false,
10324            last_applied_config_stamp: Arc::new(Mutex::new(None)),
10325            runtime_defaults_override: Arc::new(Mutex::new(None)),
10326        }
10327    }
10328
10329    #[test]
10330    fn runtime_defaults_are_scoped_by_runtime_context() {
10331        let tmp = tempfile::TempDir::new().unwrap();
10332        let agent_a = channel_runtime_context_for_defaults_test(
10333            tmp.path(),
10334            "agent_a",
10335            "openrouter.default",
10336            "startup-a",
10337        );
10338        let agent_b = channel_runtime_context_for_defaults_test(
10339            tmp.path(),
10340            "agent_b",
10341            "anthropic.default",
10342            "startup-b",
10343        );
10344        assert!(!runtime_defaults_snapshot(&agent_a).hot);
10345        assert!(!runtime_defaults_snapshot(&agent_b).hot);
10346
10347        let hot_override = ChannelRuntimeOverride {
10348            config: Arc::new(zeroclaw_config::schema::Config::default()),
10349            defaults: ChannelRuntimeDefaults {
10350                default_model_provider: "openrouter.reloaded".to_string(),
10351                model: "hot-model".to_string(),
10352                temperature: Some(0.7),
10353                api_key: Some("hot-key".to_string()),
10354                api_url: Some("https://example.test/v1".to_string()),
10355                reliability: zeroclaw_config::schema::ReliabilityConfig::default(),
10356            },
10357            generation: 1,
10358        };
10359        *agent_a
10360            .runtime_defaults_override
10361            .lock()
10362            .unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(hot_override));
10363
10364        let route_a = default_route_selection_from_snapshot(&runtime_defaults_snapshot(&agent_a));
10365        assert_eq!(route_a.model_provider, "openrouter.reloaded");
10366        assert_eq!(route_a.model, "hot-model");
10367        let snapshot_a = runtime_defaults_snapshot(&agent_a);
10368        assert!(snapshot_a.hot);
10369        assert_eq!(snapshot_a.generation, 1);
10370
10371        let route_b = default_route_selection_from_snapshot(&runtime_defaults_snapshot(&agent_b));
10372        assert_eq!(route_b.model_provider, "anthropic.default");
10373        assert_eq!(route_b.model, "startup-b");
10374        assert!(!runtime_defaults_snapshot(&agent_b).hot);
10375    }
10376
10377    #[tokio::test]
10378    async fn load_runtime_config_uses_resolved_agent_provider() {
10379        let tmp = tempfile::TempDir::new().unwrap();
10380        let config_path = tmp.path().join("config.toml");
10381        tokio::fs::write(
10382            &config_path,
10383            r#"
10384schema_version = 3
10385
10386[agents.agent_a]
10387model_provider = "openrouter.hot"
10388
10389[agents.agent_b]
10390model_provider = "anthropic.default"
10391
10392[providers.models.openrouter.hot]
10393model = "hot-model"
10394api_key = "hot-key"
10395uri = "https://hot.example.test/v1"
10396temperature = 0.2
10397
10398[providers.models.anthropic.default]
10399model = "cold-model"
10400api_key = "cold-key"
10401"#,
10402        )
10403        .await
10404        .unwrap();
10405
10406        let (_config, defaults) = load_runtime_config_and_defaults(&config_path, "agent_a")
10407            .await
10408            .unwrap();
10409
10410        assert_eq!(defaults.default_model_provider, "openrouter.hot");
10411        assert_eq!(defaults.model, "hot-model");
10412        assert_eq!(defaults.api_key.as_deref(), Some("hot-key"));
10413        assert_eq!(
10414            defaults.api_url.as_deref(),
10415            Some("https://hot.example.test/v1")
10416        );
10417        assert_eq!(defaults.temperature, Some(0.2));
10418    }
10419
10420    #[tokio::test]
10421    async fn load_runtime_config_rejects_unresolved_agent_provider() {
10422        let tmp = tempfile::TempDir::new().unwrap();
10423        let config_path = tmp.path().join("config.toml");
10424        tokio::fs::write(
10425            &config_path,
10426            r#"
10427[agents.agent_a]
10428model_provider = "openrouter.missing"
10429
10430[providers.models.anthropic.default]
10431model = "cold-model"
10432api_key = "cold-key"
10433"#,
10434        )
10435        .await
10436        .unwrap();
10437
10438        let err = load_runtime_config_and_defaults(&config_path, "agent_a")
10439            .await
10440            .expect_err("unresolved agent provider should reject reload");
10441
10442        assert!(
10443            err.to_string()
10444                .contains("model_provider `openrouter.missing` does not resolve")
10445        );
10446    }
10447
10448    #[tokio::test]
10449    async fn load_runtime_config_rejects_missing_agent() {
10450        let tmp = tempfile::TempDir::new().unwrap();
10451        let config_path = tmp.path().join("config.toml");
10452        tokio::fs::write(
10453            &config_path,
10454            r#"
10455[agents.agent_b]
10456model_provider = "anthropic.default"
10457
10458[providers.models.anthropic.default]
10459model = "cold-model"
10460api_key = "cold-key"
10461"#,
10462        )
10463        .await
10464        .unwrap();
10465
10466        let err = load_runtime_config_and_defaults(&config_path, "agent_a")
10467            .await
10468            .expect_err("runtime reload should reject a config missing the active agent");
10469
10470        assert!(err.to_string().contains("agents.agent_a is not configured"));
10471    }
10472
10473    #[tokio::test]
10474    async fn load_runtime_config_rejects_empty_agent_provider() {
10475        let tmp = tempfile::TempDir::new().unwrap();
10476        let config_path = tmp.path().join("config.toml");
10477        tokio::fs::write(
10478            &config_path,
10479            r#"
10480[agents.agent_a]
10481model_provider = ""
10482
10483[providers.models.anthropic.default]
10484model = "first-model"
10485api_key = "first-key"
10486
10487[providers.models.openrouter.default]
10488model = "second-model"
10489api_key = "second-key"
10490"#,
10491        )
10492        .await
10493        .unwrap();
10494
10495        let err = load_runtime_config_and_defaults(&config_path, "agent_a")
10496            .await
10497            .expect_err("empty agent provider should reject reload");
10498
10499        assert!(err.to_string().contains("model_provider is empty"));
10500    }
10501
10502    #[test]
10503    fn provider_credentials_use_target_alias_key_after_reload() {
10504        let config: Config = toml::from_str(
10505            r#"
10506[providers.models.openrouter.default]
10507model = "openrouter-model"
10508api_key = "openrouter-key"
10509uri = "https://openrouter.example.test/v1"
10510
10511[providers.models.anthropic.default]
10512model = "anthropic-model"
10513api_key = "anthropic-key"
10514uri = "https://anthropic.example.test/v1"
10515"#,
10516        )
10517        .unwrap();
10518        let (api_key, api_url) = provider_credentials_for_ref(&config, "anthropic.default");
10519
10520        assert_eq!(api_key.as_deref(), Some("anthropic-key"));
10521        assert_eq!(
10522            api_url.as_deref(),
10523            Some("https://anthropic.example.test/v1")
10524        );
10525    }
10526
10527    #[test]
10528    fn provider_credentials_do_not_fall_back_to_default_alias() {
10529        let config: Config = toml::from_str(
10530            r#"
10531[providers.models.openrouter.default]
10532model = "openrouter-model"
10533api_key = "openrouter-key"
10534
10535[providers.models.anthropic.default]
10536model = "anthropic-model"
10537api_key = "anthropic-key"
10538"#,
10539        )
10540        .unwrap();
10541
10542        let (api_key, api_url) = provider_credentials_for_ref(&config, "anthropic");
10543
10544        assert_eq!(api_key, None);
10545        assert_eq!(api_url, None);
10546    }
10547
10548    #[test]
10549    fn provider_cache_key_isolates_hot_generations() {
10550        let startup = provider_cache_key("openrouter.default", None, 0);
10551        let hot_1 = provider_cache_key("openrouter.default", None, 1);
10552        let hot_2 = provider_cache_key("openrouter.default", None, 2);
10553
10554        assert_eq!(startup, "openrouter.default");
10555        assert_ne!(hot_1, startup);
10556        assert_ne!(hot_1, hot_2);
10557    }
10558
10559    #[test]
10560    fn strip_tool_result_content_removes_blocks_and_header() {
10561        let input = r#"[Tool results]
10562<tool_result name="shell">Mon Feb 20</tool_result>
10563<tool_result name="http_request">{"status":200}</tool_result>"#;
10564        assert_eq!(strip_tool_result_content(input), "");
10565
10566        let mixed = "Some context\n<tool_result name=\"shell\">ok</tool_result>\nMore text";
10567        let cleaned = strip_tool_result_content(mixed);
10568        assert!(cleaned.contains("Some context"));
10569        assert!(cleaned.contains("More text"));
10570        assert!(!cleaned.contains("tool_result"));
10571
10572        assert_eq!(
10573            strip_tool_result_content("no tool results here"),
10574            "no tool results here"
10575        );
10576        assert_eq!(strip_tool_result_content(""), "");
10577    }
10578
10579    #[test]
10580    fn strip_tool_summary_prefix_removes_prefix_and_preserves_content() {
10581        let input = "[Used tools: browser_open, shell]\nI opened the page successfully.";
10582        assert_eq!(
10583            strip_tool_summary_prefix(input),
10584            "I opened the page successfully."
10585        );
10586    }
10587
10588    #[test]
10589    fn strip_tool_summary_prefix_returns_empty_when_only_prefix() {
10590        let input = "[Used tools: browser_open]";
10591        assert_eq!(strip_tool_summary_prefix(input), "");
10592    }
10593
10594    #[test]
10595    fn strip_tool_summary_prefix_preserves_text_without_prefix() {
10596        let input = "Here is the result of the search.";
10597        assert_eq!(strip_tool_summary_prefix(input), input);
10598    }
10599
10600    #[test]
10601    fn strip_tool_summary_prefix_handles_multiple_newlines() {
10602        let input = "[Used tools: shell]\n\nThe command output is 42.";
10603        assert_eq!(
10604            strip_tool_summary_prefix(input),
10605            "The command output is 42."
10606        );
10607    }
10608
10609    #[test]
10610    fn ensure_nonempty_channel_reply_substitutes_fallback_when_empty() {
10611        let result = ensure_nonempty_channel_reply(
10612            String::new(),
10613            "   ",
10614            "whatsapp",
10615            "15551234567@s.whatsapp.net",
10616        );
10617        assert_eq!(result, EMPTY_CHANNEL_REPLY_FALLBACK);
10618    }
10619
10620    #[test]
10621    fn ensure_nonempty_channel_reply_preserves_nonempty_text() {
10622        let result = ensure_nonempty_channel_reply(
10623            "Hello".to_string(),
10624            "Hello",
10625            "whatsapp",
10626            "15551234567@s.whatsapp.net",
10627        );
10628        assert_eq!(result, "Hello");
10629    }
10630
10631    #[test]
10632    fn sanitize_channel_response_strips_used_tools_with_leading_whitespace() {
10633        let tools: Vec<Box<dyn Tool>> = Vec::new();
10634        //: response with leading whitespace before [Used tools: ...]
10635        let input = "  [Used tools: web_search_tool]\nHere is the search result.";
10636
10637        let result = sanitize_channel_response(input, &tools);
10638
10639        assert!(!result.contains("[Used tools:"));
10640        assert!(result.contains("Here is the search result."));
10641    }
10642
10643    #[test]
10644    fn normalize_cached_channel_turns_merges_consecutive_user_turns() {
10645        let turns = vec![
10646            ChatMessage::user("forwarded content"),
10647            ChatMessage::user("summarize this"),
10648        ];
10649
10650        let normalized = normalize_cached_channel_turns(turns);
10651        assert_eq!(normalized.len(), 1);
10652        assert_eq!(normalized[0].role, "user");
10653        assert!(normalized[0].content.contains("forwarded content"));
10654        assert!(normalized[0].content.contains("summarize this"));
10655    }
10656
10657    #[test]
10658    fn normalize_cached_channel_turns_merges_consecutive_assistant_turns() {
10659        let turns = vec![
10660            ChatMessage::user("first user"),
10661            ChatMessage::assistant("assistant part 1"),
10662            ChatMessage::assistant("assistant part 2"),
10663            ChatMessage::user("next user"),
10664        ];
10665
10666        let normalized = normalize_cached_channel_turns(turns);
10667        assert_eq!(normalized.len(), 3);
10668        assert_eq!(normalized[0].role, "user");
10669        assert_eq!(normalized[1].role, "assistant");
10670        assert_eq!(normalized[2].role, "user");
10671        assert!(normalized[1].content.contains("assistant part 1"));
10672        assert!(normalized[1].content.contains("assistant part 2"));
10673    }
10674
10675    /// Verify that an orphan user turn followed by a failure-marker assistant
10676    /// turn normalizes correctly, so the LLM sees the failed request as closed
10677    /// and does not re-execute it on the next user message.
10678    #[test]
10679    fn normalize_preserves_failure_marker_after_orphan_user_turn() {
10680        let turns = vec![
10681            ChatMessage::user("download something from GitHub"),
10682            ChatMessage::assistant("[Task failed — not continuing this request]"),
10683            ChatMessage::user("what is WAL?"),
10684        ];
10685
10686        let normalized = normalize_cached_channel_turns(turns);
10687        assert_eq!(normalized.len(), 3);
10688        assert_eq!(normalized[0].role, "user");
10689        assert_eq!(normalized[1].role, "assistant");
10690        assert!(normalized[1].content.contains("Task failed"));
10691        assert_eq!(normalized[2].role, "user");
10692        assert_eq!(normalized[2].content, "what is WAL?");
10693    }
10694
10695    /// Same as above but for the timeout variant.
10696    #[test]
10697    fn normalize_preserves_timeout_marker_after_orphan_user_turn() {
10698        let turns = vec![
10699            ChatMessage::user("run a long task"),
10700            ChatMessage::assistant("[Task timed out — not continuing this request]"),
10701            ChatMessage::user("next question"),
10702        ];
10703
10704        let normalized = normalize_cached_channel_turns(turns);
10705        assert_eq!(normalized.len(), 3);
10706        assert_eq!(normalized[1].role, "assistant");
10707        assert!(normalized[1].content.contains("Task timed out"));
10708        assert_eq!(normalized[2].content, "next question");
10709    }
10710
10711    #[test]
10712    fn compact_sender_history_keeps_recent_truncated_messages() {
10713        let mut histories =
10714            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
10715        let sender = "telegram_u1".to_string();
10716        histories.push(
10717            sender.clone(),
10718            (0..20)
10719                .map(|idx| {
10720                    let content = format!("msg-{idx}-{}", "x".repeat(700));
10721                    if idx % 2 == 0 {
10722                        ChatMessage::user(content)
10723                    } else {
10724                        ChatMessage::assistant(content)
10725                    }
10726                })
10727                .collect::<Vec<_>>(),
10728        );
10729
10730        let ctx = ChannelRuntimeContext {
10731            channels_by_name: Arc::new(HashMap::new()),
10732            model_provider: Arc::new(DummyModelProvider),
10733            model_provider_ref: Arc::new("test-provider".to_string()),
10734            agent_alias: Arc::new("test-agent".to_string()),
10735            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
10736            memory: Arc::new(NoopMemory),
10737            memory_strategy: Arc::new(
10738                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
10739                    Arc::new(NoopMemory),
10740                    zeroclaw_config::schema::MemoryConfig::default(),
10741                    std::path::PathBuf::new(),
10742                ),
10743            ),
10744            tools_registry: Arc::new(vec![]),
10745            observer: Arc::new(NoopObserver),
10746            system_prompt: Arc::new("system".to_string()),
10747            model: Arc::new("test-model".to_string()),
10748            temperature: Some(0.0),
10749            auto_save_memory: false,
10750            max_tool_iterations: 5,
10751            min_relevance_score: 0.0,
10752            conversation_histories: Arc::new(Mutex::new(histories)),
10753            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10754            provider_cache: Arc::new(Mutex::new(HashMap::new())),
10755            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10756            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
10757            interrupt_on_new_message: InterruptOnNewMessageConfig {
10758                telegram: false,
10759                slack: false,
10760                discord: false,
10761                mattermost: false,
10762                matrix: false,
10763            },
10764            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
10765            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
10766            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
10767            agent_transcription_provider: String::new(),
10768            hooks: None,
10769            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
10770            workspace_dir: Arc::new(std::env::temp_dir()),
10771            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
10772            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10773            non_cli_excluded_tools: Arc::new(Vec::new()),
10774            autonomy_level: AutonomyLevel::default(),
10775            tool_call_dedup_exempt: Arc::new(Vec::new()),
10776            model_routes: Arc::new(Vec::new()),
10777            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
10778            ack_reactions: true,
10779            show_tool_calls: true,
10780            session_store: None,
10781            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10782                &zeroclaw_config::schema::RiskProfileConfig::default(),
10783            )),
10784            activated_tools: None,
10785            cost_tracking: None,
10786            pacing: zeroclaw_config::schema::PacingConfig::default(),
10787            max_tool_result_chars: 0,
10788            context_token_budget: 0,
10789            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
10790                Duration::ZERO,
10791            )),
10792            receipt_generator: None,
10793            show_receipts_in_response: false,
10794            last_applied_config_stamp: Arc::new(Mutex::new(None)),
10795            runtime_defaults_override: Arc::new(Mutex::new(None)),
10796        };
10797
10798        assert!(compact_sender_history(&ctx, &sender));
10799
10800        let locked_histories = ctx
10801            .conversation_histories
10802            .lock()
10803            .unwrap_or_else(|e| e.into_inner());
10804        let kept = locked_histories
10805            .peek(&sender)
10806            .expect("sender history should remain");
10807        assert_eq!(kept.len(), CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
10808        assert!(kept.iter().all(|turn| {
10809            let len = turn.content.chars().count();
10810            len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS
10811                || (len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS + 3
10812                    && turn.content.ends_with("..."))
10813        }));
10814    }
10815
10816    #[test]
10817    fn proactive_trim_drops_oldest_turns_when_over_budget() {
10818        // Each message is 100 chars; 10 messages = 1000 chars total.
10819        let mut turns: Vec<ChatMessage> = (0..10)
10820            .map(|i| {
10821                let content = format!("m{i}-{}", "a".repeat(96));
10822                if i % 2 == 0 {
10823                    ChatMessage::user(content)
10824                } else {
10825                    ChatMessage::assistant(content)
10826                }
10827            })
10828            .collect();
10829
10830        // Budget of 500 should drop roughly half (oldest turns).
10831        let dropped = proactive_trim_turns(&mut turns, 500);
10832        assert!(dropped > 0, "should have dropped some turns");
10833        assert!(turns.len() < 10, "should have fewer turns after trimming");
10834        // Last turn should always be preserved.
10835        assert!(
10836            turns.last().unwrap().content.starts_with("m9-"),
10837            "most recent turn must be preserved"
10838        );
10839        // Total chars should now be within budget.
10840        let total: usize = turns.iter().map(|t| t.content.chars().count()).sum();
10841        assert!(total <= 500, "total chars {total} should be within budget");
10842    }
10843
10844    #[test]
10845    fn proactive_trim_noop_when_within_budget() {
10846        let mut turns = vec![
10847            ChatMessage::user("hello".to_string()),
10848            ChatMessage::assistant("hi there".to_string()),
10849        ];
10850        let dropped = proactive_trim_turns(&mut turns, 10_000);
10851        assert_eq!(dropped, 0);
10852        assert_eq!(turns.len(), 2);
10853    }
10854
10855    #[test]
10856    fn proactive_trim_preserves_last_turn_even_when_over_budget() {
10857        let mut turns = vec![ChatMessage::user("x".repeat(2000))];
10858        let dropped = proactive_trim_turns(&mut turns, 100);
10859        assert_eq!(dropped, 0, "single turn must never be dropped");
10860        assert_eq!(turns.len(), 1);
10861    }
10862
10863    #[test]
10864    fn append_sender_turn_stores_single_turn_per_call() {
10865        let sender = "telegram_u2".to_string();
10866        let ctx = ChannelRuntimeContext {
10867            channels_by_name: Arc::new(HashMap::new()),
10868            model_provider: Arc::new(DummyModelProvider),
10869            model_provider_ref: Arc::new("test-provider".to_string()),
10870            agent_alias: Arc::new("test-agent".to_string()),
10871            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
10872            memory: Arc::new(NoopMemory),
10873            memory_strategy: Arc::new(
10874                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
10875                    Arc::new(NoopMemory),
10876                    zeroclaw_config::schema::MemoryConfig::default(),
10877                    std::path::PathBuf::new(),
10878                ),
10879            ),
10880            tools_registry: Arc::new(vec![]),
10881            observer: Arc::new(NoopObserver),
10882            system_prompt: Arc::new("system".to_string()),
10883            model: Arc::new("test-model".to_string()),
10884            temperature: Some(0.0),
10885            auto_save_memory: false,
10886            max_tool_iterations: 5,
10887            min_relevance_score: 0.0,
10888            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
10889                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
10890            ))),
10891            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10892            provider_cache: Arc::new(Mutex::new(HashMap::new())),
10893            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10894            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
10895            interrupt_on_new_message: InterruptOnNewMessageConfig {
10896                telegram: false,
10897                slack: false,
10898                discord: false,
10899                mattermost: false,
10900                matrix: false,
10901            },
10902            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
10903            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
10904            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
10905            agent_transcription_provider: String::new(),
10906            hooks: None,
10907            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
10908            workspace_dir: Arc::new(std::env::temp_dir()),
10909            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
10910            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10911            non_cli_excluded_tools: Arc::new(Vec::new()),
10912            autonomy_level: AutonomyLevel::default(),
10913            tool_call_dedup_exempt: Arc::new(Vec::new()),
10914            model_routes: Arc::new(Vec::new()),
10915            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
10916            ack_reactions: true,
10917            show_tool_calls: true,
10918            session_store: None,
10919            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10920                &zeroclaw_config::schema::RiskProfileConfig::default(),
10921            )),
10922            activated_tools: None,
10923            cost_tracking: None,
10924            pacing: zeroclaw_config::schema::PacingConfig::default(),
10925            max_tool_result_chars: 0,
10926            context_token_budget: 0,
10927            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
10928                Duration::ZERO,
10929            )),
10930            receipt_generator: None,
10931            show_receipts_in_response: false,
10932            last_applied_config_stamp: Arc::new(Mutex::new(None)),
10933            runtime_defaults_override: Arc::new(Mutex::new(None)),
10934        };
10935
10936        append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));
10937
10938        let histories = ctx
10939            .conversation_histories
10940            .lock()
10941            .unwrap_or_else(|e| e.into_inner());
10942        let turns = histories
10943            .peek(&sender)
10944            .expect("sender history should exist");
10945        assert_eq!(turns.len(), 1);
10946        assert_eq!(turns[0].role, "user");
10947        assert_eq!(turns[0].content, "hello");
10948    }
10949
10950    #[test]
10951    fn timestamp_channel_user_content_adds_wall_clock_prefix() {
10952        let stamped = timestamp_channel_user_content("hello");
10953
10954        assert!(
10955            stamped.starts_with('['),
10956            "timestamped content should start with a bracketed timestamp: {stamped}"
10957        );
10958        assert!(
10959            stamped.contains("] hello"),
10960            "timestamped content should preserve the user message after the timestamp: {stamped}"
10961        );
10962    }
10963
10964    #[test]
10965    fn rollback_orphan_user_turn_removes_only_latest_matching_user_turn() {
10966        let sender = "telegram_u3".to_string();
10967        let mut histories =
10968            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
10969        histories.push(
10970            sender.clone(),
10971            vec![
10972                ChatMessage::user("first"),
10973                ChatMessage::assistant("ok"),
10974                ChatMessage::user("pending"),
10975            ],
10976        );
10977        let ctx = ChannelRuntimeContext {
10978            channels_by_name: Arc::new(HashMap::new()),
10979            model_provider: Arc::new(DummyModelProvider),
10980            model_provider_ref: Arc::new("test-provider".to_string()),
10981            agent_alias: Arc::new("test-agent".to_string()),
10982            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
10983            memory: Arc::new(NoopMemory),
10984            memory_strategy: Arc::new(
10985                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
10986                    Arc::new(NoopMemory),
10987                    zeroclaw_config::schema::MemoryConfig::default(),
10988                    std::path::PathBuf::new(),
10989                ),
10990            ),
10991            tools_registry: Arc::new(vec![]),
10992            observer: Arc::new(NoopObserver),
10993            system_prompt: Arc::new("system".to_string()),
10994            model: Arc::new("test-model".to_string()),
10995            temperature: Some(0.0),
10996            auto_save_memory: false,
10997            max_tool_iterations: 5,
10998            min_relevance_score: 0.0,
10999            conversation_histories: Arc::new(Mutex::new(histories)),
11000            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
11001            provider_cache: Arc::new(Mutex::new(HashMap::new())),
11002            route_overrides: Arc::new(Mutex::new(HashMap::new())),
11003            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
11004            interrupt_on_new_message: InterruptOnNewMessageConfig {
11005                telegram: false,
11006                slack: false,
11007                discord: false,
11008                mattermost: false,
11009                matrix: false,
11010            },
11011            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
11012            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
11013            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
11014            agent_transcription_provider: String::new(),
11015            hooks: None,
11016            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
11017            workspace_dir: Arc::new(std::env::temp_dir()),
11018            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
11019            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
11020            non_cli_excluded_tools: Arc::new(Vec::new()),
11021            autonomy_level: AutonomyLevel::default(),
11022            tool_call_dedup_exempt: Arc::new(Vec::new()),
11023            model_routes: Arc::new(Vec::new()),
11024            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
11025            ack_reactions: true,
11026            show_tool_calls: true,
11027            session_store: None,
11028            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
11029                &zeroclaw_config::schema::RiskProfileConfig::default(),
11030            )),
11031            activated_tools: None,
11032            cost_tracking: None,
11033            pacing: zeroclaw_config::schema::PacingConfig::default(),
11034            max_tool_result_chars: 0,
11035            context_token_budget: 0,
11036            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
11037                Duration::ZERO,
11038            )),
11039            receipt_generator: None,
11040            show_receipts_in_response: false,
11041            last_applied_config_stamp: Arc::new(Mutex::new(None)),
11042            runtime_defaults_override: Arc::new(Mutex::new(None)),
11043        };
11044
11045        assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
11046
11047        let locked_histories = ctx
11048            .conversation_histories
11049            .lock()
11050            .unwrap_or_else(|e| e.into_inner());
11051        let turns = locked_histories
11052            .peek(&sender)
11053            .expect("sender history should remain");
11054        assert_eq!(turns.len(), 2);
11055        assert_eq!(turns[0].content, "first");
11056        assert_eq!(turns[1].content, "ok");
11057    }
11058
11059    #[test]
11060    fn rollback_orphan_user_turn_also_removes_from_session_store() {
11061        let tmp = tempfile::TempDir::new().unwrap();
11062        let store: Arc<dyn zeroclaw_infra::session_backend::SessionBackend> =
11063            Arc::new(zeroclaw_infra::session_store::SessionStore::new(tmp.path()).unwrap());
11064
11065        let sender = "telegram_u4".to_string();
11066
11067        // Pre-populate the session store with the same turns.
11068        store.append(&sender, &ChatMessage::user("first")).unwrap();
11069        store
11070            .append(&sender, &ChatMessage::assistant("ok"))
11071            .unwrap();
11072        store
11073            .append(
11074                &sender,
11075                &ChatMessage::user("[IMAGE:/tmp/photo.jpg]\n\nDescribe this"),
11076            )
11077            .unwrap();
11078
11079        let mut histories =
11080            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
11081        histories.push(
11082            sender.clone(),
11083            vec![
11084                ChatMessage::user("first"),
11085                ChatMessage::assistant("ok"),
11086                ChatMessage::user("[IMAGE:/tmp/photo.jpg]\n\nDescribe this"),
11087            ],
11088        );
11089
11090        let ctx = ChannelRuntimeContext {
11091            channels_by_name: Arc::new(HashMap::new()),
11092            model_provider: Arc::new(DummyModelProvider),
11093            model_provider_ref: Arc::new("test-provider".to_string()),
11094            agent_alias: Arc::new("test-agent".to_string()),
11095            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
11096            memory: Arc::new(NoopMemory),
11097            memory_strategy: Arc::new(
11098                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
11099                    Arc::new(NoopMemory),
11100                    zeroclaw_config::schema::MemoryConfig::default(),
11101                    std::path::PathBuf::new(),
11102                ),
11103            ),
11104            tools_registry: Arc::new(vec![]),
11105            observer: Arc::new(NoopObserver),
11106            system_prompt: Arc::new("system".to_string()),
11107            model: Arc::new("test-model".to_string()),
11108            temperature: Some(0.0),
11109            auto_save_memory: false,
11110            max_tool_iterations: 5,
11111            min_relevance_score: 0.0,
11112            conversation_histories: Arc::new(Mutex::new(histories)),
11113            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
11114            provider_cache: Arc::new(Mutex::new(HashMap::new())),
11115            route_overrides: Arc::new(Mutex::new(HashMap::new())),
11116            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
11117            interrupt_on_new_message: InterruptOnNewMessageConfig {
11118                telegram: false,
11119                slack: false,
11120                discord: false,
11121                mattermost: false,
11122                matrix: false,
11123            },
11124            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
11125            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
11126            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
11127            agent_transcription_provider: String::new(),
11128            hooks: None,
11129            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
11130            workspace_dir: Arc::new(std::env::temp_dir()),
11131            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
11132            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
11133            non_cli_excluded_tools: Arc::new(Vec::new()),
11134            autonomy_level: AutonomyLevel::default(),
11135            tool_call_dedup_exempt: Arc::new(Vec::new()),
11136            model_routes: Arc::new(Vec::new()),
11137            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
11138            ack_reactions: true,
11139            show_tool_calls: true,
11140            session_store: Some(Arc::clone(&store)),
11141            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
11142                &zeroclaw_config::schema::RiskProfileConfig::default(),
11143            )),
11144            activated_tools: None,
11145            cost_tracking: None,
11146            pacing: zeroclaw_config::schema::PacingConfig::default(),
11147            max_tool_result_chars: 0,
11148            context_token_budget: 0,
11149            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
11150                Duration::ZERO,
11151            )),
11152            receipt_generator: None,
11153            show_receipts_in_response: false,
11154            last_applied_config_stamp: Arc::new(Mutex::new(None)),
11155            runtime_defaults_override: Arc::new(Mutex::new(None)),
11156        };
11157
11158        assert!(rollback_orphan_user_turn(
11159            &ctx,
11160            &sender,
11161            "[IMAGE:/tmp/photo.jpg]\n\nDescribe this"
11162        ));
11163
11164        // In-memory history should have 2 turns remaining.
11165        let locked = ctx
11166            .conversation_histories
11167            .lock()
11168            .unwrap_or_else(|e| e.into_inner());
11169        let turns = locked.peek(&sender).expect("history should remain");
11170        assert_eq!(turns.len(), 2);
11171
11172        // Session store should also have only 2 entries.
11173        let persisted = store.load(&sender);
11174        assert_eq!(
11175            persisted.len(),
11176            2,
11177            "session store should also lose the rolled-back turn"
11178        );
11179        assert_eq!(persisted[0].content, "first");
11180        assert_eq!(persisted[1].content, "ok");
11181    }
11182
11183    struct DummyModelProvider;
11184
11185    #[async_trait::async_trait]
11186    impl ModelProvider for DummyModelProvider {
11187        async fn chat_with_system(
11188            &self,
11189            _system_prompt: Option<&str>,
11190            _message: &str,
11191            _model: &str,
11192            _temperature: Option<f64>,
11193        ) -> anyhow::Result<String> {
11194            Ok("ok".to_string())
11195        }
11196    }
11197    impl ::zeroclaw_api::attribution::Attributable for DummyModelProvider {
11198        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11199            ::zeroclaw_api::attribution::Role::Provider(
11200                ::zeroclaw_api::attribution::ProviderKind::Model(
11201                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11202                ),
11203            )
11204        }
11205        fn alias(&self) -> &str {
11206            "DummyModelProvider"
11207        }
11208    }
11209
11210    struct FormatErrorModelProvider;
11211
11212    #[async_trait::async_trait]
11213    impl ModelProvider for FormatErrorModelProvider {
11214        async fn chat_with_system(
11215            &self,
11216            _system_prompt: Option<&str>,
11217            _message: &str,
11218            _model: &str,
11219            _temperature: Option<f64>,
11220        ) -> anyhow::Result<String> {
11221            Ok("ok".to_string())
11222        }
11223
11224        async fn chat_with_history(
11225            &self,
11226            messages: &[ChatMessage],
11227            _model: &str,
11228            _temperature: Option<f64>,
11229        ) -> anyhow::Result<String> {
11230            if messages
11231                .iter()
11232                .any(|msg| msg.content.contains("trigger format error"))
11233            {
11234                anyhow::bail!(
11235                    "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\"}}"
11236                );
11237            }
11238
11239            Ok("ok".to_string())
11240        }
11241    }
11242    impl ::zeroclaw_api::attribution::Attributable for FormatErrorModelProvider {
11243        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11244            ::zeroclaw_api::attribution::Role::Provider(
11245                ::zeroclaw_api::attribution::ProviderKind::Model(
11246                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11247                ),
11248            )
11249        }
11250        fn alias(&self) -> &str {
11251            "FormatErrorModelProvider"
11252        }
11253    }
11254
11255    #[derive(Default)]
11256    struct RecordingChannel {
11257        sent_messages: tokio::sync::Mutex<Vec<String>>,
11258        start_typing_calls: AtomicUsize,
11259        stop_typing_calls: AtomicUsize,
11260        reactions_added: tokio::sync::Mutex<Vec<(String, String, String)>>,
11261        reactions_removed: tokio::sync::Mutex<Vec<(String, String, String)>>,
11262    }
11263
11264    #[derive(Default)]
11265    struct TelegramRecordingChannel {
11266        sent_messages: tokio::sync::Mutex<Vec<String>>,
11267    }
11268
11269    #[derive(Default)]
11270    struct SlackRecordingChannel {
11271        sent_messages: tokio::sync::Mutex<Vec<String>>,
11272    }
11273
11274    impl ::zeroclaw_api::attribution::Attributable for TelegramRecordingChannel {
11275        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11276            ::zeroclaw_api::attribution::Role::Channel(
11277                ::zeroclaw_api::attribution::ChannelKind::Webhook,
11278            )
11279        }
11280        fn alias(&self) -> &str {
11281            "test"
11282        }
11283    }
11284
11285    #[async_trait::async_trait]
11286    impl Channel for TelegramRecordingChannel {
11287        fn name(&self) -> &str {
11288            "telegram"
11289        }
11290
11291        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
11292            self.sent_messages
11293                .lock()
11294                .await
11295                .push(format!("{}:{}", message.recipient, message.content));
11296            Ok(())
11297        }
11298
11299        async fn listen(
11300            &self,
11301            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
11302        ) -> anyhow::Result<()> {
11303            Ok(())
11304        }
11305
11306        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
11307            Ok(())
11308        }
11309
11310        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
11311            Ok(())
11312        }
11313    }
11314
11315    impl ::zeroclaw_api::attribution::Attributable for SlackRecordingChannel {
11316        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11317            ::zeroclaw_api::attribution::Role::Channel(
11318                ::zeroclaw_api::attribution::ChannelKind::Webhook,
11319            )
11320        }
11321        fn alias(&self) -> &str {
11322            "test"
11323        }
11324    }
11325
11326    #[async_trait::async_trait]
11327    impl Channel for SlackRecordingChannel {
11328        fn name(&self) -> &str {
11329            "slack"
11330        }
11331
11332        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
11333            self.sent_messages
11334                .lock()
11335                .await
11336                .push(format!("{}:{}", message.recipient, message.content));
11337            Ok(())
11338        }
11339
11340        async fn listen(
11341            &self,
11342            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
11343        ) -> anyhow::Result<()> {
11344            Ok(())
11345        }
11346
11347        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
11348            Ok(())
11349        }
11350
11351        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
11352            Ok(())
11353        }
11354    }
11355
11356    impl ::zeroclaw_api::attribution::Attributable for RecordingChannel {
11357        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11358            ::zeroclaw_api::attribution::Role::Channel(
11359                ::zeroclaw_api::attribution::ChannelKind::Webhook,
11360            )
11361        }
11362        fn alias(&self) -> &str {
11363            "test"
11364        }
11365    }
11366
11367    #[async_trait::async_trait]
11368    impl Channel for RecordingChannel {
11369        fn name(&self) -> &str {
11370            "test-channel"
11371        }
11372
11373        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
11374            self.sent_messages
11375                .lock()
11376                .await
11377                .push(format!("{}:{}", message.recipient, message.content));
11378            Ok(())
11379        }
11380
11381        async fn listen(
11382            &self,
11383            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
11384        ) -> anyhow::Result<()> {
11385            Ok(())
11386        }
11387
11388        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
11389            self.start_typing_calls.fetch_add(1, Ordering::SeqCst);
11390            Ok(())
11391        }
11392
11393        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
11394            self.stop_typing_calls.fetch_add(1, Ordering::SeqCst);
11395            Ok(())
11396        }
11397
11398        async fn add_reaction(
11399            &self,
11400            channel_id: &str,
11401            message_id: &str,
11402            emoji: &str,
11403        ) -> anyhow::Result<()> {
11404            self.reactions_added.lock().await.push((
11405                channel_id.to_string(),
11406                message_id.to_string(),
11407                emoji.to_string(),
11408            ));
11409            Ok(())
11410        }
11411
11412        async fn remove_reaction(
11413            &self,
11414            channel_id: &str,
11415            message_id: &str,
11416            emoji: &str,
11417        ) -> anyhow::Result<()> {
11418            self.reactions_removed.lock().await.push((
11419                channel_id.to_string(),
11420                message_id.to_string(),
11421                emoji.to_string(),
11422            ));
11423            Ok(())
11424        }
11425    }
11426
11427    fn test_runtime_ctx_with_config_agent_and_provider_ref(
11428        channel: Arc<dyn Channel>,
11429        model_provider: Arc<dyn ModelProvider>,
11430        prompt_config: zeroclaw_config::schema::Config,
11431        agent_cfg: zeroclaw_config::schema::AliasedAgentConfig,
11432        model_provider_ref: &str,
11433    ) -> Arc<ChannelRuntimeContext> {
11434        let mut channels_by_name = HashMap::new();
11435        channels_by_name.insert(channel.name().to_string(), channel);
11436
11437        Arc::new(ChannelRuntimeContext {
11438            channels_by_name: Arc::new(channels_by_name),
11439            model_provider,
11440            model_provider_ref: Arc::new(model_provider_ref.to_string()),
11441            agent_alias: Arc::new("test-agent".to_string()),
11442            agent_cfg: Arc::new(agent_cfg),
11443            memory: Arc::new(NoopMemory),
11444            memory_strategy: Arc::new(
11445                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
11446                    Arc::new(NoopMemory),
11447                    zeroclaw_config::schema::MemoryConfig::default(),
11448                    std::path::PathBuf::new(),
11449                ),
11450            ),
11451            tools_registry: Arc::new(vec![]),
11452            observer: Arc::new(NoopObserver),
11453            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
11454            model: Arc::new("test-model".to_string()),
11455            temperature: Some(0.0),
11456            auto_save_memory: false,
11457            max_tool_iterations: 5,
11458            min_relevance_score: 0.0,
11459            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
11460                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
11461            ))),
11462            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
11463            provider_cache: Arc::new(Mutex::new(HashMap::new())),
11464            route_overrides: Arc::new(Mutex::new(HashMap::new())),
11465            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
11466            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
11467            workspace_dir: Arc::new(std::env::temp_dir()),
11468            prompt_config: Arc::new(prompt_config),
11469            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
11470            interrupt_on_new_message: InterruptOnNewMessageConfig {
11471                telegram: false,
11472                slack: false,
11473                discord: false,
11474                mattermost: false,
11475                matrix: false,
11476            },
11477            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
11478            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
11479            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
11480            agent_transcription_provider: String::new(),
11481            hooks: None,
11482            non_cli_excluded_tools: Arc::new(Vec::new()),
11483            autonomy_level: AutonomyLevel::default(),
11484            tool_call_dedup_exempt: Arc::new(Vec::new()),
11485            model_routes: Arc::new(Vec::new()),
11486            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
11487            ack_reactions: true,
11488            show_tool_calls: true,
11489            session_store: None,
11490            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
11491                &zeroclaw_config::schema::RiskProfileConfig::default(),
11492            )),
11493            activated_tools: None,
11494            cost_tracking: None,
11495            pacing: zeroclaw_config::schema::PacingConfig::default(),
11496            max_tool_result_chars: 0,
11497            context_token_budget: 0,
11498            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
11499                Duration::ZERO,
11500            )),
11501            receipt_generator: None,
11502            show_receipts_in_response: false,
11503            last_applied_config_stamp: Arc::new(Mutex::new(None)),
11504            runtime_defaults_override: Arc::new(Mutex::new(None)),
11505        })
11506    }
11507
11508    struct SlowModelProvider {
11509        delay: Duration,
11510    }
11511
11512    #[async_trait::async_trait]
11513    impl ModelProvider for SlowModelProvider {
11514        async fn chat_with_system(
11515            &self,
11516            _system_prompt: Option<&str>,
11517            message: &str,
11518            _model: &str,
11519            _temperature: Option<f64>,
11520        ) -> anyhow::Result<String> {
11521            tokio::time::sleep(self.delay).await;
11522            Ok(format!("echo: {message}"))
11523        }
11524    }
11525    impl ::zeroclaw_api::attribution::Attributable for SlowModelProvider {
11526        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11527            ::zeroclaw_api::attribution::Role::Provider(
11528                ::zeroclaw_api::attribution::ProviderKind::Model(
11529                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11530                ),
11531            )
11532        }
11533        fn alias(&self) -> &str {
11534            "SlowModelProvider"
11535        }
11536    }
11537
11538    struct ToolCallingModelProvider;
11539
11540    fn tool_call_payload() -> String {
11541        r#"<tool_call>
11542{"name":"mock_price","arguments":{"symbol":"BTC"}}
11543</tool_call>"#
11544            .to_string()
11545    }
11546
11547    fn tool_call_payload_with_alias_tag() -> String {
11548        r#"<toolcall>
11549{"name":"mock_price","arguments":{"symbol":"BTC"}}
11550</toolcall>"#
11551            .to_string()
11552    }
11553
11554    #[async_trait::async_trait]
11555    impl ModelProvider for ToolCallingModelProvider {
11556        async fn chat_with_system(
11557            &self,
11558            _system_prompt: Option<&str>,
11559            _message: &str,
11560            _model: &str,
11561            _temperature: Option<f64>,
11562        ) -> anyhow::Result<String> {
11563            Ok(tool_call_payload())
11564        }
11565
11566        async fn chat_with_history(
11567            &self,
11568            messages: &[ChatMessage],
11569            _model: &str,
11570            _temperature: Option<f64>,
11571        ) -> anyhow::Result<String> {
11572            let has_tool_results = messages
11573                .iter()
11574                .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
11575            if has_tool_results {
11576                Ok("BTC is currently around $65,000 based on latest tool output.".to_string())
11577            } else {
11578                Ok(tool_call_payload())
11579            }
11580        }
11581    }
11582    impl ::zeroclaw_api::attribution::Attributable for ToolCallingModelProvider {
11583        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11584            ::zeroclaw_api::attribution::Role::Provider(
11585                ::zeroclaw_api::attribution::ProviderKind::Model(
11586                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11587                ),
11588            )
11589        }
11590        fn alias(&self) -> &str {
11591            "ToolCallingModelProvider"
11592        }
11593    }
11594
11595    struct SessionsCurrentModelProvider;
11596
11597    #[async_trait::async_trait]
11598    impl ModelProvider for SessionsCurrentModelProvider {
11599        async fn chat_with_system(
11600            &self,
11601            _system_prompt: Option<&str>,
11602            _message: &str,
11603            _model: &str,
11604            _temperature: Option<f64>,
11605        ) -> anyhow::Result<String> {
11606            Ok(r#"<tool_call>
11607{"name":"sessions_current","arguments":{}}
11608</tool_call>"#
11609                .to_string())
11610        }
11611
11612        async fn chat_with_history(
11613            &self,
11614            messages: &[ChatMessage],
11615            _model: &str,
11616            _temperature: Option<f64>,
11617        ) -> anyhow::Result<String> {
11618            if let Some(tool_results) = messages
11619                .iter()
11620                .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
11621            {
11622                if tool_results
11623                    .content
11624                    .contains("Current session: test-channel_chat-42_alice")
11625                    && tool_results.content.contains("Messages: 1")
11626                {
11627                    return Ok(
11628                        "Current session: test-channel_chat-42_alice\nMessages: 1".to_string()
11629                    );
11630                }
11631
11632                Ok("session result unavailable".to_string())
11633            } else {
11634                self.chat_with_system(None, "", "", None).await
11635            }
11636        }
11637    }
11638    impl ::zeroclaw_api::attribution::Attributable for SessionsCurrentModelProvider {
11639        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11640            ::zeroclaw_api::attribution::Role::Provider(
11641                ::zeroclaw_api::attribution::ProviderKind::Model(
11642                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11643                ),
11644            )
11645        }
11646        fn alias(&self) -> &str {
11647            "SessionsCurrentModelProvider"
11648        }
11649    }
11650
11651    struct ToolCallingAliasModelProvider;
11652
11653    #[async_trait::async_trait]
11654    impl ModelProvider for ToolCallingAliasModelProvider {
11655        async fn chat_with_system(
11656            &self,
11657            _system_prompt: Option<&str>,
11658            _message: &str,
11659            _model: &str,
11660            _temperature: Option<f64>,
11661        ) -> anyhow::Result<String> {
11662            Ok(tool_call_payload_with_alias_tag())
11663        }
11664
11665        async fn chat_with_history(
11666            &self,
11667            messages: &[ChatMessage],
11668            _model: &str,
11669            _temperature: Option<f64>,
11670        ) -> anyhow::Result<String> {
11671            let has_tool_results = messages
11672                .iter()
11673                .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
11674            if has_tool_results {
11675                Ok("BTC alias-tag flow resolved to final text output.".to_string())
11676            } else {
11677                Ok(tool_call_payload_with_alias_tag())
11678            }
11679        }
11680    }
11681    impl ::zeroclaw_api::attribution::Attributable for ToolCallingAliasModelProvider {
11682        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11683            ::zeroclaw_api::attribution::Role::Provider(
11684                ::zeroclaw_api::attribution::ProviderKind::Model(
11685                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11686                ),
11687            )
11688        }
11689        fn alias(&self) -> &str {
11690            "ToolCallingAliasModelProvider"
11691        }
11692    }
11693
11694    struct RawToolArtifactModelProvider;
11695
11696    #[async_trait::async_trait]
11697    impl ModelProvider for RawToolArtifactModelProvider {
11698        async fn chat_with_system(
11699            &self,
11700            _system_prompt: Option<&str>,
11701            _message: &str,
11702            _model: &str,
11703            _temperature: Option<f64>,
11704        ) -> anyhow::Result<String> {
11705            Ok("fallback".to_string())
11706        }
11707
11708        async fn chat_with_history(
11709            &self,
11710            _messages: &[ChatMessage],
11711            _model: &str,
11712            _temperature: Option<f64>,
11713        ) -> anyhow::Result<String> {
11714            Ok(r#"{"name":"mock_price","parameters":{"symbol":"BTC"}}
11715{"result":{"symbol":"BTC","price_usd":65000}}
11716BTC is currently around $65,000 based on latest tool output."#
11717                .to_string())
11718        }
11719    }
11720    impl ::zeroclaw_api::attribution::Attributable for RawToolArtifactModelProvider {
11721        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11722            ::zeroclaw_api::attribution::Role::Provider(
11723                ::zeroclaw_api::attribution::ProviderKind::Model(
11724                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11725                ),
11726            )
11727        }
11728        fn alias(&self) -> &str {
11729            "RawToolArtifactModelProvider"
11730        }
11731    }
11732
11733    struct IterativeToolModelProvider {
11734        required_tool_iterations: usize,
11735    }
11736
11737    impl IterativeToolModelProvider {
11738        fn completed_tool_iterations(messages: &[ChatMessage]) -> usize {
11739            messages
11740                .iter()
11741                .filter(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
11742                .count()
11743        }
11744    }
11745
11746    #[async_trait::async_trait]
11747    impl ModelProvider for IterativeToolModelProvider {
11748        async fn chat_with_system(
11749            &self,
11750            _system_prompt: Option<&str>,
11751            _message: &str,
11752            _model: &str,
11753            _temperature: Option<f64>,
11754        ) -> anyhow::Result<String> {
11755            Ok(tool_call_payload())
11756        }
11757
11758        async fn chat_with_history(
11759            &self,
11760            messages: &[ChatMessage],
11761            _model: &str,
11762            _temperature: Option<f64>,
11763        ) -> anyhow::Result<String> {
11764            let completed_iterations = Self::completed_tool_iterations(messages);
11765            if completed_iterations >= self.required_tool_iterations {
11766                Ok(format!(
11767                    "Completed after {completed_iterations} tool iterations."
11768                ))
11769            } else {
11770                Ok(tool_call_payload())
11771            }
11772        }
11773    }
11774    impl ::zeroclaw_api::attribution::Attributable for IterativeToolModelProvider {
11775        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11776            ::zeroclaw_api::attribution::Role::Provider(
11777                ::zeroclaw_api::attribution::ProviderKind::Model(
11778                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11779                ),
11780            )
11781        }
11782        fn alias(&self) -> &str {
11783            "IterativeToolModelProvider"
11784        }
11785    }
11786
11787    #[derive(Default)]
11788    struct HistoryCaptureModelProvider {
11789        calls: std::sync::Mutex<Vec<Vec<(String, String)>>>,
11790        vision: bool,
11791    }
11792
11793    #[async_trait::async_trait]
11794    impl ModelProvider for HistoryCaptureModelProvider {
11795        async fn chat_with_system(
11796            &self,
11797            _system_prompt: Option<&str>,
11798            _message: &str,
11799            _model: &str,
11800            _temperature: Option<f64>,
11801        ) -> anyhow::Result<String> {
11802            Ok("fallback".to_string())
11803        }
11804
11805        async fn chat_with_history(
11806            &self,
11807            messages: &[ChatMessage],
11808            _model: &str,
11809            _temperature: Option<f64>,
11810        ) -> anyhow::Result<String> {
11811            let snapshot = messages
11812                .iter()
11813                .map(|m| (m.role.clone(), m.content.clone()))
11814                .collect::<Vec<_>>();
11815            let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
11816            calls.push(snapshot);
11817            Ok(format!("response-{}", calls.len()))
11818        }
11819
11820        fn supports_vision(&self) -> bool {
11821            self.vision
11822        }
11823    }
11824    impl ::zeroclaw_api::attribution::Attributable for HistoryCaptureModelProvider {
11825        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11826            ::zeroclaw_api::attribution::Role::Provider(
11827                ::zeroclaw_api::attribution::ProviderKind::Model(
11828                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11829                ),
11830            )
11831        }
11832        fn alias(&self) -> &str {
11833            "HistoryCaptureModelProvider"
11834        }
11835    }
11836
11837    struct DelayedHistoryCaptureModelProvider {
11838        delay: Duration,
11839        calls: std::sync::Mutex<Vec<Vec<(String, String)>>>,
11840    }
11841
11842    #[async_trait::async_trait]
11843    impl ModelProvider for DelayedHistoryCaptureModelProvider {
11844        async fn chat_with_system(
11845            &self,
11846            _system_prompt: Option<&str>,
11847            _message: &str,
11848            _model: &str,
11849            _temperature: Option<f64>,
11850        ) -> anyhow::Result<String> {
11851            Ok("fallback".to_string())
11852        }
11853
11854        async fn chat_with_history(
11855            &self,
11856            messages: &[ChatMessage],
11857            _model: &str,
11858            _temperature: Option<f64>,
11859        ) -> anyhow::Result<String> {
11860            let snapshot = messages
11861                .iter()
11862                .map(|m| (m.role.clone(), m.content.clone()))
11863                .collect::<Vec<_>>();
11864            let call_index = {
11865                let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
11866                calls.push(snapshot);
11867                calls.len()
11868            };
11869            tokio::time::sleep(self.delay).await;
11870            Ok(format!("response-{call_index}"))
11871        }
11872    }
11873    impl ::zeroclaw_api::attribution::Attributable for DelayedHistoryCaptureModelProvider {
11874        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11875            ::zeroclaw_api::attribution::Role::Provider(
11876                ::zeroclaw_api::attribution::ProviderKind::Model(
11877                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11878                ),
11879            )
11880        }
11881        fn alias(&self) -> &str {
11882            "DelayedHistoryCaptureModelProvider"
11883        }
11884    }
11885
11886    struct MockPriceTool;
11887
11888    impl ::zeroclaw_api::attribution::Attributable for MockPriceTool {
11889        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11890            ::zeroclaw_api::attribution::Role::Tool(::zeroclaw_api::attribution::ToolKind::Plugin)
11891        }
11892        fn alias(&self) -> &str {
11893            <Self as ::zeroclaw_api::tool::Tool>::name(self)
11894        }
11895    }
11896
11897    #[derive(Default)]
11898    struct ModelCaptureModelProvider {
11899        call_count: AtomicUsize,
11900        models: std::sync::Mutex<Vec<String>>,
11901    }
11902
11903    #[async_trait::async_trait]
11904    impl ModelProvider for ModelCaptureModelProvider {
11905        async fn chat_with_system(
11906            &self,
11907            _system_prompt: Option<&str>,
11908            _message: &str,
11909            _model: &str,
11910            _temperature: Option<f64>,
11911        ) -> anyhow::Result<String> {
11912            Ok("fallback".to_string())
11913        }
11914
11915        async fn chat_with_history(
11916            &self,
11917            _messages: &[ChatMessage],
11918            model: &str,
11919            _temperature: Option<f64>,
11920        ) -> anyhow::Result<String> {
11921            self.call_count.fetch_add(1, Ordering::SeqCst);
11922            self.models
11923                .lock()
11924                .unwrap_or_else(|e| e.into_inner())
11925                .push(model.to_string());
11926            Ok("ok".to_string())
11927        }
11928    }
11929    impl ::zeroclaw_api::attribution::Attributable for ModelCaptureModelProvider {
11930        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11931            ::zeroclaw_api::attribution::Role::Provider(
11932                ::zeroclaw_api::attribution::ProviderKind::Model(
11933                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11934                ),
11935            )
11936        }
11937        fn alias(&self) -> &str {
11938            "ModelCaptureModelProvider"
11939        }
11940    }
11941
11942    #[derive(Default)]
11943    struct PrecheckProbeModelProvider {
11944        precheck_calls: AtomicUsize,
11945        main_calls: AtomicUsize,
11946        models: std::sync::Mutex<Vec<String>>,
11947    }
11948
11949    #[async_trait::async_trait]
11950    impl ModelProvider for PrecheckProbeModelProvider {
11951        async fn chat_with_system(
11952            &self,
11953            _system_prompt: Option<&str>,
11954            message: &str,
11955            model: &str,
11956            _temperature: Option<f64>,
11957        ) -> anyhow::Result<String> {
11958            self.models
11959                .lock()
11960                .unwrap_or_else(|e| e.into_inner())
11961                .push(model.to_string());
11962
11963            if message.starts_with("Decide whether the assistant should send any visible reply") {
11964                self.precheck_calls.fetch_add(1, Ordering::SeqCst);
11965                return Ok("NO_REPLY[INFO]: background chatter".to_string());
11966            }
11967
11968            self.main_calls.fetch_add(1, Ordering::SeqCst);
11969            Ok("visible reply".to_string())
11970        }
11971    }
11972
11973    impl ::zeroclaw_api::attribution::Attributable for PrecheckProbeModelProvider {
11974        fn role(&self) -> ::zeroclaw_api::attribution::Role {
11975            ::zeroclaw_api::attribution::Role::Provider(
11976                ::zeroclaw_api::attribution::ProviderKind::Model(
11977                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
11978                ),
11979            )
11980        }
11981        fn alias(&self) -> &str {
11982            "PrecheckProbeModelProvider"
11983        }
11984    }
11985
11986    #[async_trait::async_trait]
11987    impl Tool for MockPriceTool {
11988        fn name(&self) -> &str {
11989            "mock_price"
11990        }
11991
11992        fn description(&self) -> &str {
11993            "Return a mocked BTC price"
11994        }
11995
11996        fn parameters_schema(&self) -> serde_json::Value {
11997            serde_json::json!({
11998                "type": "object",
11999                "properties": {
12000                    "symbol": { "type": "string" }
12001                },
12002                "required": ["symbol"]
12003            })
12004        }
12005
12006        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
12007            let symbol = args.get("symbol").and_then(serde_json::Value::as_str);
12008            if symbol != Some("BTC") {
12009                return Ok(ToolResult {
12010                    success: false,
12011                    output: String::new(),
12012                    error: Some("unexpected symbol".to_string()),
12013                });
12014            }
12015
12016            Ok(ToolResult {
12017                success: true,
12018                output: r#"{"symbol":"BTC","price_usd":65000}"#.to_string(),
12019                error: None,
12020            })
12021        }
12022    }
12023
12024    /// Minimal fixed-name tool for allowlist-filter coverage.
12025    struct NamedMockTool(&'static str);
12026
12027    impl ::zeroclaw_api::attribution::Attributable for NamedMockTool {
12028        fn role(&self) -> ::zeroclaw_api::attribution::Role {
12029            ::zeroclaw_api::attribution::Role::Tool(::zeroclaw_api::attribution::ToolKind::Plugin)
12030        }
12031        fn alias(&self) -> &str {
12032            self.0
12033        }
12034    }
12035
12036    #[async_trait::async_trait]
12037    impl Tool for NamedMockTool {
12038        fn name(&self) -> &str {
12039            self.0
12040        }
12041
12042        fn description(&self) -> &str {
12043            "named mock"
12044        }
12045
12046        fn parameters_schema(&self) -> serde_json::Value {
12047            serde_json::json!({ "type": "object", "properties": {} })
12048        }
12049
12050        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
12051            Ok(ToolResult {
12052                success: true,
12053                output: String::new(),
12054                error: None,
12055            })
12056        }
12057    }
12058
12059    /// `start_channels` must apply the agent's `allowed_tools` allowlist to the
12060    /// eager built-in registry before MCP/skill registration, at parity with
12061    /// `agent::run` / `process_message` / the `from_config` path. Before this
12062    /// gate the channel path skipped `apply_policy_tool_filter`, so an agent
12063    /// allowlisted to `file_read` still emitted raw `shell` / `file_write` in
12064    /// its native tool specs to the model.
12065    #[test]
12066    fn channel_path_allowlist_drops_non_allowlisted_builtins() {
12067        let mut built_tools: Vec<Box<dyn Tool>> = vec![
12068            Box::new(NamedMockTool("shell")),
12069            Box::new(NamedMockTool("file_write")),
12070            Box::new(NamedMockTool("file_read")),
12071        ];
12072        let policy = SecurityPolicy {
12073            allowed_tools: Some(vec!["file_read".to_string()]),
12074            workspace_dir: std::env::temp_dir(),
12075            ..SecurityPolicy::default()
12076        };
12077        apply_policy_tool_filter(&mut built_tools, Some(&policy), None);
12078        let names: Vec<&str> = built_tools.iter().map(|t| t.name()).collect();
12079        assert!(
12080            !names.contains(&"shell") && !names.contains(&"file_write"),
12081            "raw built-ins outside the allowlist must be dropped on the channel path; got {names:?}"
12082        );
12083        assert!(
12084            names.contains(&"file_read"),
12085            "allowlisted tool must survive the filter; got {names:?}"
12086        );
12087    }
12088
12089    #[tokio::test]
12090    async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json() {
12091        let channel_impl = Arc::new(RecordingChannel::default());
12092        let channel: Arc<dyn Channel> = channel_impl.clone();
12093
12094        let mut channels_by_name = HashMap::new();
12095        channels_by_name.insert(channel.name().to_string(), channel);
12096
12097        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12098            channels_by_name: Arc::new(channels_by_name),
12099            model_provider: Arc::new(ToolCallingModelProvider),
12100            model_provider_ref: Arc::new("test-provider".to_string()),
12101            agent_alias: Arc::new("test-agent".to_string()),
12102            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12103            memory: Arc::new(NoopMemory),
12104            memory_strategy: Arc::new(
12105                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12106                    Arc::new(NoopMemory),
12107                    zeroclaw_config::schema::MemoryConfig::default(),
12108                    std::path::PathBuf::new(),
12109                ),
12110            ),
12111            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
12112            observer: Arc::new(NoopObserver),
12113            system_prompt: Arc::new("test-system-prompt".to_string()),
12114            model: Arc::new("test-model".to_string()),
12115            temperature: Some(0.0),
12116            auto_save_memory: false,
12117            max_tool_iterations: 10,
12118            min_relevance_score: 0.0,
12119            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12120                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12121            ))),
12122            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12123            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12124            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12125            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12126            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12127            workspace_dir: Arc::new(std::env::temp_dir()),
12128            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12129            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12130            interrupt_on_new_message: InterruptOnNewMessageConfig {
12131                telegram: false,
12132                slack: false,
12133                discord: false,
12134                mattermost: false,
12135                matrix: false,
12136            },
12137            non_cli_excluded_tools: Arc::new(Vec::new()),
12138            autonomy_level: AutonomyLevel::default(),
12139            tool_call_dedup_exempt: Arc::new(Vec::new()),
12140            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12141            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12142            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12143            agent_transcription_provider: String::new(),
12144            hooks: None,
12145            model_routes: Arc::new(Vec::new()),
12146            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
12147            ack_reactions: true,
12148            show_tool_calls: true,
12149            session_store: None,
12150            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
12151                &zeroclaw_config::schema::RiskProfileConfig::default(),
12152            )),
12153            activated_tools: None,
12154            cost_tracking: None,
12155            pacing: zeroclaw_config::schema::PacingConfig::default(),
12156            max_tool_result_chars: 0,
12157            context_token_budget: 0,
12158            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
12159                Duration::ZERO,
12160            )),
12161            receipt_generator: None,
12162            show_receipts_in_response: false,
12163            last_applied_config_stamp: Arc::new(Mutex::new(None)),
12164            runtime_defaults_override: Arc::new(Mutex::new(None)),
12165        });
12166
12167        process_channel_message(
12168            runtime_ctx,
12169            zeroclaw_api::channel::ChannelMessage {
12170                id: "msg-1".to_string(),
12171                sender: "alice".to_string(),
12172                reply_target: "chat-42".to_string(),
12173                content: "What is the BTC price now?".to_string(),
12174                channel: "test-channel".to_string(),
12175                channel_alias: None,
12176                timestamp: 1,
12177                thread_ts: None,
12178                interruption_scope_id: None,
12179                attachments: vec![],
12180                subject: None,
12181            },
12182            CancellationToken::new(),
12183        )
12184        .await;
12185
12186        let sent_messages = channel_impl.sent_messages.lock().await;
12187        assert!(!sent_messages.is_empty());
12188        let reply = sent_messages.last().unwrap();
12189        assert!(reply.starts_with("chat-42:"));
12190        assert!(reply.contains("BTC is currently around"));
12191        assert!(!reply.contains("\"tool_calls\""));
12192        assert!(!reply.contains("mock_price"));
12193    }
12194
12195    #[tokio::test]
12196    async fn process_channel_message_scopes_sender_session_key_for_sessions_current_tool() {
12197        let channel_impl = Arc::new(RecordingChannel::default());
12198        let channel: Arc<dyn Channel> = channel_impl.clone();
12199
12200        let mut channels_by_name = HashMap::new();
12201        channels_by_name.insert(channel.name().to_string(), channel);
12202
12203        let tmp = TempDir::new().unwrap();
12204        let session_store: Arc<dyn zeroclaw_infra::session_backend::SessionBackend> =
12205            Arc::new(zeroclaw_infra::session_store::SessionStore::new(tmp.path()).unwrap());
12206
12207        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12208            channels_by_name: Arc::new(channels_by_name),
12209            model_provider: Arc::new(SessionsCurrentModelProvider),
12210            model_provider_ref: Arc::new("test-provider".to_string()),
12211            agent_alias: Arc::new("test-agent".to_string()),
12212            memory: Arc::new(NoopMemory),
12213            memory_strategy: Arc::new(
12214                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12215                    Arc::new(NoopMemory),
12216                    zeroclaw_config::schema::MemoryConfig::default(),
12217                    std::path::PathBuf::new(),
12218                ),
12219            ),
12220            tools_registry: Arc::new(vec![Box::new(
12221                zeroclaw_runtime::tools::SessionsCurrentTool::new(Arc::clone(&session_store)),
12222            )]),
12223            observer: Arc::new(NoopObserver),
12224            system_prompt: Arc::new("test-system-prompt".to_string()),
12225            model: Arc::new("test-model".to_string()),
12226            temperature: Some(0.0),
12227            auto_save_memory: false,
12228            max_tool_iterations: 10,
12229            min_relevance_score: 0.0,
12230            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12231                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12232            ))),
12233            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12234            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12235            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12236            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12237            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12238            workspace_dir: Arc::new(std::env::temp_dir()),
12239            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12240            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12241            interrupt_on_new_message: InterruptOnNewMessageConfig {
12242                telegram: false,
12243                slack: false,
12244                discord: false,
12245                mattermost: false,
12246                matrix: false,
12247            },
12248            non_cli_excluded_tools: Arc::new(Vec::new()),
12249            autonomy_level: AutonomyLevel::default(),
12250            tool_call_dedup_exempt: Arc::new(Vec::new()),
12251            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12252            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12253            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12254            hooks: None,
12255            model_routes: Arc::new(Vec::new()),
12256            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
12257            ack_reactions: true,
12258            show_tool_calls: true,
12259            session_store: Some(Arc::clone(&session_store)),
12260            approval_manager: Arc::new(ApprovalManager::for_non_interactive(&{
12261                let mut profile = zeroclaw_config::schema::RiskProfileConfig::default();
12262                profile.auto_approve.push("sessions_current".to_string());
12263                profile
12264            })),
12265            activated_tools: None,
12266            cost_tracking: None,
12267            pacing: zeroclaw_config::schema::PacingConfig::default(),
12268            max_tool_result_chars: 0,
12269            context_token_budget: 0,
12270            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
12271                Duration::ZERO,
12272            )),
12273            receipt_generator: None,
12274            show_receipts_in_response: false,
12275            last_applied_config_stamp: Arc::new(Mutex::new(None)),
12276            runtime_defaults_override: Arc::new(Mutex::new(None)),
12277            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12278            agent_transcription_provider: String::new(),
12279        });
12280
12281        process_channel_message(
12282            runtime_ctx,
12283            zeroclaw_api::channel::ChannelMessage {
12284                id: "msg-1".to_string(),
12285                sender: "alice".to_string(),
12286                reply_target: "chat-42".to_string(),
12287                content: "Which session is this?".to_string(),
12288                channel: "test-channel".to_string(),
12289                channel_alias: None,
12290                timestamp: 1,
12291                thread_ts: None,
12292                interruption_scope_id: None,
12293                attachments: vec![],
12294                subject: None,
12295            },
12296            CancellationToken::new(),
12297        )
12298        .await;
12299
12300        let sent_messages = channel_impl.sent_messages.lock().await;
12301        assert!(!sent_messages.is_empty());
12302        let reply = sent_messages.last().unwrap();
12303        assert!(reply.contains("Current session: test-channel_chat-42_alice"));
12304        assert!(reply.contains("Messages: 1"));
12305    }
12306
12307    #[tokio::test]
12308    async fn process_channel_message_renders_trailing_tool_receipts_block_when_enabled() {
12309        // Activated path: a real ReceiptGenerator + show_receipts_in_response=true
12310        // must produce a second send carrying the "Tool receipts:" block with a
12311        // valid zc-receipt-* token. Pre-#6214 this was dead code from the test
12312        // suite because every ChannelRuntimeContext literal pinned the feature
12313        // off; this test guards the integration so a regression in the block
12314        // render or send call surfaces in CI rather than in production.
12315        let channel_impl = Arc::new(RecordingChannel::default());
12316        let channel: Arc<dyn Channel> = channel_impl.clone();
12317
12318        let mut channels_by_name = HashMap::new();
12319        channels_by_name.insert(channel.name().to_string(), channel);
12320
12321        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12322            channels_by_name: Arc::new(channels_by_name),
12323            model_provider: Arc::new(ToolCallingModelProvider),
12324            model_provider_ref: Arc::new("test-provider".to_string()),
12325            agent_alias: Arc::new("test-agent".to_string()),
12326            memory: Arc::new(NoopMemory),
12327            memory_strategy: Arc::new(
12328                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12329                    Arc::new(NoopMemory),
12330                    zeroclaw_config::schema::MemoryConfig::default(),
12331                    std::path::PathBuf::new(),
12332                ),
12333            ),
12334            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
12335            observer: Arc::new(NoopObserver),
12336            system_prompt: Arc::new("test-system-prompt".to_string()),
12337            model: Arc::new("test-model".to_string()),
12338            temperature: Some(0.0),
12339            auto_save_memory: false,
12340            max_tool_iterations: 10,
12341            min_relevance_score: 0.0,
12342            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12343                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12344            ))),
12345            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12346            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12347            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12348            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12349            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12350            workspace_dir: Arc::new(std::env::temp_dir()),
12351            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12352            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12353            interrupt_on_new_message: InterruptOnNewMessageConfig {
12354                telegram: false,
12355                slack: false,
12356                discord: false,
12357                mattermost: false,
12358                matrix: false,
12359            },
12360            non_cli_excluded_tools: Arc::new(Vec::new()),
12361            // Full autonomy + auto-approve mock_price so the loop actually
12362            // reaches execute_one_tool. The other tests in this file pass
12363            // under Supervised because ToolCallingProvider returns the BTC
12364            // reply regardless of whether the tool ran (the LLM only needs
12365            // to see a `[Tool results]` user message — even a "denied"
12366            // payload triggers the deterministic response). Receipts only
12367            // generate on the actual execute path, so we need the gate
12368            // open here.
12369            autonomy_level: AutonomyLevel::Full,
12370            tool_call_dedup_exempt: Arc::new(Vec::new()),
12371            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12372            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12373            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12374            hooks: None,
12375            model_routes: Arc::new(Vec::new()),
12376            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
12377            ack_reactions: true,
12378            show_tool_calls: true,
12379            session_store: None,
12380            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
12381                &zeroclaw_config::schema::RiskProfileConfig {
12382                    level: zeroclaw_config::autonomy::AutonomyLevel::Full,
12383                    auto_approve: vec!["mock_price".to_string()],
12384                    ..Default::default()
12385                },
12386            )),
12387            activated_tools: None,
12388            cost_tracking: None,
12389            pacing: zeroclaw_config::schema::PacingConfig::default(),
12390            max_tool_result_chars: 0,
12391            context_token_budget: 0,
12392            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
12393                Duration::ZERO,
12394            )),
12395            receipt_generator: Some(
12396                zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator::new(),
12397            ),
12398            show_receipts_in_response: true,
12399            last_applied_config_stamp: Arc::new(Mutex::new(None)),
12400            runtime_defaults_override: Arc::new(Mutex::new(None)),
12401            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12402            agent_transcription_provider: String::new(),
12403        });
12404
12405        process_channel_message(
12406            runtime_ctx,
12407            zeroclaw_api::channel::ChannelMessage {
12408                id: "msg-1".to_string(),
12409                sender: "alice".to_string(),
12410                reply_target: "chat-42".to_string(),
12411                content: "What is the BTC price now?".to_string(),
12412                channel: "test-channel".to_string(),
12413                channel_alias: None,
12414                timestamp: 1,
12415                thread_ts: None,
12416                interruption_scope_id: None,
12417                attachments: vec![],
12418                subject: None,
12419            },
12420            CancellationToken::new(),
12421        )
12422        .await;
12423
12424        let sent_messages = channel_impl.sent_messages.lock().await;
12425        // Two sends: the model's reply and the trailing receipts block.
12426        assert!(
12427            sent_messages.len() >= 2,
12428            "expected at least 2 sends (reply + receipts block), got {}: {:?}",
12429            sent_messages.len(),
12430            sent_messages
12431        );
12432
12433        let receipts_message = sent_messages
12434            .iter()
12435            .find(|m| m.contains("Tool receipts:"))
12436            .unwrap_or_else(|| {
12437                panic!(
12438                    "no `Tool receipts:` send found; got {:?}",
12439                    sent_messages.as_slice()
12440                )
12441            });
12442        assert!(
12443            receipts_message.starts_with("chat-42:"),
12444            "receipts block must be sent to the same reply target as the agent reply, got {receipts_message}"
12445        );
12446        assert!(
12447            receipts_message.contains("---\nTool receipts:"),
12448            "receipts block must be prefixed with the documented `---\\nTool receipts:` separator, got {receipts_message}"
12449        );
12450        assert!(
12451            receipts_message.contains("zc-receipt-"),
12452            "receipts block must carry at least one zc-receipt-* HMAC token (proves the generator actually ran), got {receipts_message}"
12453        );
12454        assert!(
12455            receipts_message.contains("mock_price"),
12456            "receipts block should name the tool that produced the receipt, got {receipts_message}"
12457        );
12458    }
12459
12460    #[tokio::test]
12461    async fn process_channel_message_omits_receipts_block_when_disabled() {
12462        // Backward-compat: with show_receipts_in_response=false (default), no
12463        // trailing receipts message is sent — even when a generator is active
12464        // and the loop ran tools. This is the path every other test relies on
12465        // implicitly; assert it once explicitly.
12466        let channel_impl = Arc::new(RecordingChannel::default());
12467        let channel: Arc<dyn Channel> = channel_impl.clone();
12468
12469        let mut channels_by_name = HashMap::new();
12470        channels_by_name.insert(channel.name().to_string(), channel);
12471
12472        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12473            channels_by_name: Arc::new(channels_by_name),
12474            model_provider: Arc::new(ToolCallingModelProvider),
12475            model_provider_ref: Arc::new("test-provider".to_string()),
12476            agent_alias: Arc::new("test-agent".to_string()),
12477            memory: Arc::new(NoopMemory),
12478            memory_strategy: Arc::new(
12479                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12480                    Arc::new(NoopMemory),
12481                    zeroclaw_config::schema::MemoryConfig::default(),
12482                    std::path::PathBuf::new(),
12483                ),
12484            ),
12485            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
12486            observer: Arc::new(NoopObserver),
12487            system_prompt: Arc::new("test-system-prompt".to_string()),
12488            model: Arc::new("test-model".to_string()),
12489            temperature: Some(0.0),
12490            auto_save_memory: false,
12491            max_tool_iterations: 10,
12492            min_relevance_score: 0.0,
12493            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12494                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12495            ))),
12496            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12497            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12498            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12499            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12500            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12501            workspace_dir: Arc::new(std::env::temp_dir()),
12502            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12503            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12504            interrupt_on_new_message: InterruptOnNewMessageConfig {
12505                telegram: false,
12506                slack: false,
12507                discord: false,
12508                mattermost: false,
12509                matrix: false,
12510            },
12511            non_cli_excluded_tools: Arc::new(Vec::new()),
12512            // Match the enabled-test setup so the tool actually runs; the
12513            // assertion below proves the receipt-block send is gated on
12514            // `show_receipts_in_response` and not on whether the loop saw
12515            // any receipts.
12516            autonomy_level: AutonomyLevel::Full,
12517            tool_call_dedup_exempt: Arc::new(Vec::new()),
12518            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12519            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12520            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12521            hooks: None,
12522            model_routes: Arc::new(Vec::new()),
12523            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
12524            ack_reactions: true,
12525            show_tool_calls: true,
12526            session_store: None,
12527            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
12528                &zeroclaw_config::schema::RiskProfileConfig {
12529                    level: zeroclaw_config::autonomy::AutonomyLevel::Full,
12530                    auto_approve: vec!["mock_price".to_string()],
12531                    ..Default::default()
12532                },
12533            )),
12534            activated_tools: None,
12535            cost_tracking: None,
12536            pacing: zeroclaw_config::schema::PacingConfig::default(),
12537            max_tool_result_chars: 0,
12538            context_token_budget: 0,
12539            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
12540                Duration::ZERO,
12541            )),
12542            receipt_generator: Some(
12543                zeroclaw_runtime::agent::tool_receipts::ReceiptGenerator::new(),
12544            ),
12545            show_receipts_in_response: false,
12546            last_applied_config_stamp: Arc::new(Mutex::new(None)),
12547            runtime_defaults_override: Arc::new(Mutex::new(None)),
12548            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12549            agent_transcription_provider: String::new(),
12550        });
12551
12552        process_channel_message(
12553            runtime_ctx,
12554            zeroclaw_api::channel::ChannelMessage {
12555                id: "msg-1".to_string(),
12556                sender: "alice".to_string(),
12557                reply_target: "chat-42".to_string(),
12558                content: "What is the BTC price now?".to_string(),
12559                channel: "test-channel".to_string(),
12560                channel_alias: None,
12561                timestamp: 1,
12562                thread_ts: None,
12563                interruption_scope_id: None,
12564                attachments: vec![],
12565                subject: None,
12566            },
12567            CancellationToken::new(),
12568        )
12569        .await;
12570
12571        let sent_messages = channel_impl.sent_messages.lock().await;
12572        assert!(
12573            !sent_messages.iter().any(|m| m.contains("Tool receipts:")),
12574            "no receipts block must be sent when show_receipts_in_response=false; got {:?}",
12575            sent_messages.as_slice()
12576        );
12577    }
12578
12579    #[tokio::test]
12580    async fn process_channel_message_disabled_receipt_generator_emits_no_receipts_anywhere() {
12581        // Strict #6182 acceptance criterion: enabled=false must emit no
12582        // receipt anywhere — not in any sent message, not in the model's
12583        // view of conversation history. `receipt_generator: None` is the
12584        // wire-level reflection of `[agent.resolved.tool_receipts] enabled = false`.
12585        // Distinct from the show_in_response=false test above (which keeps
12586        // the generator on but suppresses the trailing block); this one
12587        // proves nothing is signed in the first place.
12588        let channel_impl = Arc::new(RecordingChannel::default());
12589        let channel: Arc<dyn Channel> = channel_impl.clone();
12590
12591        let mut channels_by_name = HashMap::new();
12592        channels_by_name.insert(channel.name().to_string(), channel);
12593
12594        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12595            channels_by_name: Arc::new(channels_by_name),
12596            model_provider: Arc::new(ToolCallingModelProvider),
12597            model_provider_ref: Arc::new("test-provider".to_string()),
12598            agent_alias: Arc::new("test-agent".to_string()),
12599            memory: Arc::new(NoopMemory),
12600            memory_strategy: Arc::new(
12601                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12602                    Arc::new(NoopMemory),
12603                    zeroclaw_config::schema::MemoryConfig::default(),
12604                    std::path::PathBuf::new(),
12605                ),
12606            ),
12607            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
12608            observer: Arc::new(NoopObserver),
12609            system_prompt: Arc::new("test-system-prompt".to_string()),
12610            model: Arc::new("test-model".to_string()),
12611            temperature: Some(0.0),
12612            auto_save_memory: false,
12613            max_tool_iterations: 10,
12614            min_relevance_score: 0.0,
12615            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12616                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12617            ))),
12618            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12619            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12620            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12621            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12622            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12623            workspace_dir: Arc::new(std::env::temp_dir()),
12624            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12625            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12626            interrupt_on_new_message: InterruptOnNewMessageConfig {
12627                telegram: false,
12628                slack: false,
12629                discord: false,
12630                mattermost: false,
12631                matrix: false,
12632            },
12633            non_cli_excluded_tools: Arc::new(Vec::new()),
12634            autonomy_level: AutonomyLevel::Full,
12635            tool_call_dedup_exempt: Arc::new(Vec::new()),
12636            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12637            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12638            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12639            hooks: None,
12640            model_routes: Arc::new(Vec::new()),
12641            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
12642            ack_reactions: true,
12643            show_tool_calls: true,
12644            session_store: None,
12645            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
12646                &zeroclaw_config::schema::RiskProfileConfig {
12647                    level: zeroclaw_config::autonomy::AutonomyLevel::Full,
12648                    auto_approve: vec!["mock_price".to_string()],
12649                    ..Default::default()
12650                },
12651            )),
12652            activated_tools: None,
12653            cost_tracking: None,
12654            pacing: zeroclaw_config::schema::PacingConfig::default(),
12655            max_tool_result_chars: 0,
12656            context_token_budget: 0,
12657            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
12658                Duration::ZERO,
12659            )),
12660            receipt_generator: None,
12661            show_receipts_in_response: false,
12662            last_applied_config_stamp: Arc::new(Mutex::new(None)),
12663            runtime_defaults_override: Arc::new(Mutex::new(None)),
12664            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12665            agent_transcription_provider: String::new(),
12666        });
12667
12668        process_channel_message(
12669            runtime_ctx.clone(),
12670            zeroclaw_api::channel::ChannelMessage {
12671                id: "msg-1".to_string(),
12672                sender: "alice".to_string(),
12673                reply_target: "chat-42".to_string(),
12674                content: "What is the BTC price now?".to_string(),
12675                channel: "test-channel".to_string(),
12676                channel_alias: None,
12677                timestamp: 1,
12678                thread_ts: None,
12679                interruption_scope_id: None,
12680                attachments: vec![],
12681                subject: None,
12682            },
12683            CancellationToken::new(),
12684        )
12685        .await;
12686
12687        let sent_messages = channel_impl.sent_messages.lock().await;
12688        assert!(
12689            !sent_messages.is_empty(),
12690            "agent must still respond when receipts are disabled"
12691        );
12692        assert!(
12693            !sent_messages.iter().any(|m| m.contains("zc-receipt-")),
12694            "no zc-receipt- token must appear in any sent message when receipts are disabled, got {:?}",
12695            sent_messages.as_slice()
12696        );
12697        assert!(
12698            !sent_messages.iter().any(|m| m.contains("Tool receipts:")),
12699            "no `Tool receipts:` block must be sent when receipts are disabled, got {:?}",
12700            sent_messages.as_slice()
12701        );
12702
12703        // Strict surface check: the model's view of conversation history must
12704        // not carry a `[receipt: ` trailer either, otherwise an LLM trained
12705        // on echoing receipts could leak signed-looking output even though
12706        // nothing was actually signed.
12707        let histories = runtime_ctx
12708            .conversation_histories
12709            .lock()
12710            .unwrap_or_else(|e| e.into_inner());
12711        for (_key, turns) in histories.iter() {
12712            for msg in turns.iter() {
12713                assert!(
12714                    !msg.content.contains("[receipt: "),
12715                    "no `[receipt: ` trailer must appear in conversation history when receipts are disabled, got: {}",
12716                    msg.content
12717                );
12718            }
12719        }
12720    }
12721
12722    #[tokio::test]
12723    async fn process_channel_message_telegram_does_not_persist_tool_summary_prefix() {
12724        let channel_impl = Arc::new(TelegramRecordingChannel::default());
12725        let channel: Arc<dyn Channel> = channel_impl.clone();
12726
12727        let mut channels_by_name = HashMap::new();
12728        channels_by_name.insert(channel.name().to_string(), channel);
12729
12730        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12731            channels_by_name: Arc::new(channels_by_name),
12732            model_provider: Arc::new(ToolCallingModelProvider),
12733            model_provider_ref: Arc::new("test-provider".to_string()),
12734            agent_alias: Arc::new("test-agent".to_string()),
12735            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12736            memory: Arc::new(NoopMemory),
12737            memory_strategy: Arc::new(
12738                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12739                    Arc::new(NoopMemory),
12740                    zeroclaw_config::schema::MemoryConfig::default(),
12741                    std::path::PathBuf::new(),
12742                ),
12743            ),
12744            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
12745            observer: Arc::new(NoopObserver),
12746            system_prompt: Arc::new("test-system-prompt".to_string()),
12747            model: Arc::new("test-model".to_string()),
12748            temperature: Some(0.0),
12749            auto_save_memory: false,
12750            max_tool_iterations: 10,
12751            min_relevance_score: 0.0,
12752            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12753                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12754            ))),
12755            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12756            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12757            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12758            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12759            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12760            workspace_dir: Arc::new(std::env::temp_dir()),
12761            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12762            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12763            interrupt_on_new_message: InterruptOnNewMessageConfig {
12764                telegram: false,
12765                slack: false,
12766                discord: false,
12767                mattermost: false,
12768                matrix: false,
12769            },
12770            non_cli_excluded_tools: Arc::new(Vec::new()),
12771            autonomy_level: AutonomyLevel::default(),
12772            tool_call_dedup_exempt: Arc::new(Vec::new()),
12773            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12774            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12775            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12776            agent_transcription_provider: String::new(),
12777            hooks: None,
12778            model_routes: Arc::new(Vec::new()),
12779            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
12780            ack_reactions: true,
12781            show_tool_calls: true,
12782            session_store: None,
12783            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
12784                &zeroclaw_config::schema::RiskProfileConfig::default(),
12785            )),
12786            activated_tools: None,
12787            cost_tracking: None,
12788            pacing: zeroclaw_config::schema::PacingConfig::default(),
12789            max_tool_result_chars: 0,
12790            context_token_budget: 0,
12791            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
12792                Duration::ZERO,
12793            )),
12794            receipt_generator: None,
12795            show_receipts_in_response: false,
12796            last_applied_config_stamp: Arc::new(Mutex::new(None)),
12797            runtime_defaults_override: Arc::new(Mutex::new(None)),
12798        });
12799
12800        process_channel_message(
12801            runtime_ctx.clone(),
12802            zeroclaw_api::channel::ChannelMessage {
12803                id: "msg-telegram-tool-1".to_string(),
12804                sender: "alice".to_string(),
12805                reply_target: "chat-telegram".to_string(),
12806                content: "What is the BTC price now?".to_string(),
12807                channel: "telegram".to_string(),
12808                channel_alias: None,
12809                timestamp: 1,
12810                thread_ts: None,
12811                interruption_scope_id: None,
12812                attachments: vec![],
12813                subject: None,
12814            },
12815            CancellationToken::new(),
12816        )
12817        .await;
12818
12819        let sent_messages = channel_impl.sent_messages.lock().await;
12820        assert!(!sent_messages.is_empty());
12821        let reply = sent_messages.last().unwrap();
12822        assert!(reply.contains("BTC is currently around"));
12823
12824        let histories = runtime_ctx
12825            .conversation_histories
12826            .lock()
12827            .unwrap_or_else(|e| e.into_inner());
12828        let turns = histories
12829            .peek("telegram_chat-telegram_alice")
12830            .expect("telegram history should be stored");
12831        let assistant_turn = turns
12832            .iter()
12833            .rev()
12834            .find(|turn| turn.role == "assistant")
12835            .expect("assistant turn should be present");
12836        assert!(
12837            !assistant_turn.content.contains("[Used tools:"),
12838            "telegram history should not persist tool-summary prefix"
12839        );
12840    }
12841
12842    #[tokio::test]
12843    async fn process_channel_message_strips_unexecuted_tool_json_artifacts_from_reply() {
12844        let channel_impl = Arc::new(RecordingChannel::default());
12845        let channel: Arc<dyn Channel> = channel_impl.clone();
12846
12847        let mut channels_by_name = HashMap::new();
12848        channels_by_name.insert(channel.name().to_string(), channel);
12849
12850        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12851            channels_by_name: Arc::new(channels_by_name),
12852            model_provider: Arc::new(RawToolArtifactModelProvider),
12853            model_provider_ref: Arc::new("test-provider".to_string()),
12854            agent_alias: Arc::new("test-agent".to_string()),
12855            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12856            memory: Arc::new(NoopMemory),
12857            memory_strategy: Arc::new(
12858                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12859                    Arc::new(NoopMemory),
12860                    zeroclaw_config::schema::MemoryConfig::default(),
12861                    std::path::PathBuf::new(),
12862                ),
12863            ),
12864            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
12865            observer: Arc::new(NoopObserver),
12866            system_prompt: Arc::new("test-system-prompt".to_string()),
12867            model: Arc::new("test-model".to_string()),
12868            temperature: Some(0.0),
12869            auto_save_memory: false,
12870            max_tool_iterations: 10,
12871            min_relevance_score: 0.0,
12872            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12873                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12874            ))),
12875            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12876            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12877            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12878            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12879            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12880            workspace_dir: Arc::new(std::env::temp_dir()),
12881            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12882            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12883            interrupt_on_new_message: InterruptOnNewMessageConfig {
12884                telegram: false,
12885                slack: false,
12886                discord: false,
12887                mattermost: false,
12888                matrix: false,
12889            },
12890            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12891            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12892            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12893            agent_transcription_provider: String::new(),
12894            hooks: None,
12895            non_cli_excluded_tools: Arc::new(Vec::new()),
12896            autonomy_level: AutonomyLevel::default(),
12897            tool_call_dedup_exempt: Arc::new(Vec::new()),
12898            model_routes: Arc::new(Vec::new()),
12899            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
12900            ack_reactions: true,
12901            show_tool_calls: true,
12902            session_store: None,
12903            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
12904                &zeroclaw_config::schema::RiskProfileConfig::default(),
12905            )),
12906            activated_tools: None,
12907            cost_tracking: None,
12908            pacing: zeroclaw_config::schema::PacingConfig::default(),
12909            max_tool_result_chars: 0,
12910            context_token_budget: 0,
12911            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
12912                Duration::ZERO,
12913            )),
12914            receipt_generator: None,
12915            show_receipts_in_response: false,
12916            last_applied_config_stamp: Arc::new(Mutex::new(None)),
12917            runtime_defaults_override: Arc::new(Mutex::new(None)),
12918        });
12919
12920        process_channel_message(
12921            runtime_ctx,
12922            zeroclaw_api::channel::ChannelMessage {
12923                id: "msg-raw-json".to_string(),
12924                sender: "alice".to_string(),
12925                reply_target: "chat-raw".to_string(),
12926                content: "What is the BTC price now?".to_string(),
12927                channel: "test-channel".to_string(),
12928                channel_alias: None,
12929                timestamp: 3,
12930                thread_ts: None,
12931                interruption_scope_id: None,
12932                attachments: vec![],
12933                subject: None,
12934            },
12935            CancellationToken::new(),
12936        )
12937        .await;
12938
12939        let sent_messages = channel_impl.sent_messages.lock().await;
12940        assert_eq!(sent_messages.len(), 1);
12941        assert!(sent_messages[0].starts_with("chat-raw:"));
12942        assert!(sent_messages[0].contains("BTC is currently around"));
12943        assert!(!sent_messages[0].contains("\"name\":\"mock_price\""));
12944        assert!(!sent_messages[0].contains("\"result\""));
12945    }
12946
12947    #[tokio::test]
12948    async fn process_channel_message_executes_tool_calls_with_alias_tags() {
12949        let channel_impl = Arc::new(RecordingChannel::default());
12950        let channel: Arc<dyn Channel> = channel_impl.clone();
12951
12952        let mut channels_by_name = HashMap::new();
12953        channels_by_name.insert(channel.name().to_string(), channel);
12954
12955        let runtime_ctx = Arc::new(ChannelRuntimeContext {
12956            channels_by_name: Arc::new(channels_by_name),
12957            model_provider: Arc::new(ToolCallingAliasModelProvider),
12958            model_provider_ref: Arc::new("test-provider".to_string()),
12959            agent_alias: Arc::new("test-agent".to_string()),
12960            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
12961            memory: Arc::new(NoopMemory),
12962            memory_strategy: Arc::new(
12963                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
12964                    Arc::new(NoopMemory),
12965                    zeroclaw_config::schema::MemoryConfig::default(),
12966                    std::path::PathBuf::new(),
12967                ),
12968            ),
12969            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
12970            observer: Arc::new(NoopObserver),
12971            system_prompt: Arc::new("test-system-prompt".to_string()),
12972            model: Arc::new("test-model".to_string()),
12973            temperature: Some(0.0),
12974            auto_save_memory: false,
12975            max_tool_iterations: 10,
12976            min_relevance_score: 0.0,
12977            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
12978                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
12979            ))),
12980            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
12981            provider_cache: Arc::new(Mutex::new(HashMap::new())),
12982            route_overrides: Arc::new(Mutex::new(HashMap::new())),
12983            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
12984            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
12985            workspace_dir: Arc::new(std::env::temp_dir()),
12986            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
12987            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
12988            interrupt_on_new_message: InterruptOnNewMessageConfig {
12989                telegram: false,
12990                slack: false,
12991                discord: false,
12992                mattermost: false,
12993                matrix: false,
12994            },
12995            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
12996            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
12997            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
12998            agent_transcription_provider: String::new(),
12999            hooks: None,
13000            non_cli_excluded_tools: Arc::new(Vec::new()),
13001            autonomy_level: AutonomyLevel::default(),
13002            tool_call_dedup_exempt: Arc::new(Vec::new()),
13003            model_routes: Arc::new(Vec::new()),
13004            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
13005            ack_reactions: true,
13006            show_tool_calls: true,
13007            session_store: None,
13008            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
13009                &zeroclaw_config::schema::RiskProfileConfig::default(),
13010            )),
13011            activated_tools: None,
13012            cost_tracking: None,
13013            pacing: zeroclaw_config::schema::PacingConfig::default(),
13014            max_tool_result_chars: 0,
13015            context_token_budget: 0,
13016            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
13017                Duration::ZERO,
13018            )),
13019            receipt_generator: None,
13020            show_receipts_in_response: false,
13021            last_applied_config_stamp: Arc::new(Mutex::new(None)),
13022            runtime_defaults_override: Arc::new(Mutex::new(None)),
13023        });
13024
13025        process_channel_message(
13026            runtime_ctx,
13027            zeroclaw_api::channel::ChannelMessage {
13028                id: "msg-2".to_string(),
13029                sender: "bob".to_string(),
13030                reply_target: "chat-84".to_string(),
13031                content: "What is the BTC price now?".to_string(),
13032                channel: "test-channel".to_string(),
13033                channel_alias: None,
13034                timestamp: 2,
13035                thread_ts: None,
13036                interruption_scope_id: None,
13037                attachments: vec![],
13038                subject: None,
13039            },
13040            CancellationToken::new(),
13041        )
13042        .await;
13043
13044        let sent_messages = channel_impl.sent_messages.lock().await;
13045        assert!(!sent_messages.is_empty());
13046        let reply = sent_messages.last().unwrap();
13047        assert!(reply.starts_with("chat-84:"));
13048        assert!(reply.contains("alias-tag flow resolved"));
13049        assert!(!reply.contains("<toolcall>"));
13050        assert!(!reply.contains("mock_price"));
13051    }
13052
13053    #[tokio::test]
13054    async fn process_channel_message_handles_models_command_without_llm_call() {
13055        let channel_impl = Arc::new(TelegramRecordingChannel::default());
13056        let channel: Arc<dyn Channel> = channel_impl.clone();
13057
13058        let mut channels_by_name = HashMap::new();
13059        channels_by_name.insert(channel.name().to_string(), channel);
13060
13061        let agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
13062        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
13063        let alt_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
13064        let alt_model_provider: Arc<dyn ModelProvider> = alt_model_provider_impl.clone();
13065
13066        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
13067        provider_cache_seed.insert(
13068            "test-provider".to_string(),
13069            Arc::clone(&agent_model_provider),
13070        );
13071        provider_cache_seed.insert("openrouter.default".to_string(), alt_model_provider);
13072
13073        let mut prompt_config = zeroclaw_config::schema::Config::default();
13074        prompt_config
13075            .providers
13076            .models
13077            .ensure("openrouter", "default")
13078            .expect("openrouter slot must exist");
13079
13080        let runtime_ctx = Arc::new(ChannelRuntimeContext {
13081            channels_by_name: Arc::new(channels_by_name),
13082            model_provider: Arc::clone(&agent_model_provider),
13083            model_provider_ref: Arc::new("test-provider".to_string()),
13084            agent_alias: Arc::new("test-agent".to_string()),
13085            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
13086            memory: Arc::new(NoopMemory),
13087            memory_strategy: Arc::new(
13088                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
13089                    Arc::new(NoopMemory),
13090                    zeroclaw_config::schema::MemoryConfig::default(),
13091                    std::path::PathBuf::new(),
13092                ),
13093            ),
13094            tools_registry: Arc::new(vec![]),
13095            observer: Arc::new(NoopObserver),
13096            system_prompt: Arc::new("test-system-prompt".to_string()),
13097            model: Arc::new("default-model".to_string()),
13098            temperature: Some(0.0),
13099            auto_save_memory: false,
13100            max_tool_iterations: 5,
13101            min_relevance_score: 0.0,
13102            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
13103                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
13104            ))),
13105            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
13106            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
13107            route_overrides: Arc::new(Mutex::new(HashMap::new())),
13108            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
13109            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
13110            workspace_dir: Arc::new(std::env::temp_dir()),
13111            prompt_config: Arc::new(prompt_config),
13112            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
13113            interrupt_on_new_message: InterruptOnNewMessageConfig {
13114                telegram: false,
13115                slack: false,
13116                discord: false,
13117                mattermost: false,
13118                matrix: false,
13119            },
13120            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
13121            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
13122            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
13123            agent_transcription_provider: String::new(),
13124            hooks: None,
13125            non_cli_excluded_tools: Arc::new(Vec::new()),
13126            autonomy_level: AutonomyLevel::default(),
13127            tool_call_dedup_exempt: Arc::new(Vec::new()),
13128            model_routes: Arc::new(Vec::new()),
13129            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
13130            ack_reactions: true,
13131            show_tool_calls: true,
13132            session_store: None,
13133            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
13134                &zeroclaw_config::schema::RiskProfileConfig::default(),
13135            )),
13136            activated_tools: None,
13137            cost_tracking: None,
13138            pacing: zeroclaw_config::schema::PacingConfig::default(),
13139            max_tool_result_chars: 0,
13140            context_token_budget: 0,
13141            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
13142                Duration::ZERO,
13143            )),
13144            receipt_generator: None,
13145            show_receipts_in_response: false,
13146            last_applied_config_stamp: Arc::new(Mutex::new(None)),
13147            runtime_defaults_override: Arc::new(Mutex::new(None)),
13148        });
13149
13150        process_channel_message(
13151            runtime_ctx.clone(),
13152            zeroclaw_api::channel::ChannelMessage {
13153                id: "msg-cmd-1".to_string(),
13154                sender: "alice".to_string(),
13155                reply_target: "chat-1".to_string(),
13156                content: "/models openrouter".to_string(),
13157                channel: "telegram".to_string(),
13158                channel_alias: None,
13159                timestamp: 1,
13160                thread_ts: None,
13161                interruption_scope_id: None,
13162                attachments: vec![],
13163                subject: None,
13164            },
13165            CancellationToken::new(),
13166        )
13167        .await;
13168
13169        let sent = channel_impl.sent_messages.lock().await;
13170        assert_eq!(sent.len(), 1);
13171        assert!(sent[0].contains("ModelProvider switched to `openrouter.default`"));
13172
13173        let route_key = "telegram_chat-1_alice";
13174        let route = runtime_ctx
13175            .route_overrides
13176            .lock()
13177            .unwrap_or_else(|e| e.into_inner())
13178            .get(route_key)
13179            .cloned()
13180            .expect("route should be stored for sender");
13181        assert_eq!(route.model_provider, "openrouter.default");
13182        assert_eq!(route.model, "default-model");
13183
13184        assert_eq!(
13185            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
13186            0
13187        );
13188        assert_eq!(alt_model_provider_impl.call_count.load(Ordering::SeqCst), 0);
13189    }
13190
13191    #[tokio::test]
13192    async fn process_channel_message_uses_route_override_provider_and_model() {
13193        let channel_impl = Arc::new(TelegramRecordingChannel::default());
13194        let channel: Arc<dyn Channel> = channel_impl.clone();
13195
13196        let mut channels_by_name = HashMap::new();
13197        channels_by_name.insert(channel.name().to_string(), channel);
13198
13199        let agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
13200        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
13201        let routed_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
13202        let routed_model_provider: Arc<dyn ModelProvider> = routed_model_provider_impl.clone();
13203
13204        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
13205        provider_cache_seed.insert(
13206            "test-provider".to_string(),
13207            Arc::clone(&agent_model_provider),
13208        );
13209        provider_cache_seed.insert("openrouter".to_string(), routed_model_provider);
13210
13211        let route_key = "telegram_chat-1_alice".to_string();
13212        let mut route_overrides = HashMap::new();
13213        route_overrides.insert(
13214            route_key,
13215            ChannelRouteSelection {
13216                model_provider: "openrouter".into(),
13217                model: "route-model".to_string(),
13218                api_key: None,
13219            },
13220        );
13221
13222        let runtime_ctx = Arc::new(ChannelRuntimeContext {
13223            channels_by_name: Arc::new(channels_by_name),
13224            model_provider: Arc::clone(&agent_model_provider),
13225            model_provider_ref: Arc::new("test-provider".to_string()),
13226            agent_alias: Arc::new("test-agent".to_string()),
13227            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
13228            memory: Arc::new(NoopMemory),
13229            memory_strategy: Arc::new(
13230                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
13231                    Arc::new(NoopMemory),
13232                    zeroclaw_config::schema::MemoryConfig::default(),
13233                    std::path::PathBuf::new(),
13234                ),
13235            ),
13236            tools_registry: Arc::new(vec![]),
13237            observer: Arc::new(NoopObserver),
13238            system_prompt: Arc::new("test-system-prompt".to_string()),
13239            model: Arc::new("default-model".to_string()),
13240            temperature: Some(0.0),
13241            auto_save_memory: false,
13242            max_tool_iterations: 5,
13243            min_relevance_score: 0.0,
13244            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
13245                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
13246            ))),
13247            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
13248            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
13249            route_overrides: Arc::new(Mutex::new(route_overrides)),
13250            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
13251            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
13252            workspace_dir: Arc::new(std::env::temp_dir()),
13253            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
13254            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
13255            interrupt_on_new_message: InterruptOnNewMessageConfig {
13256                telegram: false,
13257                slack: false,
13258                discord: false,
13259                mattermost: false,
13260                matrix: false,
13261            },
13262            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
13263            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
13264            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
13265            agent_transcription_provider: String::new(),
13266            hooks: None,
13267            non_cli_excluded_tools: Arc::new(Vec::new()),
13268            autonomy_level: AutonomyLevel::default(),
13269            tool_call_dedup_exempt: Arc::new(Vec::new()),
13270            model_routes: Arc::new(Vec::new()),
13271            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
13272            ack_reactions: true,
13273            show_tool_calls: true,
13274            session_store: None,
13275            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
13276                &zeroclaw_config::schema::RiskProfileConfig::default(),
13277            )),
13278            activated_tools: None,
13279            cost_tracking: None,
13280            pacing: zeroclaw_config::schema::PacingConfig::default(),
13281            max_tool_result_chars: 0,
13282            context_token_budget: 0,
13283            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
13284                Duration::ZERO,
13285            )),
13286            receipt_generator: None,
13287            show_receipts_in_response: false,
13288            last_applied_config_stamp: Arc::new(Mutex::new(None)),
13289            runtime_defaults_override: Arc::new(Mutex::new(None)),
13290        });
13291
13292        process_channel_message(
13293            runtime_ctx,
13294            zeroclaw_api::channel::ChannelMessage {
13295                id: "msg-routed-1".to_string(),
13296                sender: "alice".to_string(),
13297                reply_target: "chat-1".to_string(),
13298                content: "hello routed model_provider".to_string(),
13299                channel: "telegram".to_string(),
13300                channel_alias: None,
13301                timestamp: 2,
13302                thread_ts: None,
13303                interruption_scope_id: None,
13304                attachments: vec![],
13305                subject: None,
13306            },
13307            CancellationToken::new(),
13308        )
13309        .await;
13310
13311        assert_eq!(
13312            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
13313            0
13314        );
13315        assert_eq!(
13316            routed_model_provider_impl.call_count.load(Ordering::SeqCst),
13317            1
13318        );
13319        assert_eq!(
13320            routed_model_provider_impl
13321                .models
13322                .lock()
13323                .unwrap_or_else(|e| e.into_inner())
13324                .as_slice(),
13325            &["route-model".to_string()]
13326        );
13327    }
13328
13329    #[tokio::test]
13330    async fn process_channel_message_uses_classifier_provider_for_precheck_model_selection() {
13331        let channel_impl = Arc::new(RecordingChannel::default());
13332        let channel: Arc<dyn Channel> = channel_impl.clone();
13333        let main_provider_impl = Arc::new(PrecheckProbeModelProvider::default());
13334        let main_provider: Arc<dyn ModelProvider> = main_provider_impl.clone();
13335        let classifier_provider_impl = Arc::new(PrecheckProbeModelProvider::default());
13336        let classifier_provider: Arc<dyn ModelProvider> = classifier_provider_impl.clone();
13337        let mut prompt_config = zeroclaw_config::schema::Config::default();
13338        prompt_config.providers.models.openai.insert(
13339            "my-classifier".to_string(),
13340            zeroclaw_config::schema::OpenAIModelProviderConfig {
13341                base: zeroclaw_config::schema::ModelProviderConfig {
13342                    model: Some("fast-intent".to_string()),
13343                    temperature: Some(0.0),
13344                    ..Default::default()
13345                },
13346            },
13347        );
13348        let agent_cfg = zeroclaw_config::schema::AliasedAgentConfig {
13349            classifier_provider: zeroclaw_config::providers::ModelProviderRef::from(
13350                "openai.my-classifier",
13351            ),
13352            ..Default::default()
13353        };
13354        let runtime_ctx = test_runtime_ctx_with_config_agent_and_provider_ref(
13355            channel,
13356            main_provider,
13357            prompt_config,
13358            agent_cfg,
13359            "test-provider",
13360        );
13361        runtime_ctx
13362            .provider_cache
13363            .lock()
13364            .unwrap_or_else(|e| e.into_inner())
13365            .insert("openai.my-classifier".to_string(), classifier_provider);
13366
13367        process_channel_message(
13368            runtime_ctx,
13369            zeroclaw_api::channel::ChannelMessage {
13370                id: "msg-classifier-provider".to_string(),
13371                sender: "alice".to_string(),
13372                reply_target: "chat-precheck".to_string(),
13373                content: "background chatter".to_string(),
13374                channel: "test-channel".to_string(),
13375                channel_alias: None,
13376                timestamp: 1,
13377                thread_ts: None,
13378                interruption_scope_id: None,
13379                attachments: vec![],
13380                subject: None,
13381            },
13382            CancellationToken::new(),
13383        )
13384        .await;
13385
13386        assert_eq!(
13387            classifier_provider_impl
13388                .precheck_calls
13389                .load(Ordering::SeqCst),
13390            1
13391        );
13392        assert_eq!(
13393            classifier_provider_impl.main_calls.load(Ordering::SeqCst),
13394            0
13395        );
13396        assert_eq!(main_provider_impl.precheck_calls.load(Ordering::SeqCst), 0);
13397        assert_eq!(main_provider_impl.main_calls.load(Ordering::SeqCst), 0);
13398        let models = classifier_provider_impl
13399            .models
13400            .lock()
13401            .unwrap_or_else(|e| e.into_inner())
13402            .clone();
13403        assert_eq!(models.as_slice(), ["fast-intent"]);
13404        let sent_messages = channel_impl.sent_messages.lock().await;
13405        assert!(
13406            sent_messages.is_empty(),
13407            "provider returns NO_REPLY from precheck, so no visible reply should be sent"
13408        );
13409    }
13410
13411    #[tokio::test]
13412    async fn process_channel_message_prefers_cached_default_provider_instance() {
13413        let channel_impl = Arc::new(TelegramRecordingChannel::default());
13414        let channel: Arc<dyn Channel> = channel_impl.clone();
13415
13416        let mut channels_by_name = HashMap::new();
13417        channels_by_name.insert(channel.name().to_string(), channel);
13418
13419        let startup_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
13420        let startup_model_provider: Arc<dyn ModelProvider> = startup_model_provider_impl.clone();
13421        let reloaded_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
13422        let reloaded_model_provider: Arc<dyn ModelProvider> = reloaded_model_provider_impl.clone();
13423
13424        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
13425        provider_cache_seed.insert("test-provider".to_string(), reloaded_model_provider);
13426
13427        let runtime_ctx = Arc::new(ChannelRuntimeContext {
13428            channels_by_name: Arc::new(channels_by_name),
13429            model_provider: Arc::clone(&startup_model_provider),
13430            model_provider_ref: Arc::new("test-provider".to_string()),
13431            agent_alias: Arc::new("test-agent".to_string()),
13432            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
13433            memory: Arc::new(NoopMemory),
13434            memory_strategy: Arc::new(
13435                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
13436                    Arc::new(NoopMemory),
13437                    zeroclaw_config::schema::MemoryConfig::default(),
13438                    std::path::PathBuf::new(),
13439                ),
13440            ),
13441            tools_registry: Arc::new(vec![]),
13442            observer: Arc::new(NoopObserver),
13443            system_prompt: Arc::new("test-system-prompt".to_string()),
13444            model: Arc::new("default-model".to_string()),
13445            temperature: Some(0.0),
13446            auto_save_memory: false,
13447            max_tool_iterations: 5,
13448            min_relevance_score: 0.0,
13449            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
13450                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
13451            ))),
13452            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
13453            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
13454            route_overrides: Arc::new(Mutex::new(HashMap::new())),
13455            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
13456            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
13457            workspace_dir: Arc::new(std::env::temp_dir()),
13458            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
13459            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
13460            interrupt_on_new_message: InterruptOnNewMessageConfig {
13461                telegram: false,
13462                slack: false,
13463                discord: false,
13464                mattermost: false,
13465                matrix: false,
13466            },
13467            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
13468            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
13469            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
13470            agent_transcription_provider: String::new(),
13471            hooks: None,
13472            non_cli_excluded_tools: Arc::new(Vec::new()),
13473            autonomy_level: AutonomyLevel::default(),
13474            tool_call_dedup_exempt: Arc::new(Vec::new()),
13475            model_routes: Arc::new(Vec::new()),
13476            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
13477            ack_reactions: true,
13478            show_tool_calls: true,
13479            session_store: None,
13480            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
13481                &zeroclaw_config::schema::RiskProfileConfig::default(),
13482            )),
13483            activated_tools: None,
13484            cost_tracking: None,
13485            pacing: zeroclaw_config::schema::PacingConfig::default(),
13486            max_tool_result_chars: 0,
13487            context_token_budget: 0,
13488            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
13489                Duration::ZERO,
13490            )),
13491            receipt_generator: None,
13492            show_receipts_in_response: false,
13493            last_applied_config_stamp: Arc::new(Mutex::new(None)),
13494            runtime_defaults_override: Arc::new(Mutex::new(None)),
13495        });
13496
13497        process_channel_message(
13498            runtime_ctx,
13499            zeroclaw_api::channel::ChannelMessage {
13500                id: "msg-default-provider-cache".to_string(),
13501                sender: "alice".to_string(),
13502                reply_target: "chat-1".to_string(),
13503                content: "hello cached default model_provider".to_string(),
13504                channel: "telegram".to_string(),
13505                channel_alias: None,
13506                timestamp: 3,
13507                thread_ts: None,
13508                interruption_scope_id: None,
13509                attachments: vec![],
13510                subject: None,
13511            },
13512            CancellationToken::new(),
13513        )
13514        .await;
13515    }
13516
13517    #[tokio::test]
13518    async fn process_channel_message_respects_configured_max_tool_iterations_above_default() {
13519        let channel_impl = Arc::new(RecordingChannel::default());
13520        let channel: Arc<dyn Channel> = channel_impl.clone();
13521
13522        let mut channels_by_name = HashMap::new();
13523        channels_by_name.insert(channel.name().to_string(), channel);
13524
13525        let runtime_ctx = Arc::new(ChannelRuntimeContext {
13526            channels_by_name: Arc::new(channels_by_name),
13527            model_provider: Arc::new(IterativeToolModelProvider {
13528                required_tool_iterations: 11,
13529            }),
13530            model_provider_ref: Arc::new("test-provider".to_string()),
13531            agent_alias: Arc::new("test-agent".to_string()),
13532            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
13533            memory: Arc::new(NoopMemory),
13534            memory_strategy: Arc::new(
13535                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
13536                    Arc::new(NoopMemory),
13537                    zeroclaw_config::schema::MemoryConfig::default(),
13538                    std::path::PathBuf::new(),
13539                ),
13540            ),
13541            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
13542            observer: Arc::new(NoopObserver),
13543            system_prompt: Arc::new("test-system-prompt".to_string()),
13544            model: Arc::new("test-model".to_string()),
13545            temperature: Some(0.0),
13546            auto_save_memory: false,
13547            max_tool_iterations: 12,
13548            min_relevance_score: 0.0,
13549            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
13550                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
13551            ))),
13552            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
13553            provider_cache: Arc::new(Mutex::new(HashMap::new())),
13554            route_overrides: Arc::new(Mutex::new(HashMap::new())),
13555            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
13556            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
13557            workspace_dir: Arc::new(std::env::temp_dir()),
13558            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
13559            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
13560            interrupt_on_new_message: InterruptOnNewMessageConfig {
13561                telegram: false,
13562                slack: false,
13563                discord: false,
13564                mattermost: false,
13565                matrix: false,
13566            },
13567            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
13568            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
13569            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
13570            agent_transcription_provider: String::new(),
13571            hooks: None,
13572            non_cli_excluded_tools: Arc::new(Vec::new()),
13573            autonomy_level: AutonomyLevel::default(),
13574            tool_call_dedup_exempt: Arc::new(Vec::new()),
13575            model_routes: Arc::new(Vec::new()),
13576            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
13577            ack_reactions: true,
13578            show_tool_calls: true,
13579            session_store: None,
13580            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
13581                &zeroclaw_config::schema::RiskProfileConfig::default(),
13582            )),
13583            activated_tools: None,
13584            cost_tracking: None,
13585            pacing: zeroclaw_config::schema::PacingConfig {
13586                loop_detection_enabled: false,
13587                ..zeroclaw_config::schema::PacingConfig::default()
13588            },
13589            max_tool_result_chars: 0,
13590            context_token_budget: 0,
13591            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
13592                Duration::ZERO,
13593            )),
13594            receipt_generator: None,
13595            show_receipts_in_response: false,
13596            last_applied_config_stamp: Arc::new(Mutex::new(None)),
13597            runtime_defaults_override: Arc::new(Mutex::new(None)),
13598        });
13599
13600        process_channel_message(
13601            runtime_ctx,
13602            zeroclaw_api::channel::ChannelMessage {
13603                id: "msg-iter-success".to_string(),
13604                sender: "alice".to_string(),
13605                reply_target: "chat-iter-success".to_string(),
13606                content: "Loop until done".to_string(),
13607                channel: "test-channel".to_string(),
13608                channel_alias: None,
13609                timestamp: 1,
13610                thread_ts: None,
13611                interruption_scope_id: None,
13612                attachments: vec![],
13613                subject: None,
13614            },
13615            CancellationToken::new(),
13616        )
13617        .await;
13618
13619        let sent_messages = channel_impl.sent_messages.lock().await;
13620        assert!(!sent_messages.is_empty());
13621        let reply = sent_messages.last().unwrap();
13622        assert!(reply.starts_with("chat-iter-success:"));
13623        assert!(reply.contains("Completed after 11 tool iterations."));
13624        assert!(!reply.contains("⚠️ Error:"));
13625    }
13626
13627    #[tokio::test]
13628    async fn process_channel_message_reports_configured_max_tool_iterations_limit() {
13629        let channel_impl = Arc::new(RecordingChannel::default());
13630        let channel: Arc<dyn Channel> = channel_impl.clone();
13631
13632        let mut channels_by_name = HashMap::new();
13633        channels_by_name.insert(channel.name().to_string(), channel);
13634
13635        let runtime_ctx = Arc::new(ChannelRuntimeContext {
13636            channels_by_name: Arc::new(channels_by_name),
13637            model_provider: Arc::new(IterativeToolModelProvider {
13638                required_tool_iterations: 20,
13639            }),
13640            model_provider_ref: Arc::new("test-provider".to_string()),
13641            agent_alias: Arc::new("test-agent".to_string()),
13642            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
13643            memory: Arc::new(NoopMemory),
13644            memory_strategy: Arc::new(
13645                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
13646                    Arc::new(NoopMemory),
13647                    zeroclaw_config::schema::MemoryConfig::default(),
13648                    std::path::PathBuf::new(),
13649                ),
13650            ),
13651            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
13652            observer: Arc::new(NoopObserver),
13653            system_prompt: Arc::new("test-system-prompt".to_string()),
13654            model: Arc::new("test-model".to_string()),
13655            temperature: Some(0.0),
13656            auto_save_memory: false,
13657            max_tool_iterations: 3,
13658            min_relevance_score: 0.0,
13659            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
13660                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
13661            ))),
13662            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
13663            provider_cache: Arc::new(Mutex::new(HashMap::new())),
13664            route_overrides: Arc::new(Mutex::new(HashMap::new())),
13665            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
13666            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
13667            workspace_dir: Arc::new(std::env::temp_dir()),
13668            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
13669            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
13670            interrupt_on_new_message: InterruptOnNewMessageConfig {
13671                telegram: false,
13672                slack: false,
13673                discord: false,
13674                mattermost: false,
13675                matrix: false,
13676            },
13677            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
13678            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
13679            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
13680            agent_transcription_provider: String::new(),
13681            hooks: None,
13682            non_cli_excluded_tools: Arc::new(Vec::new()),
13683            autonomy_level: AutonomyLevel::default(),
13684            tool_call_dedup_exempt: Arc::new(Vec::new()),
13685            model_routes: Arc::new(Vec::new()),
13686            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
13687            ack_reactions: true,
13688            show_tool_calls: true,
13689            session_store: None,
13690            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
13691                &zeroclaw_config::schema::RiskProfileConfig::default(),
13692            )),
13693            activated_tools: None,
13694            cost_tracking: None,
13695            pacing: zeroclaw_config::schema::PacingConfig {
13696                loop_detection_enabled: false,
13697                ..zeroclaw_config::schema::PacingConfig::default()
13698            },
13699            max_tool_result_chars: 0,
13700            context_token_budget: 0,
13701            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
13702                Duration::ZERO,
13703            )),
13704            receipt_generator: None,
13705            show_receipts_in_response: false,
13706            last_applied_config_stamp: Arc::new(Mutex::new(None)),
13707            runtime_defaults_override: Arc::new(Mutex::new(None)),
13708        });
13709
13710        process_channel_message(
13711            runtime_ctx,
13712            zeroclaw_api::channel::ChannelMessage {
13713                id: "msg-iter-fail".to_string(),
13714                sender: "bob".to_string(),
13715                reply_target: "chat-iter-fail".to_string(),
13716                content: "Loop forever".to_string(),
13717                channel: "test-channel".to_string(),
13718                channel_alias: None,
13719                timestamp: 2,
13720                thread_ts: None,
13721                interruption_scope_id: None,
13722                attachments: vec![],
13723                subject: None,
13724            },
13725            CancellationToken::new(),
13726        )
13727        .await;
13728
13729        let sent_messages = channel_impl.sent_messages.lock().await;
13730        assert!(!sent_messages.is_empty());
13731        let reply = sent_messages.last().unwrap();
13732        assert!(reply.starts_with("chat-iter-fail:"));
13733        // After Phase 9, the agent attempts a graceful summary instead of erroring.
13734        // The mock model_provider returns a tool call payload as text, which the agent
13735        // returns as its "summary". The key invariant: the loop terminates and
13736        // produces a response (not hanging forever).
13737        assert!(
13738            reply.contains("⚠️ Error: Agent exceeded maximum tool iterations (3)")
13739                || reply.len() > "chat-iter-fail:".len(),
13740            "Expected either an error message or a graceful summary response"
13741        );
13742    }
13743
13744    struct NoopMemory;
13745
13746    #[async_trait::async_trait]
13747    impl Memory for NoopMemory {
13748        fn name(&self) -> &str {
13749            "noop"
13750        }
13751
13752        async fn store(
13753            &self,
13754            _key: &str,
13755            _content: &str,
13756            _category: zeroclaw_memory::MemoryCategory,
13757            _session_id: Option<&str>,
13758        ) -> anyhow::Result<()> {
13759            Ok(())
13760        }
13761
13762        async fn recall(
13763            &self,
13764            _query: &str,
13765            _limit: usize,
13766            _session_id: Option<&str>,
13767            _since: Option<&str>,
13768            _until: Option<&str>,
13769        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
13770            Ok(Vec::new())
13771        }
13772
13773        async fn get(&self, _key: &str) -> anyhow::Result<Option<zeroclaw_memory::MemoryEntry>> {
13774            Ok(None)
13775        }
13776
13777        async fn list(
13778            &self,
13779            _category: Option<&zeroclaw_memory::MemoryCategory>,
13780            _session_id: Option<&str>,
13781        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
13782            Ok(Vec::new())
13783        }
13784
13785        async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
13786            Ok(false)
13787        }
13788
13789        async fn forget_for_agent(&self, _key: &str, _agent_id: &str) -> anyhow::Result<bool> {
13790            Ok(false)
13791        }
13792
13793        async fn count(&self) -> anyhow::Result<usize> {
13794            Ok(0)
13795        }
13796
13797        async fn health_check(&self) -> bool {
13798            true
13799        }
13800
13801        async fn store_with_agent(
13802            &self,
13803            _key: &str,
13804            _content: &str,
13805            _category: zeroclaw_memory::MemoryCategory,
13806            _session_id: Option<&str>,
13807            _namespace: Option<&str>,
13808            _importance: Option<f64>,
13809            _agent_id: Option<&str>,
13810        ) -> anyhow::Result<()> {
13811            Ok(())
13812        }
13813
13814        async fn recall_for_agents(
13815            &self,
13816            _allowed_agent_ids: &[&str],
13817            _query: &str,
13818            _limit: usize,
13819            _session_id: Option<&str>,
13820            _since: Option<&str>,
13821            _until: Option<&str>,
13822        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
13823            Ok(Vec::new())
13824        }
13825    }
13826    impl ::zeroclaw_api::attribution::Attributable for NoopMemory {
13827        fn role(&self) -> ::zeroclaw_api::attribution::Role {
13828            ::zeroclaw_api::attribution::Role::Memory(
13829                ::zeroclaw_api::attribution::MemoryKind::InMemory,
13830            )
13831        }
13832        fn alias(&self) -> &str {
13833            "NoopMemory"
13834        }
13835    }
13836
13837    struct RecallMemory;
13838
13839    #[async_trait::async_trait]
13840    impl Memory for RecallMemory {
13841        fn name(&self) -> &str {
13842            "recall-memory"
13843        }
13844
13845        async fn store(
13846            &self,
13847            _key: &str,
13848            _content: &str,
13849            _category: zeroclaw_memory::MemoryCategory,
13850            _session_id: Option<&str>,
13851        ) -> anyhow::Result<()> {
13852            Ok(())
13853        }
13854
13855        async fn recall(
13856            &self,
13857            _query: &str,
13858            _limit: usize,
13859            _session_id: Option<&str>,
13860            _since: Option<&str>,
13861            _until: Option<&str>,
13862        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
13863            Ok(vec![zeroclaw_memory::MemoryEntry {
13864                id: "entry-1".to_string(),
13865                key: "memory_key_1".to_string(),
13866                content: "Age is 45".to_string(),
13867                category: zeroclaw_memory::MemoryCategory::Conversation,
13868                timestamp: "2026-02-20T00:00:00Z".to_string(),
13869                session_id: None,
13870                score: Some(0.9),
13871                namespace: "default".into(),
13872                importance: None,
13873                superseded_by: None,
13874                agent_alias: None,
13875                agent_id: None,
13876            }])
13877        }
13878
13879        async fn get(&self, _key: &str) -> anyhow::Result<Option<zeroclaw_memory::MemoryEntry>> {
13880            Ok(None)
13881        }
13882
13883        async fn list(
13884            &self,
13885            _category: Option<&zeroclaw_memory::MemoryCategory>,
13886            _session_id: Option<&str>,
13887        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
13888            Ok(Vec::new())
13889        }
13890
13891        async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
13892            Ok(false)
13893        }
13894
13895        async fn forget_for_agent(&self, _key: &str, _agent_id: &str) -> anyhow::Result<bool> {
13896            Ok(false)
13897        }
13898
13899        async fn count(&self) -> anyhow::Result<usize> {
13900            Ok(1)
13901        }
13902
13903        async fn health_check(&self) -> bool {
13904            true
13905        }
13906
13907        async fn store_with_agent(
13908            &self,
13909            _key: &str,
13910            _content: &str,
13911            _category: zeroclaw_memory::MemoryCategory,
13912            _session_id: Option<&str>,
13913            _namespace: Option<&str>,
13914            _importance: Option<f64>,
13915            _agent_id: Option<&str>,
13916        ) -> anyhow::Result<()> {
13917            Ok(())
13918        }
13919
13920        async fn recall_for_agents(
13921            &self,
13922            _allowed_agent_ids: &[&str],
13923            query: &str,
13924            limit: usize,
13925            session_id: Option<&str>,
13926            since: Option<&str>,
13927            until: Option<&str>,
13928        ) -> anyhow::Result<Vec<zeroclaw_memory::MemoryEntry>> {
13929            self.recall(query, limit, session_id, since, until).await
13930        }
13931    }
13932    impl ::zeroclaw_api::attribution::Attributable for RecallMemory {
13933        fn role(&self) -> ::zeroclaw_api::attribution::Role {
13934            ::zeroclaw_api::attribution::Role::Memory(
13935                ::zeroclaw_api::attribution::MemoryKind::InMemory,
13936            )
13937        }
13938        fn alias(&self) -> &str {
13939            "RecallMemory"
13940        }
13941    }
13942
13943    /// Model provider used by `message_dispatch_processes_messages_in_parallel`
13944    /// to observe concurrent in-flight calls directly instead of inferring
13945    /// parallelism from wall-clock elapsed time.
13946    ///
13947    /// Each `chat_with_system` invocation increments `in_flight` on entry,
13948    /// records the running peak into `peak_in_flight`, then decrements on
13949    /// exit. After the dispatch loop returns, the test asserts
13950    /// `peak_in_flight >= 2`, which directly proves two messages were being
13951    /// processed at the same time. This replaces the original
13952    /// `elapsed < 700ms` assertion (issue #6813), which flaked on slow
13953    /// runners because it depended on machine speed rather than on
13954    /// observable concurrency.
13955    struct ConcurrencyTrackingProvider {
13956        delay: Duration,
13957        in_flight: Arc<AtomicUsize>,
13958        peak_in_flight: Arc<AtomicUsize>,
13959    }
13960
13961    #[async_trait::async_trait]
13962    impl ModelProvider for ConcurrencyTrackingProvider {
13963        async fn chat_with_system(
13964            &self,
13965            _system_prompt: Option<&str>,
13966            message: &str,
13967            _model: &str,
13968            _temperature: Option<f64>,
13969        ) -> anyhow::Result<String> {
13970            let current = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
13971            self.peak_in_flight.fetch_max(current, Ordering::SeqCst);
13972            tokio::time::sleep(self.delay).await;
13973            self.in_flight.fetch_sub(1, Ordering::SeqCst);
13974            Ok(format!("echo: {message}"))
13975        }
13976    }
13977
13978    impl ::zeroclaw_api::attribution::Attributable for ConcurrencyTrackingProvider {
13979        fn role(&self) -> ::zeroclaw_api::attribution::Role {
13980            ::zeroclaw_api::attribution::Role::Provider(
13981                ::zeroclaw_api::attribution::ProviderKind::Model(
13982                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
13983                ),
13984            )
13985        }
13986        fn alias(&self) -> &str {
13987            "ConcurrencyTrackingProvider"
13988        }
13989    }
13990
13991    #[tokio::test]
13992    async fn message_dispatch_processes_messages_in_parallel() {
13993        let channel_impl = Arc::new(RecordingChannel::default());
13994        let channel: Arc<dyn Channel> = channel_impl.clone();
13995
13996        let mut channels_by_name = HashMap::new();
13997        channels_by_name.insert(channel.name().to_string(), channel);
13998
13999        let in_flight = Arc::new(AtomicUsize::new(0));
14000        let peak_in_flight = Arc::new(AtomicUsize::new(0));
14001
14002        let runtime_ctx = Arc::new(ChannelRuntimeContext {
14003            channels_by_name: Arc::new(channels_by_name),
14004            model_provider: Arc::new(ConcurrencyTrackingProvider {
14005                delay: Duration::from_millis(250),
14006                in_flight: in_flight.clone(),
14007                peak_in_flight: peak_in_flight.clone(),
14008            }),
14009            model_provider_ref: Arc::new("test-provider".to_string()),
14010            agent_alias: Arc::new("test-agent".to_string()),
14011            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
14012            memory: Arc::new(NoopMemory),
14013            memory_strategy: Arc::new(
14014                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
14015                    Arc::new(NoopMemory),
14016                    zeroclaw_config::schema::MemoryConfig::default(),
14017                    std::path::PathBuf::new(),
14018                ),
14019            ),
14020            tools_registry: Arc::new(vec![]),
14021            observer: Arc::new(NoopObserver),
14022            system_prompt: Arc::new("test-system-prompt".to_string()),
14023            model: Arc::new("test-model".to_string()),
14024            temperature: Some(0.0),
14025            auto_save_memory: false,
14026            max_tool_iterations: 10,
14027            min_relevance_score: 0.0,
14028            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
14029                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
14030            ))),
14031            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
14032            provider_cache: Arc::new(Mutex::new(HashMap::new())),
14033            route_overrides: Arc::new(Mutex::new(HashMap::new())),
14034            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
14035            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
14036            workspace_dir: Arc::new(std::env::temp_dir()),
14037            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
14038            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
14039            interrupt_on_new_message: InterruptOnNewMessageConfig {
14040                telegram: false,
14041                slack: false,
14042                discord: false,
14043                mattermost: false,
14044                matrix: false,
14045            },
14046            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
14047            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
14048            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
14049            agent_transcription_provider: String::new(),
14050            hooks: None,
14051            non_cli_excluded_tools: Arc::new(Vec::new()),
14052            autonomy_level: AutonomyLevel::default(),
14053            tool_call_dedup_exempt: Arc::new(Vec::new()),
14054            model_routes: Arc::new(Vec::new()),
14055            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
14056            ack_reactions: true,
14057            show_tool_calls: true,
14058            session_store: None,
14059            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
14060                &zeroclaw_config::schema::RiskProfileConfig::default(),
14061            )),
14062            activated_tools: None,
14063            cost_tracking: None,
14064            pacing: zeroclaw_config::schema::PacingConfig::default(),
14065            max_tool_result_chars: 0,
14066            context_token_budget: 0,
14067            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
14068                Duration::ZERO,
14069            )),
14070            receipt_generator: None,
14071            show_receipts_in_response: false,
14072            last_applied_config_stamp: Arc::new(Mutex::new(None)),
14073            runtime_defaults_override: Arc::new(Mutex::new(None)),
14074        });
14075
14076        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(4);
14077        tx.send(zeroclaw_api::channel::ChannelMessage {
14078            id: "1".to_string(),
14079            sender: "alice".to_string(),
14080            reply_target: "alice".to_string(),
14081            content: "hello".to_string(),
14082            channel: "test-channel".to_string(),
14083            channel_alias: None,
14084            timestamp: 1,
14085            thread_ts: None,
14086            interruption_scope_id: None,
14087            attachments: vec![],
14088            subject: None,
14089        })
14090        .await
14091        .unwrap();
14092        tx.send(zeroclaw_api::channel::ChannelMessage {
14093            id: "2".to_string(),
14094            sender: "bob".to_string(),
14095            reply_target: "bob".to_string(),
14096            content: "world".to_string(),
14097            channel: "test-channel".to_string(),
14098            channel_alias: None,
14099            timestamp: 2,
14100            thread_ts: None,
14101            interruption_scope_id: None,
14102            attachments: vec![],
14103            subject: None,
14104        })
14105        .await
14106        .unwrap();
14107        drop(tx);
14108
14109        run_message_dispatch_loop(rx, AgentRouter::single(runtime_ctx), 2).await;
14110
14111        // Deterministic concurrency check: the dispatcher should have processed
14112        // both messages in parallel, so the peak number of simultaneously
14113        // in-flight model calls must reach at least 2. This observes parallelism
14114        // directly rather than inferring it from wall-clock elapsed time, which
14115        // flaked on slow runners (issue #6813).
14116        let peak = peak_in_flight.load(Ordering::SeqCst);
14117        assert!(
14118            peak >= 2,
14119            "expected at least 2 concurrent in-flight dispatches, got peak {}",
14120            peak
14121        );
14122        assert_eq!(
14123            in_flight.load(Ordering::SeqCst),
14124            0,
14125            "all in-flight dispatches should have completed",
14126        );
14127
14128        let sent_messages = channel_impl.sent_messages.lock().await;
14129        assert_eq!(sent_messages.len(), 2);
14130    }
14131
14132    #[tokio::test]
14133    async fn message_dispatch_interrupts_in_flight_telegram_request_and_preserves_context() {
14134        let channel_impl = Arc::new(TelegramRecordingChannel::default());
14135        let channel: Arc<dyn Channel> = channel_impl.clone();
14136
14137        let mut channels_by_name = HashMap::new();
14138        channels_by_name.insert(channel.name().to_string(), channel);
14139
14140        let provider_impl = Arc::new(DelayedHistoryCaptureModelProvider {
14141            delay: Duration::from_millis(250),
14142            calls: std::sync::Mutex::new(Vec::new()),
14143        });
14144
14145        let runtime_ctx = Arc::new(ChannelRuntimeContext {
14146            channels_by_name: Arc::new(channels_by_name),
14147            model_provider: provider_impl.clone(),
14148            model_provider_ref: Arc::new("test-provider".to_string()),
14149            agent_alias: Arc::new("test-agent".to_string()),
14150            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
14151            memory: Arc::new(NoopMemory),
14152            memory_strategy: Arc::new(
14153                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
14154                    Arc::new(NoopMemory),
14155                    zeroclaw_config::schema::MemoryConfig::default(),
14156                    std::path::PathBuf::new(),
14157                ),
14158            ),
14159            tools_registry: Arc::new(vec![]),
14160            observer: Arc::new(NoopObserver),
14161            system_prompt: Arc::new("test-system-prompt".to_string()),
14162            model: Arc::new("test-model".to_string()),
14163            temperature: Some(0.0),
14164            auto_save_memory: false,
14165            max_tool_iterations: 10,
14166            min_relevance_score: 0.0,
14167            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
14168                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
14169            ))),
14170            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
14171            provider_cache: Arc::new(Mutex::new(HashMap::new())),
14172            route_overrides: Arc::new(Mutex::new(HashMap::new())),
14173            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
14174            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
14175            workspace_dir: Arc::new(std::env::temp_dir()),
14176            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
14177            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
14178            interrupt_on_new_message: InterruptOnNewMessageConfig {
14179                telegram: true,
14180                slack: false,
14181                discord: false,
14182                mattermost: false,
14183                matrix: false,
14184            },
14185            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
14186            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
14187            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
14188            agent_transcription_provider: String::new(),
14189            hooks: None,
14190            non_cli_excluded_tools: Arc::new(Vec::new()),
14191            autonomy_level: AutonomyLevel::default(),
14192            tool_call_dedup_exempt: Arc::new(Vec::new()),
14193            model_routes: Arc::new(Vec::new()),
14194            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
14195            ack_reactions: true,
14196            show_tool_calls: true,
14197            session_store: None,
14198            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
14199                &zeroclaw_config::schema::RiskProfileConfig::default(),
14200            )),
14201            activated_tools: None,
14202            cost_tracking: None,
14203            pacing: zeroclaw_config::schema::PacingConfig::default(),
14204            max_tool_result_chars: 0,
14205            context_token_budget: 0,
14206            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
14207                Duration::ZERO,
14208            )),
14209            receipt_generator: None,
14210            show_receipts_in_response: false,
14211            last_applied_config_stamp: Arc::new(Mutex::new(None)),
14212            runtime_defaults_override: Arc::new(Mutex::new(None)),
14213        });
14214
14215        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
14216        let send_task = zeroclaw_spawn::spawn!(async move {
14217            tx.send(zeroclaw_api::channel::ChannelMessage {
14218                id: "msg-1".to_string(),
14219                sender: "alice".to_string(),
14220                reply_target: "chat-1".to_string(),
14221                content: "forwarded content".to_string(),
14222                channel: "telegram".to_string(),
14223                channel_alias: None,
14224                timestamp: 1,
14225                thread_ts: None,
14226                interruption_scope_id: None,
14227                attachments: vec![],
14228                subject: None,
14229            })
14230            .await
14231            .unwrap();
14232            tokio::time::sleep(Duration::from_millis(40)).await;
14233            tx.send(zeroclaw_api::channel::ChannelMessage {
14234                id: "msg-2".to_string(),
14235                sender: "alice".to_string(),
14236                reply_target: "chat-1".to_string(),
14237                content: "summarize this".to_string(),
14238                channel: "telegram".to_string(),
14239                channel_alias: None,
14240                timestamp: 2,
14241                thread_ts: None,
14242                interruption_scope_id: None,
14243                attachments: vec![],
14244                subject: None,
14245            })
14246            .await
14247            .unwrap();
14248        });
14249
14250        run_message_dispatch_loop(rx, AgentRouter::single(runtime_ctx), 4).await;
14251        send_task.await.unwrap();
14252
14253        let sent_messages = channel_impl.sent_messages.lock().await;
14254        assert_eq!(sent_messages.len(), 1);
14255        assert!(sent_messages[0].starts_with("chat-1:"));
14256        assert!(sent_messages[0].contains("response-2"));
14257        drop(sent_messages);
14258
14259        let calls = provider_impl
14260            .calls
14261            .lock()
14262            .unwrap_or_else(|e| e.into_inner());
14263        assert_eq!(calls.len(), 2);
14264        let second_call = &calls[1];
14265        assert!(
14266            second_call
14267                .iter()
14268                .any(|(role, content)| { role == "user" && content.contains("forwarded content") })
14269        );
14270        assert!(
14271            second_call
14272                .iter()
14273                .any(|(role, content)| { role == "user" && content.contains("summarize this") })
14274        );
14275        assert!(
14276            !second_call.iter().any(|(role, _)| role == "assistant"),
14277            "cancelled turn should not persist an assistant response"
14278        );
14279    }
14280
14281    #[tokio::test]
14282    async fn message_dispatch_interrupts_in_flight_slack_request_and_preserves_context() {
14283        let channel_impl = Arc::new(SlackRecordingChannel::default());
14284        let channel: Arc<dyn Channel> = channel_impl.clone();
14285
14286        let mut channels_by_name = HashMap::new();
14287        channels_by_name.insert(channel.name().to_string(), channel);
14288
14289        let provider_impl = Arc::new(DelayedHistoryCaptureModelProvider {
14290            delay: Duration::from_millis(250),
14291            calls: std::sync::Mutex::new(Vec::new()),
14292        });
14293
14294        let runtime_ctx = Arc::new(ChannelRuntimeContext {
14295            channels_by_name: Arc::new(channels_by_name),
14296            model_provider: provider_impl.clone(),
14297            model_provider_ref: Arc::new("test-provider".to_string()),
14298            agent_alias: Arc::new("test-agent".to_string()),
14299            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
14300            memory: Arc::new(NoopMemory),
14301            memory_strategy: Arc::new(
14302                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
14303                    Arc::new(NoopMemory),
14304                    zeroclaw_config::schema::MemoryConfig::default(),
14305                    std::path::PathBuf::new(),
14306                ),
14307            ),
14308            tools_registry: Arc::new(vec![]),
14309            observer: Arc::new(NoopObserver),
14310            system_prompt: Arc::new("test-system-prompt".to_string()),
14311            model: Arc::new("test-model".to_string()),
14312            temperature: Some(0.0),
14313            auto_save_memory: false,
14314            max_tool_iterations: 10,
14315            min_relevance_score: 0.0,
14316            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
14317                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
14318            ))),
14319            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
14320            provider_cache: Arc::new(Mutex::new(HashMap::new())),
14321            route_overrides: Arc::new(Mutex::new(HashMap::new())),
14322            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
14323            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
14324            workspace_dir: Arc::new(std::env::temp_dir()),
14325            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
14326            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
14327            interrupt_on_new_message: InterruptOnNewMessageConfig {
14328                telegram: false,
14329                slack: true,
14330                discord: false,
14331                mattermost: false,
14332                matrix: false,
14333            },
14334            ack_reactions: true,
14335            show_tool_calls: true,
14336            session_store: None,
14337            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
14338            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
14339            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
14340            agent_transcription_provider: String::new(),
14341            hooks: None,
14342            non_cli_excluded_tools: Arc::new(Vec::new()),
14343            autonomy_level: AutonomyLevel::default(),
14344            tool_call_dedup_exempt: Arc::new(Vec::new()),
14345            model_routes: Arc::new(Vec::new()),
14346            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
14347                &zeroclaw_config::schema::RiskProfileConfig::default(),
14348            )),
14349            activated_tools: None,
14350            cost_tracking: None,
14351            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
14352            pacing: zeroclaw_config::schema::PacingConfig::default(),
14353            max_tool_result_chars: 0,
14354            context_token_budget: 0,
14355            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
14356                Duration::ZERO,
14357            )),
14358            receipt_generator: None,
14359            show_receipts_in_response: false,
14360            last_applied_config_stamp: Arc::new(Mutex::new(None)),
14361            runtime_defaults_override: Arc::new(Mutex::new(None)),
14362        });
14363
14364        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
14365        let send_task = zeroclaw_spawn::spawn!(async move {
14366            tx.send(zeroclaw_api::channel::ChannelMessage {
14367                id: "msg-1".to_string(),
14368                sender: "U123".to_string(),
14369                reply_target: "C123".to_string(),
14370                content: "first question".to_string(),
14371                channel: "slack".to_string(),
14372                channel_alias: None,
14373                timestamp: 1,
14374                thread_ts: Some("1741234567.100001".to_string()),
14375                interruption_scope_id: Some("1741234567.100001".to_string()),
14376                attachments: vec![],
14377                subject: None,
14378            })
14379            .await
14380            .unwrap();
14381            tokio::time::sleep(Duration::from_millis(40)).await;
14382            tx.send(zeroclaw_api::channel::ChannelMessage {
14383                id: "msg-2".to_string(),
14384                sender: "U123".to_string(),
14385                reply_target: "C123".to_string(),
14386                content: "second question".to_string(),
14387                channel: "slack".to_string(),
14388                channel_alias: None,
14389                timestamp: 2,
14390                thread_ts: Some("1741234567.100001".to_string()),
14391                interruption_scope_id: Some("1741234567.100001".to_string()),
14392                attachments: vec![],
14393                subject: None,
14394            })
14395            .await
14396            .unwrap();
14397        });
14398
14399        run_message_dispatch_loop(rx, AgentRouter::single(runtime_ctx), 4).await;
14400        send_task.await.unwrap();
14401
14402        let sent_messages = channel_impl.sent_messages.lock().await;
14403        assert_eq!(sent_messages.len(), 1);
14404        assert!(sent_messages[0].starts_with("C123:"));
14405        assert!(sent_messages[0].contains("response-2"));
14406        drop(sent_messages);
14407
14408        let calls = provider_impl
14409            .calls
14410            .lock()
14411            .unwrap_or_else(|e| e.into_inner());
14412        assert_eq!(calls.len(), 2);
14413        let second_call = &calls[1];
14414        assert!(
14415            second_call
14416                .iter()
14417                .any(|(role, content)| { role == "user" && content.contains("first question") })
14418        );
14419        assert!(
14420            second_call
14421                .iter()
14422                .any(|(role, content)| { role == "user" && content.contains("second question") })
14423        );
14424        assert!(
14425            !second_call.iter().any(|(role, _)| role == "assistant"),
14426            "cancelled turn should not persist an assistant response"
14427        );
14428    }
14429
14430    #[tokio::test]
14431    async fn message_dispatch_interrupt_scope_is_same_sender_same_chat() {
14432        let channel_impl = Arc::new(TelegramRecordingChannel::default());
14433        let channel: Arc<dyn Channel> = channel_impl.clone();
14434
14435        let mut channels_by_name = HashMap::new();
14436        channels_by_name.insert(channel.name().to_string(), channel);
14437
14438        let runtime_ctx = Arc::new(ChannelRuntimeContext {
14439            channels_by_name: Arc::new(channels_by_name),
14440            model_provider: Arc::new(SlowModelProvider {
14441                delay: Duration::from_millis(180),
14442            }),
14443            model_provider_ref: Arc::new("test-provider".to_string()),
14444            agent_alias: Arc::new("test-agent".to_string()),
14445            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
14446            memory: Arc::new(NoopMemory),
14447            memory_strategy: Arc::new(
14448                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
14449                    Arc::new(NoopMemory),
14450                    zeroclaw_config::schema::MemoryConfig::default(),
14451                    std::path::PathBuf::new(),
14452                ),
14453            ),
14454            tools_registry: Arc::new(vec![]),
14455            observer: Arc::new(NoopObserver),
14456            system_prompt: Arc::new("test-system-prompt".to_string()),
14457            model: Arc::new("test-model".to_string()),
14458            temperature: Some(0.0),
14459            auto_save_memory: false,
14460            max_tool_iterations: 10,
14461            min_relevance_score: 0.0,
14462            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
14463                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
14464            ))),
14465            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
14466            provider_cache: Arc::new(Mutex::new(HashMap::new())),
14467            route_overrides: Arc::new(Mutex::new(HashMap::new())),
14468            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
14469            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
14470            workspace_dir: Arc::new(std::env::temp_dir()),
14471            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
14472            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
14473            interrupt_on_new_message: InterruptOnNewMessageConfig {
14474                telegram: true,
14475                slack: false,
14476                discord: false,
14477                mattermost: false,
14478                matrix: false,
14479            },
14480            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
14481            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
14482            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
14483            agent_transcription_provider: String::new(),
14484            hooks: None,
14485            non_cli_excluded_tools: Arc::new(Vec::new()),
14486            autonomy_level: AutonomyLevel::default(),
14487            tool_call_dedup_exempt: Arc::new(Vec::new()),
14488            model_routes: Arc::new(Vec::new()),
14489            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
14490            ack_reactions: true,
14491            show_tool_calls: true,
14492            session_store: None,
14493            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
14494                &zeroclaw_config::schema::RiskProfileConfig::default(),
14495            )),
14496            activated_tools: None,
14497            cost_tracking: None,
14498            pacing: zeroclaw_config::schema::PacingConfig::default(),
14499            max_tool_result_chars: 0,
14500            context_token_budget: 0,
14501            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
14502                Duration::ZERO,
14503            )),
14504            receipt_generator: None,
14505            show_receipts_in_response: false,
14506            last_applied_config_stamp: Arc::new(Mutex::new(None)),
14507            runtime_defaults_override: Arc::new(Mutex::new(None)),
14508        });
14509
14510        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
14511        let send_task = zeroclaw_spawn::spawn!(async move {
14512            tx.send(zeroclaw_api::channel::ChannelMessage {
14513                id: "msg-a".to_string(),
14514                sender: "alice".to_string(),
14515                reply_target: "chat-1".to_string(),
14516                content: "first chat".to_string(),
14517                channel: "telegram".to_string(),
14518                channel_alias: None,
14519                timestamp: 1,
14520                thread_ts: None,
14521                interruption_scope_id: None,
14522                attachments: vec![],
14523                subject: None,
14524            })
14525            .await
14526            .unwrap();
14527            tokio::time::sleep(Duration::from_millis(30)).await;
14528            tx.send(zeroclaw_api::channel::ChannelMessage {
14529                id: "msg-b".to_string(),
14530                sender: "alice".to_string(),
14531                reply_target: "chat-2".to_string(),
14532                content: "second chat".to_string(),
14533                channel: "telegram".to_string(),
14534                channel_alias: None,
14535                timestamp: 2,
14536                thread_ts: None,
14537                interruption_scope_id: None,
14538                attachments: vec![],
14539                subject: None,
14540            })
14541            .await
14542            .unwrap();
14543        });
14544
14545        run_message_dispatch_loop(rx, AgentRouter::single(runtime_ctx), 4).await;
14546        send_task.await.unwrap();
14547
14548        let sent_messages = channel_impl.sent_messages.lock().await;
14549        assert_eq!(sent_messages.len(), 2);
14550        assert!(sent_messages.iter().any(|msg| msg.starts_with("chat-1:")));
14551        assert!(sent_messages.iter().any(|msg| msg.starts_with("chat-2:")));
14552    }
14553
14554    #[tokio::test]
14555    async fn process_channel_message_cancels_scoped_typing_task() {
14556        let channel_impl = Arc::new(RecordingChannel::default());
14557        let channel: Arc<dyn Channel> = channel_impl.clone();
14558
14559        let mut channels_by_name = HashMap::new();
14560        channels_by_name.insert(channel.name().to_string(), channel);
14561
14562        let runtime_ctx = Arc::new(ChannelRuntimeContext {
14563            channels_by_name: Arc::new(channels_by_name),
14564            model_provider: Arc::new(SlowModelProvider {
14565                delay: Duration::from_millis(20),
14566            }),
14567            model_provider_ref: Arc::new("test-provider".to_string()),
14568            agent_alias: Arc::new("test-agent".to_string()),
14569            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
14570            memory: Arc::new(NoopMemory),
14571            memory_strategy: Arc::new(
14572                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
14573                    Arc::new(NoopMemory),
14574                    zeroclaw_config::schema::MemoryConfig::default(),
14575                    std::path::PathBuf::new(),
14576                ),
14577            ),
14578            tools_registry: Arc::new(vec![]),
14579            observer: Arc::new(NoopObserver),
14580            system_prompt: Arc::new("test-system-prompt".to_string()),
14581            model: Arc::new("test-model".to_string()),
14582            temperature: Some(0.0),
14583            auto_save_memory: false,
14584            max_tool_iterations: 10,
14585            min_relevance_score: 0.0,
14586            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
14587                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
14588            ))),
14589            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
14590            provider_cache: Arc::new(Mutex::new(HashMap::new())),
14591            route_overrides: Arc::new(Mutex::new(HashMap::new())),
14592            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
14593            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
14594            workspace_dir: Arc::new(std::env::temp_dir()),
14595            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
14596            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
14597            interrupt_on_new_message: InterruptOnNewMessageConfig {
14598                telegram: false,
14599                slack: false,
14600                discord: false,
14601                mattermost: false,
14602                matrix: false,
14603            },
14604            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
14605            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
14606            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
14607            agent_transcription_provider: String::new(),
14608            hooks: None,
14609            non_cli_excluded_tools: Arc::new(Vec::new()),
14610            autonomy_level: AutonomyLevel::default(),
14611            tool_call_dedup_exempt: Arc::new(Vec::new()),
14612            model_routes: Arc::new(Vec::new()),
14613            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
14614            ack_reactions: true,
14615            show_tool_calls: true,
14616            session_store: None,
14617            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
14618                &zeroclaw_config::schema::RiskProfileConfig::default(),
14619            )),
14620            activated_tools: None,
14621            cost_tracking: None,
14622            pacing: zeroclaw_config::schema::PacingConfig::default(),
14623            max_tool_result_chars: 0,
14624            context_token_budget: 0,
14625            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
14626                Duration::ZERO,
14627            )),
14628            receipt_generator: None,
14629            show_receipts_in_response: false,
14630            last_applied_config_stamp: Arc::new(Mutex::new(None)),
14631            runtime_defaults_override: Arc::new(Mutex::new(None)),
14632        });
14633
14634        process_channel_message(
14635            runtime_ctx,
14636            zeroclaw_api::channel::ChannelMessage {
14637                id: "typing-msg".to_string(),
14638                sender: "alice".to_string(),
14639                reply_target: "chat-typing".to_string(),
14640                content: "hello".to_string(),
14641                channel: "test-channel".to_string(),
14642                channel_alias: None,
14643                timestamp: 1,
14644                thread_ts: None,
14645                interruption_scope_id: None,
14646                attachments: vec![],
14647                subject: None,
14648            },
14649            CancellationToken::new(),
14650        )
14651        .await;
14652
14653        let starts = channel_impl.start_typing_calls.load(Ordering::SeqCst);
14654        let stops = channel_impl.stop_typing_calls.load(Ordering::SeqCst);
14655        assert_eq!(starts, 1, "start_typing should be called once");
14656        assert_eq!(stops, 1, "stop_typing should be called once");
14657    }
14658
14659    #[tokio::test]
14660    async fn process_channel_message_adds_and_swaps_reactions() {
14661        let channel_impl = Arc::new(RecordingChannel::default());
14662        let channel: Arc<dyn Channel> = channel_impl.clone();
14663
14664        let mut channels_by_name = HashMap::new();
14665        channels_by_name.insert(channel.name().to_string(), channel);
14666
14667        let runtime_ctx = Arc::new(ChannelRuntimeContext {
14668            channels_by_name: Arc::new(channels_by_name),
14669            model_provider: Arc::new(SlowModelProvider {
14670                delay: Duration::from_millis(5),
14671            }),
14672            model_provider_ref: Arc::new("test-provider".to_string()),
14673            agent_alias: Arc::new("test-agent".to_string()),
14674            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
14675            memory: Arc::new(NoopMemory),
14676            memory_strategy: Arc::new(
14677                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
14678                    Arc::new(NoopMemory),
14679                    zeroclaw_config::schema::MemoryConfig::default(),
14680                    std::path::PathBuf::new(),
14681                ),
14682            ),
14683            tools_registry: Arc::new(vec![]),
14684            observer: Arc::new(NoopObserver),
14685            system_prompt: Arc::new("test-system-prompt".to_string()),
14686            model: Arc::new("test-model".to_string()),
14687            temperature: Some(0.0),
14688            auto_save_memory: false,
14689            max_tool_iterations: 10,
14690            min_relevance_score: 0.0,
14691            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
14692                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
14693            ))),
14694            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
14695            provider_cache: Arc::new(Mutex::new(HashMap::new())),
14696            route_overrides: Arc::new(Mutex::new(HashMap::new())),
14697            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
14698            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
14699            workspace_dir: Arc::new(std::env::temp_dir()),
14700            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
14701            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
14702            interrupt_on_new_message: InterruptOnNewMessageConfig {
14703                telegram: false,
14704                slack: false,
14705                discord: false,
14706                mattermost: false,
14707                matrix: false,
14708            },
14709            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
14710            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
14711            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
14712            agent_transcription_provider: String::new(),
14713            hooks: None,
14714            non_cli_excluded_tools: Arc::new(Vec::new()),
14715            autonomy_level: AutonomyLevel::default(),
14716            tool_call_dedup_exempt: Arc::new(Vec::new()),
14717            model_routes: Arc::new(Vec::new()),
14718            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
14719            ack_reactions: true,
14720            show_tool_calls: true,
14721            session_store: None,
14722            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
14723                &zeroclaw_config::schema::RiskProfileConfig::default(),
14724            )),
14725            activated_tools: None,
14726            cost_tracking: None,
14727            pacing: zeroclaw_config::schema::PacingConfig::default(),
14728            max_tool_result_chars: 0,
14729            context_token_budget: 0,
14730            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
14731                Duration::ZERO,
14732            )),
14733            receipt_generator: None,
14734            show_receipts_in_response: false,
14735            last_applied_config_stamp: Arc::new(Mutex::new(None)),
14736            runtime_defaults_override: Arc::new(Mutex::new(None)),
14737        });
14738
14739        process_channel_message(
14740            runtime_ctx,
14741            zeroclaw_api::channel::ChannelMessage {
14742                id: "react-msg".to_string(),
14743                sender: "alice".to_string(),
14744                reply_target: "chat-react".to_string(),
14745                content: "hello".to_string(),
14746                channel: "test-channel".to_string(),
14747                channel_alias: None,
14748                timestamp: 1,
14749                thread_ts: None,
14750                interruption_scope_id: None,
14751                attachments: vec![],
14752                subject: None,
14753            },
14754            CancellationToken::new(),
14755        )
14756        .await;
14757
14758        let added = channel_impl.reactions_added.lock().await;
14759        assert!(
14760            added.len() >= 2,
14761            "expected at least 2 reactions added (\u{1F440} then \u{2705}), got {}",
14762            added.len()
14763        );
14764        assert_eq!(added[0].2, "\u{1F440}", "first reaction should be eyes");
14765        assert_eq!(
14766            added.last().unwrap().2,
14767            "\u{2705}",
14768            "last reaction should be checkmark"
14769        );
14770
14771        let removed = channel_impl.reactions_removed.lock().await;
14772        assert_eq!(removed.len(), 1, "eyes reaction should be removed once");
14773        assert_eq!(removed[0].2, "\u{1F440}");
14774    }
14775
14776    #[test]
14777    fn prompt_contains_all_sections() {
14778        let ws = make_workspace();
14779        let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
14780        let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None);
14781
14782        // Section headers
14783        assert!(prompt.contains("## Tools"), "missing Tools section");
14784        assert!(prompt.contains("## Safety"), "missing Safety section");
14785        assert!(prompt.contains("## Workspace"), "missing Workspace section");
14786        assert!(
14787            prompt.contains("## Project Context"),
14788            "missing Project Context"
14789        );
14790        assert!(prompt.contains("## Current Date"), "missing Date section");
14791        assert!(
14792            !prompt.contains("## Current Date & Time"),
14793            "prompt should use date-only context"
14794        );
14795        assert!(prompt.contains("## Runtime"), "missing Runtime section");
14796    }
14797
14798    #[test]
14799    fn prompt_injects_tools() {
14800        let ws = make_workspace();
14801        let tools = vec![
14802            ("shell", "Run commands"),
14803            ("memory_recall", "Search memory"),
14804        ];
14805        let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);
14806
14807        assert!(prompt.contains("**shell**"));
14808        assert!(prompt.contains("Run commands"));
14809        assert!(prompt.contains("**memory_recall**"));
14810    }
14811
14812    #[test]
14813    fn prompt_includes_single_tool_protocol_block_after_append() {
14814        let ws = make_workspace();
14815        let tools = vec![("shell", "Run commands")];
14816        let mut prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);
14817
14818        assert!(
14819            !prompt.contains("## Tool Use Protocol"),
14820            "build_system_prompt should not emit protocol block directly"
14821        );
14822
14823        let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
14824        prompt.push_str(&build_tool_instructions(&tools_registry));
14825
14826        assert_eq!(
14827            prompt.matches("## Tool Use Protocol").count(),
14828            1,
14829            "protocol block should appear exactly once in the final prompt"
14830        );
14831    }
14832
14833    #[test]
14834    fn channel_strict_non_native_prompt_hides_text_tool_protocol() {
14835        let ws = make_workspace();
14836        let mut tool_descs = vec![("shell", "Run commands")];
14837        let mut deferred_section = "## Deferred MCP Tools\n\n- mcp__example".to_string();
14838
14839        let expose_text_protocol =
14840            apply_text_tool_prompt_policy(false, true, &mut tool_descs, &mut deferred_section);
14841
14842        let mut prompt = build_system_prompt_with_mode_and_autonomy(
14843            ws.path(),
14844            "gpt-4o",
14845            &tool_descs,
14846            &[],
14847            None,
14848            None,
14849            None,
14850            false,
14851            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
14852            false,
14853            0,
14854            false,
14855        );
14856        if expose_text_protocol {
14857            let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
14858            let effective_tool_names: HashSet<&str> =
14859                tools_registry.iter().map(|tool| tool.name()).collect();
14860            prompt.push_str(&build_tool_instructions_for_names(
14861                &tools_registry,
14862                &effective_tool_names,
14863            ));
14864        }
14865        if !deferred_section.is_empty() {
14866            prompt.push('\n');
14867            prompt.push_str(&deferred_section);
14868        }
14869
14870        assert!(!expose_text_protocol);
14871        assert!(!prompt.contains("## Tools"));
14872        assert!(!prompt.contains("## Tool Use Protocol"));
14873        assert!(!prompt.contains("<tool_call>"));
14874        assert!(!prompt.contains("mcp__example"));
14875    }
14876
14877    #[test]
14878    fn prompt_injects_safety() {
14879        let ws = make_workspace();
14880        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
14881
14882        assert!(prompt.contains("Do not exfiltrate private data"));
14883        assert!(prompt.contains("Respect the runtime autonomy policy"));
14884        assert!(prompt.contains("Prefer `trash` over `rm`"));
14885    }
14886
14887    #[test]
14888    fn prompt_injects_workspace_files() {
14889        let ws = make_workspace();
14890        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
14891
14892        assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
14893        assert!(prompt.contains("Be helpful"), "missing SOUL content");
14894        assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md");
14895        assert!(
14896            prompt.contains("Name: ZeroClaw"),
14897            "missing IDENTITY content"
14898        );
14899        assert!(prompt.contains("### USER.md"), "missing USER.md");
14900        assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md");
14901        assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md");
14902        // HEARTBEAT.md is intentionally excluded from channel prompts — it's only
14903        // relevant to the heartbeat worker and causes LLMs to emit spurious
14904        // "HEARTBEAT_OK" acknowledgments in channel conversations.
14905        assert!(
14906            !prompt.contains("### HEARTBEAT.md"),
14907            "HEARTBEAT.md should not be in channel prompt"
14908        );
14909        assert!(prompt.contains("### MEMORY.md"), "missing MEMORY.md");
14910        assert!(prompt.contains("User likes Rust"), "missing MEMORY content");
14911    }
14912
14913    #[test]
14914    fn prompt_missing_file_markers() {
14915        let tmp = TempDir::new().unwrap();
14916        // Empty workspace — no files at all
14917        let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None);
14918
14919        assert!(prompt.contains("[File not found: SOUL.md]"));
14920        assert!(prompt.contains("[File not found: AGENTS.md]"));
14921        assert!(prompt.contains("[File not found: IDENTITY.md]"));
14922    }
14923
14924    #[test]
14925    fn prompt_bootstrap_only_if_exists() {
14926        let ws = make_workspace();
14927        // No BOOTSTRAP.md — should not appear
14928        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
14929        assert!(
14930            !prompt.contains("### BOOTSTRAP.md"),
14931            "BOOTSTRAP.md should not appear when missing"
14932        );
14933
14934        // Create BOOTSTRAP.md — should appear
14935        std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap();
14936        let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None);
14937        assert!(
14938            prompt2.contains("### BOOTSTRAP.md"),
14939            "BOOTSTRAP.md should appear when present"
14940        );
14941        assert!(prompt2.contains("First run"));
14942    }
14943
14944    #[test]
14945    fn prompt_no_daily_memory_injection() {
14946        let ws = make_workspace();
14947        let memory_dir = ws.path().join("memory");
14948        std::fs::create_dir_all(&memory_dir).unwrap();
14949        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
14950        std::fs::write(
14951            memory_dir.join(format!("{today}.md")),
14952            "# Daily\nSome note.",
14953        )
14954        .unwrap();
14955
14956        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
14957
14958        // Daily notes should NOT be in the system prompt (on-demand via tools)
14959        assert!(
14960            !prompt.contains("Daily Notes"),
14961            "daily notes should not be auto-injected"
14962        );
14963        assert!(
14964            !prompt.contains("Some note"),
14965            "daily content should not be in prompt"
14966        );
14967    }
14968
14969    #[test]
14970    fn prompt_runtime_metadata() {
14971        let ws = make_workspace();
14972        let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None);
14973
14974        assert!(prompt.contains("Model: claude-sonnet-4"));
14975        assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS)));
14976        assert!(prompt.contains("Host:"));
14977    }
14978
14979    #[test]
14980    fn prompt_skills_include_instructions_and_tools() {
14981        let ws = make_workspace();
14982        let skills = vec![zeroclaw_runtime::skills::Skill {
14983            name: "code-review".into(),
14984            description: "Review code for bugs".into(),
14985            version: "1.0.0".into(),
14986            author: None,
14987            tags: vec![],
14988            tools: vec![zeroclaw_runtime::skills::SkillTool {
14989                name: "lint".into(),
14990                description: "Run static checks".into(),
14991                kind: "shell".into(),
14992                command: "cargo clippy".into(),
14993                args: HashMap::new(),
14994                target: None,
14995                locked_args: std::collections::HashMap::new(),
14996            }],
14997            prompts: vec!["Always run cargo test before final response.".into()],
14998            location: None,
14999        }];
15000
15001        let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
15002
15003        assert!(prompt.contains("<available_skills>"), "missing skills XML");
15004        assert!(prompt.contains("<name>code-review</name>"));
15005        assert!(prompt.contains("<description>Review code for bugs</description>"));
15006        assert!(prompt.contains("SKILL.md</location>"));
15007        assert!(prompt.contains("<instructions>"));
15008        assert!(
15009            prompt.contains(
15010                "<instruction>Always run cargo test before final response.</instruction>"
15011            )
15012        );
15013        // Registered tools (shell kind) appear under <callable_tools> with prefixed names
15014        assert!(prompt.contains("<callable_tools"));
15015        assert!(prompt.contains("<name>code-review__lint</name>"));
15016        assert!(!prompt.contains("loaded on demand"));
15017    }
15018
15019    #[test]
15020    fn prompt_skills_compact_mode_omits_instructions_but_keeps_tools() {
15021        let ws = make_workspace();
15022        let skills = vec![zeroclaw_runtime::skills::Skill {
15023            name: "code-review".into(),
15024            description: "Review code for bugs".into(),
15025            version: "1.0.0".into(),
15026            author: None,
15027            tags: vec![],
15028            tools: vec![zeroclaw_runtime::skills::SkillTool {
15029                name: "lint".into(),
15030                description: "Run static checks".into(),
15031                kind: "shell".into(),
15032                command: "cargo clippy".into(),
15033                args: HashMap::new(),
15034                target: None,
15035                locked_args: std::collections::HashMap::new(),
15036            }],
15037            prompts: vec!["Always run cargo test before final response.".into()],
15038            location: None,
15039        }];
15040
15041        let prompt = build_system_prompt_with_mode(
15042            ws.path(),
15043            "model",
15044            &[],
15045            &skills,
15046            None,
15047            None,
15048            false,
15049            zeroclaw_config::schema::SkillsPromptInjectionMode::Compact,
15050            AutonomyLevel::default(),
15051        );
15052
15053        assert!(prompt.contains("<available_skills>"), "missing skills XML");
15054        assert!(prompt.contains("<name>code-review</name>"));
15055        assert!(prompt.contains("<location>skills/code-review/SKILL.md</location>"));
15056        assert!(prompt.contains("loaded on demand"));
15057        assert!(!prompt.contains("<instructions>"));
15058        assert!(
15059            !prompt.contains(
15060                "<instruction>Always run cargo test before final response.</instruction>"
15061            )
15062        );
15063        // Compact mode should still include tools so the LLM knows about them.
15064        // Registered tools (shell kind) appear under <callable_tools> with prefixed names.
15065        assert!(prompt.contains("<callable_tools"));
15066        assert!(prompt.contains("<name>code-review__lint</name>"));
15067    }
15068
15069    #[test]
15070    fn prompt_skills_escape_reserved_xml_chars() {
15071        let ws = make_workspace();
15072        let skills = vec![zeroclaw_runtime::skills::Skill {
15073            name: "code<review>&".into(),
15074            description: "Review \"unsafe\" and 'risky' bits".into(),
15075            version: "1.0.0".into(),
15076            author: None,
15077            tags: vec![],
15078            tools: vec![zeroclaw_runtime::skills::SkillTool {
15079                name: "run\"linter\"".into(),
15080                description: "Run <lint> & report".into(),
15081                kind: "shell&exec".into(),
15082                command: "cargo clippy".into(),
15083                args: HashMap::new(),
15084                target: None,
15085                locked_args: std::collections::HashMap::new(),
15086            }],
15087            prompts: vec!["Use <tool_call> and & keep output \"safe\"".into()],
15088            location: None,
15089        }];
15090
15091        let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
15092
15093        assert!(prompt.contains("<name>code&lt;review&gt;&amp;</name>"));
15094        assert!(prompt.contains(
15095            "<description>Review &quot;unsafe&quot; and &apos;risky&apos; bits</description>"
15096        ));
15097        assert!(prompt.contains("<name>run&quot;linter&quot;</name>"));
15098        assert!(prompt.contains("<description>Run &lt;lint&gt; &amp; report</description>"));
15099        assert!(prompt.contains("<kind>shell&amp;exec</kind>"));
15100        assert!(prompt.contains(
15101            "<instruction>Use &lt;tool_call&gt; and &amp; keep output &quot;safe&quot;</instruction>"
15102        ));
15103    }
15104
15105    #[test]
15106    fn prompt_truncation() {
15107        let ws = make_workspace();
15108        // Write a file larger than BOOTSTRAP_MAX_CHARS
15109        let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000);
15110        std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap();
15111
15112        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
15113
15114        assert!(
15115            prompt.contains("truncated at"),
15116            "large files should be truncated"
15117        );
15118        assert!(
15119            !prompt.contains(&big_content),
15120            "full content should not appear"
15121        );
15122    }
15123
15124    #[test]
15125    fn prompt_empty_files_skipped() {
15126        let ws = make_workspace();
15127        std::fs::write(ws.path().join("TOOLS.md"), "").unwrap();
15128
15129        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
15130
15131        // Empty file should not produce a header
15132        assert!(
15133            !prompt.contains("### TOOLS.md"),
15134            "empty files should be skipped"
15135        );
15136    }
15137
15138    #[test]
15139    fn channel_log_truncation_is_utf8_safe_for_multibyte_text() {
15140        let msg = "Hello from ZeroClaw 🌍. Current status is healthy, and café-style UTF-8 text stays safe in logs.";
15141
15142        // Reproduces the production crash path where channel logs truncate at 80 chars.
15143        let result =
15144            std::panic::catch_unwind(|| zeroclaw_runtime::util::truncate_with_ellipsis(msg, 80));
15145        assert!(
15146            result.is_ok(),
15147            "truncate_with_ellipsis should never panic on UTF-8"
15148        );
15149
15150        let truncated = result.unwrap();
15151        assert!(!truncated.is_empty());
15152        assert!(truncated.is_char_boundary(truncated.len()));
15153    }
15154
15155    #[test]
15156    fn prompt_contains_channel_capabilities() {
15157        let ws = make_workspace();
15158        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
15159
15160        assert!(
15161            prompt.contains("## Channel Capabilities"),
15162            "missing Channel Capabilities section"
15163        );
15164        assert!(
15165            prompt.contains("running as a messaging bot"),
15166            "missing channel context"
15167        );
15168        assert!(
15169            prompt.contains("NEVER repeat, describe, or echo credentials"),
15170            "missing security instruction"
15171        );
15172    }
15173
15174    #[test]
15175    fn full_autonomy_prompt_executes_allowed_tools_without_extra_approval() {
15176        let ws = make_workspace();
15177        let config = zeroclaw_config::schema::RiskProfileConfig {
15178            level: zeroclaw_runtime::security::AutonomyLevel::Full,
15179            ..zeroclaw_config::schema::RiskProfileConfig::default()
15180        };
15181        let prompt = build_system_prompt_with_mode_and_autonomy(
15182            ws.path(),
15183            "model",
15184            &[],
15185            &[],
15186            None,
15187            None,
15188            Some(&config),
15189            false,
15190            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
15191            false,
15192            0,
15193            false,
15194        );
15195
15196        assert!(
15197            prompt.contains("execute it directly instead of asking the user for extra approval"),
15198            "full autonomy should instruct direct execution for allowed tools"
15199        );
15200        assert!(
15201            prompt.contains("Never pretend you are waiting for a human approval"),
15202            "full autonomy should not simulate interactive approval flows"
15203        );
15204    }
15205
15206    #[test]
15207    fn readonly_prompt_explains_policy_blocks_without_fake_approval() {
15208        let ws = make_workspace();
15209        let config = zeroclaw_config::schema::RiskProfileConfig {
15210            level: zeroclaw_runtime::security::AutonomyLevel::ReadOnly,
15211            ..zeroclaw_config::schema::RiskProfileConfig::default()
15212        };
15213        let prompt = build_system_prompt_with_mode_and_autonomy(
15214            ws.path(),
15215            "model",
15216            &[],
15217            &[],
15218            None,
15219            None,
15220            Some(&config),
15221            false,
15222            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
15223            false,
15224            0,
15225            false,
15226        );
15227
15228        assert!(
15229            prompt.contains("this runtime is read-only for side effects"),
15230            "read-only prompt should expose the runtime restriction"
15231        );
15232        assert!(
15233            prompt.contains("instead of simulating an approval flow"),
15234            "read-only prompt should explain restrictions instead of faking approval"
15235        );
15236    }
15237
15238    #[test]
15239    fn prompt_workspace_path() {
15240        let ws = make_workspace();
15241        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
15242
15243        assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
15244    }
15245
15246    #[test]
15247    fn full_autonomy_omits_approval_instructions() {
15248        let ws = make_workspace();
15249        let prompt = build_system_prompt_with_mode(
15250            ws.path(),
15251            "model",
15252            &[],
15253            &[],
15254            None,
15255            None,
15256            false,
15257            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
15258            AutonomyLevel::Full,
15259        );
15260
15261        assert!(
15262            !prompt.contains("without asking"),
15263            "full autonomy prompt must not tell the model to ask before acting"
15264        );
15265        assert!(
15266            !prompt.contains("ask before acting externally"),
15267            "full autonomy prompt must not contain ask-before-acting instruction"
15268        );
15269        // Core safety rules should still be present
15270        assert!(
15271            prompt.contains("Do not exfiltrate private data"),
15272            "data exfiltration guard must remain"
15273        );
15274        assert!(
15275            prompt.contains("Prefer `trash` over `rm`"),
15276            "trash-over-rm hint must remain"
15277        );
15278    }
15279
15280    #[test]
15281    fn supervised_autonomy_includes_approval_instructions() {
15282        let ws = make_workspace();
15283        let prompt = build_system_prompt_with_mode(
15284            ws.path(),
15285            "model",
15286            &[],
15287            &[],
15288            None,
15289            None,
15290            false,
15291            zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
15292            AutonomyLevel::Supervised,
15293        );
15294
15295        assert!(
15296            prompt.contains("without asking"),
15297            "supervised prompt must include ask-before-acting instruction"
15298        );
15299        assert!(
15300            prompt.contains("ask before acting externally"),
15301            "supervised prompt must include ask-before-acting instruction"
15302        );
15303    }
15304
15305    #[test]
15306    fn channel_notify_observer_truncates_utf8_arguments_safely() {
15307        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
15308        let observer = ChannelNotifyObserver {
15309            inner: Arc::new(NoopObserver),
15310            tx,
15311            tools_used: AtomicBool::new(false),
15312        };
15313
15314        let payload = (0..300)
15315            .map(|n| serde_json::json!({ "content": format!("{}置tail", "a".repeat(n)) }))
15316            .map(|v| v.to_string())
15317            .find(|raw| raw.len() > 120 && !raw.is_char_boundary(120))
15318            .expect("should produce non-char-boundary data at byte index 120");
15319
15320        observer.record_event(
15321            &zeroclaw_runtime::observability::traits::ObserverEvent::ToolCallStart {
15322                tool: "file_write".to_string(),
15323                tool_call_id: None,
15324                arguments: Some(payload),
15325            },
15326        );
15327
15328        let emitted = rx.try_recv().expect("observer should emit notify message");
15329        assert!(emitted.contains("`file_write`"));
15330        assert!(emitted.is_char_boundary(emitted.len()));
15331    }
15332
15333    #[test]
15334    fn conversation_memory_key_uses_message_id() {
15335        let msg = zeroclaw_api::channel::ChannelMessage {
15336            id: "msg_abc123".into(),
15337            sender: "U123".into(),
15338            reply_target: "C456".into(),
15339            content: "hello".into(),
15340            channel: "slack".into(),
15341            channel_alias: None,
15342            timestamp: 1,
15343            thread_ts: None,
15344            interruption_scope_id: None,
15345            attachments: vec![],
15346            subject: None,
15347        };
15348
15349        assert_eq!(conversation_memory_key(&msg), "slack_U123_msg_abc123");
15350    }
15351
15352    #[test]
15353    fn followup_thread_id_prefers_thread_ts() {
15354        let msg = zeroclaw_api::channel::ChannelMessage {
15355            id: "slack_C123_1741234567.123456".into(),
15356            sender: "U123".into(),
15357            reply_target: "C123".into(),
15358            content: "hello".into(),
15359            channel: "slack".into(),
15360            channel_alias: None,
15361            timestamp: 1,
15362            thread_ts: Some("1741234567.123456".into()),
15363            interruption_scope_id: None,
15364            attachments: vec![],
15365            subject: None,
15366        };
15367
15368        assert_eq!(
15369            followup_thread_id(&msg).as_deref(),
15370            Some("1741234567.123456")
15371        );
15372    }
15373
15374    #[test]
15375    fn followup_thread_id_falls_back_to_message_id() {
15376        let msg = zeroclaw_api::channel::ChannelMessage {
15377            id: "msg_abc123".into(),
15378            sender: "U123".into(),
15379            reply_target: "C456".into(),
15380            content: "hello".into(),
15381            channel: "cli".into(),
15382            channel_alias: None,
15383            timestamp: 1,
15384            thread_ts: None,
15385            interruption_scope_id: None,
15386            attachments: vec![],
15387            subject: None,
15388        };
15389
15390        assert_eq!(followup_thread_id(&msg).as_deref(), Some("msg_abc123"));
15391    }
15392
15393    #[test]
15394    fn followup_thread_id_does_not_open_matrix_thread_for_root_message() {
15395        let msg = zeroclaw_api::channel::ChannelMessage {
15396            id: "$event:server".into(),
15397            sender: "@alice:server".into(),
15398            reply_target: "!room:server".into(),
15399            content: "hello".into(),
15400            channel: "matrix".into(),
15401            channel_alias: None,
15402            timestamp: 1,
15403            thread_ts: None,
15404            interruption_scope_id: None,
15405            attachments: vec![],
15406            subject: None,
15407        };
15408
15409        assert_eq!(followup_thread_id(&msg), None);
15410    }
15411
15412    #[test]
15413    fn matrix_root_conversation_history_key_omits_event_id() {
15414        let first = zeroclaw_api::channel::ChannelMessage {
15415            id: "$first:server".into(),
15416            sender: "@alice:server".into(),
15417            reply_target: "!room:server".into(),
15418            content: "send a.txt".into(),
15419            channel: "matrix".into(),
15420            channel_alias: None,
15421            timestamp: 1,
15422            thread_ts: None,
15423            interruption_scope_id: None,
15424            attachments: vec![],
15425            subject: None,
15426        };
15427        let second = zeroclaw_api::channel::ChannelMessage {
15428            id: "$second:server".into(),
15429            content: "send it again".into(),
15430            timestamp: 2,
15431            ..first.clone()
15432        };
15433
15434        let key = conversation_history_key(&first);
15435        assert_eq!(key, conversation_history_key(&second));
15436        assert!(!key.contains("$first:server"));
15437        assert!(!key.contains("$second:server"));
15438    }
15439
15440    #[test]
15441    fn matrix_self_anchored_root_history_key_omits_event_id() {
15442        let first = zeroclaw_api::channel::ChannelMessage {
15443            id: "$first:server".into(),
15444            sender: "@alice:server".into(),
15445            reply_target: "!room:server".into(),
15446            content: "call me boss".into(),
15447            channel: "matrix".into(),
15448            channel_alias: None,
15449            timestamp: 1,
15450            thread_ts: Some("$first:server".into()),
15451            interruption_scope_id: Some("$first:server".into()),
15452            attachments: vec![],
15453            subject: None,
15454        };
15455        let second = zeroclaw_api::channel::ChannelMessage {
15456            id: "$second:server".into(),
15457            content: "hello".into(),
15458            timestamp: 2,
15459            thread_ts: Some("$second:server".into()),
15460            interruption_scope_id: Some("$second:server".into()),
15461            ..first.clone()
15462        };
15463
15464        let key = conversation_history_key(&first);
15465        assert_eq!(key, conversation_history_key(&second));
15466        assert!(!key.contains("$first:server"));
15467        assert!(!key.contains("$second:server"));
15468    }
15469
15470    #[test]
15471    fn matrix_thread_conversation_history_key_uses_thread_root() {
15472        let msg = zeroclaw_api::channel::ChannelMessage {
15473            id: "$reply:server".into(),
15474            sender: "@alice:server".into(),
15475            reply_target: "!room:server".into(),
15476            content: "thread reply".into(),
15477            channel: "matrix".into(),
15478            channel_alias: None,
15479            timestamp: 1,
15480            thread_ts: Some("$root:server".into()),
15481            interruption_scope_id: Some("$root:server".into()),
15482            attachments: vec![],
15483            subject: None,
15484        };
15485
15486        let key = conversation_history_key(&msg);
15487        assert!(key.contains("_root_server"));
15488        assert!(!key.contains("_reply_server"));
15489    }
15490
15491    #[test]
15492    fn wecom_ws_conversation_history_key_uses_reply_target_scope() {
15493        let msg = zeroclaw_api::channel::ChannelMessage {
15494            id: "msg_wecom_ws".into(),
15495            sender: "zeroclaw_user".into(),
15496            reply_target: "group--room-1".into(),
15497            content: "hello".into(),
15498            channel: "wecom_ws".into(),
15499            channel_alias: Some("work".into()),
15500            timestamp: 1,
15501            thread_ts: Some("req-1".into()),
15502            interruption_scope_id: None,
15503            attachments: vec![],
15504            subject: None,
15505        };
15506
15507        assert_eq!(
15508            conversation_history_key(&msg),
15509            "wecom_ws_work_group--room-1"
15510        );
15511        assert_eq!(interruption_scope_key(&msg), "wecom_ws_work_group--room-1");
15512    }
15513
15514    #[test]
15515    fn parse_runtime_command_allows_model_switch_for_wecom_ws() {
15516        assert_eq!(
15517            parse_runtime_command("wecom_ws", "/models openrouter"),
15518            Some(ChannelRuntimeCommand::SetProvider("openrouter".into()))
15519        );
15520        assert_eq!(
15521            parse_runtime_command("wecom_ws", "/model qwen-max"),
15522            Some(ChannelRuntimeCommand::SetModel("qwen-max".into()))
15523        );
15524    }
15525
15526    /// `/models <family>` must resolve to a configured alias-backed ref so the
15527    /// switched provider uses the alias entry's key/URI — never construct a bare
15528    /// family provider that ignores `[providers.models.<family>.<alias>]`.
15529    #[test]
15530    fn resolve_models_command_resolves_bare_family_to_configured_alias() {
15531        let mut config = zeroclaw_config::schema::Config::default();
15532        {
15533            let base = config
15534                .providers
15535                .models
15536                .ensure("openrouter", "default")
15537                .expect("openrouter slot must exist");
15538            base.api_key = Some("sk-configured".into());
15539            base.uri = Some("https://router.example/v1".into());
15540            base.model = Some("some-model".into());
15541        }
15542
15543        match resolve_models_command(&config, "openrouter") {
15544            ModelsCommandResolution::Resolved(r) => assert_eq!(r, "openrouter.default"),
15545            other => panic!("expected Resolved(openrouter.default), got {other:?}"),
15546        }
15547
15548        // The resolved ref must carry the configured alias credentials.
15549        let (key, uri) = provider_credentials_for_ref(&config, "openrouter.default");
15550        assert_eq!(key.as_deref(), Some("sk-configured"));
15551        assert_eq!(uri.as_deref(), Some("https://router.example/v1"));
15552    }
15553
15554    /// A bare family with no configured alias has no credentialed provider to
15555    /// switch to — the command must fail clearly instead of building a bare one.
15556    #[test]
15557    fn resolve_models_command_rejects_family_without_alias() {
15558        let config = zeroclaw_config::schema::Config::default();
15559        match resolve_models_command(&config, "openrouter") {
15560            ModelsCommandResolution::NoAlias(f) => assert_eq!(f, "openrouter"),
15561            other => panic!("expected NoAlias(openrouter), got {other:?}"),
15562        }
15563    }
15564
15565    /// A bare family with several configured aliases is ambiguous; the user must
15566    /// qualify which one rather than silently picking.
15567    #[test]
15568    fn resolve_models_command_flags_ambiguous_family() {
15569        let mut config = zeroclaw_config::schema::Config::default();
15570        config
15571            .providers
15572            .models
15573            .ensure("openrouter", "default")
15574            .unwrap();
15575        config
15576            .providers
15577            .models
15578            .ensure("openrouter", "secondary")
15579            .unwrap();
15580
15581        match resolve_models_command(&config, "openrouter") {
15582            ModelsCommandResolution::Ambiguous { family, aliases } => {
15583                assert_eq!(family, "openrouter");
15584                assert_eq!(
15585                    aliases,
15586                    vec!["default".to_string(), "secondary".to_string()]
15587                );
15588            }
15589            other => panic!("expected Ambiguous, got {other:?}"),
15590        }
15591    }
15592
15593    /// A dotted ref resolves only when the alias entry exists.
15594    #[test]
15595    fn resolve_models_command_accepts_existing_dotted_ref() {
15596        let mut config = zeroclaw_config::schema::Config::default();
15597        config
15598            .providers
15599            .models
15600            .ensure("openrouter", "default")
15601            .unwrap();
15602
15603        match resolve_models_command(&config, "openrouter.default") {
15604            ModelsCommandResolution::Resolved(r) => assert_eq!(r, "openrouter.default"),
15605            other => panic!("expected Resolved, got {other:?}"),
15606        }
15607        match resolve_models_command(&config, "openrouter.missing") {
15608            ModelsCommandResolution::NoAlias(r) => assert_eq!(r, "openrouter.missing"),
15609            other => panic!("expected NoAlias, got {other:?}"),
15610        }
15611    }
15612
15613    /// An unrecognized family is rejected.
15614    #[test]
15615    fn resolve_models_command_rejects_unknown_family() {
15616        let config = zeroclaw_config::schema::Config::default();
15617        assert!(matches!(
15618            resolve_models_command(&config, "definitely-not-a-provider"),
15619            ModelsCommandResolution::Unknown
15620        ));
15621    }
15622
15623    #[test]
15624    fn runtime_model_switch_resolves_bare_family_to_configured_alias() {
15625        let mut config = zeroclaw_config::schema::Config::default();
15626        config
15627            .providers
15628            .models
15629            .ensure("openrouter", "default")
15630            .unwrap();
15631
15632        let resolved = resolve_provider_ref_for_runtime_switch(&config, "openrouter").unwrap();
15633
15634        assert_eq!(resolved, "openrouter.default");
15635    }
15636
15637    #[test]
15638    fn runtime_model_switch_rejects_ambiguous_bare_family() {
15639        let mut config = zeroclaw_config::schema::Config::default();
15640        config
15641            .providers
15642            .models
15643            .ensure("openrouter", "default")
15644            .unwrap();
15645        config
15646            .providers
15647            .models
15648            .ensure("openrouter", "secondary")
15649            .unwrap();
15650
15651        let err = resolve_provider_ref_for_runtime_switch(&config, "openrouter")
15652            .expect_err("ambiguous model switch provider should reject");
15653
15654        assert!(err.to_string().contains("multiple configured aliases"));
15655    }
15656
15657    #[test]
15658    fn explicit_wecom_group_address_bypasses_reply_intent_precheck() {
15659        assert!(is_explicitly_addressed_channel_message(
15660            "wecom_ws",
15661            "[WeCom group message addressed to this bot via @danya]\n@danya say hi"
15662        ));
15663        assert!(!is_explicitly_addressed_channel_message(
15664            "wecom_ws",
15665            "@danya say hi"
15666        ));
15667        assert!(!is_explicitly_addressed_channel_message(
15668            "telegram",
15669            "[WeCom group message addressed to this bot via @danya]\n@danya say hi"
15670        ));
15671    }
15672
15673    #[test]
15674    fn conversation_memory_key_is_unique_per_message() {
15675        let msg1 = zeroclaw_api::channel::ChannelMessage {
15676            id: "msg_1".into(),
15677            sender: "U123".into(),
15678            reply_target: "C456".into(),
15679            content: "first".into(),
15680            channel: "slack".into(),
15681            channel_alias: None,
15682            timestamp: 1,
15683            thread_ts: None,
15684            interruption_scope_id: None,
15685            attachments: vec![],
15686            subject: None,
15687        };
15688        let msg2 = zeroclaw_api::channel::ChannelMessage {
15689            id: "msg_2".into(),
15690            sender: "U123".into(),
15691            reply_target: "C456".into(),
15692            content: "second".into(),
15693            channel: "slack".into(),
15694            channel_alias: None,
15695            timestamp: 2,
15696            thread_ts: None,
15697            interruption_scope_id: None,
15698            attachments: vec![],
15699            subject: None,
15700        };
15701
15702        assert_ne!(
15703            conversation_memory_key(&msg1),
15704            conversation_memory_key(&msg2)
15705        );
15706    }
15707
15708    #[tokio::test]
15709    async fn autosave_keys_preserve_multiple_conversation_facts() {
15710        let tmp = TempDir::new().unwrap();
15711        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
15712
15713        let msg1 = zeroclaw_api::channel::ChannelMessage {
15714            id: "msg_1".into(),
15715            sender: "U123".into(),
15716            reply_target: "C456".into(),
15717            content: "I'm Paul".into(),
15718            channel: "slack".into(),
15719            channel_alias: None,
15720            timestamp: 1,
15721            thread_ts: None,
15722            interruption_scope_id: None,
15723            attachments: vec![],
15724            subject: None,
15725        };
15726        let msg2 = zeroclaw_api::channel::ChannelMessage {
15727            id: "msg_2".into(),
15728            sender: "U123".into(),
15729            reply_target: "C456".into(),
15730            content: "I'm 45".into(),
15731            channel: "slack".into(),
15732            channel_alias: None,
15733            timestamp: 2,
15734            thread_ts: None,
15735            interruption_scope_id: None,
15736            attachments: vec![],
15737            subject: None,
15738        };
15739
15740        mem.store(
15741            &conversation_memory_key(&msg1),
15742            &msg1.content,
15743            MemoryCategory::Conversation,
15744            None,
15745        )
15746        .await
15747        .unwrap();
15748        mem.store(
15749            &conversation_memory_key(&msg2),
15750            &msg2.content,
15751            MemoryCategory::Conversation,
15752            None,
15753        )
15754        .await
15755        .unwrap();
15756
15757        assert_eq!(mem.count().await.unwrap(), 2);
15758
15759        let recalled = mem.recall("45", 5, None, None, None).await.unwrap();
15760        assert!(recalled.iter().any(|entry| entry.content.contains("45")));
15761    }
15762
15763    #[tokio::test]
15764    async fn build_memory_context_includes_recalled_entries() {
15765        let tmp = TempDir::new().unwrap();
15766        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
15767        mem.store("age_fact", "Age is 45", MemoryCategory::Conversation, None)
15768            .await
15769            .unwrap();
15770
15771        let context = build_memory_context(&mem, "age", 0.0, None).await;
15772        assert!(context.contains(MEMORY_CONTEXT_OPEN));
15773        assert!(context.contains("Age is 45"));
15774    }
15775
15776    #[tokio::test]
15777    async fn autosaved_conversation_memory_is_recalled_by_sender_scope() {
15778        let tmp = TempDir::new().unwrap();
15779        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
15780        let msg = zeroclaw_api::channel::ChannelMessage {
15781            id: "msg_1".into(),
15782            sender: "U123".into(),
15783            reply_target: "C456".into(),
15784            content: "Project codename is quartz".into(),
15785            channel: "slack".into(),
15786            channel_alias: None,
15787            timestamp: 1,
15788            thread_ts: None,
15789            interruption_scope_id: None,
15790            attachments: vec![],
15791            subject: None,
15792        };
15793        let history_key = conversation_history_key(&msg);
15794
15795        mem.store(
15796            &conversation_memory_key(&msg),
15797            &msg.content,
15798            MemoryCategory::Conversation,
15799            Some(&history_key),
15800        )
15801        .await
15802        .unwrap();
15803
15804        let session_ids = sender_memory_session_ids(&msg, &history_key);
15805        let session_id_refs: Vec<Option<&str>> =
15806            session_ids.iter().map(|s| Some(s.as_str())).collect();
15807        let context =
15808            build_memory_context_for_sessions(&mem, "quartz", 0.0, &session_id_refs).await;
15809
15810        assert!(
15811            context.contains("Project codename is quartz"),
15812            "sender recall should include autosaved memories stored under the current session key, got: {context}"
15813        );
15814    }
15815
15816    #[tokio::test]
15817    async fn autosaved_group_conversation_memory_stays_session_scoped() {
15818        let tmp = TempDir::new().unwrap();
15819        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
15820        let group_a_msg = zeroclaw_api::channel::ChannelMessage {
15821            id: "msg_1".into(),
15822            sender: "U123".into(),
15823            reply_target: "group:alpha".into(),
15824            content: "Group alpha codename is quartz".into(),
15825            channel: "slack".into(),
15826            channel_alias: None,
15827            timestamp: 1,
15828            thread_ts: None,
15829            interruption_scope_id: None,
15830            attachments: vec![],
15831            subject: None,
15832        };
15833        let group_b_msg = zeroclaw_api::channel::ChannelMessage {
15834            id: "msg_2".into(),
15835            sender: "U123".into(),
15836            reply_target: "group:beta".into(),
15837            content: "What was the codename?".into(),
15838            channel: "slack".into(),
15839            channel_alias: None,
15840            timestamp: 2,
15841            thread_ts: None,
15842            interruption_scope_id: None,
15843            attachments: vec![],
15844            subject: None,
15845        };
15846        let group_a_history_key = conversation_history_key(&group_a_msg);
15847        let group_b_history_key = conversation_history_key(&group_b_msg);
15848
15849        mem.store(
15850            &conversation_memory_key(&group_a_msg),
15851            &group_a_msg.content,
15852            MemoryCategory::Conversation,
15853            Some(&group_a_history_key),
15854        )
15855        .await
15856        .unwrap();
15857
15858        let group_b_sender_session_ids =
15859            sender_memory_session_ids(&group_b_msg, &group_b_history_key);
15860        assert_eq!(group_b_sender_session_ids, vec!["U123".to_string()]);
15861
15862        let group_b_sender_session_id_refs: Vec<Option<&str>> = group_b_sender_session_ids
15863            .iter()
15864            .map(|s| Some(s.as_str()))
15865            .collect();
15866        let sender_context =
15867            build_memory_context_for_sessions(&mem, "quartz", 0.0, &group_b_sender_session_id_refs)
15868                .await;
15869        let group_context =
15870            build_memory_context(&mem, "quartz", 0.0, Some(&group_b_history_key)).await;
15871        let source_group_context =
15872            build_memory_context(&mem, "quartz", 0.0, Some(&group_a_history_key)).await;
15873
15874        assert!(
15875            sender_context.is_empty(),
15876            "sender scope must not leak autosaved group memory from another group, got: {sender_context}"
15877        );
15878        assert!(
15879            group_context.is_empty(),
15880            "target group scope must not include another group's autosaved memory, got: {group_context}"
15881        );
15882        assert!(
15883            source_group_context.contains("Group alpha codename is quartz"),
15884            "source group scope should still recall its own autosaved memory, got: {source_group_context}"
15885        );
15886    }
15887
15888    #[tokio::test]
15889    async fn sender_session_ids_match_migrated_matrix_sender_rows() {
15890        let tmp = TempDir::new().unwrap();
15891        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
15892        let raw_sender = "@alice:server";
15893        let sanitized_sender = sanitize_session_key(raw_sender);
15894        assert_eq!(sanitized_sender, "_alice_server");
15895
15896        mem.store(
15897            "alice_fact",
15898            "Alice favors filtered coffee",
15899            MemoryCategory::Conversation,
15900            Some(sanitized_sender.as_str()),
15901        )
15902        .await
15903        .unwrap();
15904
15905        let msg = zeroclaw_api::channel::ChannelMessage {
15906            id: "evt_1".into(),
15907            sender: raw_sender.into(),
15908            reply_target: "!room:server".into(),
15909            content: "what coffee does alice prefer?".into(),
15910            channel: "matrix".into(),
15911            channel_alias: None,
15912            timestamp: 1,
15913            thread_ts: None,
15914            interruption_scope_id: None,
15915            attachments: vec![],
15916            subject: None,
15917        };
15918        let history_key = conversation_history_key(&msg);
15919        let session_ids = sender_memory_session_ids(&msg, &history_key);
15920        assert!(
15921            session_ids.contains(&sanitized_sender),
15922            "sender session ids must include sanitized sender, got: {session_ids:?}"
15923        );
15924        let session_id_refs: Vec<Option<&str>> =
15925            session_ids.iter().map(|s| Some(s.as_str())).collect();
15926        let context =
15927            build_memory_context_for_sessions(&mem, "coffee", 0.0, &session_id_refs).await;
15928        assert!(
15929            context.contains("Alice favors filtered coffee"),
15930            "sender recall must find migrated row stored under sanitized sender, got: {context}"
15931        );
15932    }
15933
15934    /// Auto-saved photo messages must not surface through memory context,
15935    /// otherwise the image marker gets duplicated in the model_provider request.
15936    #[tokio::test]
15937    async fn build_memory_context_excludes_image_marker_entries() {
15938        let tmp = TempDir::new().unwrap();
15939        let mem = SqliteMemory::new("test", tmp.path()).unwrap();
15940
15941        // Simulate auto-save of a photo message containing an [IMAGE:] marker.
15942        mem.store(
15943            "telegram_user_msg_photo",
15944            "[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nDescribe this screenshot",
15945            MemoryCategory::Conversation,
15946            None,
15947        )
15948        .await
15949        .unwrap();
15950        // Also store a plain text entry that shares a word with the query
15951        // so the FTS recall returns both entries.
15952        mem.store(
15953            "screenshot_preference",
15954            "User prefers screenshot descriptions to be concise",
15955            MemoryCategory::Conversation,
15956            None,
15957        )
15958        .await
15959        .unwrap();
15960
15961        let context = build_memory_context(&mem, "screenshot", 0.0, None).await;
15962
15963        // The image-marker entry must be excluded to prevent duplication.
15964        assert!(
15965            !context.contains("[IMAGE:"),
15966            "memory context must not contain image markers, got: {context}"
15967        );
15968        // Plain text entries should still be included.
15969        assert!(
15970            context.contains("screenshot descriptions"),
15971            "plain text entry should remain in context, got: {context}"
15972        );
15973    }
15974
15975    #[tokio::test]
15976    async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
15977        let channel_impl = Arc::new(RecordingChannel::default());
15978        let channel: Arc<dyn Channel> = channel_impl.clone();
15979
15980        let mut channels_by_name = HashMap::new();
15981        channels_by_name.insert(channel.name().to_string(), channel);
15982
15983        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
15984
15985        let runtime_ctx = Arc::new(ChannelRuntimeContext {
15986            channels_by_name: Arc::new(channels_by_name),
15987            model_provider: provider_impl.clone(),
15988            model_provider_ref: Arc::new("test-provider".to_string()),
15989            agent_alias: Arc::new("test-agent".to_string()),
15990            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
15991            memory: Arc::new(NoopMemory),
15992            memory_strategy: Arc::new(
15993                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
15994                    Arc::new(NoopMemory),
15995                    zeroclaw_config::schema::MemoryConfig::default(),
15996                    std::path::PathBuf::new(),
15997                ),
15998            ),
15999            tools_registry: Arc::new(vec![]),
16000            observer: Arc::new(NoopObserver),
16001            system_prompt: Arc::new("test-system-prompt".to_string()),
16002            model: Arc::new("test-model".to_string()),
16003            temperature: Some(0.0),
16004            auto_save_memory: false,
16005            max_tool_iterations: 5,
16006            min_relevance_score: 0.0,
16007            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
16008                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
16009            ))),
16010            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
16011            provider_cache: Arc::new(Mutex::new(HashMap::new())),
16012            route_overrides: Arc::new(Mutex::new(HashMap::new())),
16013            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
16014            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
16015            workspace_dir: Arc::new(std::env::temp_dir()),
16016            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
16017            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
16018            interrupt_on_new_message: InterruptOnNewMessageConfig {
16019                telegram: false,
16020                slack: false,
16021                discord: false,
16022                mattermost: false,
16023                matrix: false,
16024            },
16025            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
16026            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
16027            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
16028            agent_transcription_provider: String::new(),
16029            hooks: None,
16030            non_cli_excluded_tools: Arc::new(Vec::new()),
16031            autonomy_level: AutonomyLevel::default(),
16032            tool_call_dedup_exempt: Arc::new(Vec::new()),
16033            model_routes: Arc::new(Vec::new()),
16034            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
16035            ack_reactions: true,
16036            show_tool_calls: true,
16037            session_store: None,
16038            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
16039                &zeroclaw_config::schema::RiskProfileConfig::default(),
16040            )),
16041            activated_tools: None,
16042            cost_tracking: None,
16043            pacing: zeroclaw_config::schema::PacingConfig::default(),
16044            max_tool_result_chars: 0,
16045            context_token_budget: 0,
16046            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
16047                Duration::ZERO,
16048            )),
16049            receipt_generator: None,
16050            show_receipts_in_response: false,
16051            last_applied_config_stamp: Arc::new(Mutex::new(None)),
16052            runtime_defaults_override: Arc::new(Mutex::new(None)),
16053        });
16054
16055        process_channel_message(
16056            runtime_ctx.clone(),
16057            zeroclaw_api::channel::ChannelMessage {
16058                id: "msg-a".to_string(),
16059                sender: "alice".to_string(),
16060                reply_target: "chat-1".to_string(),
16061                content: "hello".to_string(),
16062                channel: "test-channel".to_string(),
16063                channel_alias: None,
16064                timestamp: 1,
16065                thread_ts: None,
16066                interruption_scope_id: None,
16067                attachments: vec![],
16068                subject: None,
16069            },
16070            CancellationToken::new(),
16071        )
16072        .await;
16073
16074        process_channel_message(
16075            runtime_ctx,
16076            zeroclaw_api::channel::ChannelMessage {
16077                id: "msg-b".to_string(),
16078                sender: "alice".to_string(),
16079                reply_target: "chat-1".to_string(),
16080                content: "follow up".to_string(),
16081                channel: "test-channel".to_string(),
16082                channel_alias: None,
16083                timestamp: 2,
16084                thread_ts: None,
16085                interruption_scope_id: None,
16086                attachments: vec![],
16087                subject: None,
16088            },
16089            CancellationToken::new(),
16090        )
16091        .await;
16092
16093        let calls = provider_impl
16094            .calls
16095            .lock()
16096            .unwrap_or_else(|e| e.into_inner());
16097        assert_eq!(calls.len(), 2);
16098        assert_eq!(calls[0].len(), 2);
16099        assert_eq!(calls[0][0].0, "system");
16100        assert_eq!(calls[0][1].0, "user");
16101        assert_eq!(calls[1].len(), 4);
16102        assert_eq!(calls[1][0].0, "system");
16103        assert_eq!(calls[1][1].0, "user");
16104        assert_eq!(calls[1][2].0, "assistant");
16105        assert_eq!(calls[1][3].0, "user");
16106        assert!(calls[1][1].1.starts_with('['));
16107        assert!(calls[1][1].1.contains("hello"));
16108        assert!(calls[1][2].1.contains("response-1"));
16109        assert!(calls[1][3].1.starts_with('['));
16110        assert!(calls[1][3].1.contains("follow up"));
16111    }
16112
16113    #[tokio::test]
16114    async fn process_channel_message_refreshes_available_skills_after_new_session() {
16115        let workspace = make_workspace();
16116        let mut config = Config {
16117            data_dir: workspace.path().to_path_buf(),
16118            ..Default::default()
16119        };
16120        config.skills.open_skills_enabled = false;
16121
16122        let initial_skills =
16123            zeroclaw_runtime::skills::load_skills_with_config(workspace.path(), &config);
16124        assert!(initial_skills.is_empty());
16125
16126        let default_identity = zeroclaw_config::schema::IdentityConfig::default();
16127        let initial_system_prompt = build_system_prompt_with_mode(
16128            workspace.path(),
16129            "test-model",
16130            &[],
16131            &initial_skills,
16132            Some(&default_identity),
16133            None,
16134            false,
16135            config.skills.prompt_injection_mode,
16136            AutonomyLevel::default(),
16137        );
16138        assert!(
16139            !initial_system_prompt.contains("refresh-test"),
16140            "initial prompt should not contain the new skill before it exists"
16141        );
16142
16143        let channel_impl = Arc::new(TelegramRecordingChannel::default());
16144        let channel: Arc<dyn Channel> = channel_impl.clone();
16145
16146        let mut channels_by_name = HashMap::new();
16147        channels_by_name.insert(channel.name().to_string(), channel);
16148
16149        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
16150        let runtime_ctx = Arc::new(ChannelRuntimeContext {
16151            channels_by_name: Arc::new(channels_by_name),
16152            model_provider: provider_impl.clone(),
16153            model_provider_ref: Arc::new("test-provider".to_string()),
16154            agent_alias: Arc::new("test-agent".to_string()),
16155            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
16156            memory: Arc::new(NoopMemory),
16157            memory_strategy: Arc::new(
16158                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
16159                    Arc::new(NoopMemory),
16160                    zeroclaw_config::schema::MemoryConfig::default(),
16161                    std::path::PathBuf::new(),
16162                ),
16163            ),
16164            tools_registry: Arc::new(vec![]),
16165            observer: Arc::new(NoopObserver),
16166            system_prompt: Arc::new(initial_system_prompt),
16167            model: Arc::new("test-model".to_string()),
16168            temperature: Some(0.0),
16169            auto_save_memory: false,
16170            max_tool_iterations: 5,
16171            min_relevance_score: 0.0,
16172            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
16173                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
16174            ))),
16175            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
16176            provider_cache: Arc::new(Mutex::new(HashMap::new())),
16177            route_overrides: Arc::new(Mutex::new(HashMap::new())),
16178            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
16179            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
16180            workspace_dir: Arc::new(config.data_dir.clone()),
16181            prompt_config: Arc::new(config.clone()),
16182            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
16183            interrupt_on_new_message: InterruptOnNewMessageConfig {
16184                telegram: false,
16185                slack: false,
16186                discord: false,
16187                mattermost: false,
16188                matrix: false,
16189            },
16190            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
16191            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
16192            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
16193            agent_transcription_provider: String::new(),
16194            hooks: None,
16195            non_cli_excluded_tools: Arc::new(Vec::new()),
16196            autonomy_level: AutonomyLevel::default(),
16197            tool_call_dedup_exempt: Arc::new(Vec::new()),
16198            model_routes: Arc::new(Vec::new()),
16199            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
16200            ack_reactions: true,
16201            show_tool_calls: true,
16202            session_store: None,
16203            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
16204                &zeroclaw_config::schema::RiskProfileConfig::default(),
16205            )),
16206            activated_tools: None,
16207            cost_tracking: None,
16208            pacing: zeroclaw_config::schema::PacingConfig::default(),
16209            max_tool_result_chars: 0,
16210            context_token_budget: 0,
16211            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
16212                Duration::ZERO,
16213            )),
16214            receipt_generator: None,
16215            show_receipts_in_response: false,
16216            last_applied_config_stamp: Arc::new(Mutex::new(None)),
16217            runtime_defaults_override: Arc::new(Mutex::new(None)),
16218        });
16219
16220        process_channel_message(
16221            runtime_ctx.clone(),
16222            zeroclaw_api::channel::ChannelMessage {
16223                id: "msg-before-new".to_string(),
16224                sender: "alice".to_string(),
16225                reply_target: "chat-refresh".to_string(),
16226                content: "hello".to_string(),
16227                channel: "telegram".to_string(),
16228                channel_alias: None,
16229                timestamp: 1,
16230                thread_ts: None,
16231                interruption_scope_id: None,
16232                attachments: vec![],
16233                subject: None,
16234            },
16235            CancellationToken::new(),
16236        )
16237        .await;
16238
16239        let skill_dir = workspace.path().join("skills").join("refresh-test");
16240        std::fs::create_dir_all(&skill_dir).unwrap();
16241        std::fs::write(
16242            skill_dir.join("SKILL.md"),
16243            "---\nname: refresh-test\ndescription: Refresh the available skills section\n---\n# Refresh Test\nExpose this skill after /new.\n",
16244        )
16245        .unwrap();
16246        let refreshed_skills =
16247            zeroclaw_runtime::skills::load_skills_with_config(workspace.path(), &config);
16248        assert_eq!(refreshed_skills.len(), 1);
16249        assert_eq!(refreshed_skills[0].name, "refresh-test");
16250        assert!(
16251            refreshed_new_session_system_prompt(runtime_ctx.as_ref())
16252                .contains("<name>refresh-test</name>"),
16253            "fresh-session prompt should pick up skills added after startup"
16254        );
16255
16256        process_channel_message(
16257            runtime_ctx.clone(),
16258            zeroclaw_api::channel::ChannelMessage {
16259                id: "msg-new-session".to_string(),
16260                sender: "alice".to_string(),
16261                reply_target: "chat-refresh".to_string(),
16262                content: "/new".to_string(),
16263                channel: "telegram".to_string(),
16264                channel_alias: None,
16265                timestamp: 2,
16266                thread_ts: None,
16267                interruption_scope_id: None,
16268                attachments: vec![],
16269                subject: None,
16270            },
16271            CancellationToken::new(),
16272        )
16273        .await;
16274
16275        {
16276            let histories = runtime_ctx
16277                .conversation_histories
16278                .lock()
16279                .unwrap_or_else(|e| e.into_inner());
16280            assert!(
16281                histories.peek("telegram_chat-refresh_alice").is_none(),
16282                "/new should clear the cached sender history before the next message"
16283            );
16284        }
16285
16286        {
16287            let pending_new_sessions = runtime_ctx
16288                .pending_new_sessions
16289                .lock()
16290                .unwrap_or_else(|e| e.into_inner());
16291            assert!(
16292                pending_new_sessions.contains("telegram_chat-refresh_alice"),
16293                "/new should mark the sender for a fresh next-message prompt rebuild"
16294            );
16295        }
16296
16297        process_channel_message(
16298            runtime_ctx,
16299            zeroclaw_api::channel::ChannelMessage {
16300                id: "msg-after-new".to_string(),
16301                sender: "alice".to_string(),
16302                reply_target: "chat-refresh".to_string(),
16303                content: "hello again".to_string(),
16304                channel: "telegram".to_string(),
16305                channel_alias: None,
16306                timestamp: 3,
16307                thread_ts: None,
16308                interruption_scope_id: None,
16309                attachments: vec![],
16310                subject: None,
16311            },
16312            CancellationToken::new(),
16313        )
16314        .await;
16315
16316        {
16317            let calls = provider_impl
16318                .calls
16319                .lock()
16320                .unwrap_or_else(|e| e.into_inner());
16321            assert_eq!(calls.len(), 2);
16322            assert_eq!(calls[0][0].0, "system");
16323            assert_eq!(calls[1][0].0, "system");
16324            assert!(
16325                !calls[0][0].1.contains("<name>refresh-test</name>"),
16326                "pre-/new prompt should not advertise a skill that did not exist yet"
16327            );
16328            assert!(
16329                calls[1][0].1.contains("<available_skills>"),
16330                "post-/new prompt should contain the refreshed skills block"
16331            );
16332            assert!(
16333                calls[1][0].1.contains("<name>refresh-test</name>"),
16334                "post-/new prompt should include skills discovered after the reset"
16335            );
16336        }
16337
16338        let sent_messages = channel_impl.sent_messages.lock().await;
16339        assert!(
16340            sent_messages.iter().any(|message| {
16341                message.contains("Conversation history cleared. Starting fresh.")
16342            })
16343        );
16344    }
16345
16346    #[tokio::test]
16347    async fn process_channel_message_enriches_current_turn_without_persisting_context() {
16348        let channel_impl = Arc::new(RecordingChannel::default());
16349        let channel: Arc<dyn Channel> = channel_impl.clone();
16350
16351        let mut channels_by_name = HashMap::new();
16352        channels_by_name.insert(channel.name().to_string(), channel);
16353
16354        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
16355        let runtime_ctx = Arc::new(ChannelRuntimeContext {
16356            channels_by_name: Arc::new(channels_by_name),
16357            model_provider: provider_impl.clone(),
16358            model_provider_ref: Arc::new("test-provider".to_string()),
16359            agent_alias: Arc::new("test-agent".to_string()),
16360            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
16361            memory: Arc::new(RecallMemory),
16362            memory_strategy: Arc::new(
16363                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
16364                    Arc::new(RecallMemory),
16365                    zeroclaw_config::schema::MemoryConfig::default(),
16366                    std::path::PathBuf::new(),
16367                ),
16368            ),
16369            tools_registry: Arc::new(vec![]),
16370            observer: Arc::new(NoopObserver),
16371            system_prompt: Arc::new("test-system-prompt".to_string()),
16372            model: Arc::new("test-model".to_string()),
16373            temperature: Some(0.0),
16374            auto_save_memory: false,
16375            max_tool_iterations: 5,
16376            min_relevance_score: 0.0,
16377            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
16378                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
16379            ))),
16380            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
16381            provider_cache: Arc::new(Mutex::new(HashMap::new())),
16382            route_overrides: Arc::new(Mutex::new(HashMap::new())),
16383            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
16384            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
16385            workspace_dir: Arc::new(std::env::temp_dir()),
16386            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
16387            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
16388            interrupt_on_new_message: InterruptOnNewMessageConfig {
16389                telegram: false,
16390                slack: false,
16391                discord: false,
16392                mattermost: false,
16393                matrix: false,
16394            },
16395            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
16396            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
16397            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
16398            agent_transcription_provider: String::new(),
16399            hooks: None,
16400            non_cli_excluded_tools: Arc::new(Vec::new()),
16401            autonomy_level: AutonomyLevel::default(),
16402            tool_call_dedup_exempt: Arc::new(Vec::new()),
16403            model_routes: Arc::new(Vec::new()),
16404            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
16405            ack_reactions: true,
16406            show_tool_calls: true,
16407            session_store: None,
16408            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
16409                &zeroclaw_config::schema::RiskProfileConfig::default(),
16410            )),
16411            activated_tools: None,
16412            cost_tracking: None,
16413            pacing: zeroclaw_config::schema::PacingConfig::default(),
16414            max_tool_result_chars: 0,
16415            context_token_budget: 0,
16416            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
16417                Duration::ZERO,
16418            )),
16419            receipt_generator: None,
16420            show_receipts_in_response: false,
16421            last_applied_config_stamp: Arc::new(Mutex::new(None)),
16422            runtime_defaults_override: Arc::new(Mutex::new(None)),
16423        });
16424
16425        process_channel_message(
16426            runtime_ctx.clone(),
16427            zeroclaw_api::channel::ChannelMessage {
16428                id: "msg-ctx-1".to_string(),
16429                sender: "alice".to_string(),
16430                reply_target: "chat-ctx".to_string(),
16431                content: "hello".to_string(),
16432                channel: "test-channel".to_string(),
16433                channel_alias: None,
16434                timestamp: 1,
16435                thread_ts: None,
16436                interruption_scope_id: None,
16437                attachments: vec![],
16438                subject: None,
16439            },
16440            CancellationToken::new(),
16441        )
16442        .await;
16443
16444        let calls = provider_impl
16445            .calls
16446            .lock()
16447            .unwrap_or_else(|e| e.into_inner());
16448        assert_eq!(calls.len(), 1);
16449        assert_eq!(calls[0].len(), 2);
16450        // Memory context is injected into the system prompt, not the user message.
16451        assert_eq!(calls[0][0].0, "system");
16452        assert!(calls[0][0].1.contains(MEMORY_CONTEXT_OPEN));
16453        assert!(calls[0][0].1.contains("Age is 45"));
16454        assert_eq!(calls[0][1].0, "user");
16455        assert!(calls[0][1].1.starts_with('['));
16456        assert!(
16457            calls[0][1].1.contains("] hello"),
16458            "current channel user turn should be timestamped: {}",
16459            calls[0][1].1
16460        );
16461
16462        let histories = runtime_ctx
16463            .conversation_histories
16464            .lock()
16465            .unwrap_or_else(|e| e.into_inner());
16466        let turns = histories
16467            .peek("test-channel_chat-ctx_alice")
16468            .expect("history should be stored for sender");
16469        assert_eq!(turns[0].role, "user");
16470        assert!(turns[0].content.starts_with('['));
16471        assert!(
16472            turns[0].content.contains("] hello"),
16473            "stored channel user turn should be timestamped: {}",
16474            turns[0].content
16475        );
16476        assert!(!turns[0].content.contains(MEMORY_CONTEXT_OPEN));
16477    }
16478
16479    #[tokio::test]
16480    async fn process_channel_message_sends_image_payload_without_persisting_it() {
16481        let channel_impl = Arc::new(RecordingChannel::default());
16482        let channel: Arc<dyn Channel> = channel_impl.clone();
16483
16484        let mut channels_by_name = HashMap::new();
16485        channels_by_name.insert(channel.name().to_string(), channel);
16486
16487        let provider_impl = Arc::new(HistoryCaptureModelProvider {
16488            vision: true,
16489            ..Default::default()
16490        });
16491        let runtime_ctx = Arc::new(ChannelRuntimeContext {
16492            channels_by_name: Arc::new(channels_by_name),
16493            model_provider: provider_impl.clone(),
16494            model_provider_ref: Arc::new("test-provider".to_string()),
16495            agent_alias: Arc::new("test-agent".to_string()),
16496            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
16497            memory: Arc::new(NoopMemory),
16498            memory_strategy: Arc::new(
16499                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
16500                    Arc::new(NoopMemory),
16501                    zeroclaw_config::schema::MemoryConfig::default(),
16502                    std::path::PathBuf::new(),
16503                ),
16504            ),
16505            tools_registry: Arc::new(vec![]),
16506            observer: Arc::new(NoopObserver),
16507            system_prompt: Arc::new("test-system-prompt".to_string()),
16508            model: Arc::new("test-model".to_string()),
16509            temperature: Some(0.0),
16510            auto_save_memory: false,
16511            max_tool_iterations: 5,
16512            min_relevance_score: 0.0,
16513            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
16514                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
16515            ))),
16516            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
16517            provider_cache: Arc::new(Mutex::new(HashMap::new())),
16518            route_overrides: Arc::new(Mutex::new(HashMap::new())),
16519            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
16520            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
16521            workspace_dir: Arc::new(std::env::temp_dir()),
16522            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
16523            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
16524            interrupt_on_new_message: InterruptOnNewMessageConfig {
16525                telegram: false,
16526                slack: false,
16527                discord: false,
16528                mattermost: false,
16529                matrix: false,
16530            },
16531            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
16532            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig {
16533                enabled: true,
16534                ..Default::default()
16535            },
16536            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
16537            agent_transcription_provider: String::new(),
16538            hooks: None,
16539            non_cli_excluded_tools: Arc::new(Vec::new()),
16540            autonomy_level: AutonomyLevel::default(),
16541            tool_call_dedup_exempt: Arc::new(Vec::new()),
16542            model_routes: Arc::new(Vec::new()),
16543            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
16544            ack_reactions: true,
16545            show_tool_calls: true,
16546            session_store: None,
16547            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
16548                &zeroclaw_config::schema::RiskProfileConfig::default(),
16549            )),
16550            activated_tools: None,
16551            cost_tracking: None,
16552            pacing: zeroclaw_config::schema::PacingConfig::default(),
16553            max_tool_result_chars: 0,
16554            context_token_budget: 0,
16555            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
16556                Duration::ZERO,
16557            )),
16558            receipt_generator: None,
16559            show_receipts_in_response: false,
16560            last_applied_config_stamp: Arc::new(Mutex::new(None)),
16561            runtime_defaults_override: Arc::new(Mutex::new(None)),
16562        });
16563
16564        process_channel_message(
16565            runtime_ctx.clone(),
16566            zeroclaw_api::channel::ChannelMessage {
16567                id: "msg-image-1".to_string(),
16568                sender: "alice".to_string(),
16569                reply_target: "chat-image".to_string(),
16570                content: "please inspect this".to_string(),
16571                channel: "test-channel".to_string(),
16572                channel_alias: None,
16573                timestamp: 1,
16574                thread_ts: None,
16575                interruption_scope_id: None,
16576                attachments: vec![zeroclaw_api::media::MediaAttachment {
16577                    file_name: "sticker.png".to_string(),
16578                    data: vec![1, 2, 3, 4],
16579                    mime_type: Some("image/png".to_string()),
16580                }],
16581                subject: None,
16582            },
16583            CancellationToken::new(),
16584        )
16585        .await;
16586
16587        let calls = provider_impl
16588            .calls
16589            .lock()
16590            .unwrap_or_else(|e| e.into_inner());
16591        assert_eq!(calls.len(), 1);
16592        let current_user = calls[0]
16593            .iter()
16594            .rev()
16595            .find(|(role, _)| role == "user")
16596            .expect("provider call should include current user message");
16597        assert!(current_user.1.contains("[IMAGE:data:image/png;base64,"));
16598        assert!(current_user.1.contains("please inspect this"));
16599        drop(calls);
16600
16601        let histories = runtime_ctx
16602            .conversation_histories
16603            .lock()
16604            .unwrap_or_else(|e| e.into_inner());
16605        let turns = histories
16606            .peek("test-channel_chat-image_alice")
16607            .expect("history should be stored for sender");
16608        assert_eq!(turns[0].role, "user");
16609        assert!(turns[0].content.starts_with('['));
16610        assert!(turns[0].content.contains("[Image: sticker.png attached"));
16611        assert!(turns[0].content.contains("please inspect this"));
16612        assert!(!turns[0].content.contains("[IMAGE:data:"));
16613        assert!(!turns[0].content.contains("AQIDBA"));
16614    }
16615
16616    #[tokio::test]
16617    async fn process_channel_message_telegram_keeps_system_instruction_at_top_only() {
16618        let channel_impl = Arc::new(TelegramRecordingChannel::default());
16619        let channel: Arc<dyn Channel> = channel_impl.clone();
16620
16621        let mut channels_by_name = HashMap::new();
16622        channels_by_name.insert(channel.name().to_string(), channel);
16623
16624        let provider_impl = Arc::new(HistoryCaptureModelProvider::default());
16625        let mut histories =
16626            lru::LruCache::new(std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap());
16627        histories.push(
16628            "telegram_chat-telegram_alice".to_string(),
16629            vec![
16630                ChatMessage::assistant("stale assistant"),
16631                ChatMessage::user("earlier user question"),
16632                ChatMessage::assistant("earlier assistant reply"),
16633            ],
16634        );
16635
16636        let runtime_ctx = Arc::new(ChannelRuntimeContext {
16637            channels_by_name: Arc::new(channels_by_name),
16638            model_provider: provider_impl.clone(),
16639            model_provider_ref: Arc::new("test-provider".to_string()),
16640            agent_alias: Arc::new("test-agent".to_string()),
16641            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
16642            memory: Arc::new(NoopMemory),
16643            memory_strategy: Arc::new(
16644                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
16645                    Arc::new(NoopMemory),
16646                    zeroclaw_config::schema::MemoryConfig::default(),
16647                    std::path::PathBuf::new(),
16648                ),
16649            ),
16650            tools_registry: Arc::new(vec![]),
16651            observer: Arc::new(NoopObserver),
16652            system_prompt: Arc::new("test-system-prompt".to_string()),
16653            model: Arc::new("test-model".to_string()),
16654            temperature: Some(0.0),
16655            auto_save_memory: false,
16656            max_tool_iterations: 5,
16657            min_relevance_score: 0.0,
16658            conversation_histories: Arc::new(Mutex::new(histories)),
16659            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
16660            provider_cache: Arc::new(Mutex::new(HashMap::new())),
16661            route_overrides: Arc::new(Mutex::new(HashMap::new())),
16662            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
16663            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
16664            workspace_dir: Arc::new(std::env::temp_dir()),
16665            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
16666            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
16667            interrupt_on_new_message: InterruptOnNewMessageConfig {
16668                telegram: false,
16669                slack: false,
16670                discord: false,
16671                mattermost: false,
16672                matrix: false,
16673            },
16674            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
16675            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
16676            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
16677            agent_transcription_provider: String::new(),
16678            hooks: None,
16679            non_cli_excluded_tools: Arc::new(Vec::new()),
16680            autonomy_level: AutonomyLevel::default(),
16681            tool_call_dedup_exempt: Arc::new(Vec::new()),
16682            model_routes: Arc::new(Vec::new()),
16683            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
16684            ack_reactions: true,
16685            show_tool_calls: true,
16686            session_store: None,
16687            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
16688                &zeroclaw_config::schema::RiskProfileConfig::default(),
16689            )),
16690            activated_tools: None,
16691            cost_tracking: None,
16692            pacing: zeroclaw_config::schema::PacingConfig::default(),
16693            max_tool_result_chars: 0,
16694            context_token_budget: 0,
16695            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
16696                Duration::ZERO,
16697            )),
16698            receipt_generator: None,
16699            show_receipts_in_response: false,
16700            last_applied_config_stamp: Arc::new(Mutex::new(None)),
16701            runtime_defaults_override: Arc::new(Mutex::new(None)),
16702        });
16703
16704        process_channel_message(
16705            runtime_ctx.clone(),
16706            zeroclaw_api::channel::ChannelMessage {
16707                id: "tg-msg-1".to_string(),
16708                sender: "alice".to_string(),
16709                reply_target: "chat-telegram".to_string(),
16710                content: "hello".to_string(),
16711                channel: "telegram".to_string(),
16712                channel_alias: None,
16713                timestamp: 1,
16714                thread_ts: None,
16715                interruption_scope_id: None,
16716                attachments: vec![],
16717                subject: None,
16718            },
16719            CancellationToken::new(),
16720        )
16721        .await;
16722
16723        let calls = provider_impl
16724            .calls
16725            .lock()
16726            .unwrap_or_else(|e| e.into_inner());
16727        assert_eq!(calls.len(), 1);
16728        assert_eq!(calls[0].len(), 4);
16729
16730        let roles = calls[0]
16731            .iter()
16732            .map(|(role, _)| role.as_str())
16733            .collect::<Vec<_>>();
16734        assert_eq!(roles, vec!["system", "user", "assistant", "user"]);
16735        assert!(
16736            calls[0][0].1.contains("When responding on Telegram:"),
16737            "telegram channel instructions should be embedded into the system prompt"
16738        );
16739        assert!(
16740            calls[0][0].1.contains("For media attachments use markers:"),
16741            "telegram media marker guidance should live in the system prompt"
16742        );
16743        assert!(!calls[0].iter().skip(1).any(|(role, _)| role == "system"));
16744    }
16745
16746    #[test]
16747    fn channel_delivery_instructions_for_discord_mandates_absolute_paths() {
16748        let block = channel_delivery_instructions("discord")
16749            .expect("discord channel must have a delivery-instructions block");
16750        assert!(
16751            block.contains("When responding on Discord:"),
16752            "discord block must identify itself"
16753        );
16754        assert!(
16755            block.contains("For media attachments use markers:"),
16756            "discord block must describe marker syntax"
16757        );
16758        assert!(
16759            block.contains("MUST be absolute"),
16760            "discord block must mandate absolute paths"
16761        );
16762        assert!(
16763            block.contains("workspace"),
16764            "discord block must reference workspace bounds"
16765        );
16766        assert!(
16767            block.contains("[IMAGE:<absolute-path>]"),
16768            "discord block must show the absolute-path marker form"
16769        );
16770    }
16771
16772    #[test]
16773    fn extract_tool_context_summary_collects_alias_and_native_tool_calls() {
16774        let history = vec![
16775            ChatMessage::system("sys"),
16776            ChatMessage::assistant(
16777                r#"<toolcall>
16778{"name":"shell","arguments":{"command":"date"}}
16779</toolcall>"#,
16780            ),
16781            ChatMessage::assistant(
16782                r#"{"content":null,"tool_calls":[{"id":"1","name":"web_search","arguments":"{}"}]}"#,
16783            ),
16784        ];
16785
16786        let summary = extract_tool_context_summary(&history, 1);
16787        assert_eq!(summary, "[Used tools: shell, web_search]");
16788    }
16789
16790    #[test]
16791    fn extract_tool_context_summary_collects_prompt_mode_tool_result_names() {
16792        let history = vec![
16793            ChatMessage::system("sys"),
16794            ChatMessage::assistant("Using markdown tool call fence"),
16795            ChatMessage::user(
16796                r#"[Tool results]
16797<tool_result name="http_request">
16798{"status":200}
16799</tool_result>
16800<tool_result name="shell">
16801Mon Feb 20
16802</tool_result>"#,
16803            ),
16804        ];
16805
16806        let summary = extract_tool_context_summary(&history, 1);
16807        assert_eq!(summary, "[Used tools: http_request, shell]");
16808    }
16809
16810    #[test]
16811    fn extract_tool_context_summary_respects_start_index() {
16812        let history = vec![
16813            ChatMessage::assistant(
16814                r#"<tool_call>
16815{"name":"stale_tool","arguments":{}}
16816</tool_call>"#,
16817            ),
16818            ChatMessage::assistant(
16819                r#"<tool_call>
16820{"name":"fresh_tool","arguments":{}}
16821</tool_call>"#,
16822            ),
16823        ];
16824
16825        let summary = extract_tool_context_summary(&history, 1);
16826        assert_eq!(summary, "[Used tools: fresh_tool]");
16827    }
16828
16829    #[test]
16830    fn strip_isolated_tool_json_artifacts_removes_tool_calls_and_results() {
16831        let mut known_tools = HashSet::new();
16832        known_tools.insert("schedule".to_string());
16833
16834        let input = r#"{"name":"schedule","parameters":{"action":"create","message":"test"}}
16835{"name":"schedule","parameters":{"action":"cancel","task_id":"test"}}
16836Let me create the reminder properly:
16837{"name":"schedule","parameters":{"action":"create","message":"Go to sleep"}}
16838{"result":{"task_id":"abc","status":"scheduled"}}
16839Done reminder set for 1:38 AM."#;
16840
16841        let result = strip_isolated_tool_json_artifacts(input, &known_tools);
16842        let normalized = result
16843            .lines()
16844            .filter(|line| !line.trim().is_empty())
16845            .collect::<Vec<_>>()
16846            .join("\n");
16847        assert_eq!(
16848            normalized,
16849            "Let me create the reminder properly:\nDone reminder set for 1:38 AM."
16850        );
16851    }
16852
16853    #[test]
16854    fn strip_isolated_tool_json_artifacts_preserves_non_tool_json() {
16855        let mut known_tools = HashSet::new();
16856        known_tools.insert("shell".to_string());
16857
16858        let input = r#"{"name":"profile","parameters":{"timezone":"UTC"}}
16859This is an example JSON object for profile settings."#;
16860
16861        let result = strip_isolated_tool_json_artifacts(input, &known_tools);
16862        assert_eq!(result, input);
16863    }
16864
16865    // ── AIEOS Identity Tests (Issue #168) ─────────────────────────
16866
16867    #[test]
16868    fn aieos_identity_from_file() {
16869        use tempfile::TempDir;
16870        use zeroclaw_config::schema::IdentityConfig;
16871
16872        let tmp = TempDir::new().unwrap();
16873        let identity_path = tmp.path().join("aieos_identity.json");
16874
16875        // Write AIEOS identity file
16876        let aieos_json = r#"{
16877            "identity": {
16878                "names": {"first": "Nova", "nickname": "Nov"},
16879                "bio": "A helpful AI assistant.",
16880                "origin": "Silicon Valley"
16881            },
16882            "psychology": {
16883                "mbti": "INTJ",
16884                "moral_compass": ["Be helpful", "Do no harm"]
16885            },
16886            "linguistics": {
16887                "style": "concise",
16888                "formality": "casual"
16889            }
16890        }"#;
16891        std::fs::write(&identity_path, aieos_json).unwrap();
16892
16893        // Create identity config pointing to the file
16894        let config = IdentityConfig {
16895            format: "aieos".into(),
16896            aieos_path: Some("aieos_identity.json".into()),
16897            aieos_inline: None,
16898        };
16899
16900        let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None);
16901
16902        // Should contain AIEOS sections
16903        assert!(prompt.contains("## Identity"));
16904        assert!(prompt.contains("**Name:** Nova"));
16905        assert!(prompt.contains("**Nickname:** Nov"));
16906        assert!(prompt.contains("**Bio:** A helpful AI assistant."));
16907        assert!(prompt.contains("**Origin:** Silicon Valley"));
16908
16909        assert!(prompt.contains("## Personality"));
16910        assert!(prompt.contains("**MBTI:** INTJ"));
16911        assert!(prompt.contains("**Moral Compass:**"));
16912        assert!(prompt.contains("- Be helpful"));
16913
16914        assert!(prompt.contains("## Communication Style"));
16915        assert!(prompt.contains("**Style:** concise"));
16916        assert!(prompt.contains("**Formality Level:** casual"));
16917
16918        // Should NOT contain OpenClaw bootstrap file headers
16919        assert!(!prompt.contains("### SOUL.md"));
16920        assert!(!prompt.contains("### IDENTITY.md"));
16921        assert!(!prompt.contains("[File not found"));
16922    }
16923
16924    #[test]
16925    fn aieos_identity_from_inline() {
16926        use zeroclaw_config::schema::IdentityConfig;
16927
16928        let config = IdentityConfig {
16929            format: "aieos".into(),
16930            aieos_path: None,
16931            aieos_inline: Some(r#"{"identity":{"names":{"first":"Claw"}}}"#.into()),
16932        };
16933
16934        let prompt = build_system_prompt(
16935            std::env::temp_dir().as_path(),
16936            "model",
16937            &[],
16938            &[],
16939            Some(&config),
16940            None,
16941        );
16942
16943        assert!(prompt.contains("**Name:** Claw"));
16944        assert!(prompt.contains("## Identity"));
16945    }
16946
16947    #[test]
16948    fn aieos_fallback_to_openclaw_on_parse_error() {
16949        use zeroclaw_config::schema::IdentityConfig;
16950
16951        let config = IdentityConfig {
16952            format: "aieos".into(),
16953            aieos_path: Some("nonexistent.json".into()),
16954            aieos_inline: None,
16955        };
16956
16957        let ws = make_workspace();
16958        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
16959
16960        // Should fall back to OpenClaw format when AIEOS file is not found
16961        // (Error is logged to stderr with filename, not included in prompt)
16962        assert!(prompt.contains("### SOUL.md"));
16963    }
16964
16965    #[test]
16966    fn aieos_empty_uses_openclaw() {
16967        use zeroclaw_config::schema::IdentityConfig;
16968
16969        // Format is "aieos" but neither path nor inline is set
16970        let config = IdentityConfig {
16971            format: "aieos".into(),
16972            aieos_path: None,
16973            aieos_inline: None,
16974        };
16975
16976        let ws = make_workspace();
16977        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
16978
16979        // Should use OpenClaw format (not configured for AIEOS)
16980        assert!(prompt.contains("### SOUL.md"));
16981        assert!(prompt.contains("Be helpful"));
16982    }
16983
16984    #[test]
16985    fn openclaw_format_uses_bootstrap_files() {
16986        use zeroclaw_config::schema::IdentityConfig;
16987
16988        let config = IdentityConfig {
16989            format: "openclaw".into(),
16990            aieos_path: Some("identity.json".into()),
16991            aieos_inline: None,
16992        };
16993
16994        let ws = make_workspace();
16995        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
16996
16997        // Should use OpenClaw format even if aieos_path is set
16998        assert!(prompt.contains("### SOUL.md"));
16999        assert!(prompt.contains("Be helpful"));
17000        assert!(!prompt.contains("## Identity"));
17001    }
17002
17003    #[test]
17004    fn none_identity_config_uses_openclaw() {
17005        let ws = make_workspace();
17006        // Pass None for identity config
17007        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
17008
17009        // Should use OpenClaw format
17010        assert!(prompt.contains("### SOUL.md"));
17011        assert!(prompt.contains("Be helpful"));
17012    }
17013
17014    #[test]
17015    fn classify_health_ok_true() {
17016        let state = classify_health_result(&Ok(true));
17017        assert_eq!(state, ChannelHealthState::Healthy);
17018    }
17019
17020    #[test]
17021    fn classify_health_ok_false() {
17022        let state = classify_health_result(&Ok(false));
17023        assert_eq!(state, ChannelHealthState::Unhealthy);
17024    }
17025
17026    #[tokio::test]
17027    async fn classify_health_timeout() {
17028        let result = tokio::time::timeout(Duration::from_millis(1), async {
17029            tokio::time::sleep(Duration::from_millis(20)).await;
17030            true
17031        })
17032        .await;
17033        let state = classify_health_result(&result);
17034        assert_eq!(state, ChannelHealthState::Timeout);
17035    }
17036
17037    #[cfg(feature = "channel-matrix")]
17038    #[test]
17039    fn matrix_state_dir_is_distinct_per_alias() {
17040        // Regression: two [channels.matrix.<alias>] blocks previously resolved
17041        // to the same <config>/state/matrix dir, so the second listener to
17042        // start restored the first's session.json and ran as the wrong Matrix
17043        // account. The alias component must keep them separate.
17044        let config_path = std::path::Path::new("/home/u/.zeroclaw/config.toml");
17045        let clamps = matrix_state_dir(config_path, "clamps");
17046        let bender = matrix_state_dir(config_path, "bender");
17047        assert_ne!(
17048            clamps, bender,
17049            "distinct matrix aliases must not share a state dir"
17050        );
17051        assert_eq!(
17052            clamps,
17053            std::path::Path::new("/home/u/.zeroclaw/state/matrix/clamps")
17054        );
17055        assert_eq!(
17056            bender,
17057            std::path::Path::new("/home/u/.zeroclaw/state/matrix/bender")
17058        );
17059    }
17060
17061    #[cfg(feature = "channel-mattermost")]
17062    #[test]
17063    fn collect_configured_channels_includes_mattermost_when_configured() {
17064        let mut config = Config::default();
17065        config.channels.mattermost.insert(
17066            "default".to_string(),
17067            zeroclaw_config::schema::MattermostConfig {
17068                enabled: true,
17069                url: "https://mattermost.example.com".to_string(),
17070                bot_token: Some("test-token".to_string()),
17071                login_id: None,
17072                password: None,
17073                channel_ids: vec!["channel-1".to_string()],
17074                team_ids: vec![],
17075                discover_dms: None,
17076                thread_replies: Some(true),
17077                mention_only: Some(false),
17078                interrupt_on_new_message: false,
17079                proxy_url: None,
17080                excluded_tools: vec![],
17081                reply_min_interval_secs: 0,
17082                reply_queue_depth_max: 0,
17083            },
17084        );
17085        // A channel is only collected when an enabled agent references it.
17086        config.agents.insert(
17087            "mattermost-default".to_string(),
17088            zeroclaw_config::schema::AliasedAgentConfig {
17089                channels: vec!["mattermost.default".into()],
17090                ..Default::default()
17091            },
17092        );
17093
17094        let config_arc = Arc::new(RwLock::new(config));
17095        let channels = collect_configured_channels(&config_arc, "test", &[]);
17096
17097        assert!(
17098            channels
17099                .iter()
17100                .any(|entry| entry.display_name == "Mattermost")
17101        );
17102        assert!(
17103            channels
17104                .iter()
17105                .any(|entry| entry.channel.name() == "mattermost")
17106        );
17107    }
17108
17109    #[cfg(feature = "channel-mattermost")]
17110    #[test]
17111    fn collect_configured_channels_falls_back_when_agent_bindings_missing() {
17112        let mut config = Config::default();
17113        config.channels.mattermost.insert(
17114            "default".to_string(),
17115            zeroclaw_config::schema::MattermostConfig {
17116                enabled: true,
17117                url: "https://mattermost.example.com".to_string(),
17118                bot_token: Some("test-token".to_string()),
17119                login_id: None,
17120                password: None,
17121                channel_ids: vec!["channel-1".to_string()],
17122                team_ids: vec![],
17123                discover_dms: None,
17124                thread_replies: Some(true),
17125                mention_only: Some(false),
17126                interrupt_on_new_message: false,
17127                proxy_url: None,
17128                excluded_tools: vec![],
17129                reply_min_interval_secs: 0,
17130                reply_queue_depth_max: 0,
17131            },
17132        );
17133        config.agents.clear();
17134        config.agents.insert(
17135            "legacy".to_string(),
17136            zeroclaw_config::schema::AliasedAgentConfig {
17137                enabled: true,
17138                channels: vec![],
17139                ..Default::default()
17140            },
17141        );
17142
17143        let config_arc = Arc::new(RwLock::new(config));
17144        let channels = collect_configured_channels(&config_arc, "test", &[]);
17145
17146        assert!(
17147            channels
17148                .iter()
17149                .any(|entry| entry.display_name == "Mattermost"),
17150            "enabled channels should still load when no enabled agent declares channel bindings"
17151        );
17152    }
17153
17154    #[cfg(feature = "channel-email")]
17155    #[test]
17156    fn collect_configured_channels_skips_unreferenced_email() {
17157        let mut config = Config::default();
17158        config.channels.email.insert(
17159            "default".to_string(),
17160            zeroclaw_config::scattered_types::EmailConfig::default(),
17161        );
17162
17163        let config_arc = Arc::new(RwLock::new(config));
17164        let channels = collect_configured_channels(&config_arc, "test", &[]);
17165        assert!(
17166            !channels.iter().any(|entry| entry.display_name == "Email"),
17167            "email with no agent reference should not be collected"
17168        );
17169    }
17170
17171    #[cfg(feature = "channel-voice-call")]
17172    #[test]
17173    fn collect_configured_channels_skips_unreferenced_voice_call() {
17174        let mut config = Config::default();
17175        config.channels.voice_call.insert(
17176            "default".to_string(),
17177            zeroclaw_config::scattered_types::VoiceCallConfig::default(),
17178        );
17179
17180        let config_arc = Arc::new(RwLock::new(config));
17181        let channels = collect_configured_channels(&config_arc, "test", &[]);
17182        assert!(
17183            !channels
17184                .iter()
17185                .any(|entry| entry.display_name == "Voice Call"),
17186            "voice-call with no agent reference should not be collected"
17187        );
17188    }
17189
17190    struct AlwaysFailChannel {
17191        name: &'static str,
17192        calls: Arc<AtomicUsize>,
17193    }
17194
17195    struct BlockUntilClosedChannel {
17196        name: String,
17197        calls: Arc<AtomicUsize>,
17198    }
17199
17200    struct FailOnceChannel {
17201        name: String,
17202        calls: Arc<AtomicUsize>,
17203        err: Mutex<Option<anyhow::Error>>,
17204    }
17205
17206    impl ::zeroclaw_api::attribution::Attributable for AlwaysFailChannel {
17207        fn role(&self) -> ::zeroclaw_api::attribution::Role {
17208            ::zeroclaw_api::attribution::Role::Channel(
17209                ::zeroclaw_api::attribution::ChannelKind::Webhook,
17210            )
17211        }
17212        fn alias(&self) -> &str {
17213            "test"
17214        }
17215    }
17216
17217    #[async_trait::async_trait]
17218    impl Channel for AlwaysFailChannel {
17219        fn name(&self) -> &str {
17220            self.name
17221        }
17222
17223        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
17224            Ok(())
17225        }
17226
17227        async fn listen(
17228            &self,
17229            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
17230        ) -> anyhow::Result<()> {
17231            self.calls.fetch_add(1, Ordering::SeqCst);
17232            anyhow::bail!("listen boom")
17233        }
17234    }
17235
17236    impl ::zeroclaw_api::attribution::Attributable for BlockUntilClosedChannel {
17237        fn role(&self) -> ::zeroclaw_api::attribution::Role {
17238            ::zeroclaw_api::attribution::Role::Channel(
17239                ::zeroclaw_api::attribution::ChannelKind::Webhook,
17240            )
17241        }
17242        fn alias(&self) -> &str {
17243            "test"
17244        }
17245    }
17246
17247    impl ::zeroclaw_api::attribution::Attributable for FailOnceChannel {
17248        fn role(&self) -> ::zeroclaw_api::attribution::Role {
17249            ::zeroclaw_api::attribution::Role::Channel(
17250                ::zeroclaw_api::attribution::ChannelKind::Discord,
17251            )
17252        }
17253
17254        fn alias(&self) -> &str {
17255            "default"
17256        }
17257    }
17258
17259    #[async_trait::async_trait]
17260    impl Channel for BlockUntilClosedChannel {
17261        fn name(&self) -> &str {
17262            &self.name
17263        }
17264
17265        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
17266            Ok(())
17267        }
17268
17269        async fn listen(
17270            &self,
17271            tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
17272        ) -> anyhow::Result<()> {
17273            self.calls.fetch_add(1, Ordering::SeqCst);
17274            tx.closed().await;
17275            Ok(())
17276        }
17277    }
17278
17279    #[async_trait::async_trait]
17280    impl Channel for FailOnceChannel {
17281        fn name(&self) -> &str {
17282            &self.name
17283        }
17284
17285        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
17286            Ok(())
17287        }
17288
17289        async fn listen(
17290            &self,
17291            _tx: tokio::sync::mpsc::Sender<zeroclaw_api::channel::ChannelMessage>,
17292        ) -> anyhow::Result<()> {
17293            self.calls.fetch_add(1, Ordering::SeqCst);
17294            if let Some(err) = self.err.lock().unwrap_or_else(|e| e.into_inner()).take() {
17295                return Err(err);
17296            }
17297            Ok(())
17298        }
17299    }
17300
17301    #[tokio::test]
17302    async fn supervised_listener_marks_error_and_restarts_on_failures() {
17303        let calls = Arc::new(AtomicUsize::new(0));
17304        let channel: Arc<dyn Channel> = Arc::new(AlwaysFailChannel {
17305            name: "test-supervised-fail",
17306            calls: Arc::clone(&calls),
17307        });
17308
17309        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
17310        let cancel = tokio_util::sync::CancellationToken::new();
17311        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());
17312
17313        tokio::time::sleep(Duration::from_millis(80)).await;
17314        drop(rx);
17315        cancel.cancel();
17316        let _ = tokio::time::timeout(Duration::from_millis(500), handle).await;
17317
17318        let snapshot = zeroclaw_runtime::health::snapshot_json();
17319        let component = &snapshot["components"]["channel:test-supervised-fail"];
17320        assert_eq!(component["status"], "error");
17321        assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1);
17322        assert!(
17323            component["last_error"]
17324                .as_str()
17325                .unwrap_or("")
17326                .contains("listen boom")
17327        );
17328        assert!(calls.load(Ordering::SeqCst) >= 1);
17329    }
17330
17331    #[tokio::test]
17332    async fn supervised_listener_refreshes_health_while_running() {
17333        let calls = Arc::new(AtomicUsize::new(0));
17334        let channel_name = format!("test-supervised-heartbeat-{}", uuid::Uuid::new_v4());
17335        let component_name = format!("channel:{channel_name}");
17336        let channel: Arc<dyn Channel> = Arc::new(BlockUntilClosedChannel {
17337            name: channel_name,
17338            calls: Arc::clone(&calls),
17339        });
17340
17341        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
17342        let cancel = tokio_util::sync::CancellationToken::new();
17343        let handle = spawn_supervised_listener_with_health_interval(
17344            channel,
17345            None,
17346            tx,
17347            1,
17348            1,
17349            Duration::from_millis(20),
17350            cancel.clone(),
17351        );
17352
17353        tokio::time::sleep(Duration::from_millis(35)).await;
17354        let first_last_ok = zeroclaw_runtime::health::snapshot_json()["components"]
17355            [&component_name]["last_ok"]
17356            .as_str()
17357            .unwrap_or("")
17358            .to_string();
17359        assert!(!first_last_ok.is_empty());
17360
17361        tokio::time::sleep(Duration::from_millis(70)).await;
17362        let second_last_ok = zeroclaw_runtime::health::snapshot_json()["components"]
17363            [&component_name]["last_ok"]
17364            .as_str()
17365            .unwrap_or("")
17366            .to_string();
17367        let first = chrono::DateTime::parse_from_rfc3339(&first_last_ok)
17368            .expect("last_ok should be valid RFC3339");
17369        let second = chrono::DateTime::parse_from_rfc3339(&second_last_ok)
17370            .expect("last_ok should be valid RFC3339");
17371        assert!(second > first, "expected periodic health heartbeat refresh");
17372
17373        cancel.cancel();
17374        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
17375        assert!(join.is_ok(), "listener should stop on cancel");
17376        assert!(calls.load(Ordering::SeqCst) >= 1);
17377        drop(rx);
17378    }
17379
17380    #[tokio::test]
17381    async fn supervised_listener_does_not_restart_on_non_retryable_discord_http_error() {
17382        let calls = Arc::new(AtomicUsize::new(0));
17383        let channel_name = format!("discord-{}", uuid::Uuid::new_v4());
17384        let channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
17385            name: channel_name,
17386            calls: Arc::clone(&calls),
17387            err: Mutex::new(Some(anyhow::Error::msg("401 Unauthorized"))),
17388        });
17389
17390        let component_name = format!("channel:{}", channel.name());
17391        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
17392        let cancel = tokio_util::sync::CancellationToken::new();
17393        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());
17394
17395        tokio::time::sleep(Duration::from_millis(80)).await;
17396        let snapshot = zeroclaw_runtime::health::snapshot_json();
17397        let component = &snapshot["components"][&component_name];
17398        assert_eq!(calls.load(Ordering::SeqCst), 1);
17399        assert_eq!(component["status"], "error");
17400        assert_eq!(component["restart_count"].as_u64().unwrap_or(0), 0);
17401        assert!(
17402            component["last_error"]
17403                .as_str()
17404                .unwrap_or("")
17405                .contains("401 Unauthorized")
17406        );
17407
17408        drop(rx);
17409        cancel.cancel();
17410        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
17411        assert!(join.is_ok(), "listener should stop on cancel");
17412        assert_eq!(calls.load(Ordering::SeqCst), 1);
17413    }
17414
17415    #[cfg(feature = "channel-discord")]
17416    #[tokio::test]
17417    async fn supervised_listener_enters_retry_path_on_discord_gateway_rate_limit() {
17418        let calls = Arc::new(AtomicUsize::new(0));
17419        let channel_name = format!("discord-{}", uuid::Uuid::new_v4());
17420        let channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
17421            name: channel_name,
17422            calls: Arc::clone(&calls),
17423            err: Mutex::new(Some(anyhow::Error::msg(
17424                "discord gateway preflight rate-limited (429 Too Many Requests)",
17425            ))),
17426        });
17427
17428        let component_name = format!("channel:{}", channel.name());
17429        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
17430        let cancel = tokio_util::sync::CancellationToken::new();
17431        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());
17432
17433        tokio::time::sleep(Duration::from_millis(80)).await;
17434        let snapshot = zeroclaw_runtime::health::snapshot_json();
17435        let component = &snapshot["components"][&component_name];
17436        assert_eq!(calls.load(Ordering::SeqCst), 1);
17437        assert_eq!(component["status"], "error");
17438        assert!(
17439            component["last_error"]
17440                .as_str()
17441                .unwrap_or("")
17442                .contains("429 Too Many Requests")
17443        );
17444        assert!(
17445            component["restart_count"].as_u64().unwrap_or(0) >= 1,
17446            "Discord gateway 429 should back off through the retry path instead of parking"
17447        );
17448
17449        drop(rx);
17450        cancel.cancel();
17451        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
17452        assert!(join.is_ok(), "listener should stop on cancel");
17453        assert_eq!(calls.load(Ordering::SeqCst), 1);
17454    }
17455
17456    #[cfg(feature = "channel-discord")]
17457    #[tokio::test]
17458    async fn supervised_listener_does_not_restart_on_fatal_discord_gateway_close_code() {
17459        let calls = Arc::new(AtomicUsize::new(0));
17460        let channel_name = format!("discord-{}", uuid::Uuid::new_v4());
17461        let channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
17462            name: channel_name,
17463            calls: Arc::clone(&calls),
17464            err: Mutex::new(Some(anyhow::Error::new(
17465                crate::discord::DiscordListenerFatalError::new(
17466                    "discord gateway closed with fatal code 4014: disallowed intent(s)",
17467                ),
17468            ))),
17469        });
17470
17471        let component_name = format!("channel:{}", channel.name());
17472        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
17473        let cancel = tokio_util::sync::CancellationToken::new();
17474        let handle = spawn_supervised_listener(channel, None, tx, 1, 1, cancel.clone());
17475
17476        tokio::time::sleep(Duration::from_millis(80)).await;
17477        let snapshot = zeroclaw_runtime::health::snapshot_json();
17478        let component = &snapshot["components"][&component_name];
17479        assert_eq!(calls.load(Ordering::SeqCst), 1);
17480        assert_eq!(component["status"], "error");
17481        assert_eq!(component["restart_count"].as_u64().unwrap_or(0), 0);
17482        assert!(
17483            component["last_error"]
17484                .as_str()
17485                .unwrap_or("")
17486                .contains("fatal code 4014")
17487        );
17488
17489        drop(rx);
17490        cancel.cancel();
17491        let join = tokio::time::timeout(Duration::from_millis(500), handle).await;
17492        assert!(join.is_ok(), "listener should stop on cancel");
17493        assert_eq!(calls.load(Ordering::SeqCst), 1);
17494    }
17495
17496    #[tokio::test]
17497    async fn non_retryable_listener_error_does_not_stop_other_listener_health() {
17498        let failing_calls = Arc::new(AtomicUsize::new(0));
17499        let healthy_calls = Arc::new(AtomicUsize::new(0));
17500        let failing_name = format!("discord-{}", uuid::Uuid::new_v4());
17501        let healthy_name = format!("test-supervised-sibling-{}", uuid::Uuid::new_v4());
17502        let failing_component = format!("channel:{failing_name}");
17503        let healthy_component = format!("channel:{healthy_name}");
17504
17505        let failing_channel: Arc<dyn Channel> = Arc::new(FailOnceChannel {
17506            name: failing_name,
17507            calls: Arc::clone(&failing_calls),
17508            err: Mutex::new(Some(anyhow::Error::msg("401 Unauthorized"))),
17509        });
17510        let healthy_channel: Arc<dyn Channel> = Arc::new(BlockUntilClosedChannel {
17511            name: healthy_name,
17512            calls: Arc::clone(&healthy_calls),
17513        });
17514
17515        let (failing_tx, failing_rx) =
17516            tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
17517        let (healthy_tx, healthy_rx) =
17518            tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(1);
17519        let cancel = tokio_util::sync::CancellationToken::new();
17520        let failing_handle =
17521            spawn_supervised_listener(failing_channel, None, failing_tx, 1, 1, cancel.clone());
17522        let healthy_handle = spawn_supervised_listener_with_health_interval(
17523            healthy_channel,
17524            None,
17525            healthy_tx,
17526            1,
17527            1,
17528            Duration::from_millis(20),
17529            cancel.clone(),
17530        );
17531
17532        tokio::time::sleep(Duration::from_millis(80)).await;
17533
17534        let first_last_ok = zeroclaw_runtime::health::snapshot_json()["components"]
17535            [&healthy_component]["last_ok"]
17536            .as_str()
17537            .unwrap_or("")
17538            .to_string();
17539        assert!(
17540            !first_last_ok.is_empty(),
17541            "healthy sibling should report health"
17542        );
17543
17544        tokio::time::sleep(Duration::from_millis(70)).await;
17545
17546        let snapshot = zeroclaw_runtime::health::snapshot_json();
17547        let failing = &snapshot["components"][&failing_component];
17548        let healthy = &snapshot["components"][&healthy_component];
17549        let second_last_ok = healthy["last_ok"].as_str().unwrap_or("").to_string();
17550        let first = chrono::DateTime::parse_from_rfc3339(&first_last_ok)
17551            .expect("healthy sibling last_ok should be valid RFC3339");
17552        let second = chrono::DateTime::parse_from_rfc3339(&second_last_ok)
17553            .expect("healthy sibling last_ok should be valid RFC3339");
17554
17555        assert_eq!(failing_calls.load(Ordering::SeqCst), 1);
17556        assert_eq!(failing["status"], "error");
17557        assert_eq!(failing["restart_count"].as_u64().unwrap_or(0), 0);
17558        assert!(
17559            failing["last_error"]
17560                .as_str()
17561                .unwrap_or("")
17562                .contains("401 Unauthorized")
17563        );
17564        assert_eq!(healthy["status"], "ok");
17565        assert!(
17566            second > first,
17567            "healthy sibling should keep refreshing health"
17568        );
17569        assert!(healthy_calls.load(Ordering::SeqCst) >= 1);
17570
17571        drop(failing_rx);
17572        drop(healthy_rx);
17573        cancel.cancel();
17574        let failing_join = tokio::time::timeout(Duration::from_millis(500), failing_handle).await;
17575        let healthy_join = tokio::time::timeout(Duration::from_millis(500), healthy_handle).await;
17576        assert!(
17577            failing_join.is_ok(),
17578            "non-retryable listener should stop on cancel"
17579        );
17580        assert!(
17581            healthy_join.is_ok(),
17582            "healthy sibling listener should stop on cancel"
17583        );
17584    }
17585
17586    #[test]
17587    fn maybe_restart_daemon_systemd_args_regression() {
17588        assert_eq!(
17589            SYSTEMD_STATUS_ARGS,
17590            ["--user", "is-active", "zeroclaw.service"]
17591        );
17592        assert_eq!(
17593            SYSTEMD_RESTART_ARGS,
17594            ["--user", "restart", "zeroclaw.service"]
17595        );
17596    }
17597
17598    #[test]
17599    fn maybe_restart_daemon_openrc_args_regression() {
17600        assert_eq!(OPENRC_STATUS_ARGS, ["zeroclaw", "status"]);
17601        assert_eq!(OPENRC_RESTART_ARGS, ["zeroclaw", "restart"]);
17602    }
17603
17604    #[test]
17605    fn normalize_merges_consecutive_user_turns() {
17606        let turns = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
17607        let result = normalize_cached_channel_turns(turns);
17608        assert_eq!(result.len(), 1);
17609        assert_eq!(result[0].role, "user");
17610        assert_eq!(result[0].content, "hello\n\nworld");
17611    }
17612
17613    #[test]
17614    fn normalize_preserves_strict_alternation() {
17615        let turns = vec![
17616            ChatMessage::user("hello"),
17617            ChatMessage::assistant("hi"),
17618            ChatMessage::user("bye"),
17619        ];
17620        let result = normalize_cached_channel_turns(turns);
17621        assert_eq!(result.len(), 3);
17622        assert_eq!(result[0].content, "hello");
17623        assert_eq!(result[1].content, "hi");
17624        assert_eq!(result[2].content, "bye");
17625    }
17626
17627    #[test]
17628    fn normalize_merges_multiple_consecutive_user_turns() {
17629        let turns = vec![
17630            ChatMessage::user("a"),
17631            ChatMessage::user("b"),
17632            ChatMessage::user("c"),
17633        ];
17634        let result = normalize_cached_channel_turns(turns);
17635        assert_eq!(result.len(), 1);
17636        assert_eq!(result[0].role, "user");
17637        assert_eq!(result[0].content, "a\n\nb\n\nc");
17638    }
17639
17640    #[test]
17641    fn normalize_empty_input() {
17642        let result = normalize_cached_channel_turns(vec![]);
17643        assert!(result.is_empty());
17644    }
17645
17646    #[test]
17647    fn channel_history_content_for_user_turn_strips_inline_image_payload() {
17648        let content =
17649            "[Image: sticker.webp attached]\n[IMAGE:data:image/png;base64,abcd]\n\nwhat is this?";
17650
17651        let history_content = channel_history_content_for_user_turn(content);
17652
17653        assert_eq!(
17654            history_content,
17655            "[Image: sticker.webp attached]\n\nwhat is this?"
17656        );
17657        assert!(!history_content.contains("[IMAGE:data:"));
17658        assert!(!history_content.contains("abcd"));
17659    }
17660
17661    #[test]
17662    fn channel_history_content_for_image_only_turn_keeps_compact_placeholder() {
17663        let history_content =
17664            channel_history_content_for_user_turn("[IMAGE:data:image/png;base64,abcd]");
17665
17666        assert_eq!(
17667            history_content,
17668            "[Image attachment processed by vision model]"
17669        );
17670    }
17671
17672    #[test]
17673    fn strip_historical_image_payloads_preserves_current_turn_payload() {
17674        let mut turns = vec![
17675            ChatMessage::user("[Image: old.png attached]\n[IMAGE:data:image/png;base64,old]"),
17676            ChatMessage::assistant("I saw the old image."),
17677            ChatMessage::user("[Image: now.png attached]\n[IMAGE:data:image/png;base64,current]"),
17678        ];
17679
17680        strip_historical_image_payloads(&mut turns);
17681
17682        assert_eq!(turns.len(), 3);
17683        assert!(turns[0].content.contains("[Image: old.png attached]"));
17684        assert!(!turns[0].content.contains("[IMAGE:data:"));
17685        assert!(!turns[0].content.contains("base64,old"));
17686        assert!(
17687            turns[2]
17688                .content
17689                .contains("[IMAGE:data:image/png;base64,current]")
17690        );
17691    }
17692
17693    // ── E2E: photo [IMAGE:] marker rejected by non-vision model_provider ───
17694
17695    /// End-to-end test: a photo attachment message (containing `[IMAGE:]`
17696    /// marker) sent through `process_channel_message` with a non-vision
17697    /// model_provider must produce a `"⚠️ Error: …does not support vision"` reply
17698    /// on the recording channel — no real Telegram or LLM API required.
17699    #[tokio::test]
17700    async fn e2e_photo_attachment_rejected_by_non_vision_provider() {
17701        let channel_impl = Arc::new(RecordingChannel::default());
17702        let channel: Arc<dyn Channel> = channel_impl.clone();
17703
17704        let mut channels_by_name = HashMap::new();
17705        channels_by_name.insert(channel.name().to_string(), channel);
17706
17707        // DummyModelProvider has default capabilities (vision: false).
17708        let runtime_ctx = Arc::new(ChannelRuntimeContext {
17709            channels_by_name: Arc::new(channels_by_name),
17710            model_provider: Arc::new(DummyModelProvider),
17711            model_provider_ref: Arc::new("dummy".to_string()),
17712            agent_alias: Arc::new("test-agent".to_string()),
17713            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
17714            memory: Arc::new(NoopMemory),
17715            memory_strategy: Arc::new(
17716                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
17717                    Arc::new(NoopMemory),
17718                    zeroclaw_config::schema::MemoryConfig::default(),
17719                    std::path::PathBuf::new(),
17720                ),
17721            ),
17722            tools_registry: Arc::new(vec![]),
17723            observer: Arc::new(NoopObserver),
17724            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
17725            model: Arc::new("test-model".to_string()),
17726            temperature: Some(0.0),
17727            auto_save_memory: false,
17728            max_tool_iterations: 5,
17729            min_relevance_score: 0.0,
17730            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
17731                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
17732            ))),
17733            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
17734            provider_cache: Arc::new(Mutex::new(HashMap::new())),
17735            route_overrides: Arc::new(Mutex::new(HashMap::new())),
17736            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
17737            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
17738            workspace_dir: Arc::new(std::env::temp_dir()),
17739            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
17740            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
17741            interrupt_on_new_message: InterruptOnNewMessageConfig {
17742                telegram: false,
17743                slack: false,
17744                discord: false,
17745                mattermost: false,
17746                matrix: false,
17747            },
17748            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
17749            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
17750            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
17751            agent_transcription_provider: String::new(),
17752            hooks: None,
17753            non_cli_excluded_tools: Arc::new(Vec::new()),
17754            autonomy_level: AutonomyLevel::default(),
17755            tool_call_dedup_exempt: Arc::new(Vec::new()),
17756            model_routes: Arc::new(Vec::new()),
17757            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
17758            ack_reactions: true,
17759            show_tool_calls: true,
17760            session_store: None,
17761            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
17762                &zeroclaw_config::schema::RiskProfileConfig::default(),
17763            )),
17764            activated_tools: None,
17765            cost_tracking: None,
17766            pacing: zeroclaw_config::schema::PacingConfig::default(),
17767            max_tool_result_chars: 0,
17768            context_token_budget: 0,
17769            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
17770                Duration::ZERO,
17771            )),
17772            receipt_generator: None,
17773            show_receipts_in_response: false,
17774            last_applied_config_stamp: Arc::new(Mutex::new(None)),
17775            runtime_defaults_override: Arc::new(Mutex::new(None)),
17776        });
17777
17778        // Simulate a photo attachment message with [IMAGE:] marker.
17779        process_channel_message(
17780            runtime_ctx,
17781            zeroclaw_api::channel::ChannelMessage {
17782                id: "msg-photo-1".to_string(),
17783                sender: "zeroclaw_user".to_string(),
17784                reply_target: "chat-photo".to_string(),
17785                content: "[IMAGE:/tmp/workspace/photo_99_1.jpg]\n\nWhat is this?".to_string(),
17786                channel: "test-channel".to_string(),
17787                channel_alias: None,
17788                timestamp: 1,
17789                thread_ts: None,
17790                interruption_scope_id: None,
17791                attachments: vec![],
17792                subject: None,
17793            },
17794            CancellationToken::new(),
17795        )
17796        .await;
17797
17798        let sent = channel_impl.sent_messages.lock().await;
17799        assert_eq!(sent.len(), 1, "expected exactly one reply message");
17800        assert!(
17801            sent[0].contains("does not support vision"),
17802            "reply must mention vision capability error, got: {}",
17803            sent[0]
17804        );
17805        assert!(
17806            sent[0].contains("⚠️ Error"),
17807            "reply must start with error prefix, got: {}",
17808            sent[0]
17809        );
17810    }
17811
17812    #[tokio::test]
17813    async fn e2e_failed_vision_turn_does_not_poison_follow_up_text_turn() {
17814        let channel_impl = Arc::new(RecordingChannel::default());
17815        let channel: Arc<dyn Channel> = channel_impl.clone();
17816
17817        let mut channels_by_name = HashMap::new();
17818        channels_by_name.insert(channel.name().to_string(), channel);
17819
17820        let runtime_ctx = Arc::new(ChannelRuntimeContext {
17821            channels_by_name: Arc::new(channels_by_name),
17822            model_provider: Arc::new(DummyModelProvider),
17823            model_provider_ref: Arc::new("dummy".to_string()),
17824            agent_alias: Arc::new("test-agent".to_string()),
17825            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
17826            memory: Arc::new(NoopMemory),
17827            memory_strategy: Arc::new(
17828                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
17829                    Arc::new(NoopMemory),
17830                    zeroclaw_config::schema::MemoryConfig::default(),
17831                    std::path::PathBuf::new(),
17832                ),
17833            ),
17834            tools_registry: Arc::new(vec![]),
17835            observer: Arc::new(NoopObserver),
17836            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
17837            model: Arc::new("test-model".to_string()),
17838            temperature: Some(0.0),
17839            auto_save_memory: false,
17840            max_tool_iterations: 5,
17841            min_relevance_score: 0.0,
17842            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
17843                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
17844            ))),
17845            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
17846            provider_cache: Arc::new(Mutex::new(HashMap::new())),
17847            route_overrides: Arc::new(Mutex::new(HashMap::new())),
17848            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
17849            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
17850            workspace_dir: Arc::new(std::env::temp_dir()),
17851            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
17852            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
17853            interrupt_on_new_message: InterruptOnNewMessageConfig {
17854                telegram: false,
17855                slack: false,
17856                discord: false,
17857                mattermost: false,
17858                matrix: false,
17859            },
17860            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
17861            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
17862            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
17863            agent_transcription_provider: String::new(),
17864            hooks: None,
17865            non_cli_excluded_tools: Arc::new(Vec::new()),
17866            autonomy_level: AutonomyLevel::default(),
17867            tool_call_dedup_exempt: Arc::new(Vec::new()),
17868            model_routes: Arc::new(Vec::new()),
17869            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
17870            ack_reactions: true,
17871            show_tool_calls: true,
17872            session_store: None,
17873            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
17874                &zeroclaw_config::schema::RiskProfileConfig::default(),
17875            )),
17876            activated_tools: None,
17877            cost_tracking: None,
17878            pacing: zeroclaw_config::schema::PacingConfig::default(),
17879            max_tool_result_chars: 0,
17880            context_token_budget: 0,
17881            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
17882                Duration::ZERO,
17883            )),
17884            receipt_generator: None,
17885            show_receipts_in_response: false,
17886            last_applied_config_stamp: Arc::new(Mutex::new(None)),
17887            runtime_defaults_override: Arc::new(Mutex::new(None)),
17888        });
17889
17890        process_channel_message(
17891            Arc::clone(&runtime_ctx),
17892            zeroclaw_api::channel::ChannelMessage {
17893                id: "msg-photo-1".to_string(),
17894                sender: "zeroclaw_user".to_string(),
17895                reply_target: "chat-photo".to_string(),
17896                content: "[IMAGE:/tmp/workspace/photo_99_1.jpg]\n\nWhat is this?".to_string(),
17897                channel: "test-channel".to_string(),
17898                channel_alias: None,
17899                timestamp: 1,
17900                thread_ts: None,
17901                interruption_scope_id: None,
17902                attachments: vec![],
17903                subject: None,
17904            },
17905            CancellationToken::new(),
17906        )
17907        .await;
17908
17909        process_channel_message(
17910            Arc::clone(&runtime_ctx),
17911            zeroclaw_api::channel::ChannelMessage {
17912                id: "msg-text-2".to_string(),
17913                sender: "zeroclaw_user".to_string(),
17914                reply_target: "chat-photo".to_string(),
17915                content: "What is WAL?".to_string(),
17916                channel: "test-channel".to_string(),
17917                channel_alias: None,
17918                timestamp: 2,
17919                thread_ts: None,
17920                interruption_scope_id: None,
17921                attachments: vec![],
17922                subject: None,
17923            },
17924            CancellationToken::new(),
17925        )
17926        .await;
17927
17928        let sent = channel_impl.sent_messages.lock().await;
17929        assert_eq!(sent.len(), 2, "expected one error and one successful reply");
17930        assert!(
17931            sent[0].contains("does not support vision"),
17932            "first reply must mention vision capability error, got: {}",
17933            sent[0]
17934        );
17935        assert!(
17936            sent[1].ends_with(":ok"),
17937            "second reply should succeed for text-only turn, got: {}",
17938            sent[1]
17939        );
17940        drop(sent);
17941
17942        let histories = runtime_ctx
17943            .conversation_histories
17944            .lock()
17945            .unwrap_or_else(|e| e.into_inner());
17946        let turns = histories
17947            .peek("test-channel_chat-photo_zeroclaw_user")
17948            .expect("history should exist for sender");
17949        assert_eq!(turns.len(), 2);
17950        assert_eq!(turns[0].role, "user");
17951        assert!(
17952            turns[0].content.contains("] What is WAL?"),
17953            "follow-up user turn should be timestamped: {}",
17954            turns[0].content
17955        );
17956        assert_eq!(turns[1].role, "assistant");
17957        assert_eq!(turns[1].content, "ok");
17958        assert!(
17959            turns.iter().all(|turn| !turn.content.contains("[IMAGE:")),
17960            "failed vision turn must not persist image marker content"
17961        );
17962    }
17963
17964    #[tokio::test]
17965    async fn e2e_failed_non_retryable_turn_does_not_poison_follow_up_text_turn() {
17966        let channel_impl = Arc::new(RecordingChannel::default());
17967        let channel: Arc<dyn Channel> = channel_impl.clone();
17968
17969        let mut channels_by_name = HashMap::new();
17970        channels_by_name.insert(channel.name().to_string(), channel);
17971
17972        let runtime_ctx = Arc::new(ChannelRuntimeContext {
17973            channels_by_name: Arc::new(channels_by_name),
17974            model_provider: Arc::new(FormatErrorModelProvider),
17975            model_provider_ref: Arc::new("dummy".to_string()),
17976            agent_alias: Arc::new("test-agent".to_string()),
17977            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
17978            memory: Arc::new(NoopMemory),
17979            memory_strategy: Arc::new(
17980                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
17981                    Arc::new(NoopMemory),
17982                    zeroclaw_config::schema::MemoryConfig::default(),
17983                    std::path::PathBuf::new(),
17984                ),
17985            ),
17986            tools_registry: Arc::new(vec![]),
17987            observer: Arc::new(NoopObserver),
17988            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
17989            model: Arc::new("test-model".to_string()),
17990            temperature: Some(0.0),
17991            auto_save_memory: false,
17992            max_tool_iterations: 5,
17993            min_relevance_score: 0.0,
17994            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
17995                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
17996            ))),
17997            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
17998            provider_cache: Arc::new(Mutex::new(HashMap::new())),
17999            route_overrides: Arc::new(Mutex::new(HashMap::new())),
18000            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
18001            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
18002            workspace_dir: Arc::new(std::env::temp_dir()),
18003            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
18004            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
18005            interrupt_on_new_message: InterruptOnNewMessageConfig {
18006                telegram: false,
18007                slack: false,
18008                discord: false,
18009                mattermost: false,
18010                matrix: false,
18011            },
18012            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
18013            hooks: None,
18014            non_cli_excluded_tools: Arc::new(Vec::new()),
18015            autonomy_level: AutonomyLevel::default(),
18016            tool_call_dedup_exempt: Arc::new(Vec::new()),
18017            model_routes: Arc::new(Vec::new()),
18018            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
18019            ack_reactions: true,
18020            show_tool_calls: true,
18021            session_store: None,
18022            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
18023                &zeroclaw_config::schema::RiskProfileConfig::default(),
18024            )),
18025            activated_tools: None,
18026            cost_tracking: None,
18027            pacing: zeroclaw_config::schema::PacingConfig::default(),
18028            max_tool_result_chars: 50000,
18029            context_token_budget: 128_000,
18030            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
18031                std::time::Duration::ZERO,
18032            )),
18033            receipt_generator: None,
18034            show_receipts_in_response: false,
18035            last_applied_config_stamp: Arc::new(Mutex::new(None)),
18036            runtime_defaults_override: Arc::new(Mutex::new(None)),
18037            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
18038            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
18039            agent_transcription_provider: String::new(),
18040        });
18041
18042        process_channel_message(
18043            Arc::clone(&runtime_ctx),
18044            zeroclaw_api::channel::ChannelMessage {
18045                id: "msg-bad-1".to_string(),
18046                sender: "zeroclaw_user".to_string(),
18047                reply_target: "chat-format".to_string(),
18048                content: "trigger format error".to_string(),
18049                channel: "test-channel".to_string(),
18050                channel_alias: None,
18051                timestamp: 1,
18052                thread_ts: None,
18053                interruption_scope_id: None,
18054                attachments: vec![],
18055                subject: None,
18056            },
18057            CancellationToken::new(),
18058        )
18059        .await;
18060
18061        process_channel_message(
18062            Arc::clone(&runtime_ctx),
18063            zeroclaw_api::channel::ChannelMessage {
18064                id: "msg-text-2".to_string(),
18065                sender: "zeroclaw_user".to_string(),
18066                reply_target: "chat-format".to_string(),
18067                content: "What is WAL?".to_string(),
18068                channel: "test-channel".to_string(),
18069                channel_alias: None,
18070                timestamp: 2,
18071                thread_ts: None,
18072                interruption_scope_id: None,
18073                attachments: vec![],
18074                subject: None,
18075            },
18076            CancellationToken::new(),
18077        )
18078        .await;
18079
18080        let sent = channel_impl.sent_messages.lock().await;
18081        assert_eq!(sent.len(), 2, "expected one error and one successful reply");
18082        assert!(
18083            sent[0].contains("Format Error"),
18084            "first reply must mention the request format error, got: {}",
18085            sent[0]
18086        );
18087        assert!(
18088            sent[1].ends_with(":ok"),
18089            "second reply should succeed for follow-up text, got: {}",
18090            sent[1]
18091        );
18092        drop(sent);
18093
18094        let histories = runtime_ctx
18095            .conversation_histories
18096            .lock()
18097            .unwrap_or_else(|e| e.into_inner());
18098        let turns = histories
18099            .peek("test-channel_chat-format_zeroclaw_user")
18100            .expect("history should exist for sender");
18101        assert_eq!(turns.len(), 2);
18102        assert_eq!(turns[0].role, "user");
18103        assert!(
18104            turns[0].content.contains("] What is WAL?"),
18105            "follow-up user turn should be timestamped: {}",
18106            turns[0].content
18107        );
18108        assert_eq!(turns[1].role, "assistant");
18109        assert_eq!(turns[1].content, "ok");
18110        assert!(
18111            turns
18112                .iter()
18113                .all(|turn| turn.content != "trigger format error"),
18114            "failed non-retryable turn must not persist in history"
18115        );
18116    }
18117
18118    #[test]
18119    fn build_channel_by_id_unknown_channel_returns_error() {
18120        let config = Config::default();
18121        let config_arc = Arc::new(RwLock::new(config));
18122        match build_channel_by_id(&config_arc, "nonexistent") {
18123            Err(e) => {
18124                let err_msg = e.to_string();
18125                assert!(
18126                    err_msg.contains("Unknown channel"),
18127                    "expected 'Unknown channel' in error, got: {err_msg}"
18128                );
18129            }
18130            Ok(_) => panic!("should fail for unknown channel"),
18131        }
18132    }
18133
18134    // ── Query classification in channel message processing ─────────
18135
18136    #[tokio::test]
18137    async fn process_channel_message_applies_query_classification_route() {
18138        let channel_impl = Arc::new(TelegramRecordingChannel::default());
18139        let channel: Arc<dyn Channel> = channel_impl.clone();
18140
18141        let mut channels_by_name = HashMap::new();
18142        channels_by_name.insert(channel.name().to_string(), channel);
18143
18144        let agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18145        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
18146        let vision_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18147        let vision_model_provider: Arc<dyn ModelProvider> = vision_model_provider_impl.clone();
18148
18149        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
18150        provider_cache_seed.insert(
18151            "test-provider".to_string(),
18152            Arc::clone(&agent_model_provider),
18153        );
18154        provider_cache_seed.insert("vision-provider".to_string(), vision_model_provider);
18155
18156        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
18157            enabled: true,
18158            rules: vec![zeroclaw_config::schema::ClassificationRule {
18159                hint: "vision".into(),
18160                keywords: vec!["analyze-image".into()],
18161                ..Default::default()
18162            }],
18163        };
18164
18165        let model_routes = vec![zeroclaw_config::schema::ModelRouteConfig {
18166            hint: "vision".into(),
18167            model_provider: "vision-provider".into(),
18168            model: "gpt-4-vision".into(),
18169            api_key: None,
18170        }];
18171
18172        let runtime_ctx = Arc::new(ChannelRuntimeContext {
18173            channels_by_name: Arc::new(channels_by_name),
18174            model_provider: Arc::clone(&agent_model_provider),
18175            model_provider_ref: Arc::new("test-provider".to_string()),
18176            agent_alias: Arc::new("test-agent".to_string()),
18177            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
18178            memory: Arc::new(NoopMemory),
18179            memory_strategy: Arc::new(
18180                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
18181                    Arc::new(NoopMemory),
18182                    zeroclaw_config::schema::MemoryConfig::default(),
18183                    std::path::PathBuf::new(),
18184                ),
18185            ),
18186            tools_registry: Arc::new(vec![]),
18187            observer: Arc::new(NoopObserver),
18188            system_prompt: Arc::new("test-system-prompt".to_string()),
18189            model: Arc::new("default-model".to_string()),
18190            temperature: Some(0.0),
18191            auto_save_memory: false,
18192            max_tool_iterations: 5,
18193            min_relevance_score: 0.0,
18194            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
18195                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
18196            ))),
18197            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
18198            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
18199            route_overrides: Arc::new(Mutex::new(HashMap::new())),
18200            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
18201            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
18202            workspace_dir: Arc::new(std::env::temp_dir()),
18203            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
18204            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
18205            interrupt_on_new_message: InterruptOnNewMessageConfig {
18206                telegram: false,
18207                slack: false,
18208                discord: false,
18209                mattermost: false,
18210                matrix: false,
18211            },
18212            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
18213            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
18214            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
18215            agent_transcription_provider: String::new(),
18216            hooks: None,
18217            non_cli_excluded_tools: Arc::new(Vec::new()),
18218            autonomy_level: AutonomyLevel::default(),
18219            tool_call_dedup_exempt: Arc::new(Vec::new()),
18220            model_routes: Arc::new(model_routes),
18221            query_classification: classification_config,
18222            ack_reactions: true,
18223            show_tool_calls: true,
18224            session_store: None,
18225            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
18226                &zeroclaw_config::schema::RiskProfileConfig::default(),
18227            )),
18228            activated_tools: None,
18229            cost_tracking: None,
18230            pacing: zeroclaw_config::schema::PacingConfig::default(),
18231            max_tool_result_chars: 0,
18232            context_token_budget: 0,
18233            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
18234                Duration::ZERO,
18235            )),
18236            receipt_generator: None,
18237            show_receipts_in_response: false,
18238            last_applied_config_stamp: Arc::new(Mutex::new(None)),
18239            runtime_defaults_override: Arc::new(Mutex::new(None)),
18240        });
18241
18242        process_channel_message(
18243            runtime_ctx,
18244            zeroclaw_api::channel::ChannelMessage {
18245                id: "msg-qc-1".to_string(),
18246                sender: "alice".to_string(),
18247                reply_target: "chat-1".to_string(),
18248                content: "please analyze-image from the dataset".to_string(),
18249                channel: "telegram".to_string(),
18250                channel_alias: None,
18251                timestamp: 1,
18252                thread_ts: None,
18253                interruption_scope_id: None,
18254                attachments: vec![],
18255                subject: None,
18256            },
18257            CancellationToken::new(),
18258        )
18259        .await;
18260
18261        // Vision model_provider should have been called instead of the default.
18262        assert_eq!(
18263            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
18264            0
18265        );
18266        assert_eq!(
18267            vision_model_provider_impl.call_count.load(Ordering::SeqCst),
18268            1
18269        );
18270        assert_eq!(
18271            vision_model_provider_impl
18272                .models
18273                .lock()
18274                .unwrap_or_else(|e| e.into_inner())
18275                .as_slice(),
18276            &["gpt-4-vision".to_string()]
18277        );
18278    }
18279
18280    #[tokio::test]
18281    async fn process_channel_message_classification_disabled_uses_default_route() {
18282        let channel_impl = Arc::new(TelegramRecordingChannel::default());
18283        let channel: Arc<dyn Channel> = channel_impl.clone();
18284
18285        let mut channels_by_name = HashMap::new();
18286        channels_by_name.insert(channel.name().to_string(), channel);
18287
18288        let agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18289        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
18290        let vision_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18291        let vision_model_provider: Arc<dyn ModelProvider> = vision_model_provider_impl.clone();
18292
18293        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
18294        provider_cache_seed.insert(
18295            "test-provider".to_string(),
18296            Arc::clone(&agent_model_provider),
18297        );
18298        provider_cache_seed.insert("vision-provider".to_string(), vision_model_provider);
18299
18300        // Classification is disabled — matching keyword should NOT trigger reroute.
18301        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
18302            enabled: false,
18303            rules: vec![zeroclaw_config::schema::ClassificationRule {
18304                hint: "vision".into(),
18305                keywords: vec!["analyze-image".into()],
18306                ..Default::default()
18307            }],
18308        };
18309
18310        let model_routes = vec![zeroclaw_config::schema::ModelRouteConfig {
18311            hint: "vision".into(),
18312            model_provider: "vision-provider".into(),
18313            model: "gpt-4-vision".into(),
18314            api_key: None,
18315        }];
18316
18317        let runtime_ctx = Arc::new(ChannelRuntimeContext {
18318            channels_by_name: Arc::new(channels_by_name),
18319            model_provider: Arc::clone(&agent_model_provider),
18320            model_provider_ref: Arc::new("test-provider".to_string()),
18321            agent_alias: Arc::new("test-agent".to_string()),
18322            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
18323            memory: Arc::new(NoopMemory),
18324            memory_strategy: Arc::new(
18325                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
18326                    Arc::new(NoopMemory),
18327                    zeroclaw_config::schema::MemoryConfig::default(),
18328                    std::path::PathBuf::new(),
18329                ),
18330            ),
18331            tools_registry: Arc::new(vec![]),
18332            observer: Arc::new(NoopObserver),
18333            system_prompt: Arc::new("test-system-prompt".to_string()),
18334            model: Arc::new("default-model".to_string()),
18335            temperature: Some(0.0),
18336            auto_save_memory: false,
18337            max_tool_iterations: 5,
18338            min_relevance_score: 0.0,
18339            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
18340                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
18341            ))),
18342            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
18343            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
18344            route_overrides: Arc::new(Mutex::new(HashMap::new())),
18345            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
18346            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
18347            workspace_dir: Arc::new(std::env::temp_dir()),
18348            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
18349            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
18350            interrupt_on_new_message: InterruptOnNewMessageConfig {
18351                telegram: false,
18352                slack: false,
18353                discord: false,
18354                mattermost: false,
18355                matrix: false,
18356            },
18357            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
18358            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
18359            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
18360            agent_transcription_provider: String::new(),
18361            hooks: None,
18362            non_cli_excluded_tools: Arc::new(Vec::new()),
18363            autonomy_level: AutonomyLevel::default(),
18364            tool_call_dedup_exempt: Arc::new(Vec::new()),
18365            model_routes: Arc::new(model_routes),
18366            query_classification: classification_config,
18367            ack_reactions: true,
18368            show_tool_calls: true,
18369            session_store: None,
18370            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
18371                &zeroclaw_config::schema::RiskProfileConfig::default(),
18372            )),
18373            activated_tools: None,
18374            cost_tracking: None,
18375            pacing: zeroclaw_config::schema::PacingConfig::default(),
18376            max_tool_result_chars: 0,
18377            context_token_budget: 0,
18378            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
18379                Duration::ZERO,
18380            )),
18381            receipt_generator: None,
18382            show_receipts_in_response: false,
18383            last_applied_config_stamp: Arc::new(Mutex::new(None)),
18384            runtime_defaults_override: Arc::new(Mutex::new(None)),
18385        });
18386
18387        process_channel_message(
18388            runtime_ctx,
18389            zeroclaw_api::channel::ChannelMessage {
18390                id: "msg-qc-disabled".to_string(),
18391                sender: "alice".to_string(),
18392                reply_target: "chat-1".to_string(),
18393                content: "please analyze-image from the dataset".to_string(),
18394                channel: "telegram".to_string(),
18395                channel_alias: None,
18396                timestamp: 1,
18397                thread_ts: None,
18398                interruption_scope_id: None,
18399                attachments: vec![],
18400                subject: None,
18401            },
18402            CancellationToken::new(),
18403        )
18404        .await;
18405
18406        // Default model_provider should be used since classification is disabled.
18407        assert_eq!(
18408            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
18409            1
18410        );
18411        assert_eq!(
18412            vision_model_provider_impl.call_count.load(Ordering::SeqCst),
18413            0
18414        );
18415    }
18416
18417    #[tokio::test]
18418    async fn process_channel_message_classification_no_match_uses_default_route() {
18419        let channel_impl = Arc::new(TelegramRecordingChannel::default());
18420        let channel: Arc<dyn Channel> = channel_impl.clone();
18421
18422        let mut channels_by_name = HashMap::new();
18423        channels_by_name.insert(channel.name().to_string(), channel);
18424
18425        let agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18426        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
18427        let vision_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18428        let vision_model_provider: Arc<dyn ModelProvider> = vision_model_provider_impl.clone();
18429
18430        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
18431        provider_cache_seed.insert(
18432            "test-provider".to_string(),
18433            Arc::clone(&agent_model_provider),
18434        );
18435        provider_cache_seed.insert("vision-provider".to_string(), vision_model_provider);
18436
18437        // Classification enabled with a rule that won't match the message.
18438        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
18439            enabled: true,
18440            rules: vec![zeroclaw_config::schema::ClassificationRule {
18441                hint: "vision".into(),
18442                keywords: vec!["analyze-image".into()],
18443                ..Default::default()
18444            }],
18445        };
18446
18447        let model_routes = vec![zeroclaw_config::schema::ModelRouteConfig {
18448            hint: "vision".into(),
18449            model_provider: "vision-provider".into(),
18450            model: "gpt-4-vision".into(),
18451            api_key: None,
18452        }];
18453
18454        let runtime_ctx = Arc::new(ChannelRuntimeContext {
18455            channels_by_name: Arc::new(channels_by_name),
18456            model_provider: Arc::clone(&agent_model_provider),
18457            model_provider_ref: Arc::new("test-provider".to_string()),
18458            agent_alias: Arc::new("test-agent".to_string()),
18459            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
18460            memory: Arc::new(NoopMemory),
18461            memory_strategy: Arc::new(
18462                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
18463                    Arc::new(NoopMemory),
18464                    zeroclaw_config::schema::MemoryConfig::default(),
18465                    std::path::PathBuf::new(),
18466                ),
18467            ),
18468            tools_registry: Arc::new(vec![]),
18469            observer: Arc::new(NoopObserver),
18470            system_prompt: Arc::new("test-system-prompt".to_string()),
18471            model: Arc::new("default-model".to_string()),
18472            temperature: Some(0.0),
18473            auto_save_memory: false,
18474            max_tool_iterations: 5,
18475            min_relevance_score: 0.0,
18476            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
18477                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
18478            ))),
18479            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
18480            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
18481            route_overrides: Arc::new(Mutex::new(HashMap::new())),
18482            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
18483            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
18484            workspace_dir: Arc::new(std::env::temp_dir()),
18485            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
18486            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
18487            interrupt_on_new_message: InterruptOnNewMessageConfig {
18488                telegram: false,
18489                slack: false,
18490                discord: false,
18491                mattermost: false,
18492                matrix: false,
18493            },
18494            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
18495            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
18496            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
18497            agent_transcription_provider: String::new(),
18498            hooks: None,
18499            non_cli_excluded_tools: Arc::new(Vec::new()),
18500            autonomy_level: AutonomyLevel::default(),
18501            tool_call_dedup_exempt: Arc::new(Vec::new()),
18502            model_routes: Arc::new(model_routes),
18503            query_classification: classification_config,
18504            ack_reactions: true,
18505            show_tool_calls: true,
18506            session_store: None,
18507            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
18508                &zeroclaw_config::schema::RiskProfileConfig::default(),
18509            )),
18510            activated_tools: None,
18511            cost_tracking: None,
18512            pacing: zeroclaw_config::schema::PacingConfig::default(),
18513            max_tool_result_chars: 0,
18514            context_token_budget: 0,
18515            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
18516                Duration::ZERO,
18517            )),
18518            receipt_generator: None,
18519            show_receipts_in_response: false,
18520            last_applied_config_stamp: Arc::new(Mutex::new(None)),
18521            runtime_defaults_override: Arc::new(Mutex::new(None)),
18522        });
18523
18524        process_channel_message(
18525            runtime_ctx,
18526            zeroclaw_api::channel::ChannelMessage {
18527                id: "msg-qc-nomatch".to_string(),
18528                sender: "alice".to_string(),
18529                reply_target: "chat-1".to_string(),
18530                content: "just a regular text message".to_string(),
18531                channel: "telegram".to_string(),
18532                channel_alias: None,
18533                timestamp: 1,
18534                thread_ts: None,
18535                interruption_scope_id: None,
18536                attachments: vec![],
18537                subject: None,
18538            },
18539            CancellationToken::new(),
18540        )
18541        .await;
18542
18543        // Default model_provider should be used since no classification rule matched.
18544        assert_eq!(
18545            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
18546            1
18547        );
18548        assert_eq!(
18549            vision_model_provider_impl.call_count.load(Ordering::SeqCst),
18550            0
18551        );
18552    }
18553
18554    #[tokio::test]
18555    async fn process_channel_message_classification_priority_selects_highest() {
18556        let channel_impl = Arc::new(TelegramRecordingChannel::default());
18557        let channel: Arc<dyn Channel> = channel_impl.clone();
18558
18559        let mut channels_by_name = HashMap::new();
18560        channels_by_name.insert(channel.name().to_string(), channel);
18561
18562        let agent_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18563        let agent_model_provider: Arc<dyn ModelProvider> = agent_model_provider_impl.clone();
18564        let fast_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18565        let fast_model_provider: Arc<dyn ModelProvider> = fast_model_provider_impl.clone();
18566        let code_model_provider_impl = Arc::new(ModelCaptureModelProvider::default());
18567        let code_model_provider: Arc<dyn ModelProvider> = code_model_provider_impl.clone();
18568
18569        let mut provider_cache_seed: HashMap<String, Arc<dyn ModelProvider>> = HashMap::new();
18570        provider_cache_seed.insert(
18571            "test-provider".to_string(),
18572            Arc::clone(&agent_model_provider),
18573        );
18574        provider_cache_seed.insert("fast-provider".to_string(), fast_model_provider);
18575        provider_cache_seed.insert("code-provider".to_string(), code_model_provider);
18576
18577        // Both rules match "code" keyword, but "code" rule has higher priority.
18578        let classification_config = zeroclaw_config::schema::QueryClassificationConfig {
18579            enabled: true,
18580            rules: vec![
18581                zeroclaw_config::schema::ClassificationRule {
18582                    hint: "fast".into(),
18583                    keywords: vec!["code".into()],
18584                    priority: 1,
18585                    ..Default::default()
18586                },
18587                zeroclaw_config::schema::ClassificationRule {
18588                    hint: "code".into(),
18589                    keywords: vec!["code".into()],
18590                    priority: 10,
18591                    ..Default::default()
18592                },
18593            ],
18594        };
18595
18596        let model_routes = vec![
18597            zeroclaw_config::schema::ModelRouteConfig {
18598                hint: "fast".into(),
18599                model_provider: "fast-provider".into(),
18600                model: "fast-model".into(),
18601                api_key: None,
18602            },
18603            zeroclaw_config::schema::ModelRouteConfig {
18604                hint: "code".into(),
18605                model_provider: "code-provider".into(),
18606                model: "code-model".into(),
18607                api_key: None,
18608            },
18609        ];
18610
18611        let runtime_ctx = Arc::new(ChannelRuntimeContext {
18612            channels_by_name: Arc::new(channels_by_name),
18613            model_provider: Arc::clone(&agent_model_provider),
18614            model_provider_ref: Arc::new("test-provider".to_string()),
18615            agent_alias: Arc::new("test-agent".to_string()),
18616            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
18617            memory: Arc::new(NoopMemory),
18618            memory_strategy: Arc::new(
18619                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
18620                    Arc::new(NoopMemory),
18621                    zeroclaw_config::schema::MemoryConfig::default(),
18622                    std::path::PathBuf::new(),
18623                ),
18624            ),
18625            tools_registry: Arc::new(vec![]),
18626            observer: Arc::new(NoopObserver),
18627            system_prompt: Arc::new("test-system-prompt".to_string()),
18628            model: Arc::new("default-model".to_string()),
18629            temperature: Some(0.0),
18630            auto_save_memory: false,
18631            max_tool_iterations: 5,
18632            min_relevance_score: 0.0,
18633            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
18634                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
18635            ))),
18636            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
18637            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
18638            route_overrides: Arc::new(Mutex::new(HashMap::new())),
18639            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
18640            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
18641            workspace_dir: Arc::new(std::env::temp_dir()),
18642            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
18643            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
18644            interrupt_on_new_message: InterruptOnNewMessageConfig {
18645                telegram: false,
18646                slack: false,
18647                discord: false,
18648                mattermost: false,
18649                matrix: false,
18650            },
18651            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
18652            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
18653            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
18654            agent_transcription_provider: String::new(),
18655            hooks: None,
18656            non_cli_excluded_tools: Arc::new(Vec::new()),
18657            autonomy_level: AutonomyLevel::default(),
18658            tool_call_dedup_exempt: Arc::new(Vec::new()),
18659            model_routes: Arc::new(model_routes),
18660            query_classification: classification_config,
18661            ack_reactions: true,
18662            show_tool_calls: true,
18663            session_store: None,
18664            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
18665                &zeroclaw_config::schema::RiskProfileConfig::default(),
18666            )),
18667            activated_tools: None,
18668            cost_tracking: None,
18669            pacing: zeroclaw_config::schema::PacingConfig::default(),
18670            max_tool_result_chars: 0,
18671            context_token_budget: 0,
18672            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
18673                Duration::ZERO,
18674            )),
18675            receipt_generator: None,
18676            show_receipts_in_response: false,
18677            last_applied_config_stamp: Arc::new(Mutex::new(None)),
18678            runtime_defaults_override: Arc::new(Mutex::new(None)),
18679        });
18680
18681        process_channel_message(
18682            runtime_ctx,
18683            zeroclaw_api::channel::ChannelMessage {
18684                id: "msg-qc-prio".to_string(),
18685                sender: "alice".to_string(),
18686                reply_target: "chat-1".to_string(),
18687                content: "write some code for me".to_string(),
18688                channel: "telegram".to_string(),
18689                channel_alias: None,
18690                timestamp: 1,
18691                thread_ts: None,
18692                interruption_scope_id: None,
18693                attachments: vec![],
18694                subject: None,
18695            },
18696            CancellationToken::new(),
18697        )
18698        .await;
18699
18700        // Higher-priority "code" rule (priority=10) should win over "fast" (priority=1).
18701        assert_eq!(
18702            agent_model_provider_impl.call_count.load(Ordering::SeqCst),
18703            0
18704        );
18705        assert_eq!(
18706            fast_model_provider_impl.call_count.load(Ordering::SeqCst),
18707            0
18708        );
18709        assert_eq!(
18710            code_model_provider_impl.call_count.load(Ordering::SeqCst),
18711            1
18712        );
18713        assert_eq!(
18714            code_model_provider_impl
18715                .models
18716                .lock()
18717                .unwrap_or_else(|e| e.into_inner())
18718                .as_slice(),
18719            &["code-model".to_string()]
18720        );
18721    }
18722
18723    #[cfg(feature = "channel-telegram")]
18724    #[test]
18725    fn build_channel_by_id_unconfigured_telegram_returns_error() {
18726        let config = Config::default();
18727        let config_arc = Arc::new(RwLock::new(config));
18728        match build_channel_by_id(&config_arc, "telegram") {
18729            Err(e) => {
18730                let err_msg = e.to_string();
18731                assert!(
18732                    err_msg.contains("not configured"),
18733                    "expected 'not configured' in error, got: {err_msg}"
18734                );
18735            }
18736            Ok(_) => panic!("should fail when telegram is not configured"),
18737        }
18738    }
18739
18740    #[cfg(feature = "channel-telegram")]
18741    #[test]
18742    fn build_channel_by_id_configured_telegram_succeeds() {
18743        let mut config = Config::default();
18744        config.channels.telegram.insert(
18745            "default".to_string(),
18746            zeroclaw_config::schema::TelegramConfig {
18747                enabled: true,
18748                bot_token: "test-token".to_string(),
18749                stream_mode: zeroclaw_config::schema::StreamMode::Off,
18750                draft_update_interval_ms: 1000,
18751                interrupt_on_new_message: false,
18752                mention_only: false,
18753                ack_reactions: None,
18754                proxy_url: None,
18755                approval_timeout_secs: 120,
18756                excluded_tools: vec![],
18757                reply_min_interval_secs: 0,
18758                reply_queue_depth_max: 0,
18759            },
18760        );
18761        let config_arc = Arc::new(RwLock::new(config));
18762        match build_channel_by_id(&config_arc, "telegram") {
18763            Ok(channel) => assert_eq!(channel.name(), "telegram"),
18764            Err(e) => panic!("should succeed when telegram is configured: {e}"),
18765        }
18766    }
18767
18768    #[cfg(feature = "channel-voice-call")]
18769    #[test]
18770    fn build_channel_by_id_unconfigured_voice_call_returns_error() {
18771        let config = Config::default();
18772        let config_arc = Arc::new(RwLock::new(config));
18773        match build_channel_by_id(&config_arc, "voice-call") {
18774            Err(e) => {
18775                let err_msg = e.to_string();
18776                assert!(
18777                    err_msg.contains("not configured"),
18778                    "expected 'not configured' in error, got: {err_msg}"
18779                );
18780            }
18781            Ok(_) => panic!("should fail when voice-call is not configured"),
18782        }
18783    }
18784
18785    #[cfg(feature = "channel-voice-call")]
18786    #[test]
18787    fn build_channel_by_id_configured_voice_call_succeeds() {
18788        let mut config = Config::default();
18789        config.channels.voice_call.insert(
18790            "default".to_string(),
18791            zeroclaw_config::scattered_types::VoiceCallConfig {
18792                enabled: true,
18793                model_provider: zeroclaw_config::scattered_types::VoiceProvider::Twilio,
18794                account_id: "AC_TEST".to_string(),
18795                auth_token: "test_token".to_string(),
18796                from_number: "+15551234567".to_string(),
18797                webhook_port: 8090,
18798                require_outbound_approval: true,
18799                transcription_logging: true,
18800                tts_voice: None,
18801                max_call_duration_secs: 3600,
18802                webhook_base_url: None,
18803                excluded_tools: vec![],
18804            },
18805        );
18806        let config_arc = Arc::new(RwLock::new(config));
18807        match build_channel_by_id(&config_arc, "voice-call") {
18808            Ok(channel) => assert_eq!(channel.name(), "voice_call"),
18809            Err(e) => panic!("should succeed when voice-call is configured: {e}"),
18810        }
18811    }
18812
18813    // ── is_stop_command tests ─────────────────────────────────────────────
18814
18815    #[test]
18816    fn is_stop_command_matches_bare_slash_stop() {
18817        assert!(is_stop_command("/stop"));
18818    }
18819
18820    #[test]
18821    fn is_stop_command_matches_with_leading_trailing_whitespace() {
18822        assert!(is_stop_command("  /stop  "));
18823    }
18824
18825    #[test]
18826    fn is_stop_command_is_case_insensitive() {
18827        assert!(is_stop_command("/STOP"));
18828        assert!(is_stop_command("/Stop"));
18829    }
18830
18831    #[test]
18832    fn is_stop_command_matches_with_bot_suffix() {
18833        assert!(is_stop_command("/stop@zeroclaw_bot"));
18834    }
18835
18836    #[test]
18837    fn is_stop_command_rejects_other_slash_commands() {
18838        assert!(!is_stop_command("/new"));
18839        assert!(!is_stop_command("/model gpt-4"));
18840        assert!(!is_stop_command("/models"));
18841    }
18842
18843    #[test]
18844    fn is_stop_command_rejects_plain_text() {
18845        assert!(!is_stop_command("stop"));
18846        assert!(!is_stop_command("please stop"));
18847        assert!(!is_stop_command(""));
18848    }
18849
18850    #[test]
18851    fn is_stop_command_rejects_stop_as_substring() {
18852        assert!(!is_stop_command("/stopwatch"));
18853        assert!(!is_stop_command("/stop-all"));
18854    }
18855
18856    #[test]
18857    fn interrupt_on_new_message_enabled_for_mattermost_when_true() {
18858        let cfg = InterruptOnNewMessageConfig {
18859            telegram: false,
18860            slack: false,
18861            discord: false,
18862            mattermost: true,
18863            matrix: false,
18864        };
18865        assert!(cfg.enabled_for_channel("mattermost"));
18866    }
18867
18868    #[test]
18869    fn interrupt_on_new_message_disabled_for_mattermost_by_default() {
18870        let cfg = InterruptOnNewMessageConfig {
18871            telegram: false,
18872            slack: false,
18873            discord: false,
18874            mattermost: false,
18875            matrix: false,
18876        };
18877        assert!(!cfg.enabled_for_channel("mattermost"));
18878    }
18879
18880    #[test]
18881    fn interrupt_on_new_message_enabled_for_discord() {
18882        let cfg = InterruptOnNewMessageConfig {
18883            telegram: false,
18884            slack: false,
18885            discord: true,
18886            mattermost: false,
18887            matrix: false,
18888        };
18889        assert!(cfg.enabled_for_channel("discord"));
18890    }
18891
18892    #[test]
18893    fn interrupt_on_new_message_disabled_for_discord_by_default() {
18894        let cfg = InterruptOnNewMessageConfig {
18895            telegram: false,
18896            slack: false,
18897            discord: false,
18898            mattermost: false,
18899            matrix: false,
18900        };
18901        assert!(!cfg.enabled_for_channel("discord"));
18902    }
18903
18904    // ── interruption_scope_key tests ──────────────────────────────────────
18905
18906    #[test]
18907    fn interruption_scope_key_without_scope_id_is_three_component() {
18908        let msg = zeroclaw_api::channel::ChannelMessage {
18909            id: "1".into(),
18910            sender: "alice".into(),
18911            reply_target: "room".into(),
18912            content: "hi".into(),
18913            channel: "matrix".into(),
18914            channel_alias: None,
18915            timestamp: 0,
18916            thread_ts: None,
18917            interruption_scope_id: None,
18918            attachments: vec![],
18919            subject: None,
18920        };
18921        assert_eq!(interruption_scope_key(&msg), "matrix_room_alice");
18922    }
18923
18924    #[test]
18925    fn interruption_scope_key_with_scope_id_is_four_component() {
18926        let msg = zeroclaw_api::channel::ChannelMessage {
18927            id: "1".into(),
18928            sender: "alice".into(),
18929            reply_target: "room".into(),
18930            content: "hi".into(),
18931            channel: "matrix".into(),
18932            channel_alias: None,
18933            timestamp: 0,
18934            thread_ts: Some("$thread1".into()),
18935            interruption_scope_id: Some("$thread1".into()),
18936            attachments: vec![],
18937            subject: None,
18938        };
18939        assert_eq!(interruption_scope_key(&msg), "matrix_room_alice_$thread1");
18940    }
18941
18942    #[test]
18943    fn interruption_scope_key_thread_ts_alone_does_not_affect_key() {
18944        // thread_ts used for reply anchoring should not bleed into scope key
18945        let msg = zeroclaw_api::channel::ChannelMessage {
18946            id: "1".into(),
18947            sender: "alice".into(),
18948            reply_target: "C123".into(),
18949            content: "hi".into(),
18950            channel: "slack".into(),
18951            channel_alias: None,
18952            timestamp: 0,
18953            thread_ts: Some("1234567890.000100".into()), // Slack top-level fallback
18954            interruption_scope_id: None,                 // but NOT a thread reply
18955            attachments: vec![],
18956            subject: None,
18957        };
18958        assert_eq!(interruption_scope_key(&msg), "slack_C123_alice");
18959    }
18960
18961    #[tokio::test]
18962    async fn message_dispatch_different_threads_do_not_cancel_each_other() {
18963        let channel_impl = Arc::new(SlackRecordingChannel::default());
18964        let channel: Arc<dyn Channel> = channel_impl.clone();
18965
18966        let mut channels_by_name = HashMap::new();
18967        channels_by_name.insert(channel.name().to_string(), channel);
18968
18969        let runtime_ctx = Arc::new(ChannelRuntimeContext {
18970            channels_by_name: Arc::new(channels_by_name),
18971            model_provider: Arc::new(SlowModelProvider {
18972                delay: Duration::from_millis(150),
18973            }),
18974            model_provider_ref: Arc::new("test-provider".to_string()),
18975            agent_alias: Arc::new("test-agent".to_string()),
18976            agent_cfg: Arc::new(zeroclaw_config::schema::AliasedAgentConfig::default()),
18977            memory: Arc::new(NoopMemory),
18978            memory_strategy: Arc::new(
18979                zeroclaw_runtime::agent::memory_strategy::DefaultMemoryStrategy::with_config(
18980                    Arc::new(NoopMemory),
18981                    zeroclaw_config::schema::MemoryConfig::default(),
18982                    std::path::PathBuf::new(),
18983                ),
18984            ),
18985            tools_registry: Arc::new(vec![]),
18986            observer: Arc::new(NoopObserver),
18987            system_prompt: Arc::new("test-system-prompt".to_string()),
18988            model: Arc::new("test-model".to_string()),
18989            temperature: Some(0.0),
18990            auto_save_memory: false,
18991            max_tool_iterations: 10,
18992            min_relevance_score: 0.0,
18993            conversation_histories: Arc::new(Mutex::new(lru::LruCache::new(
18994                std::num::NonZeroUsize::new(MAX_CONVERSATION_SENDERS).unwrap(),
18995            ))),
18996            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
18997            provider_cache: Arc::new(Mutex::new(HashMap::new())),
18998            route_overrides: Arc::new(Mutex::new(HashMap::new())),
18999            reliability: Arc::new(zeroclaw_config::schema::ReliabilityConfig::default()),
19000            provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions::default(),
19001            workspace_dir: Arc::new(std::env::temp_dir()),
19002            prompt_config: Arc::new(zeroclaw_config::schema::Config::default()),
19003            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
19004            interrupt_on_new_message: InterruptOnNewMessageConfig {
19005                telegram: false,
19006                slack: true,
19007                discord: false,
19008                mattermost: false,
19009                matrix: false,
19010            },
19011            multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
19012            media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
19013            transcription_config: zeroclaw_config::schema::TranscriptionConfig::default(),
19014            agent_transcription_provider: String::new(),
19015            hooks: None,
19016            non_cli_excluded_tools: Arc::new(Vec::new()),
19017            autonomy_level: AutonomyLevel::default(),
19018            tool_call_dedup_exempt: Arc::new(Vec::new()),
19019            model_routes: Arc::new(Vec::new()),
19020            query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
19021            ack_reactions: true,
19022            show_tool_calls: true,
19023            session_store: None,
19024            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
19025                &zeroclaw_config::schema::RiskProfileConfig::default(),
19026            )),
19027            activated_tools: None,
19028            cost_tracking: None,
19029            pacing: zeroclaw_config::schema::PacingConfig::default(),
19030            max_tool_result_chars: 0,
19031            context_token_budget: 0,
19032            debouncer: Arc::new(zeroclaw_infra::debounce::MessageDebouncer::new(
19033                Duration::ZERO,
19034            )),
19035            receipt_generator: None,
19036            show_receipts_in_response: false,
19037            last_applied_config_stamp: Arc::new(Mutex::new(None)),
19038            runtime_defaults_override: Arc::new(Mutex::new(None)),
19039        });
19040
19041        let (tx, rx) = tokio::sync::mpsc::channel::<zeroclaw_api::channel::ChannelMessage>(8);
19042        let send_task = zeroclaw_spawn::spawn!(async move {
19043            // Two messages from same sender but in different Slack threads —
19044            // they must NOT cancel each other.
19045            tx.send(zeroclaw_api::channel::ChannelMessage {
19046                id: "1741234567.100001".to_string(),
19047                sender: "alice".to_string(),
19048                reply_target: "C123".to_string(),
19049                content: "thread-a question".to_string(),
19050                channel: "slack".to_string(),
19051                channel_alias: None,
19052                timestamp: 1,
19053                thread_ts: Some("1741234567.100001".to_string()),
19054                interruption_scope_id: Some("1741234567.100001".to_string()),
19055                attachments: vec![],
19056                subject: None,
19057            })
19058            .await
19059            .unwrap();
19060            tokio::time::sleep(Duration::from_millis(30)).await;
19061            tx.send(zeroclaw_api::channel::ChannelMessage {
19062                id: "1741234567.200002".to_string(),
19063                sender: "alice".to_string(),
19064                reply_target: "C123".to_string(),
19065                content: "thread-b question".to_string(),
19066                channel: "slack".to_string(),
19067                channel_alias: None,
19068                timestamp: 2,
19069                thread_ts: Some("1741234567.200002".to_string()),
19070                interruption_scope_id: Some("1741234567.200002".to_string()),
19071                attachments: vec![],
19072                subject: None,
19073            })
19074            .await
19075            .unwrap();
19076        });
19077
19078        run_message_dispatch_loop(rx, AgentRouter::single(runtime_ctx), 4).await;
19079        send_task.await.unwrap();
19080
19081        // Both tasks should have completed — different threads, no cancellation.
19082        let sent_messages = channel_impl.sent_messages.lock().await;
19083        assert_eq!(
19084            sent_messages.len(),
19085            2,
19086            "both Slack thread messages should complete, got: {sent_messages:?}"
19087        );
19088    }
19089
19090    #[test]
19091    fn sanitize_channel_response_redacts_detected_credentials() {
19092        let tools: Vec<Box<dyn Tool>> = Vec::new();
19093        let leaked = "Temporary key: AKIAABCDEFGHIJKLMNOP"; // gitleaks:allow
19094
19095        let result = sanitize_channel_response(leaked, &tools);
19096
19097        assert!(!result.contains("AKIAABCDEFGHIJKLMNOP")); // gitleaks:allow
19098        assert!(result.contains("[REDACTED"));
19099    }
19100
19101    #[test]
19102    fn sanitize_channel_response_passes_clean_text() {
19103        let tools: Vec<Box<dyn Tool>> = Vec::new();
19104        let clean_text = "This is a normal message with no credentials.";
19105
19106        let result = sanitize_channel_response(clean_text, &tools);
19107
19108        assert_eq!(result, clean_text);
19109    }
19110
19111    #[test]
19112    fn sanitize_channel_response_preserves_schema_json_array_without_tools() {
19113        let tools: Vec<Box<dyn Tool>> = Vec::new();
19114        let schema = r#"[{"name":"planner","parameters":{"goal":"string"}}]"#;
19115
19116        let result = sanitize_channel_response(schema, &tools);
19117
19118        assert_eq!(result, schema);
19119    }
19120
19121    #[test]
19122    fn sanitize_channel_response_preserves_tool_calls_audit_json() {
19123        let tools: Vec<Box<dyn Tool>> = Vec::new();
19124        let audit_json =
19125            r#"{"tool_calls":[{"id":"case-1","status":"queued","service":"billing"}]}"#;
19126
19127        let result = sanitize_channel_response(audit_json, &tools);
19128
19129        assert_eq!(result, audit_json);
19130    }
19131
19132    #[test]
19133    fn sanitize_channel_response_preserves_reference_function_call_json_without_tools() {
19134        let tools: Vec<Box<dyn Tool>> = Vec::new();
19135        let reference_json =
19136            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
19137
19138        let result = sanitize_channel_response(reference_json, &tools);
19139
19140        assert_eq!(result, reference_json);
19141    }
19142
19143    #[test]
19144    fn sanitize_channel_response_preserves_reference_function_call_json_with_tools() {
19145        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19146        let reference_json =
19147            r#"{"type":"function_call","name":"support_case","arguments":{"id":"A1"}}"#;
19148
19149        let result = sanitize_channel_response(reference_json, &tools);
19150
19151        assert_eq!(result, reference_json);
19152    }
19153
19154    #[test]
19155    fn sanitize_channel_response_preserves_unknown_tool_calls_json_with_tools() {
19156        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19157        let business_json = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}]}"#;
19158
19159        let result = sanitize_channel_response(business_json, &tools);
19160
19161        assert_eq!(result, business_json);
19162    }
19163
19164    #[test]
19165    fn sanitize_channel_response_preserves_malformed_unknown_tool_calls_json_with_tools() {
19166        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19167        let business_json = r#"{"tool_calls":[{"name":"support_case","arguments":{"id":"A1"}}"#;
19168
19169        let result = sanitize_channel_response(business_json, &tools);
19170
19171        assert_eq!(result, business_json);
19172    }
19173
19174    #[test]
19175    fn sanitize_channel_response_preserves_json_fenced_tool_protocol_example() {
19176        let tools: Vec<Box<dyn Tool>> = Vec::new();
19177        let example = r#"Here is a protocol example:
19178```json
19179{"tool_calls":[{"name":"shell","arguments":{"command":"pwd"}}]}
19180```"#;
19181
19182        let result = sanitize_channel_response(example, &tools);
19183
19184        assert_eq!(result, example);
19185    }
19186
19187    #[test]
19188    fn sanitize_channel_response_removes_registered_tool_json_array() {
19189        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19190        let internal = r#"[{"name":"mock_price","parameters":{"symbol":"BTC"}}]"#;
19191
19192        let result = sanitize_channel_response(internal, &tools);
19193
19194        assert_eq!(result, "");
19195    }
19196
19197    #[test]
19198    fn sanitize_channel_response_removes_internal_tool_protocol_envelopes() {
19199        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19200        let internal = r#"{"toolcalls":[{"name":"mock_price","arguments":{"symbol":"BTC"}}]}"#;
19201
19202        let result = sanitize_channel_response(internal, &tools);
19203
19204        assert_eq!(result, "");
19205    }
19206
19207    #[test]
19208    fn sanitize_channel_response_removes_json_fenced_internal_tool_protocol() {
19209        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19210        let internal = r#"```json
19211{"tool_calls":[{"name":"mock_price","arguments":{"symbol":"BTC"}}]}
19212```"#;
19213
19214        let result = sanitize_channel_response(internal, &tools);
19215
19216        assert_eq!(result, "");
19217    }
19218
19219    #[test]
19220    fn sanitize_channel_response_removes_embedded_json_fenced_internal_tool_protocol() {
19221        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19222        let response = r#"Intro
19223```json
19224{"tool_calls":[{"name":"mock_price","arguments":{"symbol":"BTC"}}]}
19225```
19226Done."#;
19227
19228        let result = sanitize_channel_response(response, &tools);
19229
19230        assert!(result.contains("Intro"));
19231        assert!(result.contains("Done."));
19232        assert!(!result.contains("tool_calls"));
19233        assert!(!result.contains("mock_price"));
19234    }
19235
19236    #[test]
19237    fn sanitize_channel_response_removes_embedded_tool_call_fence() {
19238        let tools: Vec<Box<dyn Tool>> = Vec::new();
19239        let response = r#"Let me call it:
19240```tool_call
19241{"name":"shell","arguments":{"command":"pwd"}}
19242```
19243Done."#;
19244
19245        let result = sanitize_channel_response(response, &tools);
19246
19247        assert!(result.contains("Done."));
19248        assert!(!result.contains("tool_call"));
19249        assert!(!result.contains("shell"));
19250        assert!(!result.contains("command"));
19251    }
19252
19253    #[test]
19254    fn sanitize_channel_response_preserves_tool_call_fenced_example() {
19255        let tools: Vec<Box<dyn Tool>> = Vec::new();
19256        let example = r#"```tool_call
19257{"name":"shell","arguments":{"command":"pwd"}}
19258```
19259This is an example, not an invocation."#;
19260
19261        let result = sanitize_channel_response(example, &tools);
19262
19263        assert_eq!(result, example);
19264    }
19265
19266    #[test]
19267    fn sanitize_channel_response_removes_standalone_tool_call_fence() {
19268        let tools: Vec<Box<dyn Tool>> = Vec::new();
19269        let internal = r#"```tool_call
19270{"name":"shell","arguments":{"command":"pwd"}}
19271```"#;
19272
19273        let result = sanitize_channel_response(internal, &tools);
19274
19275        assert_eq!(result, "");
19276    }
19277
19278    #[test]
19279    fn sanitize_channel_response_removes_standalone_tool_name_fence() {
19280        let tools: Vec<Box<dyn Tool>> = Vec::new();
19281        let internal = r#"```tool shell
19282{"command":"pwd"}
19283```"#;
19284
19285        let result = sanitize_channel_response(internal, &tools);
19286
19287        assert_eq!(result, "");
19288    }
19289
19290    #[test]
19291    fn sanitize_channel_response_preserves_tool_call_tag_example() {
19292        let tools: Vec<Box<dyn Tool>> = Vec::new();
19293        let example = r#"<tool_call>
19294{"name":"shell","arguments":{"command":"pwd"}}
19295</tool_call>
19296This is an example, not an invocation."#;
19297
19298        let result = sanitize_channel_response(example, &tools);
19299
19300        assert_eq!(result, example);
19301    }
19302
19303    #[test]
19304    fn sanitize_channel_response_strips_tagged_tool_call_before_trailing_text() {
19305        let tools: Vec<Box<dyn Tool>> = Vec::new();
19306        let response = r#"<tool_call>
19307{"name":"shell","arguments":{"command":"pwd"}}
19308</tool_call>
19309Done."#;
19310
19311        let result = sanitize_channel_response(response, &tools);
19312
19313        assert_eq!(result, "Done.");
19314    }
19315
19316    #[test]
19317    fn sanitize_channel_response_removes_malformed_top_level_protocol() {
19318        let tools: Vec<Box<dyn Tool>> = Vec::new();
19319        let internal = r#"{"tool_call_id":"call_1","content":"raw"#;
19320
19321        let result = sanitize_channel_response(internal, &tools);
19322
19323        assert_eq!(result, "");
19324    }
19325
19326    #[test]
19327    fn sanitize_channel_response_removes_embedded_malformed_protocol_json() {
19328        let tools: Vec<Box<dyn Tool>> = Vec::new();
19329        let response =
19330            "Intro\n{\"tool_calls\":[{\"call_id\":\"call_1\",\"arguments\":{\"value\":\"X\"}\nDone";
19331
19332        let result = sanitize_channel_response(response, &tools);
19333
19334        assert!(result.contains("Intro"));
19335        assert!(result.contains("Done"));
19336        assert!(!result.contains("tool_calls"));
19337        assert!(!result.contains("arguments"));
19338    }
19339
19340    #[test]
19341    fn sanitize_channel_response_removes_multiline_embedded_malformed_protocol_json() {
19342        let tools: Vec<Box<dyn Tool>> = Vec::new();
19343        let response = "Intro\n{\n  \"tool_calls\": [{\"call_id\":\"call_1\",\"arguments\":{\"value\":\"X\"}}\nDone";
19344
19345        let result = sanitize_channel_response(response, &tools);
19346
19347        assert!(result.contains("Intro"));
19348        assert!(result.contains("Done"));
19349        assert!(!result.contains("tool_calls"));
19350        assert!(!result.contains("arguments"));
19351    }
19352
19353    #[test]
19354    fn sanitize_channel_response_keeps_protocol_explanation_text() {
19355        let tools: Vec<Box<dyn Tool>> = Vec::new();
19356        let explanation =
19357            "A markdown block starting with ```tool can be used in protocol examples.";
19358
19359        let result = sanitize_channel_response(explanation, &tools);
19360
19361        assert_eq!(result, explanation);
19362    }
19363
19364    #[test]
19365    fn sanitize_channel_response_keeps_safe_protocol_envelope_content_with_tools() {
19366        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19367        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.";
19368
19369        let result = sanitize_channel_response(response, &tools);
19370
19371        assert!(result.contains("Intro text"));
19372        assert!(result.contains("A markdown block starting with ```tool"));
19373        assert!(result.contains("Done."));
19374        assert!(!result.contains("tool_calls"));
19375    }
19376
19377    #[test]
19378    fn sanitize_channel_response_removes_isolated_tool_result_envelope_content_with_tools() {
19379        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19380        let response =
19381            "Intro text\n{\"tool_call_id\":\"call_1\",\"content\":\"raw tool output\"}\nDone.";
19382
19383        let result = sanitize_channel_response(response, &tools);
19384
19385        assert!(result.contains("Intro text"));
19386        assert!(result.contains("Done."));
19387        assert!(!result.contains("tool_call_id"));
19388        assert!(!result.contains("raw tool output"));
19389    }
19390
19391    #[test]
19392    fn sanitize_channel_response_removes_nested_protocol_content_with_tools() {
19393        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockPriceTool)];
19394        let response = "Intro text\n{\"content\":\"{\\\"toolcalls\\\":[{\\\"name\\\":\\\"mock_price\\\",\\\"arguments\\\":{\\\"symbol\\\":\\\"BTC\\\"}}]}\",\"tool_calls\":[{\"name\":\"mock_price\",\"arguments\":{\"symbol\":\"BTC\"}}]}\nDone.";
19395
19396        let result = sanitize_channel_response(response, &tools);
19397
19398        assert!(result.contains("Intro text"));
19399        assert!(result.contains("Done."));
19400        assert!(!result.contains("toolcalls"));
19401        assert!(!result.contains("shell"));
19402    }
19403
19404    #[test]
19405    fn sanitize_channel_response_strips_xml_tool_result_blocks() {
19406        let tools: Vec<Box<dyn Tool>> = Vec::new();
19407        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.";
19408
19409        let result = sanitize_channel_response(input, &tools);
19410
19411        assert!(!result.contains("tool_result"));
19412        assert!(!result.contains("exit_code"));
19413        assert!(result.contains("Here is what I found."));
19414    }
19415
19416    #[test]
19417    fn sanitize_channel_response_strips_mixed_tool_result_and_text() {
19418        let tools: Vec<Box<dyn Tool>> = Vec::new();
19419        let input = "Let me check.\n<tool_result name=\"shell\">\noutput here\n</tool_result>\nThe answer is 42.";
19420
19421        let result = sanitize_channel_response(input, &tools);
19422
19423        assert!(!result.contains("<tool_result"));
19424        assert!(!result.contains("output here"));
19425        assert!(result.contains("The answer is 42."));
19426    }
19427
19428    // ── Tests for strip_think_tags_inline (streaming draft sanitization) ──
19429
19430    #[test]
19431    fn strip_think_tags_inline_removes_single_block() {
19432        assert_eq!(
19433            strip_think_tags_inline("<think>reasoning</think>Hello"),
19434            "Hello"
19435        );
19436    }
19437
19438    #[test]
19439    fn strip_think_tags_inline_removes_multiple_blocks() {
19440        assert_eq!(
19441            strip_think_tags_inline("<think>a</think>X<think>b</think>Y"),
19442            "XY"
19443        );
19444    }
19445
19446    #[test]
19447    fn strip_think_tags_inline_handles_unclosed_block() {
19448        assert_eq!(
19449            strip_think_tags_inline("visible<think>hidden tail"),
19450            "visible"
19451        );
19452    }
19453
19454    #[test]
19455    fn strip_think_tags_inline_preserves_text_without_tags() {
19456        assert_eq!(strip_think_tags_inline("plain text"), "plain text");
19457    }
19458
19459    #[test]
19460    fn strip_think_tags_inline_handles_empty_string() {
19461        assert_eq!(strip_think_tags_inline(""), "");
19462    }
19463
19464    #[test]
19465    fn strip_think_tags_inline_strips_surrounding_whitespace() {
19466        assert_eq!(
19467            strip_think_tags_inline("<think>hidden</think>  Answer  "),
19468            "Answer"
19469        );
19470    }
19471
19472    // ── Tests for #4827: tool context preservation ──────────────
19473
19474    #[test]
19475    fn extract_current_turn_tool_messages_returns_intermediate_messages() {
19476        let history = vec![
19477            ChatMessage::system("sys"),
19478            ChatMessage::user("older msg"),
19479            ChatMessage::assistant("older reply"),
19480            ChatMessage::user("block the iPad"),
19481            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
19482            ChatMessage::tool("ok"),
19483            ChatMessage::assistant("Done, iPad is blocked."),
19484        ];
19485
19486        let tool_msgs = extract_current_turn_tool_messages(&history);
19487        assert_eq!(tool_msgs.len(), 2);
19488        assert_eq!(tool_msgs[0].role, "assistant");
19489        assert!(tool_msgs[0].content.contains("tool_call"));
19490        assert_eq!(tool_msgs[1].role, "tool");
19491    }
19492
19493    #[test]
19494    fn extract_current_turn_tool_messages_empty_when_no_tools() {
19495        let history = vec![
19496            ChatMessage::user("hello"),
19497            ChatMessage::assistant("Hi there!"),
19498        ];
19499
19500        let tool_msgs = extract_current_turn_tool_messages(&history);
19501        assert!(tool_msgs.is_empty());
19502    }
19503
19504    #[test]
19505    fn extract_current_turn_tool_messages_multiple_tool_rounds() {
19506        let history = vec![
19507            ChatMessage::user("do two things"),
19508            ChatMessage::assistant("{\"tool_call\": \"read_skill\"}"),
19509            ChatMessage::tool("skill content"),
19510            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
19511            ChatMessage::tool("shell output"),
19512            ChatMessage::assistant("All done."),
19513        ];
19514
19515        let tool_msgs = extract_current_turn_tool_messages(&history);
19516        assert_eq!(tool_msgs.len(), 4);
19517    }
19518
19519    #[test]
19520    fn is_tool_call_content_detects_tool_calls() {
19521        assert!(is_tool_call_content("{\"tool_call\": \"shell\"}"));
19522        assert!(is_tool_call_content("<tool_call>shell</tool_call>"));
19523        assert!(is_tool_call_content(
19524            "{\"name\": \"read_file\", \"args\": {}}"
19525        ));
19526        assert!(!is_tool_call_content("The iPad has been blocked."));
19527        assert!(!is_tool_call_content(""));
19528    }
19529
19530    #[test]
19531    fn is_tool_call_content_does_not_misclassify_regular_name_json() {
19532        assert!(!is_tool_call_content(
19533            "{\"name\":\"Alice\",\"role\":\"admin\"}"
19534        ));
19535    }
19536
19537    #[test]
19538    fn strip_old_tool_context_preserves_recent_tool_context_when_history_within_keep_window() {
19539        let ctx = router_test_ctx();
19540        let sender = "tool-window-short";
19541        seed_sender_history(
19542            ctx.as_ref(),
19543            sender,
19544            vec![
19545                ChatMessage::user("block the iPad"),
19546                ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
19547                ChatMessage::tool(r#"{"tool_call_id":"call_1","content":"ok"}"#),
19548                ChatMessage::assistant("Done, iPad is blocked."),
19549            ],
19550        );
19551
19552        strip_old_tool_context(ctx.as_ref(), sender, 2);
19553
19554        let turns = cloned_sender_history(ctx.as_ref(), sender);
19555        assert_eq!(
19556            turns.len(),
19557            4,
19558            "tool context in protected turns must not be stripped"
19559        );
19560        assert_eq!(turns[1].role, "assistant");
19561        assert!(turns[1].content.contains("tool_call"));
19562        assert_eq!(turns[2].role, "tool");
19563    }
19564
19565    #[test]
19566    fn strip_old_tool_context_strips_tool_context_before_keep_window_boundary() {
19567        let ctx = router_test_ctx();
19568        let sender = "tool-window-boundary";
19569        seed_sender_history(
19570            ctx.as_ref(),
19571            sender,
19572            vec![
19573                ChatMessage::user("first task"),
19574                ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
19575                ChatMessage::tool("ok"),
19576                ChatMessage::assistant("first task done"),
19577                ChatMessage::user("second task"),
19578                ChatMessage::assistant("second task done"),
19579                ChatMessage::user("third task"),
19580                ChatMessage::assistant("third task done"),
19581            ],
19582        );
19583
19584        strip_old_tool_context(ctx.as_ref(), sender, 2);
19585
19586        let turns = cloned_sender_history(ctx.as_ref(), sender);
19587        assert_eq!(
19588            history_signature(&turns),
19589            vec![
19590                ("user".to_string(), "first task".to_string()),
19591                ("assistant".to_string(), "first task done".to_string()),
19592                ("user".to_string(), "second task".to_string()),
19593                ("assistant".to_string(), "second task done".to_string()),
19594                ("user".to_string(), "third task".to_string()),
19595                ("assistant".to_string(), "third task done".to_string()),
19596            ],
19597            "tool context older than the protected keep window should be stripped"
19598        );
19599    }
19600
19601    #[test]
19602    fn strip_old_tool_context_removes_native_tool_call_assistant_messages() {
19603        let ctx = router_test_ctx();
19604        let sender = "tool-window-native";
19605        seed_sender_history(
19606            ctx.as_ref(),
19607            sender,
19608            vec![
19609                ChatMessage::user("first task"),
19610                ChatMessage::assistant(
19611                    r#"{"content":"Need to call tool","tool_calls":[{"id":"call_1","name":"shell","arguments":"{\"command\":\"pwd\"}"}]}"#,
19612                ),
19613                ChatMessage::tool(r#"{"tool_call_id":"call_1","content":"ok"}"#),
19614                ChatMessage::assistant("first task done"),
19615                ChatMessage::user("second task"),
19616                ChatMessage::assistant("second task done"),
19617                ChatMessage::user("third task"),
19618                ChatMessage::assistant("third task done"),
19619            ],
19620        );
19621
19622        strip_old_tool_context(ctx.as_ref(), sender, 1);
19623
19624        let turns = cloned_sender_history(ctx.as_ref(), sender);
19625        assert_eq!(
19626            history_signature(&turns),
19627            vec![
19628                ("user".to_string(), "first task".to_string()),
19629                ("assistant".to_string(), "first task done".to_string()),
19630                ("user".to_string(), "second task".to_string()),
19631                ("assistant".to_string(), "second task done".to_string()),
19632                ("user".to_string(), "third task".to_string()),
19633                ("assistant".to_string(), "third task done".to_string()),
19634            ],
19635            "native assistant tool-call JSON should be stripped together with old tool results"
19636        );
19637    }
19638
19639    #[test]
19640    fn normalize_cached_channel_turns_passes_through_tool_messages() {
19641        let turns = vec![
19642            ChatMessage::user("block the iPad"),
19643            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
19644            ChatMessage::tool("ok"),
19645            ChatMessage::assistant("iPad blocked."),
19646            ChatMessage::user("next question"),
19647        ];
19648
19649        let normalized = normalize_cached_channel_turns(turns);
19650        // user, assistant(tool_call), tool, assistant(final), user
19651        assert_eq!(normalized.len(), 5);
19652        assert_eq!(normalized[2].role, "tool");
19653    }
19654
19655    #[test]
19656    fn default_keep_tool_context_turns_is_two() {
19657        let config = zeroclaw_config::schema::AliasedAgentConfig::default();
19658        assert_eq!(config.resolved.keep_tool_context_turns, 2);
19659    }
19660
19661    #[test]
19662    fn build_channel_system_prompt_includes_sender_id() {
19663        let prompt = build_channel_system_prompt(
19664            "You are a helpful assistant.",
19665            "mattermost",
19666            "channel123:root456",
19667            "user_abc123",
19668            "msg-xyz789",
19669            None,
19670        );
19671        // Pin the comma-separated tuple in the format string so a refactor
19672        // that splits, reorders, or rewords the context block fails loudly
19673        // rather than silently.
19674        assert!(
19675            prompt.contains(
19676                "channel=mattermost, reply_target=channel123:root456, \
19677                 sender=user_abc123, message_id=msg-xyz789"
19678            ),
19679            "prompt missing the joint channel-context tuple: {prompt}"
19680        );
19681    }
19682
19683    #[test]
19684    fn build_channel_system_prompt_omits_context_when_reply_target_empty() {
19685        let prompt = build_channel_system_prompt(
19686            "Base prompt.",
19687            "mattermost",
19688            "",
19689            "user_abc123",
19690            "msg-xyz789",
19691            None,
19692        );
19693        assert!(!prompt.contains("sender="));
19694        assert!(!prompt.contains("Channel context:"));
19695    }
19696
19697    #[test]
19698    fn build_channel_system_prompt_sender_distinguishes_users() {
19699        let prompt_a = build_channel_system_prompt(
19700            "Base.",
19701            "mattermost",
19702            "ch:thread",
19703            "user_aaa",
19704            "msg-1",
19705            None,
19706        );
19707        let prompt_b = build_channel_system_prompt(
19708            "Base.",
19709            "mattermost",
19710            "ch:thread",
19711            "user_bbb",
19712            "msg-1",
19713            None,
19714        );
19715        assert!(prompt_a.contains("sender=user_aaa"));
19716        assert!(prompt_b.contains("sender=user_bbb"));
19717        assert_ne!(prompt_a, prompt_b);
19718    }
19719
19720    #[test]
19721    fn build_channel_system_prompt_refreshes_legacy_datetime_section_to_date_only() {
19722        let prompt = build_channel_system_prompt(
19723            "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",
19724            "mattermost",
19725            "ch:thread",
19726            "user_aaa",
19727            "msg-1",
19728            None,
19729        );
19730
19731        assert!(prompt.contains("## Current Date\n\n"));
19732        assert!(prompt.contains("Project note, not generated date context."));
19733        assert!(!prompt.contains("## Current Date & Time"));
19734        assert!(!prompt.contains("01:02:03"));
19735        let generated_section = prompt
19736            .split("## Runtime")
19737            .next()
19738            .expect("prompt should contain runtime section before generated date assertion");
19739        let date_line = generated_section
19740            .rsplit("## Current Date\n\n")
19741            .next()
19742            .and_then(|rest| rest.lines().next())
19743            .expect("current date section should have a date line");
19744        assert_eq!(
19745            &date_line[..10],
19746            &chrono::Local::now().format("%Y-%m-%d").to_string()
19747        );
19748        assert!(
19749            date_line[10..].starts_with(" ("),
19750            "date line should contain only date plus UTC offset: {date_line}"
19751        );
19752    }
19753
19754    #[test]
19755    fn build_channel_system_prompt_refreshes_current_date_section() {
19756        let prompt = build_channel_system_prompt(
19757            "Base.\n\n## Current Date\n\n2026-01-01 (+00:00)\n\n## Runtime\n\nHost: old\n",
19758            "mattermost",
19759            "ch:thread",
19760            "user_aaa",
19761            "msg-1",
19762            None,
19763        );
19764
19765        assert!(prompt.contains("## Current Date\n\n"));
19766        assert!(!prompt.contains("2026-01-01 (+00:00)"));
19767        let date_line = prompt
19768            .split("## Current Date\n\n")
19769            .nth(1)
19770            .and_then(|rest| rest.lines().next())
19771            .expect("current date section should have a date line");
19772        assert_eq!(
19773            &date_line[..10],
19774            &chrono::Local::now().format("%Y-%m-%d").to_string()
19775        );
19776    }
19777
19778    #[test]
19779    fn build_channel_system_prompt_for_message_propagates_channel_fields() {
19780        // The wrapper unpacks ChannelMessage into build_channel_system_prompt
19781        // args. Pin the rendered prompt against every msg.* field the LLM
19782        // is expected to see so a future refactor adding more fields can't
19783        // silently drop existing ones.
19784        let msg = channel_message("discord", None);
19785        let prompt = build_channel_system_prompt_for_message("Base.", &msg, None);
19786        assert!(
19787            prompt.contains("channel=discord, reply_target=r1, sender=u1, message_id=m1"),
19788            "wrapper did not propagate channel/reply_target/sender/message_id \
19789             from ChannelMessage: {prompt}"
19790        );
19791    }
19792
19793    #[test]
19794    fn build_channel_system_prompt_webhook_cron_hint_carries_thread_id() {
19795        // On the webhook channel `reply_target` is the inbound thread/conversation
19796        // id, not a recipient. Using it as `delivery.to` would strip the thread
19797        // context from the cron-announce callback (see #6634). The hint must
19798        // place the sender in `to` and the reply_target in `thread_id`.
19799        let prompt = build_channel_system_prompt(
19800            "Base.",
19801            "webhook",
19802            "agent-chat:agent-1:thread-7",
19803            "user:abc",
19804            "msg-1",
19805            None,
19806        );
19807        assert!(
19808            prompt.contains("\"to\":\"user:abc\""),
19809            "webhook cron hint must use sender as `to`: {prompt}"
19810        );
19811        assert!(
19812            prompt.contains("\"thread_id\":\"agent-chat:agent-1:thread-7\""),
19813            "webhook cron hint must carry the reply_target as `thread_id`: {prompt}"
19814        );
19815        assert!(
19816            !prompt.contains("\"to\":\"agent-chat:agent-1:thread-7\""),
19817            "webhook cron hint must not put the thread id in `to`: {prompt}"
19818        );
19819    }
19820
19821    #[test]
19822    fn build_channel_system_prompt_non_webhook_cron_hint_keeps_to_as_reply_target() {
19823        let prompt =
19824            build_channel_system_prompt("Base.", "slack", "C12345", "U67890", "msg-1", None);
19825        assert!(
19826            prompt.contains("\"to\":\"C12345\""),
19827            "non-webhook cron hint should keep reply_target as `to`: {prompt}"
19828        );
19829        assert!(
19830            !prompt.contains("\"thread_id\""),
19831            "non-webhook cron hint should not emit a thread_id field: {prompt}"
19832        );
19833    }
19834
19835    #[tokio::test]
19836    #[cfg(feature = "channel-lark")]
19837    async fn deliver_announcement_routes_lark_to_lark_arm() {
19838        // Both names must enter the merged lark|feishu arm. Falling through
19839        // to `unsupported delivery channel` would mean the schema enum and
19840        // the match arm have drifted apart.
19841        let config = zeroclaw_config::schema::Config::default();
19842
19843        for channel in ["lark.default", "feishu.default"] {
19844            let err = deliver_announcement(&config, channel, "oc_test_chat", None, "hi")
19845                .await
19846                .err()
19847                .unwrap_or_else(|| {
19848                    panic!("expected {channel} to bail because channel is not configured")
19849                });
19850            let msg = format!("{err:#}");
19851            assert!(
19852                !msg.contains("unsupported delivery channel"),
19853                "{channel} must route to lark|feishu arm, not fall through; got: {msg}"
19854            );
19855            assert!(
19856                msg.contains("[channels.lark.default] not configured"),
19857                "{channel} must report the real config table [channels.lark.default]; got: {msg}"
19858            );
19859        }
19860    }
19861
19862    #[tokio::test]
19863    #[cfg(feature = "channel-lark")]
19864    async fn deliver_announcement_rejects_feishu_value_when_use_feishu_false() {
19865        // Reject (not warn): otherwise the message silently lands on the
19866        // Lark endpoint despite the user explicitly naming Feishu.
19867        let mut config = zeroclaw_config::schema::Config::default();
19868        config.channels.lark.insert(
19869            "work".to_string(),
19870            zeroclaw_config::schema::LarkConfig {
19871                enabled: true,
19872                use_feishu: false,
19873                app_id: "cli_test".to_string(),
19874                app_secret: "secret".to_string(),
19875                approval_timeout_secs: 300,
19876                per_user_session: false,
19877                ..Default::default()
19878            },
19879        );
19880
19881        let err = deliver_announcement(&config, "feishu.work", "oc_test_chat", None, "hi")
19882            .await
19883            .expect_err("expected bail when channel=feishu but use_feishu=false");
19884        let msg = format!("{err:#}");
19885        assert!(
19886            msg.contains("use_feishu=false"),
19887            "bail must explain the use_feishu mismatch; got: {msg}"
19888        );
19889        assert!(
19890            msg.contains("[channels.lark.work]"),
19891            "bail must point at the real config table; got: {msg}"
19892        );
19893    }
19894
19895    fn email_msg(id: &str, subject: Option<&str>) -> ChannelMessage {
19896        ChannelMessage {
19897            subject: subject.map(Into::into),
19898            ..ChannelMessage::new(
19899                id,
19900                "user@example.com",
19901                "user@example.com",
19902                "Hello",
19903                "email",
19904                0,
19905            )
19906        }
19907    }
19908
19909    #[test]
19910    fn reply_to_sets_in_reply_to_and_re_subject() {
19911        let msg = email_msg("<abc123@mail.example>", Some("Weekly report"));
19912        let sm = SendMessage::reply_to(&msg, "Here is the answer");
19913        assert_eq!(sm.in_reply_to.as_deref(), Some("<abc123@mail.example>"));
19914        assert_eq!(sm.subject.as_deref(), Some("Re: Weekly report"));
19915    }
19916
19917    #[test]
19918    fn reply_to_does_not_double_re_prefix() {
19919        let msg = email_msg("<abc123@mail.example>", Some("Re: Weekly report"));
19920        let sm = SendMessage::reply_to(&msg, "Here is the answer");
19921        assert_eq!(sm.subject.as_deref(), Some("Re: Weekly report"));
19922    }
19923
19924    #[test]
19925    fn reply_to_no_subject_still_sets_in_reply_to() {
19926        let msg = email_msg("<abc123@mail.example>", None);
19927        let sm = SendMessage::reply_to(&msg, "Here is the answer");
19928        assert_eq!(sm.in_reply_to.as_deref(), Some("<abc123@mail.example>"));
19929        assert!(sm.subject.is_none());
19930    }
19931}
19932
19933#[cfg(test)]
19934mod omitted_feature_tests {
19935    /// When `channel-telegram` is not compiled, a configured Telegram entry must
19936    /// produce no channel in `collect_configured_channels`. This pins the behaviour
19937    /// that selective builds never silently include a channel whose feature was
19938    /// omitted, and ensures the `#[cfg(not(feature = "channel-telegram"))]` warn
19939    /// path compiles correctly.
19940    #[cfg(not(feature = "channel-telegram"))]
19941    #[test]
19942    fn collect_configured_channels_omits_telegram_when_compiled_out() {
19943        use super::*;
19944        let mut config = Config::default();
19945        config.channels.telegram.insert(
19946            "default".to_string(),
19947            zeroclaw_config::schema::TelegramConfig {
19948                enabled: true,
19949                ..Default::default()
19950            },
19951        );
19952        let config_arc = Arc::new(RwLock::new(config));
19953        let channels = collect_configured_channels(&config_arc, "test", &[]);
19954        assert!(
19955            channels.iter().all(|c| c.display_name != "Telegram"),
19956            "Telegram must be absent from collect_configured_channels when \
19957             channel-telegram feature is not compiled in"
19958        );
19959    }
19960}