Skip to main content

construct/channels/
mod.rs

1//! Channel subsystem for messaging platform integrations.
2//!
3//! This module provides the multi-channel messaging infrastructure that connects
4//! Construct to external platforms. Each channel implements the [`Channel`] trait
5//! defined in [`traits`], which provides a uniform interface for sending messages,
6//! listening for incoming messages, health checking, and typing indicators.
7//!
8//! Channels are instantiated by [`start_channels`] based on the runtime configuration.
9//! The subsystem manages per-sender conversation history, concurrent message processing
10//! with configurable parallelism, and exponential-backoff reconnection for resilience.
11//!
12//! # Extension
13//!
14//! To add a new channel, implement [`Channel`] in a new submodule and wire it into
15//! [`start_channels`]. See `AGENTS.md` §7.2 for the full change playbook.
16
17pub mod acp_server;
18pub mod bluesky;
19pub mod clawdtalk;
20pub mod cli;
21pub mod debounce;
22pub mod dingtalk;
23pub mod discord;
24pub mod discord_history;
25pub mod email_channel;
26pub mod gmail_push;
27pub mod imessage;
28pub mod irc;
29#[cfg(feature = "channel-lark")]
30pub mod lark;
31pub mod link_enricher;
32pub mod linq;
33#[cfg(feature = "channel-matrix")]
34pub mod matrix;
35pub mod mattermost;
36pub mod media_pipeline;
37pub mod mochat;
38pub mod nextcloud_talk;
39#[cfg(feature = "channel-nostr")]
40pub mod nostr;
41pub mod notion;
42pub mod qq;
43pub mod reddit;
44pub mod session_backend;
45pub mod session_sqlite;
46pub mod session_store;
47pub mod signal;
48pub mod slack;
49pub mod telegram;
50pub mod traits;
51pub mod transcription;
52pub mod tts;
53pub mod twitter;
54pub mod voice_call;
55#[cfg(feature = "voice-wake")]
56pub mod voice_wake;
57pub mod wati;
58pub mod webhook;
59pub mod wecom;
60pub mod whatsapp;
61#[cfg(feature = "whatsapp-web")]
62pub mod whatsapp_storage;
63#[cfg(feature = "whatsapp-web")]
64pub mod whatsapp_web;
65
66pub use bluesky::BlueskyChannel;
67pub use clawdtalk::{ClawdTalkChannel, ClawdTalkConfig};
68pub use cli::CliChannel;
69pub use dingtalk::DingTalkChannel;
70pub use discord::DiscordChannel;
71#[allow(unused_imports)]
72pub use discord_history::DiscordHistoryChannel;
73pub use email_channel::EmailChannel;
74pub use gmail_push::GmailPushChannel;
75pub use imessage::IMessageChannel;
76pub use irc::IrcChannel;
77#[cfg(feature = "channel-lark")]
78pub use lark::LarkChannel;
79pub use linq::LinqChannel;
80#[cfg(feature = "channel-matrix")]
81pub use matrix::MatrixChannel;
82pub use mattermost::MattermostChannel;
83pub use mochat::MochatChannel;
84pub use nextcloud_talk::NextcloudTalkChannel;
85#[cfg(feature = "channel-nostr")]
86pub use nostr::NostrChannel;
87pub use notion::NotionChannel;
88pub use qq::QQChannel;
89pub use reddit::RedditChannel;
90pub use signal::SignalChannel;
91pub use slack::SlackChannel;
92pub use telegram::TelegramChannel;
93pub use traits::{Channel, SendMessage};
94#[allow(unused_imports)]
95pub use tts::{TtsManager, TtsProvider};
96pub use twitter::TwitterChannel;
97#[allow(unused_imports)]
98pub use voice_call::{VoiceCallChannel, VoiceCallConfig};
99#[cfg(feature = "voice-wake")]
100pub use voice_wake::VoiceWakeChannel;
101pub use wati::WatiChannel;
102pub use webhook::WebhookChannel;
103pub use wecom::WeComChannel;
104pub use whatsapp::WhatsAppChannel;
105#[cfg(feature = "whatsapp-web")]
106pub use whatsapp_web::WhatsAppWebChannel;
107
108use crate::agent::loop_::{
109    build_tool_instructions, clear_model_switch_request, get_model_switch_state,
110    is_model_switch_requested, run_tool_call_loop, scrub_credentials,
111};
112use crate::approval::ApprovalManager;
113use crate::config::Config;
114use crate::identity;
115use crate::memory::{self, Memory};
116use crate::observability::traits::{ObserverEvent, ObserverMetric};
117use crate::observability::{self, Observer, runtime_trace};
118use crate::providers::reliable::{scope_provider_fallback, take_last_provider_fallback};
119use crate::providers::{self, ChatMessage, Provider};
120use crate::runtime;
121use crate::security::{AutonomyLevel, SecurityPolicy};
122use crate::tools::{self, Tool};
123use crate::util::truncate_with_ellipsis;
124use anyhow::{Context, Result};
125use portable_atomic::{AtomicU64, Ordering};
126use serde::Deserialize;
127use std::collections::{HashMap, HashSet};
128use std::fmt::Write;
129use std::path::{Path, PathBuf};
130use std::process::Command;
131use std::sync::atomic::AtomicBool;
132use std::sync::{Arc, Mutex, OnceLock};
133use std::time::{Duration, Instant, SystemTime};
134use tokio_util::sync::CancellationToken;
135
136/// Observer wrapper that forwards tool-call events to a channel sender
137/// for real-time threaded notifications.
138struct ChannelNotifyObserver {
139    inner: Arc<dyn Observer>,
140    tx: tokio::sync::mpsc::UnboundedSender<String>,
141    tools_used: AtomicBool,
142}
143
144impl Observer for ChannelNotifyObserver {
145    fn record_event(&self, event: &ObserverEvent) {
146        if let ObserverEvent::ToolCallStart { tool, arguments } = event {
147            self.tools_used.store(true, Ordering::Relaxed);
148            let detail = match arguments {
149                Some(args) if !args.is_empty() => {
150                    if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
151                        if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
152                            format!(": `{}`", truncate_with_ellipsis(cmd, 200))
153                        } else if let Some(q) = v.get("query").and_then(|c| c.as_str()) {
154                            format!(": {}", truncate_with_ellipsis(q, 200))
155                        } else if let Some(p) = v.get("path").and_then(|c| c.as_str()) {
156                            format!(": {p}")
157                        } else if let Some(u) = v.get("url").and_then(|c| c.as_str()) {
158                            format!(": {u}")
159                        } else {
160                            let s = args.to_string();
161                            format!(": {}", truncate_with_ellipsis(&s, 120))
162                        }
163                    } else {
164                        let s = args.to_string();
165                        format!(": {}", truncate_with_ellipsis(&s, 120))
166                    }
167                }
168                _ => String::new(),
169            };
170            let _ = self.tx.send(format!("\u{1F527} `{tool}`{detail}"));
171        }
172        self.inner.record_event(event);
173    }
174    fn record_metric(&self, metric: &ObserverMetric) {
175        self.inner.record_metric(metric);
176    }
177    fn flush(&self) {
178        self.inner.flush();
179    }
180    fn name(&self) -> &str {
181        "channel-notify"
182    }
183    fn as_any(&self) -> &dyn std::any::Any {
184        self
185    }
186}
187
188/// Per-sender conversation history for channel messages.
189type ConversationHistoryMap = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
190/// Senders that requested `/new` and must force a fresh prompt on their next message.
191type PendingNewSessionSet = Arc<Mutex<HashSet<String>>>;
192/// Maximum history messages to keep per sender.
193const MAX_CHANNEL_HISTORY: usize = 50;
194/// Minimum user-message length (in chars) for auto-save to memory.
195/// Messages shorter than this (e.g. "ok", "thanks") are not stored,
196/// reducing noise in memory recall.
197const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
198
199/// Maximum characters per injected workspace file (matches `OpenClaw` default).
200const BOOTSTRAP_MAX_CHARS: usize = 20_000;
201
202const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
203const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
204const MIN_CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 30;
205/// Default timeout for processing a single channel message (LLM + tools).
206/// Used as fallback when not configured in channels_config.message_timeout_secs.
207const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
208/// Cap timeout scaling so large max_tool_iterations values do not create unbounded waits.
209const CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP: u64 = 4;
210const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4;
211const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8;
212const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
213const CHANNEL_TYPING_REFRESH_INTERVAL_SECS: u64 = 4;
214const CHANNEL_HEALTH_HEARTBEAT_SECS: u64 = 30;
215const MODEL_CACHE_FILE: &str = "models_cache.json";
216const MODEL_CACHE_PREVIEW_LIMIT: usize = 10;
217const MEMORY_CONTEXT_MAX_ENTRIES: usize = 4;
218const MEMORY_CONTEXT_ENTRY_MAX_CHARS: usize = 800;
219const MEMORY_CONTEXT_MAX_CHARS: usize = 4_000;
220const CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES: usize = 12;
221const CHANNEL_HISTORY_COMPACT_CONTENT_CHARS: usize = 600;
222/// Proactive context-window budget in estimated characters (~4 chars/token).
223/// When the total character count of conversation history exceeds this limit,
224/// older turns are dropped before the request is sent to the provider,
225/// preventing context-window-exceeded errors.  Set conservatively below
226/// common context windows (128 k tokens ≈ 512 k chars) to leave room for
227/// system prompt, memory context, and model output.
228const PROACTIVE_CONTEXT_BUDGET_CHARS: usize = 400_000;
229/// Guardrail for hook-modified outbound channel content.
230const CHANNEL_HOOK_MAX_OUTBOUND_CHARS: usize = 20_000;
231
232type ProviderCacheMap = Arc<Mutex<HashMap<String, Arc<dyn Provider>>>>;
233type RouteSelectionMap = Arc<Mutex<HashMap<String, ChannelRouteSelection>>>;
234
235fn effective_channel_message_timeout_secs(configured: u64) -> u64 {
236    configured.max(MIN_CHANNEL_MESSAGE_TIMEOUT_SECS)
237}
238
239fn channel_message_timeout_budget_secs(
240    message_timeout_secs: u64,
241    max_tool_iterations: usize,
242) -> u64 {
243    channel_message_timeout_budget_secs_with_cap(
244        message_timeout_secs,
245        max_tool_iterations,
246        CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP,
247    )
248}
249
250fn channel_message_timeout_budget_secs_with_cap(
251    message_timeout_secs: u64,
252    max_tool_iterations: usize,
253    scale_cap: u64,
254) -> u64 {
255    let iterations = max_tool_iterations.max(1) as u64;
256    let scale = iterations.min(scale_cap);
257    message_timeout_secs.saturating_mul(scale)
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
261struct ChannelRouteSelection {
262    provider: String,
263    model: String,
264    /// Route-specific API key override. When set, this takes precedence over
265    /// the global `api_key` in [`ChannelRuntimeContext`] when creating the
266    /// provider for this route.
267    api_key: Option<String>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
271enum ChannelRuntimeCommand {
272    ShowProviders,
273    SetProvider(String),
274    ShowModel,
275    SetModel(String),
276    ShowConfig,
277    NewSession,
278}
279
280#[derive(Debug, Clone, Default, Deserialize)]
281struct ModelCacheState {
282    entries: Vec<ModelCacheEntry>,
283}
284
285#[derive(Debug, Clone, Default, Deserialize)]
286struct ModelCacheEntry {
287    provider: String,
288    models: Vec<String>,
289}
290
291#[derive(Debug, Clone)]
292struct ChannelRuntimeDefaults {
293    default_provider: String,
294    model: String,
295    temperature: f64,
296    api_key: Option<String>,
297    api_url: Option<String>,
298    reliability: crate::config::ReliabilityConfig,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302struct ConfigFileStamp {
303    modified: SystemTime,
304    len: u64,
305}
306
307#[derive(Debug, Clone)]
308struct RuntimeConfigState {
309    defaults: ChannelRuntimeDefaults,
310    last_applied_stamp: Option<ConfigFileStamp>,
311}
312
313fn runtime_config_store() -> &'static Mutex<HashMap<PathBuf, RuntimeConfigState>> {
314    static STORE: OnceLock<Mutex<HashMap<PathBuf, RuntimeConfigState>>> = OnceLock::new();
315    STORE.get_or_init(|| Mutex::new(HashMap::new()))
316}
317
318const SYSTEMD_STATUS_ARGS: [&str; 3] = ["--user", "is-active", "construct.service"];
319const SYSTEMD_RESTART_ARGS: [&str; 3] = ["--user", "restart", "construct.service"];
320const OPENRC_STATUS_ARGS: [&str; 2] = ["construct", "status"];
321const OPENRC_RESTART_ARGS: [&str; 2] = ["construct", "restart"];
322
323#[derive(Clone, Copy)]
324#[allow(clippy::struct_excessive_bools)]
325struct InterruptOnNewMessageConfig {
326    telegram: bool,
327    slack: bool,
328    discord: bool,
329    mattermost: bool,
330    matrix: bool,
331}
332
333impl InterruptOnNewMessageConfig {
334    fn enabled_for_channel(self, channel: &str) -> bool {
335        match channel {
336            "telegram" => self.telegram,
337            "slack" => self.slack,
338            "discord" => self.discord,
339            "mattermost" => self.mattermost,
340            "matrix" => self.matrix,
341            _ => false,
342        }
343    }
344}
345
346#[derive(Clone)]
347struct ChannelCostTrackingState {
348    tracker: Arc<crate::cost::CostTracker>,
349    prices: Arc<HashMap<String, crate::config::schema::ModelPricing>>,
350}
351
352#[derive(Clone)]
353struct ChannelRuntimeContext {
354    channels_by_name: Arc<HashMap<String, Arc<dyn Channel>>>,
355    provider: Arc<dyn Provider>,
356    default_provider: Arc<String>,
357    prompt_config: Arc<crate::config::Config>,
358    memory: Arc<dyn Memory>,
359    tools_registry: Arc<Vec<Box<dyn Tool>>>,
360    observer: Arc<dyn Observer>,
361    system_prompt: Arc<String>,
362    model: Arc<String>,
363    audit_logger: Option<Arc<crate::security::audit::AuditLogger>>,
364    temperature: f64,
365    auto_save_memory: bool,
366    max_tool_iterations: usize,
367    min_relevance_score: f64,
368    conversation_histories: ConversationHistoryMap,
369    pending_new_sessions: PendingNewSessionSet,
370    provider_cache: ProviderCacheMap,
371    route_overrides: RouteSelectionMap,
372    api_key: Option<String>,
373    api_url: Option<String>,
374    reliability: Arc<crate::config::ReliabilityConfig>,
375    provider_runtime_options: providers::ProviderRuntimeOptions,
376    workspace_dir: Arc<PathBuf>,
377    message_timeout_secs: u64,
378    interrupt_on_new_message: InterruptOnNewMessageConfig,
379    multimodal: crate::config::MultimodalConfig,
380    media_pipeline: crate::config::MediaPipelineConfig,
381    transcription_config: crate::config::TranscriptionConfig,
382    hooks: Option<Arc<crate::hooks::HookRunner>>,
383    non_cli_excluded_tools: Arc<Vec<String>>,
384    autonomy_level: AutonomyLevel,
385    tool_call_dedup_exempt: Arc<Vec<String>>,
386    model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
387    query_classification: crate::config::QueryClassificationConfig,
388    ack_reactions: bool,
389    show_tool_calls: bool,
390    session_store: Option<Arc<session_store::SessionStore>>,
391    /// Non-interactive approval manager for channel-driven runs.
392    /// Enforces `auto_approve` / `always_ask` / supervised policy from
393    /// `[autonomy]` config; auto-denies tools that would need interactive
394    /// approval since no operator is present on channel runs.
395    approval_manager: Arc<ApprovalManager>,
396    activated_tools: Option<std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
397    /// MCP registry for direct tool calls (e.g. kumiho memory engage).
398    mcp_registry: Option<Arc<crate::tools::McpRegistry>>,
399    cost_tracking: Option<ChannelCostTrackingState>,
400    pacing: crate::config::PacingConfig,
401    max_tool_result_chars: usize,
402    context_token_budget: usize,
403    debouncer: Arc<debounce::MessageDebouncer>,
404}
405
406#[derive(Clone)]
407struct InFlightSenderTaskState {
408    task_id: u64,
409    cancellation: CancellationToken,
410    completion: Arc<InFlightTaskCompletion>,
411}
412
413struct InFlightTaskCompletion {
414    done: AtomicBool,
415    notify: tokio::sync::Notify,
416}
417
418impl InFlightTaskCompletion {
419    fn new() -> Self {
420        Self {
421            done: AtomicBool::new(false),
422            notify: tokio::sync::Notify::new(),
423        }
424    }
425
426    fn mark_done(&self) {
427        self.done.store(true, Ordering::Release);
428        self.notify.notify_waiters();
429    }
430
431    async fn wait(&self) {
432        if self.done.load(Ordering::Acquire) {
433            return;
434        }
435        self.notify.notified().await;
436    }
437}
438
439fn conversation_memory_key(msg: &traits::ChannelMessage) -> String {
440    // Include thread_ts for per-topic memory isolation in forum groups
441    match &msg.thread_ts {
442        Some(tid) => format!("{}_{}_{}_{}", msg.channel, tid, msg.sender, msg.id),
443        None => format!("{}_{}_{}", msg.channel, msg.sender, msg.id),
444    }
445}
446
447fn conversation_history_key(msg: &traits::ChannelMessage) -> String {
448    // Include reply_target for per-channel isolation (e.g. distinct Discord/Slack
449    // channels) and thread_ts for per-topic isolation in forum groups.
450    match &msg.thread_ts {
451        Some(tid) => format!(
452            "{}_{}_{}_{}",
453            msg.channel, msg.reply_target, tid, msg.sender
454        ),
455        None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
456    }
457}
458
459fn followup_thread_id(msg: &traits::ChannelMessage) -> Option<String> {
460    msg.thread_ts.clone().or_else(|| Some(msg.id.clone()))
461}
462
463fn interruption_scope_key(msg: &traits::ChannelMessage) -> String {
464    match &msg.interruption_scope_id {
465        Some(scope) => format!(
466            "{}_{}_{}_{}",
467            msg.channel, msg.reply_target, msg.sender, scope
468        ),
469        None => format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender),
470    }
471}
472
473/// Returns `true` when `content` is a `/stop` command (with optional `@botname` suffix).
474/// Not gated on channel type — all non-CLI channels support `/stop`.
475fn is_stop_command(content: &str) -> bool {
476    let trimmed = content.trim();
477    if !trimmed.starts_with('/') {
478        return false;
479    }
480    let cmd = trimmed.split_whitespace().next().unwrap_or("");
481    let base = cmd.split('@').next().unwrap_or(cmd);
482    base.eq_ignore_ascii_case("/stop")
483}
484
485/// Strip tool-call XML tags from outgoing messages.
486///
487/// LLM responses may contain `<function_calls>`, `<function_call>`,
488/// `<tool_call>`, `<toolcall>`, `<tool-call>`, `<tool>`, or `<invoke>`
489/// blocks that are internal protocol and must not be forwarded to end
490/// users on any channel.
491fn strip_tool_call_tags(message: &str) -> String {
492    const TOOL_CALL_OPEN_TAGS: [&str; 7] = [
493        "<function_calls>",
494        "<function_call>",
495        "<tool_call>",
496        "<toolcall>",
497        "<tool-call>",
498        "<tool>",
499        "<invoke>",
500    ];
501
502    fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> {
503        tags.iter()
504            .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag)))
505            .min_by_key(|(idx, _)| *idx)
506    }
507
508    fn matching_close_tag(open_tag: &str) -> Option<&'static str> {
509        match open_tag {
510            "<function_calls>" => Some("</function_calls>"),
511            "<function_call>" => Some("</function_call>"),
512            "<tool_call>" => Some("</tool_call>"),
513            "<toolcall>" => Some("</toolcall>"),
514            "<tool-call>" => Some("</tool-call>"),
515            "<tool>" => Some("</tool>"),
516            "<invoke>" => Some("</invoke>"),
517            _ => None,
518        }
519    }
520
521    fn extract_first_json_end(input: &str) -> Option<usize> {
522        let trimmed = input.trim_start();
523        let trim_offset = input.len().saturating_sub(trimmed.len());
524
525        for (byte_idx, ch) in trimmed.char_indices() {
526            if ch != '{' && ch != '[' {
527                continue;
528            }
529
530            let slice = &trimmed[byte_idx..];
531            let mut stream =
532                serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
533            if let Some(Ok(_value)) = stream.next() {
534                let consumed = stream.byte_offset();
535                if consumed > 0 {
536                    return Some(trim_offset + byte_idx + consumed);
537                }
538            }
539        }
540
541        None
542    }
543
544    fn strip_leading_close_tags(mut input: &str) -> &str {
545        loop {
546            let trimmed = input.trim_start();
547            if !trimmed.starts_with("</") {
548                return trimmed;
549            }
550
551            let Some(close_end) = trimmed.find('>') else {
552                return "";
553            };
554            input = &trimmed[close_end + 1..];
555        }
556    }
557
558    let mut kept_segments = Vec::new();
559    let mut remaining = message;
560
561    while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) {
562        let before = &remaining[..start];
563        if !before.is_empty() {
564            kept_segments.push(before.to_string());
565        }
566
567        let Some(close_tag) = matching_close_tag(open_tag) else {
568            break;
569        };
570        let after_open = &remaining[start + open_tag.len()..];
571
572        if let Some(close_idx) = after_open.find(close_tag) {
573            remaining = &after_open[close_idx + close_tag.len()..];
574            continue;
575        }
576
577        if let Some(consumed_end) = extract_first_json_end(after_open) {
578            remaining = strip_leading_close_tags(&after_open[consumed_end..]);
579            continue;
580        }
581
582        kept_segments.push(remaining[start..].to_string());
583        remaining = "";
584        break;
585    }
586
587    if !remaining.is_empty() {
588        kept_segments.push(remaining.to_string());
589    }
590
591    let mut result = kept_segments.concat();
592
593    // Clean up any resulting blank lines (but preserve paragraphs)
594    while result.contains("\n\n\n") {
595        result = result.replace("\n\n\n", "\n\n");
596    }
597
598    result.trim().to_string()
599}
600
601fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
602    match channel_name {
603        "matrix" => Some(
604            "When responding on Matrix:\n\
605             - Use Markdown formatting (bold, italic, code blocks)\n\
606             - Be concise and direct\n\
607             - When you receive a [Voice message], the user spoke to you. Respond naturally as in conversation.\n\
608             - Your text reply will automatically be converted to audio and sent back as a voice message.\n",
609        ),
610        "telegram" => Some(
611            "When responding on Telegram:\n\
612             - Include media markers for files or URLs that should be sent as attachments\n\
613             - Use **bold** for key terms, section titles, and important info (renders as <b>)\n\
614             - Use *italic* for emphasis (renders as <i>)\n\
615             - Use `backticks` for inline code, commands, or technical terms\n\
616             - Use triple backticks for code blocks\n\
617             - Use emoji naturally to add personality — but don't overdo it\n\
618             - Be concise and direct. Skip filler phrases like 'Great question!' or 'Certainly!'\n\
619             - Structure longer answers with bold headers, not raw markdown ## headers\n\
620             - 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\
621             - Keep normal text outside markers and never wrap markers in code fences.\n\
622             - Use tool results silently: answer the latest user message directly, and do not narrate delayed/internal tool execution bookkeeping.",
623        ),
624        "qq" => Some(
625            "When responding on QQ:\n\
626             - Use Markdown formatting\n\
627             - Be concise and direct\n\
628             - For media attachments use markers: [IMAGE:<path-or-url>], [DOCUMENT:<path-or-url>], \
629               [VIDEO:<path-or-url>], [VOICE:<path-or-url>]\n\
630             - Voice supports .wav, .mp3, .silk formats only. Other audio formats use [DOCUMENT:]\n\
631             - Keep normal text outside markers and never wrap markers in code fences.\n",
632        ),
633        _ => None,
634    }
635}
636
637fn build_channel_system_prompt(
638    base_prompt: &str,
639    channel_name: &str,
640    reply_target: &str,
641) -> String {
642    let mut prompt = base_prompt.to_string();
643
644    // Refresh the stale datetime in the cached system prompt
645    {
646        let now = chrono::Local::now();
647        let fresh = format!(
648            "## Current Date & Time\n\n{} ({})\n",
649            now.format("%Y-%m-%d %H:%M:%S"),
650            now.format("%Z"),
651        );
652        if let Some(start) = prompt.find("## Current Date & Time\n\n") {
653            // Find the end of this section (next "## " heading or end of string)
654            let rest = &prompt[start + 24..]; // skip past "## Current Date & Time\n\n"
655            let section_end = rest
656                .find("\n## ")
657                .map(|i| start + 24 + i)
658                .unwrap_or(prompt.len());
659            prompt.replace_range(start..section_end, fresh.trim_end());
660        }
661    }
662
663    if let Some(instructions) = channel_delivery_instructions(channel_name) {
664        if prompt.is_empty() {
665            prompt = instructions.to_string();
666        } else {
667            prompt = format!("{prompt}\n\n{instructions}");
668        }
669    }
670
671    if !reply_target.is_empty() {
672        let context = format!(
673            "\n\nChannel context: You are currently responding on channel={channel_name}, \
674             reply_target={reply_target}. When scheduling delayed messages or reminders \
675             via cron_add for this conversation, use delivery={{\"mode\":\"announce\",\
676             \"channel\":\"{channel_name}\",\"to\":\"{reply_target}\"}} so the message \
677             reaches the user."
678        );
679        prompt.push_str(&context);
680    }
681
682    prompt
683}
684
685fn normalize_cached_channel_turns(turns: Vec<ChatMessage>) -> Vec<ChatMessage> {
686    let mut normalized = Vec::with_capacity(turns.len());
687    let mut expecting_user = true;
688
689    for turn in turns {
690        match (expecting_user, turn.role.as_str()) {
691            // Pass through tool-role messages preserved by
692            // keep_tool_context_turns (#4827).  After a tool result the
693            // next expected message is an assistant response, same as
694            // after a user message.
695            (_, "tool") | (true, "user") => {
696                normalized.push(turn);
697                expecting_user = false;
698            }
699            (false, "assistant") => {
700                normalized.push(turn);
701                expecting_user = true;
702            }
703            // Interrupted channel turns can produce consecutive user messages
704            // (no assistant persisted yet). Merge instead of dropping.
705            (false, "user") | (true, "assistant") => {
706                if let Some(last_turn) = normalized.last_mut() {
707                    if !turn.content.is_empty() {
708                        if !last_turn.content.is_empty() {
709                            last_turn.content.push_str("\n\n");
710                        }
711                        last_turn.content.push_str(&turn.content);
712                    }
713                }
714            }
715            _ => {}
716        }
717    }
718
719    normalized
720}
721
722/// Remove `<tool_result …>…</tool_result>` blocks (and a leading `[Tool results]`
723/// header, if present) from a conversation-history entry so that stale tool
724/// output is never presented to the LLM without the corresponding `<tool_call>`.
725fn strip_tool_result_content(text: &str) -> String {
726    static TOOL_RESULT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
727        regex::Regex::new(r"(?s)<tool_result[^>]*>.*?</tool_result>").unwrap()
728    });
729
730    let cleaned = TOOL_RESULT_RE.replace_all(text, "");
731    let cleaned = cleaned.trim();
732
733    // If the only remaining content is the header, drop it entirely.
734    if cleaned == "[Tool results]" || cleaned.is_empty() {
735        return String::new();
736    }
737
738    cleaned.to_string()
739}
740
741/// Remove a leading `[Used tools: ...]` line from a cached assistant turn.
742///
743/// The tool-context summary is prepended to history entries so the LLM retains
744/// awareness of prior tool usage. However, when these entries are loaded back
745/// into the LLM context, the bracket-format leaks into generated output and
746/// gets forwarded to end users as-is (bug #4400). Stripping the prefix on
747/// reload prevents the model from learning and reproducing this internal format.
748fn strip_tool_summary_prefix(text: &str) -> String {
749    if let Some(rest) = text.strip_prefix("[Used tools:") {
750        // Find the closing bracket, then skip it and any leading newline(s).
751        if let Some(bracket_end) = rest.find(']') {
752            let after_bracket = &rest[bracket_end + 1..];
753            let trimmed = after_bracket.trim_start_matches('\n');
754            if trimmed.is_empty() {
755                return String::new();
756            }
757            return trimmed.to_string();
758        }
759    }
760    text.to_string()
761}
762
763fn supports_runtime_model_switch(channel_name: &str) -> bool {
764    matches!(channel_name, "telegram" | "discord" | "matrix" | "slack")
765}
766
767fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRuntimeCommand> {
768    let trimmed = content.trim();
769    if !trimmed.starts_with('/') {
770        return None;
771    }
772
773    let mut parts = trimmed.split_whitespace();
774    let command_token = parts.next()?;
775    let base_command = command_token
776        .split('@')
777        .next()
778        .unwrap_or(command_token)
779        .to_ascii_lowercase();
780
781    match base_command.as_str() {
782        // `/new` is available on every channel — no model-switch gate.
783        "/new" => Some(ChannelRuntimeCommand::NewSession),
784        // Model/provider switching is channel-gated.
785        "/models" if supports_runtime_model_switch(channel_name) => {
786            if let Some(provider) = parts.next() {
787                Some(ChannelRuntimeCommand::SetProvider(
788                    provider.trim().to_string(),
789                ))
790            } else {
791                Some(ChannelRuntimeCommand::ShowProviders)
792            }
793        }
794        "/model" if supports_runtime_model_switch(channel_name) => {
795            let model = parts.collect::<Vec<_>>().join(" ").trim().to_string();
796            if model.is_empty() {
797                Some(ChannelRuntimeCommand::ShowModel)
798            } else {
799                Some(ChannelRuntimeCommand::SetModel(model))
800            }
801        }
802        "/config" if supports_runtime_model_switch(channel_name) => {
803            Some(ChannelRuntimeCommand::ShowConfig)
804        }
805        _ => None,
806    }
807}
808
809fn resolve_provider_alias(name: &str) -> Option<String> {
810    let candidate = name.trim();
811    if candidate.is_empty() {
812        return None;
813    }
814
815    let providers_list = providers::list_providers();
816    for provider in providers_list {
817        if provider.name.eq_ignore_ascii_case(candidate)
818            || provider
819                .aliases
820                .iter()
821                .any(|alias| alias.eq_ignore_ascii_case(candidate))
822        {
823            return Some(provider.name.to_string());
824        }
825    }
826
827    None
828}
829
830fn resolved_default_provider(config: &Config) -> String {
831    config
832        .default_provider
833        .clone()
834        .unwrap_or_else(|| "openrouter".to_string())
835}
836
837fn resolved_default_model(config: &Config) -> String {
838    config
839        .default_model
840        .clone()
841        .unwrap_or_else(|| "anthropic/claude-sonnet-4.6".to_string())
842}
843
844fn runtime_defaults_from_config(config: &Config) -> ChannelRuntimeDefaults {
845    ChannelRuntimeDefaults {
846        default_provider: resolved_default_provider(config),
847        model: resolved_default_model(config),
848        temperature: config.default_temperature,
849        api_key: config.api_key.clone(),
850        api_url: config.api_url.clone(),
851        reliability: config.reliability.clone(),
852    }
853}
854
855fn runtime_config_path(ctx: &ChannelRuntimeContext) -> Option<PathBuf> {
856    ctx.provider_runtime_options
857        .construct_dir
858        .as_ref()
859        .map(|dir| dir.join("config.toml"))
860}
861
862fn runtime_defaults_snapshot(ctx: &ChannelRuntimeContext) -> ChannelRuntimeDefaults {
863    if let Some(config_path) = runtime_config_path(ctx) {
864        let store = runtime_config_store()
865            .lock()
866            .unwrap_or_else(|e| e.into_inner());
867        if let Some(state) = store.get(&config_path) {
868            return state.defaults.clone();
869        }
870    }
871
872    ChannelRuntimeDefaults {
873        default_provider: ctx.default_provider.as_str().to_string(),
874        model: ctx.model.as_str().to_string(),
875        temperature: ctx.temperature,
876        api_key: ctx.api_key.clone(),
877        api_url: ctx.api_url.clone(),
878        reliability: (*ctx.reliability).clone(),
879    }
880}
881
882async fn config_file_stamp(path: &Path) -> Option<ConfigFileStamp> {
883    let metadata = tokio::fs::metadata(path).await.ok()?;
884    let modified = metadata.modified().ok()?;
885    Some(ConfigFileStamp {
886        modified,
887        len: metadata.len(),
888    })
889}
890
891fn decrypt_optional_secret_for_runtime_reload(
892    store: &crate::security::SecretStore,
893    value: &mut Option<String>,
894    field_name: &str,
895) -> Result<()> {
896    if let Some(raw) = value.clone() {
897        if crate::security::SecretStore::is_encrypted(&raw) {
898            *value = Some(
899                store
900                    .decrypt(&raw)
901                    .with_context(|| format!("Failed to decrypt {field_name}"))?,
902            );
903        }
904    }
905    Ok(())
906}
907
908async fn load_runtime_defaults_from_config_file(path: &Path) -> Result<ChannelRuntimeDefaults> {
909    let contents = tokio::fs::read_to_string(path)
910        .await
911        .with_context(|| format!("Failed to read {}", path.display()))?;
912    let mut parsed: Config =
913        toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))?;
914    parsed.config_path = path.to_path_buf();
915
916    if let Some(construct_dir) = path.parent() {
917        let store = crate::security::SecretStore::new(construct_dir, parsed.secrets.encrypt);
918        decrypt_optional_secret_for_runtime_reload(&store, &mut parsed.api_key, "config.api_key")?;
919        // Decrypt TTS provider API keys for runtime reload
920        if let Some(ref mut openai) = parsed.tts.openai {
921            decrypt_optional_secret_for_runtime_reload(
922                &store,
923                &mut openai.api_key,
924                "config.tts.openai.api_key",
925            )?;
926        }
927        if let Some(ref mut elevenlabs) = parsed.tts.elevenlabs {
928            decrypt_optional_secret_for_runtime_reload(
929                &store,
930                &mut elevenlabs.api_key,
931                "config.tts.elevenlabs.api_key",
932            )?;
933        }
934        if let Some(ref mut google) = parsed.tts.google {
935            decrypt_optional_secret_for_runtime_reload(
936                &store,
937                &mut google.api_key,
938                "config.tts.google.api_key",
939            )?;
940        }
941    }
942
943    parsed.apply_env_overrides();
944    Ok(runtime_defaults_from_config(&parsed))
945}
946
947async fn maybe_apply_runtime_config_update(ctx: &ChannelRuntimeContext) -> Result<()> {
948    let Some(config_path) = runtime_config_path(ctx) else {
949        return Ok(());
950    };
951
952    let Some(stamp) = config_file_stamp(&config_path).await else {
953        return Ok(());
954    };
955
956    {
957        let store = runtime_config_store()
958            .lock()
959            .unwrap_or_else(|e| e.into_inner());
960        if let Some(state) = store.get(&config_path) {
961            if state.last_applied_stamp == Some(stamp) {
962                return Ok(());
963            }
964        }
965    }
966
967    let next_defaults = load_runtime_defaults_from_config_file(&config_path).await?;
968    let next_default_provider = providers::create_resilient_provider_with_options(
969        &next_defaults.default_provider,
970        next_defaults.api_key.as_deref(),
971        next_defaults.api_url.as_deref(),
972        &next_defaults.reliability,
973        &ctx.provider_runtime_options,
974    )?;
975    let next_default_provider: Arc<dyn Provider> = Arc::from(next_default_provider);
976
977    if let Err(err) = next_default_provider.warmup().await {
978        if crate::providers::reliable::is_non_retryable(&err) {
979            tracing::warn!(
980                provider = %next_defaults.default_provider,
981                model = %next_defaults.model,
982                "Rejecting config reload: model not available (non-retryable): {err}"
983            );
984            return Ok(());
985        }
986        tracing::warn!(
987            provider = %next_defaults.default_provider,
988            "Provider warmup failed after config reload (retryable, applying anyway): {err}"
989        );
990    }
991
992    {
993        let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
994        cache.clear();
995        cache.insert(
996            next_defaults.default_provider.clone(),
997            Arc::clone(&next_default_provider),
998        );
999    }
1000
1001    {
1002        let mut store = runtime_config_store()
1003            .lock()
1004            .unwrap_or_else(|e| e.into_inner());
1005        store.insert(
1006            config_path.clone(),
1007            RuntimeConfigState {
1008                defaults: next_defaults.clone(),
1009                last_applied_stamp: Some(stamp),
1010            },
1011        );
1012    }
1013
1014    tracing::info!(
1015        path = %config_path.display(),
1016        provider = %next_defaults.default_provider,
1017        model = %next_defaults.model,
1018        temperature = next_defaults.temperature,
1019        "Applied updated channel runtime config from disk"
1020    );
1021
1022    Ok(())
1023}
1024
1025fn default_route_selection(ctx: &ChannelRuntimeContext) -> ChannelRouteSelection {
1026    let defaults = runtime_defaults_snapshot(ctx);
1027    ChannelRouteSelection {
1028        provider: defaults.default_provider,
1029        model: defaults.model,
1030        api_key: None,
1031    }
1032}
1033
1034fn get_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str) -> ChannelRouteSelection {
1035    ctx.route_overrides
1036        .lock()
1037        .unwrap_or_else(|e| e.into_inner())
1038        .get(sender_key)
1039        .cloned()
1040        .unwrap_or_else(|| default_route_selection(ctx))
1041}
1042
1043fn set_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str, next: ChannelRouteSelection) {
1044    let default_route = default_route_selection(ctx);
1045    let mut routes = ctx
1046        .route_overrides
1047        .lock()
1048        .unwrap_or_else(|e| e.into_inner());
1049    if next == default_route {
1050        routes.remove(sender_key);
1051    } else {
1052        routes.insert(sender_key.to_string(), next);
1053    }
1054}
1055
1056fn clear_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) {
1057    ctx.conversation_histories
1058        .lock()
1059        .unwrap_or_else(|e| e.into_inner())
1060        .remove(sender_key);
1061}
1062
1063fn mark_sender_for_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) {
1064    ctx.pending_new_sessions
1065        .lock()
1066        .unwrap_or_else(|e| e.into_inner())
1067        .insert(sender_key.to_string());
1068}
1069
1070fn take_pending_new_session(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
1071    ctx.pending_new_sessions
1072        .lock()
1073        .unwrap_or_else(|e| e.into_inner())
1074        .remove(sender_key)
1075}
1076
1077fn replace_available_skills_section(base_prompt: &str, refreshed_skills: &str) -> String {
1078    const SKILLS_HEADER: &str = "## Available Skills\n\n";
1079    const SKILLS_END: &str = "</available_skills>";
1080    const WORKSPACE_HEADER: &str = "## Workspace\n\n";
1081
1082    if let Some(start) = base_prompt.find(SKILLS_HEADER) {
1083        if let Some(rel_end) = base_prompt[start..].find(SKILLS_END) {
1084            let end = start + rel_end + SKILLS_END.len();
1085            let tail = base_prompt[end..]
1086                .strip_prefix("\n\n")
1087                .unwrap_or(&base_prompt[end..]);
1088
1089            let mut refreshed = String::with_capacity(
1090                base_prompt.len().saturating_sub(end.saturating_sub(start))
1091                    + refreshed_skills.len()
1092                    + 2,
1093            );
1094            refreshed.push_str(&base_prompt[..start]);
1095            if !refreshed_skills.is_empty() {
1096                refreshed.push_str(refreshed_skills);
1097                refreshed.push_str("\n\n");
1098            }
1099            refreshed.push_str(tail);
1100            return refreshed;
1101        }
1102    }
1103
1104    if refreshed_skills.is_empty() {
1105        return base_prompt.to_string();
1106    }
1107
1108    if let Some(workspace_start) = base_prompt.find(WORKSPACE_HEADER) {
1109        let mut refreshed = String::with_capacity(base_prompt.len() + refreshed_skills.len() + 2);
1110        refreshed.push_str(&base_prompt[..workspace_start]);
1111        refreshed.push_str(refreshed_skills);
1112        refreshed.push_str("\n\n");
1113        refreshed.push_str(&base_prompt[workspace_start..]);
1114        return refreshed;
1115    }
1116
1117    format!("{base_prompt}\n\n{refreshed_skills}")
1118}
1119
1120fn refreshed_new_session_system_prompt(ctx: &ChannelRuntimeContext) -> String {
1121    let refreshed_skills = crate::skills::skills_to_prompt_with_mode(
1122        &crate::skills::load_skills_with_config(
1123            ctx.workspace_dir.as_ref(),
1124            ctx.prompt_config.as_ref(),
1125        ),
1126        ctx.workspace_dir.as_ref(),
1127        ctx.prompt_config.skills.prompt_injection_mode,
1128    );
1129    replace_available_skills_section(ctx.system_prompt.as_str(), &refreshed_skills)
1130}
1131
1132fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
1133    let mut histories = ctx
1134        .conversation_histories
1135        .lock()
1136        .unwrap_or_else(|e| e.into_inner());
1137
1138    let Some(turns) = histories.get_mut(sender_key) else {
1139        return false;
1140    };
1141
1142    if turns.is_empty() {
1143        return false;
1144    }
1145
1146    let keep_from = turns
1147        .len()
1148        .saturating_sub(CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
1149    let mut compacted = normalize_cached_channel_turns(turns[keep_from..].to_vec());
1150
1151    for turn in &mut compacted {
1152        if turn.content.chars().count() > CHANNEL_HISTORY_COMPACT_CONTENT_CHARS {
1153            turn.content =
1154                truncate_with_ellipsis(&turn.content, CHANNEL_HISTORY_COMPACT_CONTENT_CHARS);
1155        }
1156    }
1157
1158    if compacted.is_empty() {
1159        turns.clear();
1160        return false;
1161    }
1162
1163    *turns = compacted;
1164    true
1165}
1166
1167/// Proactively trim conversation turns so that the total estimated character
1168/// count stays within [`PROACTIVE_CONTEXT_BUDGET_CHARS`].  Drops the oldest
1169/// turns first, but always preserves the most recent turn (the current user
1170/// message).  Returns the number of turns dropped.
1171fn proactive_trim_turns(turns: &mut Vec<ChatMessage>, budget: usize) -> usize {
1172    let total_chars: usize = turns.iter().map(|t| t.content.chars().count()).sum();
1173    if total_chars <= budget || turns.len() <= 1 {
1174        return 0;
1175    }
1176
1177    let mut excess = total_chars.saturating_sub(budget);
1178    let mut drop_count = 0;
1179
1180    // Walk from the oldest turn forward, but never drop the very last turn.
1181    while excess > 0 && drop_count < turns.len().saturating_sub(1) {
1182        excess = excess.saturating_sub(turns[drop_count].content.chars().count());
1183        drop_count += 1;
1184    }
1185
1186    if drop_count > 0 {
1187        turns.drain(..drop_count);
1188    }
1189    drop_count
1190}
1191
1192fn append_sender_turn(ctx: &ChannelRuntimeContext, sender_key: &str, turn: ChatMessage) {
1193    // Persist to JSONL before adding to in-memory history.
1194    if let Some(ref store) = ctx.session_store {
1195        if let Err(e) = store.append(sender_key, &turn) {
1196            tracing::warn!("Failed to persist session turn: {e}");
1197        }
1198    }
1199
1200    // Use the user-configured max_history_messages (fall back to
1201    // MAX_CHANNEL_HISTORY when the config value is 0 or absent).
1202    let max_history = {
1203        let configured = ctx.prompt_config.agent.max_history_messages;
1204        if configured > 0 {
1205            configured
1206        } else {
1207            MAX_CHANNEL_HISTORY
1208        }
1209    };
1210
1211    let mut histories = ctx
1212        .conversation_histories
1213        .lock()
1214        .unwrap_or_else(|e| e.into_inner());
1215    let turns = histories.entry(sender_key.to_string()).or_default();
1216    turns.push(turn);
1217    while turns.len() > max_history {
1218        turns.remove(0);
1219    }
1220}
1221
1222/// Extract tool-call (assistant with tool_call content) and tool-result
1223/// messages from the current turn in the LLM history, excluding the final
1224/// assistant text response.  "Current turn" = everything after the last
1225/// user-role message.
1226fn extract_current_turn_tool_messages(history: &[ChatMessage]) -> Vec<ChatMessage> {
1227    // Find the index of the last user message — tool messages for the
1228    // current turn come after it.
1229    let last_user_idx = history.iter().rposition(|m| m.role == "user").unwrap_or(0);
1230
1231    let tail = &history[last_user_idx + 1..];
1232    if tail.is_empty() {
1233        return Vec::new();
1234    }
1235
1236    // Everything except the very last assistant message (which is the
1237    // final text response that gets stored separately).
1238    let end = if tail.last().is_some_and(|m| m.role == "assistant") {
1239        tail.len() - 1
1240    } else {
1241        tail.len()
1242    };
1243
1244    tail[..end]
1245        .iter()
1246        .filter(|m| m.role == "assistant" || m.role == "tool")
1247        .cloned()
1248        .collect()
1249}
1250
1251/// Remove tool-role and intermediate assistant tool-call messages from
1252/// conversation turns older than the most recent `keep_turns` user→assistant
1253/// exchanges.  This prevents unbounded history growth while preserving
1254/// tool context for the N most recent turns.
1255fn strip_old_tool_context(ctx: &ChannelRuntimeContext, sender_key: &str, keep_turns: usize) {
1256    let mut histories = ctx
1257        .conversation_histories
1258        .lock()
1259        .unwrap_or_else(|e| e.into_inner());
1260
1261    let Some(turns) = histories.get_mut(sender_key) else {
1262        return;
1263    };
1264
1265    // Walk backwards to find the boundary: count user messages to
1266    // identify which turns are "recent" (protected from stripping).
1267    let mut user_count = 0;
1268    let mut protect_from = turns.len();
1269    for (i, turn) in turns.iter().enumerate().rev() {
1270        if turn.role == "user" {
1271            user_count += 1;
1272            if user_count > keep_turns {
1273                // Everything before this index is old enough to strip.
1274                protect_from = i + 1; // protect from next message onward
1275                break;
1276            }
1277        }
1278    }
1279
1280    // Remove tool and intermediate assistant messages before the boundary.
1281    // An "intermediate assistant" is one whose content looks like a tool
1282    // call (contains `<tool_call>` or starts with `{\"tool_call`).
1283    let mut i = 0;
1284    while i < protect_from && i < turns.len() {
1285        let dominated = turns[i].role == "tool"
1286            || (turns[i].role == "assistant" && is_tool_call_content(&turns[i].content));
1287        if dominated {
1288            turns.remove(i);
1289            // Adjust boundary since we removed an element.
1290            protect_from = protect_from.saturating_sub(1);
1291        } else {
1292            i += 1;
1293        }
1294    }
1295}
1296
1297/// Heuristic: does this assistant message content represent a tool call
1298/// rather than a final text response?
1299fn is_tool_call_content(content: &str) -> bool {
1300    let trimmed = content.trim();
1301    trimmed.contains("<tool_call>")
1302        || trimmed.starts_with("{\"tool_call\"")
1303        || trimmed.starts_with("{\"name\"")
1304}
1305
1306fn rollback_orphan_user_turn(
1307    ctx: &ChannelRuntimeContext,
1308    sender_key: &str,
1309    expected_content: &str,
1310) -> bool {
1311    let mut histories = ctx
1312        .conversation_histories
1313        .lock()
1314        .unwrap_or_else(|e| e.into_inner());
1315    let Some(turns) = histories.get_mut(sender_key) else {
1316        return false;
1317    };
1318
1319    let should_pop = turns
1320        .last()
1321        .is_some_and(|turn| turn.role == "user" && turn.content == expected_content);
1322    if !should_pop {
1323        return false;
1324    }
1325
1326    turns.pop();
1327    if turns.is_empty() {
1328        histories.remove(sender_key);
1329    }
1330
1331    // Also remove the orphan turn from the persisted JSONL session store so
1332    // it doesn't resurface after a daemon restart (fixes #3674).
1333    if let Some(ref store) = ctx.session_store {
1334        if let Err(e) = store.remove_last(sender_key) {
1335            tracing::warn!("Failed to rollback session store entry: {e}");
1336        }
1337    }
1338
1339    true
1340}
1341
1342fn should_rollback_failed_user_turn(error: &anyhow::Error) -> bool {
1343    if error
1344        .downcast_ref::<providers::ProviderCapabilityError>()
1345        .is_some_and(|capability| capability.capability.eq_ignore_ascii_case("vision"))
1346    {
1347        return true;
1348    }
1349
1350    crate::providers::reliable::is_non_retryable(error)
1351}
1352
1353fn should_skip_memory_context_entry(key: &str, content: &str) -> bool {
1354    if memory::is_assistant_autosave_key(key) {
1355        return true;
1356    }
1357
1358    if memory::should_skip_autosave_content(content) {
1359        return true;
1360    }
1361
1362    if key.trim().to_ascii_lowercase().ends_with("_history") {
1363        return true;
1364    }
1365
1366    // Skip entries containing image markers to prevent duplication.
1367    // When auto_save stores a photo message to memory, a subsequent
1368    // memory recall on the same turn would surface the marker again,
1369    // causing two identical image blocks in the provider request.
1370    if content.contains("[IMAGE:") {
1371        return true;
1372    }
1373
1374    // Skip entries containing tool_result blocks. After a daemon restart
1375    // these can be recalled from SQLite and injected as memory context,
1376    // presenting the LLM with a `<tool_result>` without a preceding
1377    // `<tool_call>` and triggering hallucinated output.
1378    if content.contains("<tool_result") {
1379        return true;
1380    }
1381
1382    content.chars().count() > MEMORY_CONTEXT_MAX_CHARS
1383}
1384
1385fn is_context_window_overflow_error(err: &anyhow::Error) -> bool {
1386    let lower = err.to_string().to_lowercase();
1387    [
1388        "exceeds the context window",
1389        "context window of this model",
1390        "maximum context length",
1391        "context length exceeded",
1392        "too many tokens",
1393        "token limit exceeded",
1394        "prompt is too long",
1395        "input is too long",
1396    ]
1397    .iter()
1398    .any(|hint| lower.contains(hint))
1399}
1400
1401fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec<String> {
1402    let cache_path = workspace_dir.join("state").join(MODEL_CACHE_FILE);
1403    let Ok(raw) = std::fs::read_to_string(cache_path) else {
1404        return Vec::new();
1405    };
1406    let Ok(state) = serde_json::from_str::<ModelCacheState>(&raw) else {
1407        return Vec::new();
1408    };
1409
1410    state
1411        .entries
1412        .into_iter()
1413        .find(|entry| entry.provider == provider_name)
1414        .map(|entry| {
1415            entry
1416                .models
1417                .into_iter()
1418                .take(MODEL_CACHE_PREVIEW_LIMIT)
1419                .collect::<Vec<_>>()
1420        })
1421        .unwrap_or_default()
1422}
1423
1424/// Build a cache key that includes the provider name and, when a
1425/// route-specific API key is supplied, a hash of that key. This prevents
1426/// cache poisoning when multiple routes target the same provider with
1427/// different credentials.
1428fn provider_cache_key(provider_name: &str, route_api_key: Option<&str>) -> String {
1429    match route_api_key {
1430        Some(key) => {
1431            use std::hash::{Hash, Hasher};
1432            let mut hasher = std::collections::hash_map::DefaultHasher::new();
1433            key.hash(&mut hasher);
1434            format!("{provider_name}@{:x}", hasher.finish())
1435        }
1436        None => provider_name.to_string(),
1437    }
1438}
1439
1440async fn get_or_create_provider(
1441    ctx: &ChannelRuntimeContext,
1442    provider_name: &str,
1443    route_api_key: Option<&str>,
1444) -> anyhow::Result<Arc<dyn Provider>> {
1445    let cache_key = provider_cache_key(provider_name, route_api_key);
1446
1447    if let Some(existing) = ctx
1448        .provider_cache
1449        .lock()
1450        .unwrap_or_else(|e| e.into_inner())
1451        .get(&cache_key)
1452        .cloned()
1453    {
1454        return Ok(existing);
1455    }
1456
1457    // Only return the pre-built default provider when there is no
1458    // route-specific credential override — otherwise the default was
1459    // created with the global key and would be wrong.
1460    if route_api_key.is_none() && provider_name == ctx.default_provider.as_str() {
1461        return Ok(Arc::clone(&ctx.provider));
1462    }
1463
1464    let defaults = runtime_defaults_snapshot(ctx);
1465    let api_url = if provider_name == defaults.default_provider.as_str() {
1466        defaults.api_url.as_deref()
1467    } else {
1468        None
1469    };
1470
1471    // Prefer route-specific credential; fall back to the global key.
1472    let effective_api_key = route_api_key
1473        .map(ToString::to_string)
1474        .or_else(|| ctx.api_key.clone());
1475
1476    let provider = create_resilient_provider_nonblocking(
1477        provider_name,
1478        effective_api_key,
1479        api_url.map(ToString::to_string),
1480        ctx.reliability.as_ref().clone(),
1481        ctx.provider_runtime_options.clone(),
1482    )
1483    .await?;
1484    let provider: Arc<dyn Provider> = Arc::from(provider);
1485
1486    if let Err(err) = provider.warmup().await {
1487        tracing::warn!(provider = provider_name, "Provider warmup failed: {err}");
1488    }
1489
1490    let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
1491    let cached = cache
1492        .entry(cache_key)
1493        .or_insert_with(|| Arc::clone(&provider));
1494    Ok(Arc::clone(cached))
1495}
1496
1497async fn create_resilient_provider_nonblocking(
1498    provider_name: &str,
1499    api_key: Option<String>,
1500    api_url: Option<String>,
1501    reliability: crate::config::ReliabilityConfig,
1502    provider_runtime_options: providers::ProviderRuntimeOptions,
1503) -> anyhow::Result<Box<dyn Provider>> {
1504    let provider_name = provider_name.to_string();
1505    tokio::task::spawn_blocking(move || {
1506        providers::create_resilient_provider_with_options(
1507            &provider_name,
1508            api_key.as_deref(),
1509            api_url.as_deref(),
1510            &reliability,
1511            &provider_runtime_options,
1512        )
1513    })
1514    .await
1515    .context("failed to join provider initialization task")?
1516}
1517
1518fn build_models_help_response(
1519    current: &ChannelRouteSelection,
1520    workspace_dir: &Path,
1521    model_routes: &[crate::config::ModelRouteConfig],
1522) -> String {
1523    let mut response = String::new();
1524    let _ = writeln!(
1525        response,
1526        "Current provider: `{}`\nCurrent model: `{}`",
1527        current.provider, current.model
1528    );
1529    response.push_str("\nSwitch model with `/model <model-id>` or `/model <hint>`.\n");
1530
1531    if !model_routes.is_empty() {
1532        response.push_str("\nConfigured model routes:\n");
1533        for route in model_routes {
1534            let _ = writeln!(
1535                response,
1536                "  `{}` → {} ({})",
1537                route.hint, route.model, route.provider
1538            );
1539        }
1540    }
1541
1542    let cached_models = load_cached_model_preview(workspace_dir, &current.provider);
1543    if cached_models.is_empty() {
1544        let _ = writeln!(
1545            response,
1546            "\nNo cached model list found for `{}`. Ask the operator to run `construct models refresh --provider {}`.",
1547            current.provider, current.provider
1548        );
1549    } else {
1550        let _ = writeln!(
1551            response,
1552            "\nCached model IDs (top {}):",
1553            cached_models.len()
1554        );
1555        for model in cached_models {
1556            let _ = writeln!(response, "- `{model}`");
1557        }
1558    }
1559
1560    response
1561}
1562
1563fn build_providers_help_response(current: &ChannelRouteSelection) -> String {
1564    let mut response = String::new();
1565    let _ = writeln!(
1566        response,
1567        "Current provider: `{}`\nCurrent model: `{}`",
1568        current.provider, current.model
1569    );
1570    response.push_str("\nSwitch provider with `/models <provider>`.\n");
1571    response.push_str("Switch model with `/model <model-id>`.\n\n");
1572    response.push_str("Available providers:\n");
1573    for provider in providers::list_providers() {
1574        if provider.aliases.is_empty() {
1575            let _ = writeln!(response, "- {}", provider.name);
1576        } else {
1577            let _ = writeln!(
1578                response,
1579                "- {} (aliases: {})",
1580                provider.name,
1581                provider.aliases.join(", ")
1582            );
1583        }
1584    }
1585    response
1586}
1587
1588/// Build a plain-text `/config` response for non-Slack channels.
1589fn build_config_text_response(
1590    current: &ChannelRouteSelection,
1591    _workspace_dir: &Path,
1592    model_routes: &[crate::config::ModelRouteConfig],
1593) -> String {
1594    let mut resp = String::new();
1595    let _ = writeln!(
1596        resp,
1597        "Current provider: `{}`\nCurrent model: `{}`",
1598        current.provider, current.model
1599    );
1600    resp.push_str("\nAvailable providers:\n");
1601    for p in providers::list_providers() {
1602        let _ = writeln!(resp, "- `{}`", p.name);
1603    }
1604    if !model_routes.is_empty() {
1605        resp.push_str("\nConfigured model routes:\n");
1606        for route in model_routes {
1607            let _ = writeln!(
1608                resp,
1609                "  `{}` -> {} ({})",
1610                route.hint, route.model, route.provider
1611            );
1612        }
1613    }
1614    resp.push_str(
1615        "\nUse `/models <provider>` to switch provider.\nUse `/model <model-id>` to switch model.",
1616    );
1617    resp
1618}
1619
1620/// Prefix used to signal that a runtime command response contains raw Block Kit
1621/// JSON instead of plain text. [`SlackChannel::send`] detects this and posts
1622/// the blocks directly via `chat.postMessage`.
1623const BLOCK_KIT_PREFIX: &str = "__CONSTRUCT_BLOCK_KIT__";
1624
1625/// Build a Slack Block Kit JSON payload for the `/config` interactive UI.
1626fn build_config_block_kit(
1627    current: &ChannelRouteSelection,
1628    workspace_dir: &Path,
1629    model_routes: &[crate::config::ModelRouteConfig],
1630) -> String {
1631    let provider_options: Vec<serde_json::Value> = providers::list_providers()
1632        .iter()
1633        .map(|p| {
1634            serde_json::json!({
1635                "text": { "type": "plain_text", "text": p.display_name },
1636                "value": p.name
1637            })
1638        })
1639        .collect();
1640
1641    // Build model options from model_routes + cached models.
1642    let mut model_options: Vec<serde_json::Value> = model_routes
1643        .iter()
1644        .map(|r| {
1645            let label = if r.hint.is_empty() {
1646                r.model.clone()
1647            } else {
1648                format!("{} ({})", r.model, r.hint)
1649            };
1650            serde_json::json!({
1651                "text": { "type": "plain_text", "text": label },
1652                "value": r.model
1653            })
1654        })
1655        .collect();
1656
1657    let cached = load_cached_model_preview(workspace_dir, &current.provider);
1658    for model_id in cached {
1659        if !model_options.iter().any(|o| {
1660            o.get("value")
1661                .and_then(|v| v.as_str())
1662                .is_some_and(|v| v == model_id)
1663        }) {
1664            model_options.push(serde_json::json!({
1665                "text": { "type": "plain_text", "text": model_id },
1666                "value": model_id
1667            }));
1668        }
1669    }
1670
1671    // If the current model is not in the list, prepend it.
1672    if !model_options.iter().any(|o| {
1673        o.get("value")
1674            .and_then(|v| v.as_str())
1675            .is_some_and(|v| v == current.model)
1676    }) {
1677        model_options.insert(
1678            0,
1679            serde_json::json!({
1680                "text": { "type": "plain_text", "text": &current.model },
1681                "value": &current.model
1682            }),
1683        );
1684    }
1685
1686    // Find initial options matching current selection.
1687    let initial_provider = provider_options
1688        .iter()
1689        .find(|o| {
1690            o.get("value")
1691                .and_then(|v| v.as_str())
1692                .is_some_and(|v| v == current.provider)
1693        })
1694        .cloned();
1695
1696    let initial_model = model_options
1697        .iter()
1698        .find(|o| {
1699            o.get("value")
1700                .and_then(|v| v.as_str())
1701                .is_some_and(|v| v == current.model)
1702        })
1703        .cloned();
1704
1705    let mut provider_select = serde_json::json!({
1706        "type": "static_select",
1707        "action_id": "construct_config_provider",
1708        "placeholder": { "type": "plain_text", "text": "Select provider" },
1709        "options": provider_options
1710    });
1711    if let Some(init) = initial_provider {
1712        provider_select["initial_option"] = init;
1713    }
1714
1715    let mut model_select = serde_json::json!({
1716        "type": "static_select",
1717        "action_id": "construct_config_model",
1718        "placeholder": { "type": "plain_text", "text": "Select model" },
1719        "options": model_options
1720    });
1721    if let Some(init) = initial_model {
1722        model_select["initial_option"] = init;
1723    }
1724
1725    let blocks = serde_json::json!([
1726        {
1727            "type": "section",
1728            "text": {
1729                "type": "mrkdwn",
1730                "text": format!(
1731                    "*Model Configuration*\nCurrent: `{}` / `{}`",
1732                    current.provider, current.model
1733                )
1734            }
1735        },
1736        {
1737            "type": "section",
1738            "block_id": "config_provider_block",
1739            "text": { "type": "mrkdwn", "text": "*Provider*" },
1740            "accessory": provider_select
1741        },
1742        {
1743            "type": "section",
1744            "block_id": "config_model_block",
1745            "text": { "type": "mrkdwn", "text": "*Model*" },
1746            "accessory": model_select
1747        }
1748    ]);
1749
1750    blocks.to_string()
1751}
1752
1753async fn handle_runtime_command_if_needed(
1754    ctx: &ChannelRuntimeContext,
1755    msg: &traits::ChannelMessage,
1756    target_channel: Option<&Arc<dyn Channel>>,
1757) -> bool {
1758    let Some(command) = parse_runtime_command(&msg.channel, &msg.content) else {
1759        return false;
1760    };
1761
1762    let Some(channel) = target_channel else {
1763        return true;
1764    };
1765
1766    let sender_key = conversation_history_key(msg);
1767    let mut current = get_route_selection(ctx, &sender_key);
1768
1769    let response = match command {
1770        ChannelRuntimeCommand::ShowProviders => build_providers_help_response(&current),
1771        ChannelRuntimeCommand::SetProvider(raw_provider) => {
1772            match resolve_provider_alias(&raw_provider) {
1773                Some(provider_name) => {
1774                    match get_or_create_provider(ctx, &provider_name, None).await {
1775                        Ok(_) => {
1776                            if provider_name != current.provider {
1777                                current.provider = provider_name.clone();
1778                                set_route_selection(ctx, &sender_key, current.clone());
1779                            }
1780
1781                            format!(
1782                                "Provider switched to `{provider_name}` for this sender session. Current model is `{}`.\nUse `/model <model-id>` to set a provider-compatible model.",
1783                                current.model
1784                            )
1785                        }
1786                        Err(err) => {
1787                            let safe_err = providers::sanitize_api_error(&err.to_string());
1788                            format!(
1789                                "Failed to initialize provider `{provider_name}`. Route unchanged.\nDetails: {safe_err}"
1790                            )
1791                        }
1792                    }
1793                }
1794                None => format!(
1795                    "Unknown provider `{raw_provider}`. Use `/models` to list valid providers."
1796                ),
1797            }
1798        }
1799        ChannelRuntimeCommand::ShowModel => {
1800            build_models_help_response(&current, ctx.workspace_dir.as_path(), &ctx.model_routes)
1801        }
1802        ChannelRuntimeCommand::SetModel(raw_model) => {
1803            let model = raw_model.trim().trim_matches('`').to_string();
1804            if model.is_empty() {
1805                "Model ID cannot be empty. Use `/model <model-id>`.".to_string()
1806            } else {
1807                // Resolve provider+model from model_routes (match by model name or hint)
1808                if let Some(route) = ctx.model_routes.iter().find(|r| {
1809                    r.model.eq_ignore_ascii_case(&model) || r.hint.eq_ignore_ascii_case(&model)
1810                }) {
1811                    current.provider = route.provider.clone();
1812                    current.model = route.model.clone();
1813                    current.api_key = route.api_key.clone();
1814                } else {
1815                    current.model = model.clone();
1816                }
1817                set_route_selection(ctx, &sender_key, current.clone());
1818
1819                format!(
1820                    "Model switched to `{}` (provider: `{}`). Context preserved.",
1821                    current.model, current.provider
1822                )
1823            }
1824        }
1825        ChannelRuntimeCommand::ShowConfig => {
1826            if msg.channel == "slack" {
1827                let blocks_json = build_config_block_kit(
1828                    &current,
1829                    ctx.workspace_dir.as_path(),
1830                    &ctx.model_routes,
1831                );
1832                // Use a magic prefix so SlackChannel::send() can detect Block Kit JSON.
1833                format!("__CONSTRUCT_BLOCK_KIT__{blocks_json}")
1834            } else {
1835                build_config_text_response(&current, ctx.workspace_dir.as_path(), &ctx.model_routes)
1836            }
1837        }
1838        ChannelRuntimeCommand::NewSession => {
1839            clear_sender_history(ctx, &sender_key);
1840            if let Some(ref store) = ctx.session_store {
1841                if let Err(e) = store.delete_session(&sender_key) {
1842                    tracing::warn!("Failed to delete persisted session for {sender_key}: {e}");
1843                }
1844            }
1845            mark_sender_for_new_session(ctx, &sender_key);
1846            "Conversation history cleared. Starting fresh.".to_string()
1847        }
1848    };
1849
1850    if let Err(err) = channel
1851        .send(&SendMessage::new(response, &msg.reply_target).in_thread(msg.thread_ts.clone()))
1852        .await
1853    {
1854        tracing::warn!(
1855            "Failed to send runtime command response on {}: {err}",
1856            channel.name()
1857        );
1858    }
1859
1860    true
1861}
1862
1863/// Call `kumiho_memory_engage` via MCP before the LLM turn.
1864///
1865/// Returns a formatted memory context string to inject into the system prompt,
1866/// or an empty string if the call fails or returns no results.
1867///
1868/// MCP tools return `{"content": [{"type": "text", "text": "<json>"}]}`.
1869/// The inner `text` field contains the actual kumiho response JSON with
1870/// `context`, `results`, and `source_krefs` fields.
1871/// Maximum characters of kumiho engage context to inject into the system
1872
1873async fn kumiho_engage_for_channel(
1874    registry: &crate::tools::McpRegistry,
1875    user_msg: &str,
1876    memory_project: &str,
1877) -> String {
1878    let args = serde_json::json!({
1879        "query": user_msg,
1880        "space_paths": [memory_project],
1881        "recall_mode": "summarized",
1882        "limit": 5,
1883    });
1884    match registry
1885        .call_tool("kumiho-memory__kumiho_memory_engage", args)
1886        .await
1887    {
1888        Ok(raw) => {
1889            // MCP result is JSON: {"content": [{"type": "text", "text": "..."}]}
1890            // First unwrap the MCP content envelope to get the inner text.
1891            let inner_text = extract_mcp_text(&raw);
1892            tracing::debug!(
1893                raw_len = raw.len(),
1894                inner_len = inner_text.len(),
1895                "Kumiho engage raw response"
1896            );
1897
1898            if inner_text.is_empty() {
1899                tracing::info!("Kumiho engage returned empty response");
1900                return String::new();
1901            }
1902
1903            // The inner text is the kumiho response — could be JSON or plain text.
1904            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&inner_text) {
1905                // Try `context` field first (formatted summary from engage).
1906                if let Some(ctx_text) = parsed.get("context").and_then(|v| v.as_str()) {
1907                    if !ctx_text.is_empty() {
1908                        tracing::info!(
1909                            context_chars = ctx_text.len(),
1910                            "Kumiho engage returned context"
1911                        );
1912                        return format!("[Memory context (Kumiho)]\n{ctx_text}");
1913                    }
1914                }
1915                // Fall back to `results` array.
1916                if let Some(results) = parsed.get("results").and_then(|v| v.as_array()) {
1917                    if !results.is_empty() {
1918                        let mut buf = String::from("[Memory context (Kumiho)]\n");
1919                        for r in results.iter().take(10) {
1920                            if let Some(content) = r.get("content").and_then(|v| v.as_str()) {
1921                                let title =
1922                                    r.get("title").and_then(|v| v.as_str()).unwrap_or("memory");
1923                                buf.push_str(&format!("- {title}: {content}\n"));
1924                            }
1925                        }
1926                        if buf.len() > "[Memory context (Kumiho)]\n".len() {
1927                            tracing::info!(
1928                                results_count = results.len(),
1929                                "Kumiho engage returned results"
1930                            );
1931                            return buf;
1932                        }
1933                    }
1934                }
1935            }
1936
1937            // If not valid JSON, treat the whole inner text as context.
1938            if !inner_text.is_empty() {
1939                tracing::info!(
1940                    text_len = inner_text.len(),
1941                    "Kumiho engage returned plain text"
1942                );
1943                return format!("[Memory context (Kumiho)]\n{inner_text}");
1944            }
1945
1946            String::new()
1947        }
1948        Err(e) => {
1949            tracing::warn!("Kumiho engage failed: {e:#}");
1950            String::new()
1951        }
1952    }
1953}
1954
1955/// Extract the text payload from an MCP tool result envelope.
1956///
1957/// MCP results are shaped as:
1958/// ```json
1959/// {"content": [{"type": "text", "text": "...actual content..."}]}
1960/// ```
1961/// This function returns the inner `text` string, or falls back to
1962/// treating the raw input as direct content.
1963fn extract_mcp_text(raw: &str) -> String {
1964    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw) {
1965        // Standard MCP content array
1966        if let Some(content_arr) = parsed.get("content").and_then(|v| v.as_array()) {
1967            let mut texts = Vec::new();
1968            for item in content_arr {
1969                if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
1970                    texts.push(text.to_string());
1971                }
1972            }
1973            if !texts.is_empty() {
1974                return texts.join("\n");
1975            }
1976        }
1977        // Maybe it's already a direct response (no content wrapper)
1978        if parsed.get("context").and_then(|v| v.as_str()).is_some() {
1979            return serde_json::to_string(&parsed).unwrap_or_default();
1980        }
1981        if parsed.get("results").is_some() {
1982            return serde_json::to_string(&parsed).unwrap_or_default();
1983        }
1984    }
1985    // Last resort: return raw
1986    raw.to_string()
1987}
1988
1989async fn build_memory_context(
1990    mem: &dyn Memory,
1991    user_msg: &str,
1992    min_relevance_score: f64,
1993    session_id: Option<&str>,
1994) -> String {
1995    let mut context = String::new();
1996
1997    if let Ok(entries) = mem.recall(user_msg, 5, session_id, None, None).await {
1998        let mut included = 0usize;
1999        let mut used_chars = 0usize;
2000
2001        for entry in entries.iter().filter(|e| match e.score {
2002            Some(score) => score >= min_relevance_score,
2003            None => true, // keep entries without a score (e.g. non-vector backends)
2004        }) {
2005            if included >= MEMORY_CONTEXT_MAX_ENTRIES {
2006                break;
2007            }
2008
2009            if should_skip_memory_context_entry(&entry.key, &entry.content) {
2010                continue;
2011            }
2012
2013            let content = if entry.content.chars().count() > MEMORY_CONTEXT_ENTRY_MAX_CHARS {
2014                truncate_with_ellipsis(&entry.content, MEMORY_CONTEXT_ENTRY_MAX_CHARS)
2015            } else {
2016                entry.content.clone()
2017            };
2018
2019            let line = format!("- {}: {}\n", entry.key, content);
2020            let line_chars = line.chars().count();
2021            if used_chars + line_chars > MEMORY_CONTEXT_MAX_CHARS {
2022                break;
2023            }
2024
2025            if included == 0 {
2026                context.push_str("[Memory context]\n");
2027            }
2028
2029            context.push_str(&line);
2030            used_chars += line_chars;
2031            included += 1;
2032        }
2033
2034        if included > 0 {
2035            context.push_str("[/Memory context]\n\n");
2036        }
2037    }
2038
2039    context
2040}
2041
2042/// Extract a compact summary of tool interactions from history messages added
2043/// during `run_tool_call_loop`. Scans assistant messages for `<tool_call>` tags
2044/// or native tool-call JSON to collect tool names used.
2045/// Returns an empty string when no tools were invoked.
2046#[cfg(test)]
2047fn extract_tool_context_summary(history: &[ChatMessage], start_index: usize) -> String {
2048    fn push_unique_tool_name(tool_names: &mut Vec<String>, name: &str) {
2049        let candidate = name.trim();
2050        if candidate.is_empty() {
2051            return;
2052        }
2053        if !tool_names.iter().any(|existing| existing == candidate) {
2054            tool_names.push(candidate.to_string());
2055        }
2056    }
2057
2058    fn collect_tool_names_from_tool_call_tags(content: &str, tool_names: &mut Vec<String>) {
2059        const TAG_PAIRS: [(&str, &str); 4] = [
2060            ("<tool_call>", "</tool_call>"),
2061            ("<toolcall>", "</toolcall>"),
2062            ("<tool-call>", "</tool-call>"),
2063            ("<invoke>", "</invoke>"),
2064        ];
2065
2066        for (open_tag, close_tag) in TAG_PAIRS {
2067            for segment in content.split(open_tag) {
2068                if let Some(json_end) = segment.find(close_tag) {
2069                    let json_str = segment[..json_end].trim();
2070                    if let Ok(val) = serde_json::from_str::<serde_json::Value>(json_str) {
2071                        if let Some(name) = val.get("name").and_then(|n| n.as_str()) {
2072                            push_unique_tool_name(tool_names, name);
2073                        }
2074                    }
2075                }
2076            }
2077        }
2078    }
2079
2080    fn collect_tool_names_from_native_json(content: &str, tool_names: &mut Vec<String>) {
2081        if let Ok(val) = serde_json::from_str::<serde_json::Value>(content) {
2082            if let Some(calls) = val.get("tool_calls").and_then(|c| c.as_array()) {
2083                for call in calls {
2084                    let name = call
2085                        .get("function")
2086                        .and_then(|f| f.get("name"))
2087                        .and_then(|n| n.as_str())
2088                        .or_else(|| call.get("name").and_then(|n| n.as_str()));
2089                    if let Some(name) = name {
2090                        push_unique_tool_name(tool_names, name);
2091                    }
2092                }
2093            }
2094        }
2095    }
2096
2097    fn collect_tool_names_from_tool_results(content: &str, tool_names: &mut Vec<String>) {
2098        let marker = "<tool_result name=\"";
2099        let mut remaining = content;
2100        while let Some(start) = remaining.find(marker) {
2101            let name_start = start + marker.len();
2102            let after_name_start = &remaining[name_start..];
2103            if let Some(name_end) = after_name_start.find('"') {
2104                let name = &after_name_start[..name_end];
2105                push_unique_tool_name(tool_names, name);
2106                remaining = &after_name_start[name_end + 1..];
2107            } else {
2108                break;
2109            }
2110        }
2111    }
2112
2113    let mut tool_names: Vec<String> = Vec::new();
2114
2115    for msg in history.iter().skip(start_index) {
2116        match msg.role.as_str() {
2117            "assistant" => {
2118                collect_tool_names_from_tool_call_tags(&msg.content, &mut tool_names);
2119                collect_tool_names_from_native_json(&msg.content, &mut tool_names);
2120            }
2121            "user" => {
2122                // Prompt-mode tool calls are always followed by [Tool results] entries
2123                // containing `<tool_result name="...">` tags with canonical tool names.
2124                collect_tool_names_from_tool_results(&msg.content, &mut tool_names);
2125            }
2126            _ => {}
2127        }
2128    }
2129
2130    if tool_names.is_empty() {
2131        return String::new();
2132    }
2133
2134    format!("[Used tools: {}]", tool_names.join(", "))
2135}
2136
2137fn sanitize_channel_response(response: &str, tools: &[Box<dyn Tool>]) -> String {
2138    let known_tool_names: HashSet<String> = tools
2139        .iter()
2140        .map(|tool| tool.name().to_ascii_lowercase())
2141        .collect();
2142    // Strip any [Used tools: ...] prefix that the LLM may have echoed from
2143    // history context (#4400). Trim first to handle leading/trailing whitespace.
2144    let trimmed_response = response.trim();
2145    let stripped_summary = strip_tool_summary_prefix(trimmed_response);
2146    // Strip XML-style tool-call tags (e.g. <tool_call>...</tool_call>)
2147    let stripped_xml = strip_tool_call_tags(&stripped_summary);
2148    // Strip isolated tool-call JSON artifacts
2149    let stripped_json = strip_isolated_tool_json_artifacts(&stripped_xml, &known_tool_names);
2150    // Strip leading narration lines that announce tool usage
2151    let sanitized = strip_tool_narration(&stripped_json);
2152
2153    // Scan for credential leaks before returning to caller
2154    match crate::security::LeakDetector::new().scan(&sanitized) {
2155        crate::security::LeakResult::Clean => sanitized,
2156        crate::security::LeakResult::Detected { patterns, redacted } => {
2157            tracing::warn!(
2158                patterns = ?patterns,
2159                "output guardrail: credential leak detected in outbound channel response"
2160            );
2161            redacted
2162        }
2163    }
2164}
2165
2166/// Remove leading lines that narrate tool usage (e.g. "Let me check the weather for you.").
2167///
2168/// Only strips lines from the very beginning of the message that match common
2169/// narration patterns, so genuine content is preserved.
2170fn strip_tool_narration(message: &str) -> String {
2171    let narration_prefixes: &[&str] = &[
2172        "let me ",
2173        "i'll ",
2174        "i will ",
2175        "i am going to ",
2176        "i'm going to ",
2177        "searching ",
2178        "looking up ",
2179        "fetching ",
2180        "checking ",
2181        "using the ",
2182        "using my ",
2183        "one moment",
2184        "hold on",
2185        "just a moment",
2186        "give me a moment",
2187        "allow me to ",
2188    ];
2189
2190    let mut result_lines: Vec<&str> = Vec::new();
2191    let mut past_narration = false;
2192
2193    for line in message.lines() {
2194        if past_narration {
2195            result_lines.push(line);
2196            continue;
2197        }
2198        let trimmed = line.trim();
2199        if trimmed.is_empty() {
2200            continue;
2201        }
2202        let lower = trimmed.to_lowercase();
2203        if narration_prefixes.iter().any(|p| lower.starts_with(p)) {
2204            // Skip this narration line
2205            continue;
2206        }
2207        // First non-narration, non-empty line — keep everything from here
2208        past_narration = true;
2209        result_lines.push(line);
2210    }
2211
2212    let joined = result_lines.join("\n");
2213    let trimmed = joined.trim();
2214    if trimmed.is_empty() && !message.trim().is_empty() {
2215        // If stripping removed everything, return original to avoid empty reply
2216        message.to_string()
2217    } else {
2218        trimmed.to_string()
2219    }
2220}
2221
2222fn is_tool_call_payload(value: &serde_json::Value, known_tool_names: &HashSet<String>) -> bool {
2223    let Some(object) = value.as_object() else {
2224        return false;
2225    };
2226
2227    let (name, has_args) =
2228        if let Some(function) = object.get("function").and_then(|f| f.as_object()) {
2229            (
2230                function
2231                    .get("name")
2232                    .and_then(|v| v.as_str())
2233                    .or_else(|| object.get("name").and_then(|v| v.as_str())),
2234                function.contains_key("arguments")
2235                    || function.contains_key("parameters")
2236                    || object.contains_key("arguments")
2237                    || object.contains_key("parameters"),
2238            )
2239        } else {
2240            (
2241                object.get("name").and_then(|v| v.as_str()),
2242                object.contains_key("arguments") || object.contains_key("parameters"),
2243            )
2244        };
2245
2246    let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else {
2247        return false;
2248    };
2249
2250    has_args && known_tool_names.contains(&name.to_ascii_lowercase())
2251}
2252
2253fn is_tool_result_payload(
2254    object: &serde_json::Map<String, serde_json::Value>,
2255    saw_tool_call_payload: bool,
2256) -> bool {
2257    if !saw_tool_call_payload || !object.contains_key("result") {
2258        return false;
2259    }
2260
2261    object.keys().all(|key| {
2262        matches!(
2263            key.as_str(),
2264            "result" | "id" | "tool_call_id" | "name" | "tool"
2265        )
2266    })
2267}
2268
2269fn sanitize_tool_json_value(
2270    value: &serde_json::Value,
2271    known_tool_names: &HashSet<String>,
2272    saw_tool_call_payload: bool,
2273) -> Option<(String, bool)> {
2274    if is_tool_call_payload(value, known_tool_names) {
2275        return Some((String::new(), true));
2276    }
2277
2278    if let Some(array) = value.as_array() {
2279        if !array.is_empty()
2280            && array
2281                .iter()
2282                .all(|item| is_tool_call_payload(item, known_tool_names))
2283        {
2284            return Some((String::new(), true));
2285        }
2286        return None;
2287    }
2288
2289    let object = value.as_object()?;
2290
2291    if let Some(tool_calls) = object.get("tool_calls").and_then(|value| value.as_array()) {
2292        if !tool_calls.is_empty()
2293            && tool_calls
2294                .iter()
2295                .all(|call| is_tool_call_payload(call, known_tool_names))
2296        {
2297            let content = object
2298                .get("content")
2299                .and_then(|value| value.as_str())
2300                .unwrap_or("")
2301                .trim()
2302                .to_string();
2303            return Some((content, true));
2304        }
2305    }
2306
2307    if is_tool_result_payload(object, saw_tool_call_payload) {
2308        return Some((String::new(), false));
2309    }
2310
2311    None
2312}
2313
2314fn is_line_isolated_json_segment(message: &str, start: usize, end: usize) -> bool {
2315    let line_start = message[..start].rfind('\n').map_or(0, |idx| idx + 1);
2316    let line_end = message[end..]
2317        .find('\n')
2318        .map_or(message.len(), |idx| end + idx);
2319
2320    message[line_start..start].trim().is_empty() && message[end..line_end].trim().is_empty()
2321}
2322
2323fn strip_isolated_tool_json_artifacts(message: &str, known_tool_names: &HashSet<String>) -> String {
2324    let mut cleaned = String::with_capacity(message.len());
2325    let mut cursor = 0usize;
2326    let mut saw_tool_call_payload = false;
2327
2328    while cursor < message.len() {
2329        let Some(rel_start) = message[cursor..].find(['{', '[']) else {
2330            cleaned.push_str(&message[cursor..]);
2331            break;
2332        };
2333
2334        let start = cursor + rel_start;
2335        cleaned.push_str(&message[cursor..start]);
2336
2337        let candidate = &message[start..];
2338        let mut stream =
2339            serde_json::Deserializer::from_str(candidate).into_iter::<serde_json::Value>();
2340
2341        if let Some(Ok(value)) = stream.next() {
2342            let consumed = stream.byte_offset();
2343            if consumed > 0 {
2344                let end = start + consumed;
2345                if is_line_isolated_json_segment(message, start, end) {
2346                    if let Some((replacement, marks_tool_call)) =
2347                        sanitize_tool_json_value(&value, known_tool_names, saw_tool_call_payload)
2348                    {
2349                        if marks_tool_call {
2350                            saw_tool_call_payload = true;
2351                        }
2352                        if !replacement.trim().is_empty() {
2353                            cleaned.push_str(replacement.trim());
2354                        }
2355                        cursor = end;
2356                        continue;
2357                    }
2358                }
2359            }
2360        }
2361
2362        let Some(ch) = message[start..].chars().next() else {
2363            break;
2364        };
2365        cleaned.push(ch);
2366        cursor = start + ch.len_utf8();
2367    }
2368
2369    let mut result = cleaned.replace("\r\n", "\n");
2370    while result.contains("\n\n\n") {
2371        result = result.replace("\n\n\n", "\n\n");
2372    }
2373    result.trim().to_string()
2374}
2375
2376fn spawn_supervised_listener(
2377    ch: Arc<dyn Channel>,
2378    tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
2379    initial_backoff_secs: u64,
2380    max_backoff_secs: u64,
2381) -> tokio::task::JoinHandle<()> {
2382    spawn_supervised_listener_with_health_interval(
2383        ch,
2384        tx,
2385        initial_backoff_secs,
2386        max_backoff_secs,
2387        Duration::from_secs(CHANNEL_HEALTH_HEARTBEAT_SECS),
2388    )
2389}
2390
2391fn spawn_supervised_listener_with_health_interval(
2392    ch: Arc<dyn Channel>,
2393    tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
2394    initial_backoff_secs: u64,
2395    max_backoff_secs: u64,
2396    health_interval: Duration,
2397) -> tokio::task::JoinHandle<()> {
2398    let health_interval = if health_interval.is_zero() {
2399        Duration::from_secs(1)
2400    } else {
2401        health_interval
2402    };
2403
2404    tokio::spawn(async move {
2405        let component = format!("channel:{}", ch.name());
2406        let mut backoff = initial_backoff_secs.max(1);
2407        let max_backoff = max_backoff_secs.max(backoff);
2408
2409        loop {
2410            crate::health::mark_component_ok(&component);
2411            let mut health = tokio::time::interval(health_interval);
2412            health.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2413            let result = {
2414                let listen_future = ch.listen(tx.clone());
2415                tokio::pin!(listen_future);
2416
2417                loop {
2418                    tokio::select! {
2419                        _ = health.tick() => {
2420                            crate::health::mark_component_ok(&component);
2421                        }
2422                        result = &mut listen_future => break result,
2423                    }
2424                }
2425            };
2426
2427            if tx.is_closed() {
2428                break;
2429            }
2430
2431            match result {
2432                Ok(()) => {
2433                    tracing::warn!("Channel {} exited unexpectedly; restarting", ch.name());
2434                    crate::health::mark_component_error(&component, "listener exited unexpectedly");
2435                    // Clean exit — reset backoff since the listener ran successfully
2436                    backoff = initial_backoff_secs.max(1);
2437                }
2438                Err(e) => {
2439                    tracing::error!("Channel {} error: {e}; restarting", ch.name());
2440                    crate::health::mark_component_error(&component, e.to_string());
2441                }
2442            }
2443
2444            crate::health::bump_component_restart(&component);
2445            tokio::time::sleep(Duration::from_secs(backoff)).await;
2446            // Double backoff AFTER sleeping so first error uses initial_backoff
2447            backoff = backoff.saturating_mul(2).min(max_backoff);
2448        }
2449    })
2450}
2451
2452fn compute_max_in_flight_messages(channel_count: usize) -> usize {
2453    channel_count
2454        .saturating_mul(CHANNEL_PARALLELISM_PER_CHANNEL)
2455        .clamp(
2456            CHANNEL_MIN_IN_FLIGHT_MESSAGES,
2457            CHANNEL_MAX_IN_FLIGHT_MESSAGES,
2458        )
2459}
2460
2461fn log_worker_join_result(result: Result<(), tokio::task::JoinError>) {
2462    if let Err(error) = result {
2463        tracing::error!("Channel message worker crashed: {error}");
2464    }
2465}
2466
2467fn spawn_scoped_typing_task(
2468    channel: Arc<dyn Channel>,
2469    recipient: String,
2470    cancellation_token: CancellationToken,
2471) -> tokio::task::JoinHandle<()> {
2472    let stop_signal = cancellation_token;
2473    let refresh_interval = Duration::from_secs(CHANNEL_TYPING_REFRESH_INTERVAL_SECS);
2474    tokio::spawn(async move {
2475        let mut interval = tokio::time::interval(refresh_interval);
2476        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2477
2478        loop {
2479            tokio::select! {
2480                () = stop_signal.cancelled() => break,
2481                _ = interval.tick() => {
2482                    if let Err(e) = channel.start_typing(&recipient).await {
2483                        tracing::debug!("Failed to start typing on {}: {e}", channel.name());
2484                    }
2485                }
2486            }
2487        }
2488
2489        if let Err(e) = channel.stop_typing(&recipient).await {
2490            tracing::debug!("Failed to stop typing on {}: {e}", channel.name());
2491        }
2492    })
2493}
2494
2495async fn process_channel_message(
2496    ctx: Arc<ChannelRuntimeContext>,
2497    msg: traits::ChannelMessage,
2498    cancellation_token: CancellationToken,
2499) {
2500    if cancellation_token.is_cancelled() {
2501        return;
2502    }
2503
2504    println!(
2505        "  💬 [{}] from {}: {}",
2506        msg.channel,
2507        msg.sender,
2508        truncate_with_ellipsis(&msg.content, 80)
2509    );
2510    runtime_trace::record_event(
2511        "channel_message_inbound",
2512        Some(msg.channel.as_str()),
2513        None,
2514        None,
2515        None,
2516        None,
2517        None,
2518        serde_json::json!({
2519            "sender": msg.sender,
2520            "message_id": msg.id,
2521            "reply_target": msg.reply_target,
2522            "content_preview": truncate_with_ellipsis(&msg.content, 160),
2523        }),
2524    );
2525
2526    // ── Hook: on_message_received (modifying) ────────────
2527    let mut msg = if let Some(hooks) = &ctx.hooks {
2528        match hooks.run_on_message_received(msg).await {
2529            crate::hooks::HookResult::Cancel(reason) => {
2530                tracing::info!(%reason, "incoming message dropped by hook");
2531                return;
2532            }
2533            crate::hooks::HookResult::Continue(modified) => modified,
2534        }
2535    } else {
2536        msg
2537    };
2538
2539    // ── Media pipeline: enrich inbound message with media annotations ──
2540    if ctx.media_pipeline.enabled && !msg.attachments.is_empty() {
2541        let vision = ctx.provider.supports_vision();
2542        let pipeline = media_pipeline::MediaPipeline::new(
2543            &ctx.media_pipeline,
2544            &ctx.transcription_config,
2545            vision,
2546        );
2547        msg.content = Box::pin(pipeline.process(&msg.content, &msg.attachments)).await;
2548    }
2549
2550    // ── Link enricher: prepend URL summaries before agent sees the message ──
2551    let le_config = &ctx.prompt_config.link_enricher;
2552    if le_config.enabled {
2553        let enricher_cfg = link_enricher::LinkEnricherConfig {
2554            enabled: le_config.enabled,
2555            max_links: le_config.max_links,
2556            timeout_secs: le_config.timeout_secs,
2557        };
2558        let enriched = link_enricher::enrich_message(&msg.content, &enricher_cfg).await;
2559        if enriched != msg.content {
2560            tracing::info!(
2561                channel = %msg.channel,
2562                sender = %msg.sender,
2563                "Link enricher: prepended URL summaries to message"
2564            );
2565            msg.content = enriched;
2566        }
2567    }
2568
2569    let target_channel = ctx
2570        .channels_by_name
2571        .get(&msg.channel)
2572        .or_else(|| {
2573            // Multi-room channels use "name:qualifier" format (e.g. "matrix:!roomId");
2574            // fall back to base channel name for routing.
2575            msg.channel
2576                .split_once(':')
2577                .and_then(|(base, _)| ctx.channels_by_name.get(base))
2578        })
2579        .cloned();
2580    if let Err(err) = maybe_apply_runtime_config_update(ctx.as_ref()).await {
2581        tracing::warn!("Failed to apply runtime config update: {err}");
2582    }
2583    if handle_runtime_command_if_needed(ctx.as_ref(), &msg, target_channel.as_ref()).await {
2584        return;
2585    }
2586
2587    let history_key = conversation_history_key(&msg);
2588    let mut route = get_route_selection(ctx.as_ref(), &history_key);
2589
2590    // ── Query classification: override route when a rule matches ──
2591    if let Some(hint) = crate::agent::classifier::classify(&ctx.query_classification, &msg.content)
2592    {
2593        if let Some(matched_route) = ctx
2594            .model_routes
2595            .iter()
2596            .find(|r| r.hint.eq_ignore_ascii_case(&hint))
2597        {
2598            tracing::info!(
2599                target: "query_classification",
2600                hint = hint.as_str(),
2601                provider = matched_route.provider.as_str(),
2602                model = matched_route.model.as_str(),
2603                channel = %msg.channel,
2604                "Channel message classified — overriding route"
2605            );
2606            route = ChannelRouteSelection {
2607                provider: matched_route.provider.clone(),
2608                model: matched_route.model.clone(),
2609                api_key: matched_route.api_key.clone(),
2610            };
2611        }
2612    }
2613
2614    let runtime_defaults = runtime_defaults_snapshot(ctx.as_ref());
2615    let mut active_provider = match get_or_create_provider(
2616        ctx.as_ref(),
2617        &route.provider,
2618        route.api_key.as_deref(),
2619    )
2620    .await
2621    {
2622        Ok(provider) => provider,
2623        Err(err) => {
2624            let safe_err = providers::sanitize_api_error(&err.to_string());
2625            let message = format!(
2626                "⚠️ Failed to initialize provider `{}`. Please run `/models` to choose another provider.\nDetails: {safe_err}",
2627                route.provider
2628            );
2629            if let Some(channel) = target_channel.as_ref() {
2630                let _ = channel
2631                    .send(
2632                        &SendMessage::new(message, &msg.reply_target)
2633                            .in_thread(msg.thread_ts.clone()),
2634                    )
2635                    .await;
2636            }
2637            return;
2638        }
2639    };
2640    if ctx.auto_save_memory
2641        && msg.content.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS
2642        && !memory::should_skip_autosave_content(&msg.content)
2643    {
2644        let autosave_key = conversation_memory_key(&msg);
2645        let _ = ctx
2646            .memory
2647            .store(
2648                &autosave_key,
2649                &msg.content,
2650                crate::memory::MemoryCategory::Conversation,
2651                Some(&history_key),
2652            )
2653            .await;
2654    }
2655
2656    println!("  ⏳ Processing message...");
2657    let started_at = Instant::now();
2658
2659    let force_fresh_session = take_pending_new_session(ctx.as_ref(), &history_key);
2660    if force_fresh_session {
2661        // `/new` should make the next user turn completely fresh even if
2662        // older cached turns reappear before this message starts.
2663        clear_sender_history(ctx.as_ref(), &history_key);
2664    }
2665
2666    let had_prior_history = if force_fresh_session {
2667        false
2668    } else {
2669        ctx.conversation_histories
2670            .lock()
2671            .unwrap_or_else(|e| e.into_inner())
2672            .get(&history_key)
2673            .is_some_and(|turns| !turns.is_empty())
2674    };
2675
2676    // Preserve user turn before the LLM call so interrupted requests keep context.
2677    append_sender_turn(ctx.as_ref(), &history_key, ChatMessage::user(&msg.content));
2678
2679    // Build history from per-sender conversation cache.
2680    let prior_turns_raw = if force_fresh_session {
2681        vec![ChatMessage::user(&msg.content)]
2682    } else {
2683        ctx.conversation_histories
2684            .lock()
2685            .unwrap_or_else(|e| e.into_inner())
2686            .get(&history_key)
2687            .cloned()
2688            .unwrap_or_default()
2689    };
2690    let mut prior_turns = normalize_cached_channel_turns(prior_turns_raw);
2691
2692    // Strip stale tool_result blocks from cached turns so the LLM never
2693    // sees a `<tool_result>` without a preceding `<tool_call>`, which
2694    // causes hallucinated output on subsequent heartbeat ticks or sessions.
2695    for turn in &mut prior_turns {
2696        if turn.content.contains("<tool_result") {
2697            turn.content = strip_tool_result_content(&turn.content);
2698        }
2699    }
2700
2701    // Strip [Used tools: ...] prefixes from cached assistant turns so the
2702    // LLM never sees (and reproduces) this internal summary format (#4400).
2703    for turn in &mut prior_turns {
2704        if turn.role == "assistant" && turn.content.starts_with("[Used tools:") {
2705            turn.content = strip_tool_summary_prefix(&turn.content);
2706        }
2707    }
2708
2709    // Strip [IMAGE:] markers from *older* history messages when the active
2710    // provider does not support vision. This prevents "history poisoning"
2711    // where a previously-sent image marker gets reloaded from the JSONL
2712    // session file and permanently breaks the conversation (fixes #3674).
2713    // We skip the last turn (the current message) so the vision check can
2714    // still reject fresh image sends with a proper error.
2715    if !active_provider.supports_vision() && prior_turns.len() > 1 {
2716        let last_idx = prior_turns.len() - 1;
2717        for turn in &mut prior_turns[..last_idx] {
2718            if turn.content.contains("[IMAGE:") {
2719                let (cleaned, _refs) = crate::multimodal::parse_image_markers(&turn.content);
2720                turn.content = cleaned;
2721            }
2722        }
2723        // Drop older turns that became empty after marker removal (e.g. image-only messages).
2724        // Keep the last turn (current message) intact.
2725        let current = prior_turns.pop();
2726        prior_turns.retain(|turn| !turn.content.trim().is_empty());
2727        if let Some(current) = current {
2728            prior_turns.push(current);
2729        }
2730    }
2731
2732    // Proactively trim conversation history before sending to the provider
2733    // to prevent context-window-exceeded errors (bug #3460).
2734    let dropped = proactive_trim_turns(&mut prior_turns, PROACTIVE_CONTEXT_BUDGET_CHARS);
2735    if dropped > 0 {
2736        tracing::info!(
2737            channel = %msg.channel,
2738            sender = %msg.sender,
2739            dropped_turns = dropped,
2740            remaining_turns = prior_turns.len(),
2741            "Proactively trimmed conversation history to fit context budget"
2742        );
2743    }
2744
2745    // ── Memory recall (Kumiho MCP or legacy) ───────────────────────
2746    // When an MCP registry with kumiho-memory is available, call
2747    // kumiho_memory_engage directly before the LLM call.  The result
2748    // is injected into the system prompt so the model has context
2749    // without needing to spend a tool-call turn on recall.
2750    let mem_recall_start = Instant::now();
2751    let memory_context = if let Some(ref registry) = ctx.mcp_registry {
2752        kumiho_engage_for_channel(
2753            registry,
2754            &msg.content,
2755            &ctx.prompt_config.kumiho.memory_project,
2756        )
2757        .await
2758    } else {
2759        // Fallback to legacy dual-scope recall for non-MCP setups.
2760        let is_group_chat =
2761            msg.reply_target.contains("@g.us") || msg.reply_target.starts_with("group:");
2762        let sender_memory_fut = build_memory_context(
2763            ctx.memory.as_ref(),
2764            &msg.content,
2765            ctx.min_relevance_score,
2766            Some(&msg.sender),
2767        );
2768        let (sender_memory, group_memory) = if is_group_chat {
2769            let group_memory_fut = build_memory_context(
2770                ctx.memory.as_ref(),
2771                &msg.content,
2772                ctx.min_relevance_score,
2773                Some(&history_key),
2774            );
2775            tokio::join!(sender_memory_fut, group_memory_fut)
2776        } else {
2777            (sender_memory_fut.await, String::new())
2778        };
2779        if group_memory.is_empty() {
2780            sender_memory
2781        } else if sender_memory.is_empty() {
2782            group_memory
2783        } else {
2784            format!("{sender_memory}\n{group_memory}")
2785        }
2786    };
2787    #[allow(clippy::cast_possible_truncation)]
2788    let mem_recall_ms = mem_recall_start.elapsed().as_millis() as u64;
2789    tracing::info!(
2790        mem_recall_ms,
2791        kumiho = ctx.mcp_registry.is_some(),
2792        context_len = memory_context.len(),
2793        "⏱ Memory recall completed"
2794    );
2795
2796    // Use refreshed system prompt for new sessions (master's /new support),
2797    // and inject memory into system prompt (not user message) so it
2798    // doesn't pollute session history and is re-fetched each turn.
2799    let base_system_prompt = if had_prior_history {
2800        ctx.system_prompt.as_str().to_string()
2801    } else {
2802        refreshed_new_session_system_prompt(ctx.as_ref())
2803    };
2804    let mut system_prompt =
2805        build_channel_system_prompt(&base_system_prompt, &msg.channel, &msg.reply_target);
2806    if !memory_context.is_empty() {
2807        let _ = write!(system_prompt, "\n\n{memory_context}");
2808    }
2809    let mut history = vec![ChatMessage::system(system_prompt)];
2810    history.extend(prior_turns);
2811
2812    // ── Proactive context compression ────────────────────────────
2813    // Use the existing ContextCompressor to summarize older history
2814    // before the LLM call, preventing context-window-exceeded errors
2815    // and preserving key decisions through LLM-driven summarization.
2816    {
2817        let cc_config = ctx.prompt_config.agent.context_compression.clone();
2818        let compressor = crate::agent::context_compressor::ContextCompressor::new(
2819            cc_config,
2820            ctx.context_token_budget,
2821        )
2822        .with_memory(Arc::clone(&ctx.memory));
2823        match compressor
2824            .compress_if_needed(&mut history, active_provider.as_ref(), route.model.as_str())
2825            .await
2826        {
2827            Ok(result) if result.compressed => {
2828                tracing::info!(
2829                    channel = %msg.channel,
2830                    sender = %msg.sender,
2831                    tokens_before = result.tokens_before,
2832                    tokens_after = result.tokens_after,
2833                    passes = result.passes_used,
2834                    "Proactive context compression applied before LLM call"
2835                );
2836            }
2837            Err(e) => {
2838                tracing::warn!("Context compression failed, proceeding without: {e}");
2839            }
2840            _ => {}
2841        }
2842    }
2843
2844    let use_draft_streaming = target_channel
2845        .as_ref()
2846        .is_some_and(|ch| ch.supports_draft_updates());
2847
2848    tracing::debug!(
2849        channel = %msg.channel,
2850        has_target_channel = target_channel.is_some(),
2851        use_draft_streaming,
2852        "Streaming decision"
2853    );
2854
2855    // Partial mode: delta channel for draft updates (progress + text).
2856    let (delta_tx, delta_rx) = if use_draft_streaming {
2857        let (tx, rx) = tokio::sync::mpsc::channel::<crate::agent::loop_::DraftEvent>(64);
2858        (Some(tx), Some(rx))
2859    } else {
2860        (None, None)
2861    };
2862
2863    // Partial mode: send an initial draft message for progressive editing.
2864    let draft_message_id = if use_draft_streaming {
2865        if let Some(channel) = target_channel.as_ref() {
2866            match channel
2867                .send_draft(
2868                    &SendMessage::new("...", &msg.reply_target).in_thread(msg.thread_ts.clone()),
2869                )
2870                .await
2871            {
2872                Ok(id) => id,
2873                Err(e) => {
2874                    tracing::debug!("Failed to send draft on {}: {e}", channel.name());
2875                    None
2876                }
2877            }
2878        } else {
2879            None
2880        }
2881    } else {
2882        None
2883    };
2884
2885    // Spawn the appropriate handler for the delta channel.
2886    let draft_updater = if use_draft_streaming {
2887        // Partial: accumulate text and edit a single draft message.
2888        if let (Some(mut rx), Some(draft_id_ref), Some(channel_ref)) = (
2889            delta_rx,
2890            draft_message_id.as_deref(),
2891            target_channel.as_ref(),
2892        ) {
2893            let channel = Arc::clone(channel_ref);
2894            let reply_target = msg.reply_target.clone();
2895            let draft_id = draft_id_ref.to_string();
2896            Some(tokio::spawn(async move {
2897                use crate::agent::loop_::DraftEvent;
2898                let mut accumulated = String::new();
2899                while let Some(event) = rx.recv().await {
2900                    match event {
2901                        DraftEvent::Clear => {
2902                            accumulated.clear();
2903                        }
2904                        DraftEvent::Progress(text) => {
2905                            if let Err(e) = channel
2906                                .update_draft_progress(&reply_target, &draft_id, &text)
2907                                .await
2908                            {
2909                                tracing::debug!("Draft progress update failed: {e}");
2910                            }
2911                        }
2912                        DraftEvent::Content(text) => {
2913                            accumulated.push_str(&text);
2914                            if let Err(e) = channel
2915                                .update_draft(&reply_target, &draft_id, &accumulated)
2916                                .await
2917                            {
2918                                tracing::debug!("Draft update failed: {e}");
2919                            }
2920                        }
2921                    }
2922                }
2923            }))
2924        } else {
2925            None
2926        }
2927    } else {
2928        None
2929    };
2930
2931    // React with 👀 to acknowledge the incoming message
2932    if ctx.ack_reactions {
2933        if let Some(channel) = target_channel.as_ref() {
2934            if let Err(e) = channel
2935                .add_reaction(&msg.reply_target, &msg.id, "\u{1F440}")
2936                .await
2937            {
2938                tracing::debug!("Failed to add reaction: {e}");
2939            }
2940        }
2941    }
2942
2943    // Skip typing only for Partial mode — the draft message itself provides
2944    // visual feedback. MultiMessage and Off both keep typing active.
2945    let is_partial_draft = target_channel
2946        .as_ref()
2947        .is_some_and(|ch| ch.supports_draft_updates() && !ch.supports_multi_message_streaming());
2948    let typing_cancellation = if is_partial_draft {
2949        None
2950    } else {
2951        target_channel.as_ref().map(|_| CancellationToken::new())
2952    };
2953    let typing_task = match (target_channel.as_ref(), typing_cancellation.as_ref()) {
2954        (Some(channel), Some(token)) => Some(spawn_scoped_typing_task(
2955            Arc::clone(channel),
2956            msg.reply_target.clone(),
2957            token.clone(),
2958        )),
2959        _ => None,
2960    };
2961
2962    // Wrap observer to forward tool events as live thread messages
2963    let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
2964    let notify_observer: Arc<ChannelNotifyObserver> = Arc::new(ChannelNotifyObserver {
2965        inner: Arc::clone(&ctx.observer),
2966        tx: notify_tx,
2967        tools_used: AtomicBool::new(false),
2968    });
2969    let notify_observer_flag = Arc::clone(&notify_observer);
2970    let notify_channel = target_channel.clone();
2971    let notify_reply_target = msg.reply_target.clone();
2972    let notify_thread_root = followup_thread_id(&msg);
2973    let notify_task = if msg.channel == "cli" || !ctx.show_tool_calls {
2974        Some(tokio::spawn(async move {
2975            while notify_rx.recv().await.is_some() {}
2976        }))
2977    } else {
2978        Some(tokio::spawn(async move {
2979            let thread_ts = notify_thread_root;
2980            while let Some(text) = notify_rx.recv().await {
2981                if let Some(ref ch) = notify_channel {
2982                    let _ = ch
2983                        .send(
2984                            &SendMessage::new(&text, &notify_reply_target)
2985                                .in_thread(thread_ts.clone()),
2986                        )
2987                        .await;
2988                }
2989            }
2990        }))
2991    };
2992
2993    enum LlmExecutionResult {
2994        Completed(Result<Result<String, anyhow::Error>, tokio::time::error::Elapsed>),
2995        Cancelled,
2996    }
2997
2998    let model_switch_callback = get_model_switch_state();
2999    let scale_cap = ctx
3000        .pacing
3001        .message_timeout_scale_max
3002        .unwrap_or(CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP);
3003    let timeout_budget_secs = channel_message_timeout_budget_secs_with_cap(
3004        ctx.message_timeout_secs,
3005        ctx.max_tool_iterations,
3006        scale_cap,
3007    );
3008    let cost_tracking_context = ctx.cost_tracking.clone().map(|state| {
3009        crate::agent::loop_::ToolLoopCostTrackingContext::new(state.tracker, state.prices)
3010    });
3011    let llm_call_start = Instant::now();
3012    #[allow(clippy::cast_possible_truncation)]
3013    let elapsed_before_llm_ms = started_at.elapsed().as_millis() as u64;
3014    tracing::info!(elapsed_before_llm_ms, "⏱ Starting LLM call");
3015    let (llm_result, fallback_info) = scope_provider_fallback(async {
3016        let llm_result = loop {
3017            let loop_result = tokio::select! {
3018                () = cancellation_token.cancelled() => LlmExecutionResult::Cancelled,
3019                result = tokio::time::timeout(
3020                    Duration::from_secs(timeout_budget_secs),
3021                    crate::agent::loop_::TOOL_LOOP_COST_TRACKING_CONTEXT.scope(
3022                        cost_tracking_context.clone(),
3023                    run_tool_call_loop(
3024                        active_provider.as_ref(),
3025                        &mut history,
3026                        ctx.tools_registry.as_ref(),
3027                        notify_observer.as_ref() as &dyn Observer,
3028                        route.provider.as_str(),
3029                        route.model.as_str(),
3030                        runtime_defaults.temperature,
3031                        true,
3032                        Some(&*ctx.approval_manager),
3033                        msg.channel.as_str(),
3034                        Some(msg.reply_target.as_str()),
3035                        &ctx.multimodal,
3036                        ctx.max_tool_iterations,
3037                        Some(cancellation_token.clone()),
3038                        delta_tx.clone(),
3039                        ctx.hooks.as_deref(),
3040                        if msg.channel == "cli"
3041                            || ctx.autonomy_level == AutonomyLevel::Full
3042                        {
3043                            &[]
3044                        } else {
3045                            ctx.non_cli_excluded_tools.as_ref()
3046                        },
3047                        ctx.tool_call_dedup_exempt.as_ref(),
3048                        ctx.activated_tools.as_ref(),
3049                        Some(model_switch_callback.clone()),
3050                        &ctx.pacing,
3051                        ctx.max_tool_result_chars,
3052                        ctx.context_token_budget,
3053                        None, // shared_budget
3054                    ),
3055                    ),
3056                ) => LlmExecutionResult::Completed(result),
3057            };
3058
3059            // Handle model switch: re-create the provider and retry
3060            if let LlmExecutionResult::Completed(Ok(Err(ref e))) = loop_result {
3061                if let Some((new_provider, new_model)) = is_model_switch_requested(e) {
3062                    tracing::info!(
3063                        "Model switch requested, switching from {} {} to {} {}",
3064                        route.provider,
3065                        route.model,
3066                        new_provider,
3067                        new_model
3068                    );
3069
3070                    match create_resilient_provider_nonblocking(
3071                        &new_provider,
3072                        ctx.api_key.clone(),
3073                        ctx.api_url.clone(),
3074                        ctx.reliability.as_ref().clone(),
3075                        ctx.provider_runtime_options.clone(),
3076                    )
3077                    .await
3078                    {
3079                        Ok(new_prov) => {
3080                            active_provider = Arc::from(new_prov);
3081                            route.provider = new_provider;
3082                            route.model = new_model;
3083                            clear_model_switch_request();
3084
3085                            ctx.observer.record_event(&ObserverEvent::AgentStart {
3086                                provider: route.provider.clone(),
3087                                model: route.model.clone(),
3088                            });
3089
3090                            continue;
3091                        }
3092                        Err(err) => {
3093                            tracing::error!("Failed to create provider after model switch: {err}");
3094                            clear_model_switch_request();
3095                            // Fall through with the original error
3096                        }
3097                    }
3098                }
3099            }
3100
3101            break loop_result;
3102        };
3103        let fb = take_last_provider_fallback();
3104        (llm_result, fb)
3105    })
3106    .await;
3107
3108    // Drop all senders so updater tasks can exit (rx.recv() returns None).
3109    tracing::debug!("Post-loop: dropping delta_tx and awaiting draft updater");
3110    drop(delta_tx);
3111    if let Some(handle) = draft_updater {
3112        let _ = handle.await;
3113    }
3114    tracing::debug!("Post-loop: draft updater completed");
3115
3116    // Thread the final reply only if tools were used (multi-message response)
3117    if notify_observer_flag.tools_used.load(Ordering::Relaxed) && msg.channel != "cli" {
3118        msg.thread_ts = followup_thread_id(&msg);
3119    }
3120    // Drop the notify sender so the forwarder task finishes
3121    drop(notify_observer);
3122    drop(notify_observer_flag);
3123    if let Some(handle) = notify_task {
3124        let _ = handle.await;
3125    }
3126
3127    #[allow(clippy::cast_possible_truncation)]
3128    let llm_call_ms = llm_call_start.elapsed().as_millis() as u64;
3129    #[allow(clippy::cast_possible_truncation)]
3130    let total_ms = started_at.elapsed().as_millis() as u64;
3131    tracing::info!(llm_call_ms, total_ms, "⏱ LLM call completed");
3132
3133    if let Some(token) = typing_cancellation.as_ref() {
3134        token.cancel();
3135    }
3136    if let Some(handle) = typing_task {
3137        log_worker_join_result(handle.await);
3138    }
3139
3140    let llm_success = matches!(&llm_result, LlmExecutionResult::Completed(Ok(Ok(_))));
3141    let reaction_done_emoji = if llm_success {
3142        "\u{2705}" // ✅
3143    } else {
3144        "\u{26A0}\u{FE0F}" // ⚠️
3145    };
3146
3147    match llm_result {
3148        LlmExecutionResult::Cancelled => {
3149            tracing::info!(
3150                channel = %msg.channel,
3151                sender = %msg.sender,
3152                "Cancelled in-flight channel request due to newer message"
3153            );
3154            runtime_trace::record_event(
3155                "channel_message_cancelled",
3156                Some(msg.channel.as_str()),
3157                Some(route.provider.as_str()),
3158                Some(route.model.as_str()),
3159                None,
3160                Some(false),
3161                Some("cancelled due to newer inbound message"),
3162                serde_json::json!({
3163                    "sender": msg.sender,
3164                    "elapsed_ms": started_at.elapsed().as_millis(),
3165                }),
3166            );
3167            if let (Some(channel), Some(draft_id)) =
3168                (target_channel.as_ref(), draft_message_id.as_deref())
3169            {
3170                if let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await {
3171                    tracing::debug!("Failed to cancel draft on {}: {err}", channel.name());
3172                }
3173            }
3174        }
3175        LlmExecutionResult::Completed(Ok(Ok(response))) => {
3176            // ── Hook: on_message_sending (modifying) ─────────
3177            let mut outbound_response = response;
3178            if let Some(hooks) = &ctx.hooks {
3179                match hooks
3180                    .run_on_message_sending(
3181                        msg.channel.clone(),
3182                        msg.reply_target.clone(),
3183                        outbound_response.clone(),
3184                    )
3185                    .await
3186                {
3187                    crate::hooks::HookResult::Cancel(reason) => {
3188                        tracing::info!(%reason, "outgoing message suppressed by hook");
3189                        if let (Some(channel), Some(draft_id)) =
3190                            (target_channel.as_ref(), draft_message_id.as_deref())
3191                        {
3192                            let _ = channel.cancel_draft(&msg.reply_target, draft_id).await;
3193                        }
3194                        return;
3195                    }
3196                    crate::hooks::HookResult::Continue((
3197                        hook_channel,
3198                        hook_recipient,
3199                        mut modified_content,
3200                    )) => {
3201                        if hook_channel != msg.channel || hook_recipient != msg.reply_target {
3202                            tracing::warn!(
3203                                from_channel = %msg.channel,
3204                                from_recipient = %msg.reply_target,
3205                                to_channel = %hook_channel,
3206                                to_recipient = %hook_recipient,
3207                                "on_message_sending attempted to rewrite channel routing; only content mutation is applied"
3208                            );
3209                        }
3210
3211                        let modified_len = modified_content.chars().count();
3212                        if modified_len > CHANNEL_HOOK_MAX_OUTBOUND_CHARS {
3213                            tracing::warn!(
3214                                limit = CHANNEL_HOOK_MAX_OUTBOUND_CHARS,
3215                                attempted = modified_len,
3216                                "hook-modified outbound content exceeded limit; truncating"
3217                            );
3218                            modified_content = truncate_with_ellipsis(
3219                                &modified_content,
3220                                CHANNEL_HOOK_MAX_OUTBOUND_CHARS,
3221                            );
3222                        }
3223
3224                        if modified_content != outbound_response {
3225                            tracing::info!(
3226                                channel = %msg.channel,
3227                                sender = %msg.sender,
3228                                before_len = outbound_response.chars().count(),
3229                                after_len = modified_content.chars().count(),
3230                                "outgoing message content modified by hook"
3231                            );
3232                        }
3233
3234                        outbound_response = modified_content;
3235                    }
3236                }
3237            }
3238
3239            let sanitized_response =
3240                sanitize_channel_response(&outbound_response, ctx.tools_registry.as_ref());
3241            let mut delivered_response = if sanitized_response.is_empty()
3242                && !outbound_response.trim().is_empty()
3243            {
3244                "I encountered malformed tool-call output and could not produce a safe reply. Please try again.".to_string()
3245            } else {
3246                sanitized_response
3247            };
3248
3249            // Append a footer when the response was served by a different provider family.
3250            // Intra-family fallbacks (e.g. minimax → minimax-cn) are suppressed.
3251            if let Some(fb) = fallback_info.as_ref() {
3252                let req_base = fb.requested_provider.split(':').next().unwrap_or("");
3253                let act_base = fb.actual_provider.split(':').next().unwrap_or("");
3254                let same_family = req_base == act_base
3255                    || req_base.starts_with(act_base)
3256                    || act_base.starts_with(req_base);
3257                if !same_family {
3258                    use std::fmt::Write as _;
3259                    write!(
3260                        delivered_response,
3261                        "\n\n---\n\u{26A1} `{}` unavailable \u{2014} response from **{}** (`{}`)\nSwitch model: /models",
3262                        fb.requested_provider, fb.actual_provider, fb.actual_model,
3263                    )
3264                    .ok();
3265                }
3266            }
3267
3268            runtime_trace::record_event(
3269                "channel_message_outbound",
3270                Some(msg.channel.as_str()),
3271                Some(route.provider.as_str()),
3272                Some(route.model.as_str()),
3273                None,
3274                Some(true),
3275                None,
3276                serde_json::json!({
3277                    "sender": msg.sender,
3278                    "elapsed_ms": started_at.elapsed().as_millis(),
3279                    "response": scrub_credentials(&delivered_response),
3280                }),
3281            );
3282
3283            // Persist intermediate tool-call/result messages from this turn
3284            // so the model retains concrete "I used tools" examples in
3285            // context, preventing drift toward tool-less responses (#4827).
3286            let keep_tool_turns = ctx.prompt_config.agent.keep_tool_context_turns;
3287            if keep_tool_turns > 0 {
3288                // Find tool messages for the current turn: everything after
3289                // the last user message up to (but not including) the final
3290                // assistant response that matches our delivered text.
3291                let tool_messages: Vec<ChatMessage> = extract_current_turn_tool_messages(&history);
3292                for tool_msg in tool_messages {
3293                    append_sender_turn(ctx.as_ref(), &history_key, tool_msg);
3294                }
3295            }
3296
3297            let history_response = delivered_response.clone();
3298            append_sender_turn(
3299                ctx.as_ref(),
3300                &history_key,
3301                ChatMessage::assistant(&history_response),
3302            );
3303
3304            // Strip tool-call messages from turns older than
3305            // keep_tool_context_turns to prevent unbounded growth.
3306            if keep_tool_turns > 0 {
3307                strip_old_tool_context(ctx.as_ref(), &history_key, keep_tool_turns);
3308            }
3309
3310            println!(
3311                "  🤖 Reply ({}ms): {}",
3312                started_at.elapsed().as_millis(),
3313                truncate_with_ellipsis(&delivered_response, 80)
3314            );
3315            if let Some(channel) = target_channel.as_ref() {
3316                if let Some(ref draft_id) = draft_message_id {
3317                    if let Err(e) = channel
3318                        .finalize_draft(&msg.reply_target, draft_id, &delivered_response)
3319                        .await
3320                    {
3321                        tracing::warn!("Failed to finalize draft: {e}; sending as new message");
3322                        let _ = channel
3323                            .send(
3324                                &SendMessage::new(&delivered_response, &msg.reply_target)
3325                                    .in_thread(msg.thread_ts.clone()),
3326                            )
3327                            .await;
3328                    }
3329                } else if let Err(e) = channel
3330                    .send(
3331                        &SendMessage::new(&delivered_response, &msg.reply_target)
3332                            .in_thread(msg.thread_ts.clone())
3333                            .with_cancellation(cancellation_token.clone()),
3334                    )
3335                    .await
3336                {
3337                    eprintln!("  ❌ Failed to reply on {}: {e}", channel.name());
3338                }
3339            }
3340        }
3341        LlmExecutionResult::Completed(Ok(Err(e))) => {
3342            if crate::agent::loop_::is_tool_loop_cancelled(&e) || cancellation_token.is_cancelled()
3343            {
3344                tracing::info!(
3345                    channel = %msg.channel,
3346                    sender = %msg.sender,
3347                    "Cancelled in-flight channel request due to newer message"
3348                );
3349                runtime_trace::record_event(
3350                    "channel_message_cancelled",
3351                    Some(msg.channel.as_str()),
3352                    Some(route.provider.as_str()),
3353                    Some(route.model.as_str()),
3354                    None,
3355                    Some(false),
3356                    Some("cancelled during tool-call loop"),
3357                    serde_json::json!({
3358                        "sender": msg.sender,
3359                        "elapsed_ms": started_at.elapsed().as_millis(),
3360                    }),
3361                );
3362                if let (Some(channel), Some(draft_id)) =
3363                    (target_channel.as_ref(), draft_message_id.as_deref())
3364                {
3365                    if let Err(err) = channel.cancel_draft(&msg.reply_target, draft_id).await {
3366                        tracing::debug!("Failed to cancel draft on {}: {err}", channel.name());
3367                    }
3368                }
3369            } else if is_context_window_overflow_error(&e) {
3370                let compacted = compact_sender_history(ctx.as_ref(), &history_key);
3371                let error_text = if compacted {
3372                    "⚠️ Context window exceeded for this conversation. I compacted recent history and kept the latest context. Please resend your last message."
3373                } else {
3374                    "⚠️ Context window exceeded for this conversation. Please resend your last message."
3375                };
3376                eprintln!(
3377                    "  ⚠️ Context window exceeded after {}ms; sender history compacted={}",
3378                    started_at.elapsed().as_millis(),
3379                    compacted
3380                );
3381                runtime_trace::record_event(
3382                    "channel_message_error",
3383                    Some(msg.channel.as_str()),
3384                    Some(route.provider.as_str()),
3385                    Some(route.model.as_str()),
3386                    None,
3387                    Some(false),
3388                    Some("context window exceeded"),
3389                    serde_json::json!({
3390                        "sender": msg.sender,
3391                        "elapsed_ms": started_at.elapsed().as_millis(),
3392                        "history_compacted": compacted,
3393                    }),
3394                );
3395                if let Some(channel) = target_channel.as_ref() {
3396                    if let Some(ref draft_id) = draft_message_id {
3397                        let _ = channel
3398                            .finalize_draft(&msg.reply_target, draft_id, error_text)
3399                            .await;
3400                    } else {
3401                        let _ = channel
3402                            .send(
3403                                &SendMessage::new(error_text, &msg.reply_target)
3404                                    .in_thread(msg.thread_ts.clone()),
3405                            )
3406                            .await;
3407                    }
3408                }
3409            } else {
3410                eprintln!(
3411                    "  ❌ LLM error after {}ms: {e}",
3412                    started_at.elapsed().as_millis()
3413                );
3414                let safe_error = providers::sanitize_api_error(&e.to_string());
3415                runtime_trace::record_event(
3416                    "channel_message_error",
3417                    Some(msg.channel.as_str()),
3418                    Some(route.provider.as_str()),
3419                    Some(route.model.as_str()),
3420                    None,
3421                    Some(false),
3422                    Some(&safe_error),
3423                    serde_json::json!({
3424                        "sender": msg.sender,
3425                        "elapsed_ms": started_at.elapsed().as_millis(),
3426                    }),
3427                );
3428                let should_rollback_user_turn = should_rollback_failed_user_turn(&e);
3429                let rolled_back = should_rollback_user_turn
3430                    && rollback_orphan_user_turn(ctx.as_ref(), &history_key, &msg.content);
3431
3432                if !rolled_back {
3433                    // Close the orphan user turn so subsequent messages don't
3434                    // inherit this failed request as unfinished context.
3435                    append_sender_turn(
3436                        ctx.as_ref(),
3437                        &history_key,
3438                        ChatMessage::assistant("[Task failed — not continuing this request]"),
3439                    );
3440                }
3441                if let Some(channel) = target_channel.as_ref() {
3442                    if let Some(ref draft_id) = draft_message_id {
3443                        let _ = channel
3444                            .finalize_draft(&msg.reply_target, draft_id, &format!("⚠️ Error: {e}"))
3445                            .await;
3446                    } else {
3447                        let _ = channel
3448                            .send(
3449                                &SendMessage::new(format!("⚠️ Error: {e}"), &msg.reply_target)
3450                                    .in_thread(msg.thread_ts.clone()),
3451                            )
3452                            .await;
3453                    }
3454                }
3455            }
3456        }
3457        LlmExecutionResult::Completed(Err(_)) => {
3458            let timeout_msg = format!(
3459                "LLM response timed out after {}s (base={}s, max_tool_iterations={})",
3460                timeout_budget_secs, ctx.message_timeout_secs, ctx.max_tool_iterations
3461            );
3462            runtime_trace::record_event(
3463                "channel_message_timeout",
3464                Some(msg.channel.as_str()),
3465                Some(route.provider.as_str()),
3466                Some(route.model.as_str()),
3467                None,
3468                Some(false),
3469                Some(&timeout_msg),
3470                serde_json::json!({
3471                    "sender": msg.sender,
3472                    "elapsed_ms": started_at.elapsed().as_millis(),
3473                }),
3474            );
3475            eprintln!(
3476                "  ❌ {} (elapsed: {}ms)",
3477                timeout_msg,
3478                started_at.elapsed().as_millis()
3479            );
3480            // Close the orphan user turn so subsequent messages don't
3481            // inherit this timed-out request as unfinished context.
3482            append_sender_turn(
3483                ctx.as_ref(),
3484                &history_key,
3485                ChatMessage::assistant("[Task timed out — not continuing this request]"),
3486            );
3487            if let Some(channel) = target_channel.as_ref() {
3488                let error_text =
3489                    "⚠️ Request timed out while waiting for the model. Please try again.";
3490                if let Some(ref draft_id) = draft_message_id {
3491                    let _ = channel
3492                        .finalize_draft(&msg.reply_target, draft_id, error_text)
3493                        .await;
3494                } else {
3495                    let _ = channel
3496                        .send(
3497                            &SendMessage::new(error_text, &msg.reply_target)
3498                                .in_thread(msg.thread_ts.clone()),
3499                        )
3500                        .await;
3501                }
3502            }
3503        }
3504    }
3505
3506    // Audit log the completed message processing
3507    if let Some(ref logger) = ctx.audit_logger {
3508        let _ = logger.log_command(
3509            &msg.channel,
3510            &truncate_with_ellipsis(&msg.content, 120),
3511            "low",
3512            true,
3513            true,
3514            llm_success,
3515            total_ms,
3516        );
3517    }
3518
3519    // Swap 👀 → ✅ (or ⚠️ on error) to signal processing is complete
3520    if ctx.ack_reactions {
3521        if let Some(channel) = target_channel.as_ref() {
3522            let _ = channel
3523                .remove_reaction(&msg.reply_target, &msg.id, "\u{1F440}")
3524                .await;
3525            let _ = channel
3526                .add_reaction(&msg.reply_target, &msg.id, reaction_done_emoji)
3527                .await;
3528        }
3529    }
3530}
3531
3532/// Shared worker body extracted so both the normal path and the debounce path
3533/// can reuse the same in-flight tracking / cancellation / process logic.
3534async fn dispatch_worker(
3535    ctx: Arc<ChannelRuntimeContext>,
3536    msg: traits::ChannelMessage,
3537    in_flight: Arc<tokio::sync::Mutex<HashMap<String, InFlightSenderTaskState>>>,
3538    task_sequence: Arc<AtomicU64>,
3539    permit: tokio::sync::OwnedSemaphorePermit,
3540) {
3541    let _permit = permit;
3542    let interrupt_enabled = ctx
3543        .interrupt_on_new_message
3544        .enabled_for_channel(msg.channel.as_str());
3545    let sender_scope_key = interruption_scope_key(&msg);
3546    let cancellation_token = CancellationToken::new();
3547    let completion = Arc::new(InFlightTaskCompletion::new());
3548    let task_id = task_sequence.fetch_add(1, Ordering::Relaxed) as u64;
3549
3550    let register_in_flight = msg.channel != "cli";
3551
3552    if register_in_flight {
3553        let previous = {
3554            let mut active = in_flight.lock().await;
3555            active.insert(
3556                sender_scope_key.clone(),
3557                InFlightSenderTaskState {
3558                    task_id,
3559                    cancellation: cancellation_token.clone(),
3560                    completion: Arc::clone(&completion),
3561                },
3562            )
3563        };
3564
3565        if interrupt_enabled {
3566            if let Some(previous) = previous {
3567                tracing::info!(
3568                    channel = %msg.channel,
3569                    sender = %msg.sender,
3570                    "Interrupting previous in-flight request for sender"
3571                );
3572                previous.cancellation.cancel();
3573                previous.completion.wait().await;
3574            }
3575        }
3576    }
3577
3578    process_channel_message(ctx, msg, cancellation_token).await;
3579
3580    if register_in_flight {
3581        let mut active = in_flight.lock().await;
3582        if active
3583            .get(&sender_scope_key)
3584            .is_some_and(|state| state.task_id == task_id)
3585        {
3586            active.remove(&sender_scope_key);
3587        }
3588    }
3589
3590    completion.mark_done();
3591}
3592
3593async fn run_message_dispatch_loop(
3594    mut rx: tokio::sync::mpsc::Receiver<traits::ChannelMessage>,
3595    ctx: Arc<ChannelRuntimeContext>,
3596    max_in_flight_messages: usize,
3597) {
3598    let semaphore = Arc::new(tokio::sync::Semaphore::new(max_in_flight_messages));
3599    let mut workers = tokio::task::JoinSet::new();
3600    let in_flight_by_sender = Arc::new(tokio::sync::Mutex::new(HashMap::<
3601        String,
3602        InFlightSenderTaskState,
3603    >::new()));
3604    let task_sequence = Arc::new(AtomicU64::new(1));
3605
3606    while let Some(msg) = rx.recv().await {
3607        // Fast path: /stop cancels the in-flight task for this sender scope without
3608        // spawning a worker or registering a new task. Handled here — before semaphore
3609        // acquisition — so the target task is still in the store and is never replaced.
3610        if msg.channel != "cli" && is_stop_command(&msg.content) {
3611            let scope_key = interruption_scope_key(&msg);
3612            let previous = {
3613                let mut active = in_flight_by_sender.lock().await;
3614                active.remove(&scope_key)
3615            };
3616            let reply = if let Some(state) = previous {
3617                state.cancellation.cancel();
3618                "Stop signal sent.".to_string()
3619            } else {
3620                "No in-flight task for this sender scope.".to_string()
3621            };
3622            let channel = ctx
3623                .channels_by_name
3624                .get(&msg.channel)
3625                .or_else(|| {
3626                    // Multi-room channels use "name:qualifier" format (e.g. "matrix:!roomId");
3627                    // fall back to base channel name for routing.
3628                    msg.channel
3629                        .split_once(':')
3630                        .and_then(|(base, _)| ctx.channels_by_name.get(base))
3631                })
3632                .cloned();
3633            if let Some(channel) = channel {
3634                let reply_target = msg.reply_target.clone();
3635                let thread_ts = msg.thread_ts.clone();
3636                tokio::spawn(async move {
3637                    let _ = channel
3638                        .send(&SendMessage::new(reply, &reply_target).in_thread(thread_ts))
3639                        .await;
3640                });
3641            } else {
3642                tracing::warn!(
3643                    channel = %msg.channel,
3644                    "stop command: no registered channel found for reply"
3645                );
3646            }
3647            continue;
3648        }
3649
3650        // ── Debounce: accumulate rapid messages per sender ──────────
3651        // CLI messages bypass debouncing so the interactive loop stays responsive.
3652        let msg = if msg.channel != "cli" && ctx.debouncer.enabled() {
3653            let debounce_key = conversation_history_key(&msg);
3654            match ctx.debouncer.debounce(&debounce_key, &msg.content).await {
3655                debounce::DebounceResult::Pending(rx) => {
3656                    // Spawn a lightweight task that waits for the debounce window
3657                    // to expire, then feeds the combined message through the normal
3658                    // worker path below.
3659                    let debounce_ctx = Arc::clone(&ctx);
3660                    let debounce_in_flight = Arc::clone(&in_flight_by_sender);
3661                    let debounce_semaphore = Arc::clone(&semaphore);
3662                    let debounce_task_seq = Arc::clone(&task_sequence);
3663                    let mut debounce_msg = msg;
3664                    workers.spawn(async move {
3665                        let combined = match rx.await {
3666                            Ok(combined) => combined,
3667                            Err(_) => {
3668                                // Receiver dropped — a newer message superseded this one.
3669                                return;
3670                            }
3671                        };
3672                        debounce_msg.content = combined;
3673                        tracing::info!(
3674                            channel = %debounce_msg.channel,
3675                            sender = %debounce_msg.sender,
3676                            "Debounced message ready — dispatching combined message"
3677                        );
3678
3679                        let permit = match debounce_semaphore.acquire_owned().await {
3680                            Ok(permit) => permit,
3681                            Err(_) => return,
3682                        };
3683
3684                        dispatch_worker(
3685                            debounce_ctx,
3686                            debounce_msg,
3687                            debounce_in_flight,
3688                            debounce_task_seq,
3689                            permit,
3690                        )
3691                        .await;
3692                    });
3693                    continue;
3694                }
3695                debounce::DebounceResult::Passthrough(content) => {
3696                    let mut m = msg;
3697                    m.content = content;
3698                    m
3699                }
3700            }
3701        } else {
3702            msg
3703        };
3704
3705        let permit = match Arc::clone(&semaphore).acquire_owned().await {
3706            Ok(permit) => permit,
3707            Err(_) => break,
3708        };
3709
3710        let worker_ctx = Arc::clone(&ctx);
3711        let in_flight = Arc::clone(&in_flight_by_sender);
3712        let task_sequence = Arc::clone(&task_sequence);
3713        workers.spawn(async move {
3714            dispatch_worker(worker_ctx, msg, in_flight, task_sequence, permit).await;
3715        });
3716
3717        while let Some(result) = workers.try_join_next() {
3718            log_worker_join_result(result);
3719        }
3720    }
3721
3722    while let Some(result) = workers.join_next().await {
3723        log_worker_join_result(result);
3724    }
3725}
3726
3727/// Load OpenClaw format bootstrap files into the prompt.
3728fn load_openclaw_bootstrap_files(
3729    prompt: &mut String,
3730    workspace_dir: &std::path::Path,
3731    max_chars_per_file: usize,
3732) {
3733    prompt.push_str(
3734        "The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n",
3735    );
3736
3737    let bootstrap_files = ["AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md"];
3738
3739    for filename in &bootstrap_files {
3740        inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file);
3741    }
3742
3743    // BOOTSTRAP.md — only if it exists (first-run ritual)
3744    let bootstrap_path = workspace_dir.join("BOOTSTRAP.md");
3745    if bootstrap_path.exists() {
3746        inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file);
3747    }
3748
3749    // MEMORY.md — curated long-term memory (main session only)
3750    inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file);
3751}
3752
3753/// Load workspace identity files and build a system prompt.
3754///
3755/// Follows the `OpenClaw` framework structure by default:
3756/// 1. Tooling — tool list + descriptions
3757/// 2. Safety — guardrail reminder
3758/// 3. Skills — full skill instructions and tool metadata
3759/// 4. Workspace — working directory
3760/// 5. Bootstrap files — AGENTS, SOUL, TOOLS, IDENTITY, USER, BOOTSTRAP, MEMORY
3761/// 6. Date & Time — timezone for cache stability
3762/// 7. Runtime — host, OS, model
3763///
3764/// When `identity_config` is set to AIEOS format, the bootstrap files section
3765/// is replaced with the AIEOS identity data loaded from file or inline JSON.
3766///
3767/// Daily memory files (`memory/*.md`) are NOT injected — they are accessed
3768/// on-demand via `memory_recall` / `memory_search` tools.
3769pub fn build_system_prompt(
3770    workspace_dir: &std::path::Path,
3771    model_name: &str,
3772    tools: &[(&str, &str)],
3773    skills: &[crate::skills::Skill],
3774    identity_config: Option<&crate::config::IdentityConfig>,
3775    bootstrap_max_chars: Option<usize>,
3776) -> String {
3777    build_system_prompt_with_mode(
3778        workspace_dir,
3779        model_name,
3780        tools,
3781        skills,
3782        identity_config,
3783        bootstrap_max_chars,
3784        false,
3785        crate::config::SkillsPromptInjectionMode::Full,
3786        AutonomyLevel::default(),
3787    )
3788}
3789
3790pub fn build_system_prompt_with_mode(
3791    workspace_dir: &std::path::Path,
3792    model_name: &str,
3793    tools: &[(&str, &str)],
3794    skills: &[crate::skills::Skill],
3795    identity_config: Option<&crate::config::IdentityConfig>,
3796    bootstrap_max_chars: Option<usize>,
3797    native_tools: bool,
3798    skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
3799    autonomy_level: AutonomyLevel,
3800) -> String {
3801    let autonomy_cfg = crate::config::AutonomyConfig {
3802        level: autonomy_level,
3803        ..Default::default()
3804    };
3805    build_system_prompt_with_mode_and_autonomy(
3806        workspace_dir,
3807        model_name,
3808        tools,
3809        skills,
3810        identity_config,
3811        bootstrap_max_chars,
3812        Some(&autonomy_cfg),
3813        native_tools,
3814        skills_prompt_mode,
3815        false,
3816        0,
3817    )
3818}
3819
3820#[allow(clippy::too_many_arguments)]
3821pub fn build_system_prompt_with_mode_and_autonomy(
3822    workspace_dir: &std::path::Path,
3823    model_name: &str,
3824    tools: &[(&str, &str)],
3825    skills: &[crate::skills::Skill],
3826    identity_config: Option<&crate::config::IdentityConfig>,
3827    bootstrap_max_chars: Option<usize>,
3828    autonomy_config: Option<&crate::config::AutonomyConfig>,
3829    native_tools: bool,
3830    skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
3831    compact_context: bool,
3832    max_system_prompt_chars: usize,
3833) -> String {
3834    use std::fmt::Write;
3835    let mut prompt = String::with_capacity(8192);
3836
3837    // ── 0. Anti-narration (top priority) ───────────────────────
3838    prompt.push_str(
3839        "## CRITICAL: No Tool Narration\n\n\
3840         NEVER narrate, announce, describe, or explain your tool usage to the user. \
3841         Do NOT say things like 'Let me check...', 'I will use http_request to...', \
3842         'I'll fetch that for you', 'Searching now...', or 'Using the web_search tool'. \
3843         The user must ONLY see the final answer. Tool calls are invisible infrastructure — \
3844         never reference them. If you catch yourself starting a sentence about what tool \
3845         you are about to use or just used, DELETE it and give the answer directly.\n\n",
3846    );
3847
3848    // ── 0b. Tool Honesty ───────────────────────────────────────
3849    prompt.push_str(
3850        "## CRITICAL: Tool Honesty\n\n\
3851         - NEVER fabricate, invent, or guess tool results. If a tool returns empty results, say \"No results found.\"\n\
3852         - If a tool call fails, report the error — never make up data to fill the gap.\n\
3853         - When unsure whether a tool call succeeded, ask the user rather than guessing.\n\n",
3854    );
3855
3856    // ── 1. Tooling ──────────────────────────────────────────────
3857    if !tools.is_empty() {
3858        prompt.push_str("## Tools\n\n");
3859        if compact_context {
3860            // Compact mode: tool names only, no descriptions/schemas
3861            prompt.push_str("Available tools: ");
3862            let names: Vec<&str> = tools.iter().map(|(name, _)| *name).collect();
3863            prompt.push_str(&names.join(", "));
3864            prompt.push_str("\n\n");
3865        } else {
3866            prompt.push_str("You have access to the following tools:\n\n");
3867            for (name, desc) in tools {
3868                let _ = writeln!(prompt, "- **{name}**: {desc}");
3869            }
3870            prompt.push('\n');
3871        }
3872    }
3873
3874    // ── 1b. Hardware (when gpio/arduino tools present) ───────────
3875    let has_hardware = tools.iter().any(|(name, _)| {
3876        *name == "gpio_read"
3877            || *name == "gpio_write"
3878            || *name == "arduino_upload"
3879            || *name == "hardware_memory_map"
3880            || *name == "hardware_board_info"
3881            || *name == "hardware_memory_read"
3882            || *name == "hardware_capabilities"
3883    });
3884    if has_hardware {
3885        prompt.push_str(
3886            "## Hardware Access\n\n\
3887             You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\
3888             All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\
3889             When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\
3890             When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\
3891             Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n",
3892        );
3893    }
3894
3895    // ── 1c. Action instruction (avoid meta-summary) ───────────────
3896    if native_tools {
3897        prompt.push_str(
3898            "## Your Task\n\n\
3899             When the user sends a message, ACT on it using your tools. Do not just talk about what you could do — call the tools directly.\n\
3900             If the user asks to start a workflow, call `get_workflow_context` immediately. If they ask about agents, call `list_agents`. Always try the relevant tool first before asking clarifying questions.\n\
3901             For questions, explanations, or follow-ups about prior messages, answer directly from conversation context — do NOT ask the user to repeat themselves.\n\
3902             Do NOT: summarize this configuration, describe your capabilities, ask unnecessary clarifying questions, or output step-by-step meta-commentary.\n\n",
3903        );
3904    } else {
3905        prompt.push_str(
3906            "## Your Task\n\n\
3907             When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\
3908             Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\
3909             Instead: emit actual <tool_call> tags when you need to act. Just do what they ask.\n\n",
3910        );
3911    }
3912
3913    // ── 2. Safety ───────────────────────────────────────────────
3914    prompt.push_str("## Safety\n\n");
3915    prompt.push_str("- Do not exfiltrate private data.\n");
3916    if autonomy_config.map(|cfg| cfg.level) != Some(crate::security::AutonomyLevel::Full) {
3917        prompt.push_str(
3918            "- Do not run destructive commands without asking.\n\
3919             - Do not bypass oversight or approval mechanisms.\n",
3920        );
3921    }
3922    prompt.push_str("- Prefer `trash` over `rm` (recoverable beats gone forever).\n");
3923    prompt.push_str(match autonomy_config.map(|cfg| cfg.level) {
3924        Some(crate::security::AutonomyLevel::Full) => {
3925            "- Respect the runtime autonomy policy: if a tool or action is allowed, execute it directly instead of asking the user for extra approval.\n\
3926             - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n"
3927        }
3928        Some(crate::security::AutonomyLevel::ReadOnly) => {
3929            "- Respect the runtime autonomy policy: this runtime is read-only for side effects unless a tool explicitly reports otherwise.\n\
3930             - If a requested action is blocked by policy, explain the restriction directly instead of simulating an approval dialog.\n"
3931        }
3932        _ => {
3933            "- When in doubt, ask before acting externally.\n\
3934             - Respect the runtime autonomy policy: ask for approval only when the current runtime policy actually requires it.\n\
3935             - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n"
3936        }
3937    });
3938    prompt.push('\n');
3939
3940    // ── 3. Skills (full or compact, based on config) ─────────────
3941    if !skills.is_empty() {
3942        prompt.push_str(&crate::skills::skills_to_prompt_with_mode(
3943            skills,
3944            workspace_dir,
3945            skills_prompt_mode,
3946        ));
3947        prompt.push_str("\n\n");
3948    }
3949
3950    // ── 4. Workspace ────────────────────────────────────────────
3951    let _ = writeln!(
3952        prompt,
3953        "## Workspace\n\nWorking directory: `{}`\n",
3954        workspace_dir.display()
3955    );
3956
3957    // ── 5. Bootstrap files (injected into context) ──────────────
3958    prompt.push_str("## Project Context\n\n");
3959
3960    // Check if AIEOS identity is configured
3961    if let Some(config) = identity_config {
3962        if identity::is_aieos_configured(config) {
3963            // Load AIEOS identity
3964            match identity::load_aieos_identity(config, workspace_dir) {
3965                Ok(Some(aieos_identity)) => {
3966                    let aieos_prompt = identity::aieos_to_system_prompt(&aieos_identity);
3967                    if !aieos_prompt.is_empty() {
3968                        prompt.push_str(&aieos_prompt);
3969                        prompt.push_str("\n\n");
3970                    }
3971                }
3972                Ok(None) => {
3973                    // No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true)
3974                    // Fall back to OpenClaw bootstrap files
3975                    let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
3976                    load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
3977                }
3978                Err(e) => {
3979                    // Log error but don't fail - fall back to OpenClaw
3980                    eprintln!(
3981                        "Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format."
3982                    );
3983                    let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
3984                    load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
3985                }
3986            }
3987        } else {
3988            // OpenClaw format
3989            let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
3990            load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
3991        }
3992    } else {
3993        // No identity config - use OpenClaw format
3994        let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
3995        load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
3996    }
3997
3998    // ── 6. Date & Time ──────────────────────────────────────────
3999    let now = chrono::Local::now();
4000    let _ = writeln!(
4001        prompt,
4002        "## Current Date & Time\n\n{} ({})\n",
4003        now.format("%Y-%m-%d %H:%M:%S"),
4004        now.format("%Z")
4005    );
4006
4007    // ── 7. Runtime ──────────────────────────────────────────────
4008    let host =
4009        hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
4010    let _ = writeln!(
4011        prompt,
4012        "## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n",
4013        std::env::consts::OS,
4014    );
4015
4016    // ── 8. Channel Capabilities (skipped in compact_context mode) ──
4017    if !compact_context {
4018        prompt.push_str("## Channel Capabilities\n\n");
4019        prompt.push_str("- You are running as a messaging bot. Your response is automatically sent back to the user's channel.\n");
4020        prompt
4021            .push_str("- You do NOT need to ask permission to respond — just respond directly.\n");
4022        prompt.push_str(match autonomy_config.map(|cfg| cfg.level) {
4023        Some(crate::security::AutonomyLevel::Full) => {
4024            "- If the runtime policy already allows a tool, use it directly; do not ask the user for extra approval.\n\
4025             - Never pretend you are waiting for a human approval click or confirmation when the runtime policy already permits the action.\n\
4026             - If the runtime policy blocks an action, say that directly instead of simulating an approval flow.\n"
4027        }
4028        Some(crate::security::AutonomyLevel::ReadOnly) => {
4029            "- This runtime may reject write-side effects; if that happens, explain the policy restriction directly instead of simulating an approval flow.\n"
4030        }
4031        _ => {
4032            "- Ask for approval only when the runtime policy actually requires it.\n\
4033             - If there is no approval path for this channel or the runtime blocks an action, explain that restriction directly instead of simulating an approval flow.\n"
4034        }
4035    });
4036        prompt.push_str("- NEVER repeat, describe, or echo credentials, tokens, API keys, or secrets in your responses.\n");
4037        prompt.push_str("- If a tool output contains credentials, they have already been redacted — do not mention them.\n");
4038        prompt.push_str("- When a user sends a voice note, it is automatically transcribed to text. Your text reply is automatically converted to a voice note and sent back. Do NOT attempt to generate audio yourself — TTS is handled by the channel.\n");
4039        prompt.push_str("- NEVER narrate or describe your tool usage. Do NOT say 'Let me fetch...', 'I will use...', 'Searching...', or similar. Give the FINAL ANSWER only — no intermediate steps, no tool mentions, no progress updates.\n\n");
4040    } // end if !compact_context (Channel Capabilities)
4041
4042    // ── 9. Truncation (max_system_prompt_chars budget) ──────────
4043    if max_system_prompt_chars > 0 && prompt.len() > max_system_prompt_chars {
4044        // Truncate on a char boundary, keeping the top portion (identity + safety).
4045        let mut end = max_system_prompt_chars;
4046        // Ensure we don't split a multi-byte UTF-8 character.
4047        while !prompt.is_char_boundary(end) && end > 0 {
4048            end -= 1;
4049        }
4050        prompt.truncate(end);
4051        prompt.push_str("\n\n[System prompt truncated to fit context budget]\n");
4052    }
4053
4054    if prompt.is_empty() {
4055        "You are Construct, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct."
4056            .to_string()
4057    } else {
4058        prompt
4059    }
4060}
4061
4062/// Inject a single workspace file into the prompt with truncation and missing-file markers.
4063fn inject_workspace_file(
4064    prompt: &mut String,
4065    workspace_dir: &std::path::Path,
4066    filename: &str,
4067    max_chars: usize,
4068) {
4069    use std::fmt::Write;
4070
4071    let path = workspace_dir.join(filename);
4072    match std::fs::read_to_string(&path) {
4073        Ok(content) => {
4074            let trimmed = content.trim();
4075            if trimmed.is_empty() {
4076                return;
4077            }
4078            let _ = writeln!(prompt, "### {filename}\n");
4079            // Use character-boundary-safe truncation for UTF-8
4080            let truncated = if trimmed.chars().count() > max_chars {
4081                trimmed
4082                    .char_indices()
4083                    .nth(max_chars)
4084                    .map(|(idx, _)| &trimmed[..idx])
4085                    .unwrap_or(trimmed)
4086            } else {
4087                trimmed
4088            };
4089            if truncated.len() < trimmed.len() {
4090                prompt.push_str(truncated);
4091                let _ = writeln!(
4092                    prompt,
4093                    "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n"
4094                );
4095            } else {
4096                prompt.push_str(trimmed);
4097                prompt.push_str("\n\n");
4098            }
4099        }
4100        Err(_) => {
4101            // Missing-file marker (matches OpenClaw behavior)
4102            let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n");
4103        }
4104    }
4105}
4106
4107fn normalize_telegram_identity(value: &str) -> String {
4108    value.trim().trim_start_matches('@').to_string()
4109}
4110
4111async fn bind_telegram_identity(config: &Config, identity: &str) -> Result<()> {
4112    let normalized = normalize_telegram_identity(identity);
4113    if normalized.is_empty() {
4114        anyhow::bail!("Telegram identity cannot be empty");
4115    }
4116
4117    let mut updated = config.clone();
4118    let Some(telegram) = updated.channels_config.telegram.as_mut() else {
4119        anyhow::bail!(
4120            "Telegram channel is not configured. Run `construct onboard --channels-only` first"
4121        );
4122    };
4123
4124    if telegram.allowed_users.iter().any(|u| u == "*") {
4125        println!(
4126            "⚠️ Telegram allowlist is currently wildcard (`*`) — binding is unnecessary until you remove '*'."
4127        );
4128    }
4129
4130    if telegram
4131        .allowed_users
4132        .iter()
4133        .map(|entry| normalize_telegram_identity(entry))
4134        .any(|entry| entry == normalized)
4135    {
4136        println!("✅ Telegram identity already bound: {normalized}");
4137        return Ok(());
4138    }
4139
4140    telegram.allowed_users.push(normalized.clone());
4141    updated.save().await?;
4142    println!("✅ Bound Telegram identity: {normalized}");
4143    println!("   Saved to {}", updated.config_path.display());
4144    match maybe_restart_managed_daemon_service() {
4145        Ok(true) => {
4146            println!("🔄 Detected running managed daemon service; reloaded automatically.");
4147        }
4148        Ok(false) => {
4149            println!(
4150                "ℹ️ No managed daemon service detected. If `construct daemon`/`channel start` is already running, restart it to load the updated allowlist."
4151            );
4152        }
4153        Err(e) => {
4154            eprintln!(
4155                "⚠️ Allowlist saved, but failed to reload daemon service automatically: {e}\n\
4156                 Restart service manually with `construct service stop && construct service start`."
4157            );
4158        }
4159    }
4160    Ok(())
4161}
4162
4163fn maybe_restart_managed_daemon_service() -> Result<bool> {
4164    if cfg!(target_os = "macos") {
4165        let home = directories::UserDirs::new()
4166            .map(|u| u.home_dir().to_path_buf())
4167            .context("Could not find home directory")?;
4168        let plist = home
4169            .join("Library")
4170            .join("LaunchAgents")
4171            .join("com.construct.daemon.plist");
4172        if !plist.exists() {
4173            return Ok(false);
4174        }
4175
4176        let list_output = Command::new("launchctl")
4177            .arg("list")
4178            .output()
4179            .context("Failed to query launchctl list")?;
4180        let listed = String::from_utf8_lossy(&list_output.stdout);
4181        if !listed.contains("com.construct.daemon") {
4182            return Ok(false);
4183        }
4184
4185        let _ = Command::new("launchctl")
4186            .args(["stop", "com.construct.daemon"])
4187            .output();
4188        let start_output = Command::new("launchctl")
4189            .args(["start", "com.construct.daemon"])
4190            .output()
4191            .context("Failed to start launchd daemon service")?;
4192        if !start_output.status.success() {
4193            let stderr = String::from_utf8_lossy(&start_output.stderr);
4194            anyhow::bail!("launchctl start failed: {}", stderr.trim());
4195        }
4196
4197        return Ok(true);
4198    }
4199
4200    if cfg!(target_os = "linux") {
4201        // OpenRC (system-wide) takes precedence over systemd (user-level)
4202        let openrc_init_script = PathBuf::from("/etc/init.d/construct");
4203        if openrc_init_script.exists() {
4204            if let Ok(status_output) = Command::new("rc-service").args(OPENRC_STATUS_ARGS).output()
4205            {
4206                // rc-service exits 0 if running, non-zero otherwise
4207                if status_output.status.success() {
4208                    let restart_output = Command::new("rc-service")
4209                        .args(OPENRC_RESTART_ARGS)
4210                        .output()
4211                        .context("Failed to restart OpenRC daemon service")?;
4212                    if !restart_output.status.success() {
4213                        let stderr = String::from_utf8_lossy(&restart_output.stderr);
4214                        anyhow::bail!("rc-service restart failed: {}", stderr.trim());
4215                    }
4216                    return Ok(true);
4217                }
4218            }
4219        }
4220
4221        // Systemd (user-level)
4222        let home = directories::UserDirs::new()
4223            .map(|u| u.home_dir().to_path_buf())
4224            .context("Could not find home directory")?;
4225        let unit_path: PathBuf = home
4226            .join(".config")
4227            .join("systemd")
4228            .join("user")
4229            .join("construct.service");
4230        if !unit_path.exists() {
4231            return Ok(false);
4232        }
4233
4234        let active_output = Command::new("systemctl")
4235            .args(SYSTEMD_STATUS_ARGS)
4236            .output()
4237            .context("Failed to query systemd service state")?;
4238        let state = String::from_utf8_lossy(&active_output.stdout);
4239        if !state.trim().eq_ignore_ascii_case("active") {
4240            return Ok(false);
4241        }
4242
4243        let restart_output = Command::new("systemctl")
4244            .args(SYSTEMD_RESTART_ARGS)
4245            .output()
4246            .context("Failed to restart systemd daemon service")?;
4247        if !restart_output.status.success() {
4248            let stderr = String::from_utf8_lossy(&restart_output.stderr);
4249            anyhow::bail!("systemctl restart failed: {}", stderr.trim());
4250        }
4251
4252        return Ok(true);
4253    }
4254
4255    Ok(false)
4256}
4257
4258pub(crate) async fn handle_command(command: crate::ChannelCommands, config: &Config) -> Result<()> {
4259    match command {
4260        crate::ChannelCommands::Start => {
4261            anyhow::bail!("Start must be handled in main.rs (requires async runtime)")
4262        }
4263        crate::ChannelCommands::Doctor => {
4264            anyhow::bail!("Doctor must be handled in main.rs (requires async runtime)")
4265        }
4266        crate::ChannelCommands::List => {
4267            println!("Channels:");
4268            println!("  ✅ CLI (always available)");
4269            for (channel, configured) in config.channels_config.channels() {
4270                println!(
4271                    "  {} {}",
4272                    if configured { "✅" } else { "❌" },
4273                    channel.name()
4274                );
4275            }
4276            // Notion is a top-level config section, not part of ChannelsConfig
4277            {
4278                let notion_configured =
4279                    config.notion.enabled && !config.notion.database_id.trim().is_empty();
4280                println!("  {} Notion", if notion_configured { "✅" } else { "❌" });
4281            }
4282            if !cfg!(feature = "channel-matrix") {
4283                println!(
4284                    "  ℹ️ Matrix channel support is disabled in this build (enable `channel-matrix`)."
4285                );
4286            }
4287            if !cfg!(feature = "channel-lark") {
4288                println!(
4289                    "  ℹ️ Lark/Feishu channel support is disabled in this build (enable `channel-lark`)."
4290                );
4291            }
4292            println!("\nTo start channels: construct channel start");
4293            println!("To check health:    construct channel doctor");
4294            println!("To configure:      construct onboard");
4295            Ok(())
4296        }
4297        crate::ChannelCommands::Add {
4298            channel_type,
4299            config: _,
4300        } => {
4301            anyhow::bail!(
4302                "Channel type '{channel_type}' — use `construct onboard` to configure channels"
4303            );
4304        }
4305        crate::ChannelCommands::Remove { name } => {
4306            anyhow::bail!("Remove channel '{name}' — edit ~/.construct/config.toml directly");
4307        }
4308        crate::ChannelCommands::BindTelegram { identity } => {
4309            Box::pin(bind_telegram_identity(config, &identity)).await
4310        }
4311        crate::ChannelCommands::Send {
4312            message,
4313            channel_id,
4314            recipient,
4315        } => send_channel_message(config, &channel_id, &recipient, &message).await,
4316    }
4317}
4318
4319/// Build a single channel instance by config section name (e.g. "telegram").
4320fn build_channel_by_id(config: &Config, channel_id: &str) -> Result<Arc<dyn Channel>> {
4321    match channel_id {
4322        "telegram" => {
4323            let tg = config
4324                .channels_config
4325                .telegram
4326                .as_ref()
4327                .context("Telegram channel is not configured")?;
4328            let ack = tg
4329                .ack_reactions
4330                .unwrap_or(config.channels_config.ack_reactions);
4331            Ok(Arc::new(
4332                TelegramChannel::new(
4333                    tg.bot_token.clone(),
4334                    tg.allowed_users.clone(),
4335                    tg.mention_only,
4336                )
4337                .with_ack_reactions(ack)
4338                .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
4339                .with_transcription(config.transcription.clone())
4340                .with_tts(config.tts.clone())
4341                .with_workspace_dir(config.workspace_dir.clone()),
4342            ))
4343        }
4344        "discord" => {
4345            let dc = config
4346                .channels_config
4347                .discord
4348                .as_ref()
4349                .context("Discord channel is not configured")?;
4350            Ok(Arc::new(
4351                DiscordChannel::new(
4352                    dc.bot_token.clone(),
4353                    dc.guild_id.clone(),
4354                    dc.allowed_users.clone(),
4355                    dc.listen_to_bots,
4356                    dc.mention_only,
4357                )
4358                .with_streaming(
4359                    dc.stream_mode,
4360                    dc.draft_update_interval_ms,
4361                    dc.multi_message_delay_ms,
4362                )
4363                .with_transcription(config.transcription.clone()),
4364            ))
4365        }
4366        "slack" => {
4367            let sl = config
4368                .channels_config
4369                .slack
4370                .as_ref()
4371                .context("Slack channel is not configured")?;
4372            Ok(Arc::new(
4373                SlackChannel::new(
4374                    sl.bot_token.clone(),
4375                    sl.app_token.clone(),
4376                    sl.channel_id.clone(),
4377                    sl.channel_ids.clone(),
4378                    sl.allowed_users.clone(),
4379                )
4380                .with_workspace_dir(config.workspace_dir.clone())
4381                .with_markdown_blocks(sl.use_markdown_blocks)
4382                .with_transcription(config.transcription.clone())
4383                .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
4384                .with_cancel_reaction(sl.cancel_reaction.clone()),
4385            ))
4386        }
4387        "mattermost" => {
4388            let mm = config
4389                .channels_config
4390                .mattermost
4391                .as_ref()
4392                .context("Mattermost channel is not configured")?;
4393            Ok(Arc::new(MattermostChannel::new(
4394                mm.url.clone(),
4395                mm.bot_token.clone(),
4396                mm.channel_id.clone(),
4397                mm.allowed_users.clone(),
4398                mm.thread_replies.unwrap_or(true),
4399                mm.mention_only.unwrap_or(false),
4400            )))
4401        }
4402        "signal" => {
4403            let sg = config
4404                .channels_config
4405                .signal
4406                .as_ref()
4407                .context("Signal channel is not configured")?;
4408            Ok(Arc::new(SignalChannel::new(
4409                sg.http_url.clone(),
4410                sg.account.clone(),
4411                sg.group_id.clone(),
4412                sg.allowed_from.clone(),
4413                sg.ignore_attachments,
4414                sg.ignore_stories,
4415            )))
4416        }
4417        "matrix" => {
4418            #[cfg(feature = "channel-matrix")]
4419            {
4420                let mx = config
4421                    .channels_config
4422                    .matrix
4423                    .as_ref()
4424                    .context("Matrix channel is not configured")?;
4425                Ok(Arc::new(MatrixChannel::new(
4426                    mx.homeserver.clone(),
4427                    mx.access_token.clone(),
4428                    mx.room_id.clone(),
4429                    mx.allowed_users.clone(),
4430                )))
4431            }
4432            #[cfg(not(feature = "channel-matrix"))]
4433            {
4434                anyhow::bail!("Matrix channel requires the `channel-matrix` feature");
4435            }
4436        }
4437        "whatsapp" | "whatsapp-web" | "whatsapp_web" => {
4438            #[cfg(feature = "whatsapp-web")]
4439            {
4440                let wa = config
4441                    .channels_config
4442                    .whatsapp
4443                    .as_ref()
4444                    .context("WhatsApp channel is not configured")?;
4445                if !wa.is_web_config() {
4446                    anyhow::bail!(
4447                        "WhatsApp channel send requires Web mode (session_path must be set)"
4448                    );
4449                }
4450                Ok(Arc::new(WhatsAppWebChannel::new(
4451                    wa.session_path.clone().unwrap_or_default(),
4452                    wa.pair_phone.clone(),
4453                    wa.pair_code.clone(),
4454                    wa.allowed_numbers.clone(),
4455                    wa.mode.clone(),
4456                    wa.dm_policy.clone(),
4457                    wa.group_policy.clone(),
4458                    wa.self_chat_mode,
4459                )))
4460            }
4461            #[cfg(not(feature = "whatsapp-web"))]
4462            {
4463                anyhow::bail!("WhatsApp channel requires the `whatsapp-web` feature");
4464            }
4465        }
4466        "qq" => {
4467            let qq = config
4468                .channels_config
4469                .qq
4470                .as_ref()
4471                .context("QQ channel is not configured")?;
4472            Ok(Arc::new(QQChannel::new(
4473                qq.app_id.clone(),
4474                qq.app_secret.clone(),
4475                qq.allowed_users.clone(),
4476            )))
4477        }
4478        other => anyhow::bail!(
4479            "Unknown channel '{other}'. Supported: telegram, discord, slack, mattermost, signal, matrix, whatsapp, qq"
4480        ),
4481    }
4482}
4483
4484/// Send a one-off message to a configured channel.
4485async fn send_channel_message(
4486    config: &Config,
4487    channel_id: &str,
4488    recipient: &str,
4489    message: &str,
4490) -> Result<()> {
4491    let channel = build_channel_by_id(config, channel_id)?;
4492    let msg = SendMessage::new(message, recipient);
4493    channel
4494        .send(&msg)
4495        .await
4496        .with_context(|| format!("Failed to send message via {channel_id}"))?;
4497    println!("Message sent via {channel_id}.");
4498    Ok(())
4499}
4500
4501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4502enum ChannelHealthState {
4503    Healthy,
4504    Unhealthy,
4505    Timeout,
4506}
4507
4508fn classify_health_result(
4509    result: &std::result::Result<bool, tokio::time::error::Elapsed>,
4510) -> ChannelHealthState {
4511    match result {
4512        Ok(true) => ChannelHealthState::Healthy,
4513        Ok(false) => ChannelHealthState::Unhealthy,
4514        Err(_) => ChannelHealthState::Timeout,
4515    }
4516}
4517
4518struct ConfiguredChannel {
4519    display_name: &'static str,
4520    channel: Arc<dyn Channel>,
4521}
4522
4523fn collect_configured_channels(
4524    config: &Config,
4525    matrix_skip_context: &str,
4526) -> Vec<ConfiguredChannel> {
4527    let _ = matrix_skip_context;
4528    let mut channels = Vec::new();
4529
4530    if let Some(ref tg) = config.channels_config.telegram {
4531        let ack = tg
4532            .ack_reactions
4533            .unwrap_or(config.channels_config.ack_reactions);
4534        channels.push(ConfiguredChannel {
4535            display_name: "Telegram",
4536            channel: Arc::new(
4537                TelegramChannel::new(
4538                    tg.bot_token.clone(),
4539                    tg.allowed_users.clone(),
4540                    tg.mention_only,
4541                )
4542                .with_ack_reactions(ack)
4543                .with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
4544                .with_transcription(config.transcription.clone())
4545                .with_tts(config.tts.clone())
4546                .with_workspace_dir(config.workspace_dir.clone())
4547                .with_proxy_url(tg.proxy_url.clone())
4548                .with_approval_registry(
4549                    crate::gateway::approval_registry::global(),
4550                    config.gateway.port,
4551                ),
4552            ),
4553        });
4554    }
4555
4556    if let Some(ref dc) = config.channels_config.discord {
4557        channels.push(ConfiguredChannel {
4558            display_name: "Discord",
4559            channel: Arc::new(
4560                DiscordChannel::new(
4561                    dc.bot_token.clone(),
4562                    dc.guild_id.clone(),
4563                    dc.allowed_users.clone(),
4564                    dc.listen_to_bots,
4565                    dc.mention_only,
4566                )
4567                .with_streaming(
4568                    dc.stream_mode,
4569                    dc.draft_update_interval_ms,
4570                    dc.multi_message_delay_ms,
4571                )
4572                .with_proxy_url(dc.proxy_url.clone())
4573                .with_transcription(config.transcription.clone())
4574                .with_approval_registry(
4575                    crate::gateway::approval_registry::global(),
4576                    config.gateway.port,
4577                ),
4578            ),
4579        });
4580    }
4581
4582    if config.channels_config.discord_history.is_some() {
4583        tracing::warn!(
4584            "discord_history: persistent history storage has been removed \
4585             (SQLite backend deleted; channel is disabled). \
4586             Use Kumiho MCP for persistent memory."
4587        );
4588    }
4589
4590    if let Some(ref sl) = config.channels_config.slack {
4591        channels.push(ConfiguredChannel {
4592            display_name: "Slack",
4593            channel: Arc::new(
4594                SlackChannel::new(
4595                    sl.bot_token.clone(),
4596                    sl.app_token.clone(),
4597                    sl.channel_id.clone(),
4598                    sl.channel_ids.clone(),
4599                    sl.allowed_users.clone(),
4600                )
4601                .with_thread_replies(sl.thread_replies.unwrap_or(true))
4602                .with_group_reply_policy(sl.mention_only, Vec::new())
4603                .with_workspace_dir(config.workspace_dir.clone())
4604                .with_markdown_blocks(sl.use_markdown_blocks)
4605                .with_proxy_url(sl.proxy_url.clone())
4606                .with_transcription(config.transcription.clone())
4607                .with_streaming(sl.stream_drafts, sl.draft_update_interval_ms)
4608                .with_cancel_reaction(sl.cancel_reaction.clone())
4609                .with_approval_registry(
4610                    crate::gateway::approval_registry::global(),
4611                    config.gateway.port,
4612                ),
4613            ),
4614        });
4615    }
4616
4617    if let Some(ref mm) = config.channels_config.mattermost {
4618        channels.push(ConfiguredChannel {
4619            display_name: "Mattermost",
4620            channel: Arc::new(
4621                MattermostChannel::new(
4622                    mm.url.clone(),
4623                    mm.bot_token.clone(),
4624                    mm.channel_id.clone(),
4625                    mm.allowed_users.clone(),
4626                    mm.thread_replies.unwrap_or(true),
4627                    mm.mention_only.unwrap_or(false),
4628                )
4629                .with_proxy_url(mm.proxy_url.clone())
4630                .with_transcription(config.transcription.clone()),
4631            ),
4632        });
4633    }
4634
4635    if let Some(ref im) = config.channels_config.imessage {
4636        channels.push(ConfiguredChannel {
4637            display_name: "iMessage",
4638            channel: Arc::new(IMessageChannel::new(im.allowed_contacts.clone())),
4639        });
4640    }
4641
4642    #[cfg(feature = "channel-matrix")]
4643    if let Some(ref mx) = config.channels_config.matrix {
4644        channels.push(ConfiguredChannel {
4645            display_name: "Matrix",
4646            channel: Arc::new(
4647                MatrixChannel::new_full(
4648                    mx.homeserver.clone(),
4649                    mx.access_token.clone(),
4650                    mx.room_id.clone(),
4651                    mx.allowed_users.clone(),
4652                    mx.allowed_rooms.clone(),
4653                    mx.user_id.clone(),
4654                    mx.device_id.clone(),
4655                    config.config_path.parent().map(|path| path.to_path_buf()),
4656                    mx.recovery_key.clone(),
4657                )
4658                .with_streaming(
4659                    mx.stream_mode,
4660                    mx.draft_update_interval_ms,
4661                    mx.multi_message_delay_ms,
4662                )
4663                .with_transcription(config.transcription.clone()),
4664            ),
4665        });
4666    }
4667
4668    #[cfg(not(feature = "channel-matrix"))]
4669    if config.channels_config.matrix.is_some() {
4670        tracing::warn!(
4671            "Matrix channel is configured but this build was compiled without `channel-matrix`; skipping Matrix {}.",
4672            matrix_skip_context
4673        );
4674    }
4675
4676    if let Some(ref sig) = config.channels_config.signal {
4677        channels.push(ConfiguredChannel {
4678            display_name: "Signal",
4679            channel: Arc::new(
4680                SignalChannel::new(
4681                    sig.http_url.clone(),
4682                    sig.account.clone(),
4683                    sig.group_id.clone(),
4684                    sig.allowed_from.clone(),
4685                    sig.ignore_attachments,
4686                    sig.ignore_stories,
4687                )
4688                .with_proxy_url(sig.proxy_url.clone()),
4689            ),
4690        });
4691    }
4692
4693    if let Some(ref wa) = config.channels_config.whatsapp {
4694        if wa.is_ambiguous_config() {
4695            tracing::warn!(
4696                "WhatsApp config has both phone_number_id and session_path set; preferring Cloud API mode. Remove one selector to avoid ambiguity."
4697            );
4698        }
4699        // Runtime negotiation: detect backend type from config
4700        match wa.backend_type() {
4701            "cloud" => {
4702                // Cloud API mode: requires phone_number_id, access_token, verify_token
4703                if wa.is_cloud_config() {
4704                    channels.push(ConfiguredChannel {
4705                        display_name: "WhatsApp",
4706                        channel: Arc::new(
4707                            WhatsAppChannel::new(
4708                                wa.access_token.clone().unwrap_or_default(),
4709                                wa.phone_number_id.clone().unwrap_or_default(),
4710                                wa.verify_token.clone().unwrap_or_default(),
4711                                wa.allowed_numbers.clone(),
4712                            )
4713                            .with_proxy_url(wa.proxy_url.clone())
4714                            .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
4715                            .with_group_mention_patterns(wa.group_mention_patterns.clone()),
4716                        ),
4717                    });
4718                } else {
4719                    tracing::warn!(
4720                        "WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)"
4721                    );
4722                }
4723            }
4724            "web" => {
4725                // Web mode: requires session_path
4726                #[cfg(feature = "whatsapp-web")]
4727                if wa.is_web_config() {
4728                    channels.push(ConfiguredChannel {
4729                        display_name: "WhatsApp",
4730                        channel: Arc::new(
4731                            WhatsAppWebChannel::new(
4732                                wa.session_path.clone().unwrap_or_default(),
4733                                wa.pair_phone.clone(),
4734                                wa.pair_code.clone(),
4735                                wa.allowed_numbers.clone(),
4736                                wa.mode.clone(),
4737                                wa.dm_policy.clone(),
4738                                wa.group_policy.clone(),
4739                                wa.self_chat_mode,
4740                            )
4741                            .with_transcription(config.transcription.clone())
4742                            .with_tts(config.tts.clone())
4743                            .with_dm_mention_patterns(wa.dm_mention_patterns.clone())
4744                            .with_group_mention_patterns(wa.group_mention_patterns.clone()),
4745                        ),
4746                    });
4747                } else {
4748                    tracing::warn!("WhatsApp Web configured but session_path not set");
4749                }
4750                #[cfg(not(feature = "whatsapp-web"))]
4751                {
4752                    tracing::warn!(
4753                        "WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web"
4754                    );
4755                    eprintln!(
4756                        "  ⚠ WhatsApp Web is configured but the 'whatsapp-web' feature is not compiled in."
4757                    );
4758                    eprintln!("    Rebuild with: cargo build --features whatsapp-web");
4759                }
4760            }
4761            _ => {
4762                tracing::warn!(
4763                    "WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set"
4764                );
4765            }
4766        }
4767    }
4768
4769    if let Some(ref lq) = config.channels_config.linq {
4770        channels.push(ConfiguredChannel {
4771            display_name: "Linq",
4772            channel: Arc::new(LinqChannel::new(
4773                lq.api_token.clone(),
4774                lq.from_phone.clone(),
4775                lq.allowed_senders.clone(),
4776            )),
4777        });
4778    }
4779
4780    if let Some(ref wati_cfg) = config.channels_config.wati {
4781        let wati_channel = WatiChannel::new_with_proxy(
4782            wati_cfg.api_token.clone(),
4783            wati_cfg.api_url.clone(),
4784            wati_cfg.tenant_id.clone(),
4785            wati_cfg.allowed_numbers.clone(),
4786            wati_cfg.proxy_url.clone(),
4787        )
4788        .with_transcription(config.transcription.clone());
4789
4790        channels.push(ConfiguredChannel {
4791            display_name: "WATI",
4792            channel: Arc::new(wati_channel),
4793        });
4794    }
4795
4796    if let Some(ref nc) = config.channels_config.nextcloud_talk {
4797        channels.push(ConfiguredChannel {
4798            display_name: "Nextcloud Talk",
4799            channel: Arc::new(NextcloudTalkChannel::new_with_proxy(
4800                nc.base_url.clone(),
4801                nc.app_token.clone(),
4802                nc.bot_name.clone().unwrap_or_default(),
4803                nc.allowed_users.clone(),
4804                nc.proxy_url.clone(),
4805            )),
4806        });
4807    }
4808
4809    if let Some(ref email_cfg) = config.channels_config.email {
4810        channels.push(ConfiguredChannel {
4811            display_name: "Email",
4812            channel: Arc::new(EmailChannel::new(email_cfg.clone())),
4813        });
4814    }
4815
4816    if let Some(ref gp_cfg) = config.channels_config.gmail_push {
4817        if gp_cfg.enabled {
4818            channels.push(ConfiguredChannel {
4819                display_name: "Gmail Push",
4820                channel: Arc::new(GmailPushChannel::new(gp_cfg.clone())),
4821            });
4822        }
4823    }
4824
4825    if let Some(ref irc) = config.channels_config.irc {
4826        channels.push(ConfiguredChannel {
4827            display_name: "IRC",
4828            channel: Arc::new(IrcChannel::new(irc::IrcChannelConfig {
4829                server: irc.server.clone(),
4830                port: irc.port,
4831                nickname: irc.nickname.clone(),
4832                username: irc.username.clone(),
4833                channels: irc.channels.clone(),
4834                allowed_users: irc.allowed_users.clone(),
4835                server_password: irc.server_password.clone(),
4836                nickserv_password: irc.nickserv_password.clone(),
4837                sasl_password: irc.sasl_password.clone(),
4838                verify_tls: irc.verify_tls.unwrap_or(true),
4839            })),
4840        });
4841    }
4842
4843    #[cfg(feature = "channel-lark")]
4844    if let Some(ref lk) = config.channels_config.lark {
4845        if lk.use_feishu {
4846            if config.channels_config.feishu.is_some() {
4847                tracing::warn!(
4848                    "Both [channels_config.feishu] and legacy [channels_config.lark].use_feishu=true are configured; ignoring legacy Feishu fallback in lark."
4849                );
4850            } else {
4851                tracing::warn!(
4852                    "Using legacy [channels_config.lark].use_feishu=true compatibility path; prefer [channels_config.feishu]."
4853                );
4854                channels.push(ConfiguredChannel {
4855                    display_name: "Feishu",
4856                    channel: Arc::new(
4857                        LarkChannel::from_config(lk)
4858                            .with_transcription(config.transcription.clone()),
4859                    ),
4860                });
4861            }
4862        } else {
4863            channels.push(ConfiguredChannel {
4864                display_name: "Lark",
4865                channel: Arc::new(
4866                    LarkChannel::from_lark_config(lk)
4867                        .with_transcription(config.transcription.clone()),
4868                ),
4869            });
4870        }
4871    }
4872
4873    #[cfg(feature = "channel-lark")]
4874    if let Some(ref fs) = config.channels_config.feishu {
4875        channels.push(ConfiguredChannel {
4876            display_name: "Feishu",
4877            channel: Arc::new(
4878                LarkChannel::from_feishu_config(fs)
4879                    .with_transcription(config.transcription.clone()),
4880            ),
4881        });
4882    }
4883
4884    #[cfg(not(feature = "channel-lark"))]
4885    if config.channels_config.lark.is_some() || config.channels_config.feishu.is_some() {
4886        tracing::warn!(
4887            "Lark/Feishu channel is configured but this build was compiled without `channel-lark`; skipping Lark/Feishu health check."
4888        );
4889    }
4890
4891    if let Some(ref dt) = config.channels_config.dingtalk {
4892        channels.push(ConfiguredChannel {
4893            display_name: "DingTalk",
4894            channel: Arc::new(
4895                DingTalkChannel::new(
4896                    dt.client_id.clone(),
4897                    dt.client_secret.clone(),
4898                    dt.allowed_users.clone(),
4899                )
4900                .with_proxy_url(dt.proxy_url.clone()),
4901            ),
4902        });
4903    }
4904
4905    if let Some(ref qq) = config.channels_config.qq {
4906        channels.push(ConfiguredChannel {
4907            display_name: "QQ",
4908            channel: Arc::new(
4909                QQChannel::new(
4910                    qq.app_id.clone(),
4911                    qq.app_secret.clone(),
4912                    qq.allowed_users.clone(),
4913                )
4914                .with_workspace_dir(config.workspace_dir.clone())
4915                .with_proxy_url(qq.proxy_url.clone()),
4916            ),
4917        });
4918    }
4919
4920    if let Some(ref tw) = config.channels_config.twitter {
4921        channels.push(ConfiguredChannel {
4922            display_name: "X/Twitter",
4923            channel: Arc::new(TwitterChannel::new(
4924                tw.bearer_token.clone(),
4925                tw.allowed_users.clone(),
4926            )),
4927        });
4928    }
4929
4930    if let Some(ref mc) = config.channels_config.mochat {
4931        channels.push(ConfiguredChannel {
4932            display_name: "Mochat",
4933            channel: Arc::new(MochatChannel::new(
4934                mc.api_url.clone(),
4935                mc.api_token.clone(),
4936                mc.allowed_users.clone(),
4937                mc.poll_interval_secs,
4938            )),
4939        });
4940    }
4941
4942    if let Some(ref wc) = config.channels_config.wecom {
4943        channels.push(ConfiguredChannel {
4944            display_name: "WeCom",
4945            channel: Arc::new(WeComChannel::new(
4946                wc.webhook_key.clone(),
4947                wc.allowed_users.clone(),
4948            )),
4949        });
4950    }
4951
4952    if let Some(ref ct) = config.channels_config.clawdtalk {
4953        channels.push(ConfiguredChannel {
4954            display_name: "ClawdTalk",
4955            channel: Arc::new(ClawdTalkChannel::new(ct.clone())),
4956        });
4957    }
4958
4959    // Notion database poller channel
4960    if config.notion.enabled && !config.notion.database_id.trim().is_empty() {
4961        let notion_api_key = if config.notion.api_key.trim().is_empty() {
4962            std::env::var("NOTION_API_KEY").unwrap_or_default()
4963        } else {
4964            config.notion.api_key.trim().to_string()
4965        };
4966        if notion_api_key.trim().is_empty() {
4967            tracing::warn!(
4968                "Notion channel enabled but no API key found (set notion.api_key or NOTION_API_KEY env var)"
4969            );
4970        } else {
4971            channels.push(ConfiguredChannel {
4972                display_name: "Notion",
4973                channel: Arc::new(NotionChannel::new(
4974                    notion_api_key,
4975                    config.notion.database_id.clone(),
4976                    config.notion.poll_interval_secs,
4977                    config.notion.status_property.clone(),
4978                    config.notion.input_property.clone(),
4979                    config.notion.result_property.clone(),
4980                    config.notion.max_concurrent,
4981                    config.notion.recover_stale,
4982                )),
4983            });
4984        }
4985    }
4986
4987    if let Some(ref rd) = config.channels_config.reddit {
4988        channels.push(ConfiguredChannel {
4989            display_name: "Reddit",
4990            channel: Arc::new(RedditChannel::new(
4991                rd.client_id.clone(),
4992                rd.client_secret.clone(),
4993                rd.refresh_token.clone(),
4994                rd.username.clone(),
4995                rd.subreddit.clone(),
4996            )),
4997        });
4998    }
4999
5000    if let Some(ref bs) = config.channels_config.bluesky {
5001        channels.push(ConfiguredChannel {
5002            display_name: "Bluesky",
5003            channel: Arc::new(BlueskyChannel::new(
5004                bs.handle.clone(),
5005                bs.app_password.clone(),
5006            )),
5007        });
5008    }
5009
5010    #[cfg(feature = "voice-wake")]
5011    if let Some(ref vw) = config.channels_config.voice_wake {
5012        channels.push(ConfiguredChannel {
5013            display_name: "VoiceWake",
5014            channel: Arc::new(VoiceWakeChannel::new(
5015                vw.clone(),
5016                config.transcription.clone(),
5017            )),
5018        });
5019    }
5020
5021    if let Some(ref wh) = config.channels_config.webhook {
5022        channels.push(ConfiguredChannel {
5023            display_name: "Webhook",
5024            channel: Arc::new(WebhookChannel::new(
5025                wh.port,
5026                wh.listen_path.clone(),
5027                wh.send_url.clone(),
5028                wh.send_method.clone(),
5029                wh.auth_header.clone(),
5030                wh.secret.clone(),
5031            )),
5032        });
5033    }
5034
5035    channels
5036}
5037
5038/// Run health checks for configured channels.
5039pub async fn doctor_channels(config: Config) -> Result<()> {
5040    #[allow(unused_mut)]
5041    let mut channels = collect_configured_channels(&config, "health check");
5042
5043    #[cfg(feature = "channel-nostr")]
5044    if let Some(ref ns) = config.channels_config.nostr {
5045        channels.push(ConfiguredChannel {
5046            display_name: "Nostr",
5047            channel: Arc::new(
5048                NostrChannel::new(&ns.private_key, ns.relays.clone(), &ns.allowed_pubkeys).await?,
5049            ),
5050        });
5051    }
5052
5053    if channels.is_empty() {
5054        println!("No real-time channels configured. Run `construct onboard` first.");
5055        return Ok(());
5056    }
5057
5058    println!("🩺 Construct Channel Doctor");
5059    println!();
5060
5061    let mut healthy = 0_u32;
5062    let mut unhealthy = 0_u32;
5063    let mut timeout = 0_u32;
5064
5065    for configured in channels {
5066        let result =
5067            tokio::time::timeout(Duration::from_secs(10), configured.channel.health_check()).await;
5068        let state = classify_health_result(&result);
5069
5070        match state {
5071            ChannelHealthState::Healthy => {
5072                healthy += 1;
5073                println!("  ✅ {:<9} healthy", configured.display_name);
5074            }
5075            ChannelHealthState::Unhealthy => {
5076                unhealthy += 1;
5077                println!(
5078                    "  ❌ {:<9} unhealthy (auth/config/network)",
5079                    configured.display_name
5080                );
5081            }
5082            ChannelHealthState::Timeout => {
5083                timeout += 1;
5084                println!("  ⏱️  {:<9} timed out (>10s)", configured.display_name);
5085            }
5086        }
5087    }
5088
5089    if config.channels_config.webhook.is_some() {
5090        println!("  ℹ️  Webhook   check via `construct gateway` then GET /health");
5091    }
5092
5093    println!();
5094    println!("Summary: {healthy} healthy, {unhealthy} unhealthy, {timeout} timed out");
5095    Ok(())
5096}
5097
5098/// Start all configured channels and route messages to the agent
5099#[allow(clippy::too_many_lines)]
5100pub async fn start_channels(config: Config) -> Result<()> {
5101    // Inject Kumiho + Operator MCP servers so channel agents have full
5102    // orchestration capabilities (user may issue operator orders via Discord).
5103    let config = crate::agent::kumiho::inject_kumiho(config, false);
5104    let config = crate::agent::operator::inject_operator(config, false);
5105
5106    let provider_name = resolved_default_provider(&config);
5107    let provider_runtime_options = providers::ProviderRuntimeOptions {
5108        auth_profile_override: None,
5109        provider_api_url: config.api_url.clone(),
5110        construct_dir: config.config_path.parent().map(std::path::PathBuf::from),
5111        secrets_encrypt: config.secrets.encrypt,
5112        reasoning_enabled: config.runtime.reasoning_enabled,
5113        reasoning_effort: config.runtime.reasoning_effort.clone(),
5114        provider_timeout_secs: Some(config.provider_timeout_secs),
5115        extra_headers: config.extra_headers.clone(),
5116        api_path: config.api_path.clone(),
5117        provider_max_tokens: config.provider_max_tokens,
5118    };
5119    let provider: Arc<dyn Provider> = Arc::from(
5120        create_resilient_provider_nonblocking(
5121            &provider_name,
5122            config.api_key.clone(),
5123            config.api_url.clone(),
5124            config.reliability.clone(),
5125            provider_runtime_options.clone(),
5126        )
5127        .await?,
5128    );
5129
5130    // Warm up the provider connection pool (TLS handshake, DNS, HTTP/2 setup)
5131    // so the first real message doesn't hit a cold-start timeout.
5132    if let Err(e) = provider.warmup().await {
5133        tracing::warn!("Provider warmup failed (non-fatal): {e}");
5134    }
5135
5136    let initial_stamp = config_file_stamp(&config.config_path).await;
5137    {
5138        let mut store = runtime_config_store()
5139            .lock()
5140            .unwrap_or_else(|e| e.into_inner());
5141        store.insert(
5142            config.config_path.clone(),
5143            RuntimeConfigState {
5144                defaults: runtime_defaults_from_config(&config),
5145                last_applied_stamp: initial_stamp,
5146            },
5147        );
5148    }
5149
5150    let observer: Arc<dyn Observer> =
5151        Arc::from(observability::create_observer(&config.observability));
5152    let runtime: Arc<dyn runtime::RuntimeAdapter> =
5153        Arc::from(runtime::create_runtime(&config.runtime)?);
5154    let security = Arc::new(SecurityPolicy::from_config(
5155        &config.autonomy,
5156        &config.workspace_dir,
5157    ));
5158    let model = resolved_default_model(&config);
5159    let temperature = config.default_temperature;
5160    let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
5161        &config.memory,
5162        &config.embedding_routes,
5163        Some(&config.storage.provider.config),
5164        &config.workspace_dir,
5165        config.api_key.as_deref(),
5166    )?);
5167    let (composio_key, composio_entity_id) = if config.composio.enabled {
5168        (
5169            config.composio.api_key.as_deref(),
5170            Some(config.composio.entity_id.as_str()),
5171        )
5172    } else {
5173        (None, None)
5174    };
5175    // Build system prompt from workspace identity files + skills
5176    let workspace = config.workspace_dir.clone();
5177    let (
5178        mut built_tools,
5179        delegate_handle_ch,
5180        reaction_handle_ch,
5181        _channel_map_handle,
5182        ask_user_handle_ch,
5183        escalate_handle_ch,
5184    ) = tools::all_tools_with_runtime(
5185        Arc::new(config.clone()),
5186        &security,
5187        runtime,
5188        Arc::clone(&mem),
5189        composio_key,
5190        composio_entity_id,
5191        &config.browser,
5192        &config.http_request,
5193        &config.web_fetch,
5194        &workspace,
5195        &config.agents,
5196        config.api_key.as_deref(),
5197        &config,
5198        None,
5199    );
5200
5201    // Wire MCP tools into the registry before freezing — non-fatal.
5202    // When `deferred_loading` is enabled, MCP tools are NOT added eagerly.
5203    // Instead, a `tool_search` built-in is registered for on-demand loading.
5204    let mut deferred_section = String::new();
5205    let mut ch_activated_handle: Option<
5206        std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>,
5207    > = None;
5208    let mut ch_mcp_registry: Option<Arc<crate::tools::McpRegistry>> = None;
5209    if config.mcp.enabled && !config.mcp.servers.is_empty() {
5210        tracing::info!(
5211            "Initializing MCP client — {} server(s) configured",
5212            config.mcp.servers.len()
5213        );
5214        match crate::tools::McpRegistry::connect_all(&config.mcp.servers).await {
5215            Ok(registry) => {
5216                let registry = std::sync::Arc::new(registry);
5217                ch_mcp_registry = Some(std::sync::Arc::clone(&registry));
5218                if config.mcp.deferred_loading {
5219                    // Hybrid: eagerly load essential tools, defer the rest.
5220                    //
5221                    // Local models (Ollama) get the minimal local eager set
5222                    // because large tool sets cause hallucinated tool names.
5223                    // Cloud providers get the curated operator-seat eager set
5224                    // (operator essentials + Kumiho memory reflexes); the rest
5225                    // is discoverable via tool_search to keep per-turn input
5226                    // tokens bounded.
5227                    let is_local_provider = resolved_default_provider(&config) == "ollama";
5228
5229                    let all_names = registry.tool_names();
5230                    let mut eager_count = 0usize;
5231
5232                    for name in &all_names {
5233                        let should_eager = if is_local_provider {
5234                            crate::tools::mcp_deferred::is_local_model_eager_tool(name)
5235                        } else {
5236                            crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
5237                        };
5238                        if should_eager {
5239                            if let Some(def) = registry.get_tool_def(name).await {
5240                                let wrapper: std::sync::Arc<dyn Tool> =
5241                                    std::sync::Arc::new(crate::tools::McpToolWrapper::new(
5242                                        name.clone(),
5243                                        def,
5244                                        std::sync::Arc::clone(&registry),
5245                                    ));
5246                                if let Some(ref handle) = delegate_handle_ch {
5247                                    handle.write().push(std::sync::Arc::clone(&wrapper));
5248                                }
5249                                built_tools.push(Box::new(crate::tools::ArcToolRef(wrapper)));
5250                                eager_count += 1;
5251                            }
5252                        }
5253                    }
5254
5255                    let deferred_set = crate::tools::DeferredMcpToolSet::from_registry_filtered(
5256                        std::sync::Arc::clone(&registry),
5257                        move |name: &str| {
5258                            if is_local_provider {
5259                                !crate::tools::mcp_deferred::is_local_model_eager_tool(name)
5260                            } else {
5261                                !crate::tools::mcp_deferred::is_operator_seat_eager_tool(name)
5262                            }
5263                        },
5264                    )
5265                    .await;
5266                    tracing::info!(
5267                        "MCP hybrid: {} eager tool(s), {} deferred stub(s) from {} server(s) (local_provider={})",
5268                        eager_count,
5269                        deferred_set.len(),
5270                        registry.server_count(),
5271                        is_local_provider,
5272                    );
5273                    deferred_section =
5274                        crate::tools::mcp_deferred::build_deferred_tools_section(&deferred_set);
5275                    let activated = std::sync::Arc::new(std::sync::Mutex::new(
5276                        crate::tools::ActivatedToolSet::new(),
5277                    ));
5278                    ch_activated_handle = Some(std::sync::Arc::clone(&activated));
5279                    built_tools.push(Box::new(crate::tools::ToolSearchTool::new(
5280                        deferred_set,
5281                        activated,
5282                    )));
5283                } else {
5284                    let names = registry.tool_names();
5285                    let mut registered = 0usize;
5286                    for name in names {
5287                        if let Some(def) = registry.get_tool_def(&name).await {
5288                            let wrapper: std::sync::Arc<dyn Tool> =
5289                                std::sync::Arc::new(crate::tools::McpToolWrapper::new(
5290                                    name,
5291                                    def,
5292                                    std::sync::Arc::clone(&registry),
5293                                ));
5294                            if let Some(ref handle) = delegate_handle_ch {
5295                                handle.write().push(std::sync::Arc::clone(&wrapper));
5296                            }
5297                            built_tools.push(Box::new(crate::tools::ArcToolRef(wrapper)));
5298                            registered += 1;
5299                        }
5300                    }
5301                    tracing::info!(
5302                        "MCP: {} tool(s) registered from {} server(s)",
5303                        registered,
5304                        registry.server_count()
5305                    );
5306                }
5307            }
5308            Err(e) => {
5309                // Non-fatal — daemon continues with the tools registered above.
5310                tracing::error!("MCP registry failed to initialize: {e:#}");
5311            }
5312        }
5313    }
5314
5315    let tools_registry = Arc::new(built_tools);
5316
5317    let skills = crate::skills::load_skills_with_config(&workspace, &config);
5318
5319    // ── Load locale-aware tool descriptions ────────────────────────
5320    let i18n_locale = config
5321        .locale
5322        .as_deref()
5323        .filter(|s| !s.is_empty())
5324        .map(ToString::to_string)
5325        .unwrap_or_else(crate::i18n::detect_locale);
5326    let i18n_search_dirs = crate::i18n::default_search_dirs(&workspace);
5327    let i18n_descs = crate::i18n::ToolDescriptions::load(&i18n_locale, &i18n_search_dirs);
5328
5329    // Collect tool descriptions for the prompt
5330    let mut tool_descs: Vec<(&str, &str)> = vec![
5331        (
5332            "shell",
5333            "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.",
5334        ),
5335        (
5336            "file_read",
5337            "Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
5338        ),
5339        (
5340            "file_write",
5341            "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.",
5342        ),
5343        (
5344            "memory_store",
5345            "Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
5346        ),
5347        (
5348            "memory_recall",
5349            "Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
5350        ),
5351        (
5352            "memory_forget",
5353            "Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
5354        ),
5355    ];
5356
5357    if matches!(
5358        config.skills.prompt_injection_mode,
5359        crate::config::SkillsPromptInjectionMode::Compact
5360    ) {
5361        tool_descs.push((
5362            "read_skill",
5363            "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.",
5364        ));
5365    }
5366
5367    if config.browser.enabled {
5368        tool_descs.push((
5369            "browser_open",
5370            "Open approved HTTPS URLs in system browser (allowlist-only, no scraping)",
5371        ));
5372    }
5373    if config.composio.enabled {
5374        tool_descs.push((
5375            "composio",
5376            "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.",
5377        ));
5378    }
5379    tool_descs.push((
5380        "schedule",
5381        "Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
5382    ));
5383    tool_descs.push((
5384        "pushover",
5385        "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.",
5386    ));
5387    if !config.agents.is_empty() {
5388        tool_descs.push((
5389            "delegate",
5390            "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.",
5391        ));
5392    }
5393
5394    // Filter out tools excluded for non-CLI channels so the system prompt
5395    // does not advertise them for channel-driven runs.
5396    // Skip this filter when autonomy is `Full` — full-autonomy agents keep
5397    // all tools available regardless of channel.
5398    let excluded = &config.autonomy.non_cli_excluded_tools;
5399    if !excluded.is_empty() && config.autonomy.level != AutonomyLevel::Full {
5400        tool_descs.retain(|(name, _)| {
5401            !excluded.iter().any(|ex| {
5402                if let Some(prefix) = ex.strip_suffix('*') {
5403                    name.starts_with(prefix)
5404                } else {
5405                    ex == name
5406                }
5407            })
5408        });
5409    }
5410
5411    let bootstrap_max_chars = if config.agent.compact_context {
5412        Some(6000)
5413    } else {
5414        None
5415    };
5416    let native_tools = provider.supports_native_tools();
5417    let mut system_prompt = build_system_prompt_with_mode_and_autonomy(
5418        &workspace,
5419        &model,
5420        &tool_descs,
5421        &skills,
5422        Some(&config.identity),
5423        bootstrap_max_chars,
5424        Some(&config.autonomy),
5425        native_tools,
5426        config.skills.prompt_injection_mode,
5427        config.agent.compact_context,
5428        config.agent.max_system_prompt_chars,
5429    );
5430    if !native_tools {
5431        system_prompt.push_str(&build_tool_instructions(
5432            tools_registry.as_ref(),
5433            Some(&i18n_descs),
5434        ));
5435    }
5436
5437    // Append deferred MCP tool names so the LLM knows what is available
5438    if !deferred_section.is_empty() {
5439        system_prompt.push('\n');
5440        system_prompt.push_str(&deferred_section);
5441    }
5442
5443    // Append lightweight Kumiho + Operator bootstraps (~400 tokens total).
5444    // Full instructions are loaded on-demand via MCP skill on first turn,
5445    // following OpenClaw's one-shot pattern — no bloat on every message.
5446    crate::agent::kumiho::append_kumiho_channel_bootstrap(&mut system_prompt, &config, false);
5447    crate::agent::operator::append_operator_channel_prompt(
5448        &mut system_prompt,
5449        &config,
5450        false,
5451        &model,
5452    );
5453
5454    if !skills.is_empty() {
5455        println!(
5456            "  🧩 Skills:   {}",
5457            skills
5458                .iter()
5459                .map(|s| s.name.as_str())
5460                .collect::<Vec<_>>()
5461                .join(", ")
5462        );
5463    }
5464
5465    // Collect active channels from a shared builder to keep startup and doctor parity.
5466    #[allow(unused_mut)]
5467    let mut channels: Vec<Arc<dyn Channel>> =
5468        collect_configured_channels(&config, "runtime startup")
5469            .into_iter()
5470            .map(|configured| configured.channel)
5471            .collect();
5472
5473    #[cfg(feature = "channel-nostr")]
5474    if let Some(ref ns) = config.channels_config.nostr {
5475        channels.push(Arc::new(
5476            NostrChannel::new(&ns.private_key, ns.relays.clone(), &ns.allowed_pubkeys).await?,
5477        ));
5478    }
5479    if channels.is_empty() {
5480        println!("No channels configured. Run `construct onboard` to set up channels.");
5481        return Ok(());
5482    }
5483
5484    println!("🦀 Construct Channel Server");
5485    println!("  🤖 Model:    {model}");
5486    let effective_backend = memory::effective_memory_backend_name(
5487        &config.memory.backend,
5488        Some(&config.storage.provider.config),
5489    );
5490    println!(
5491        "  🧠 Memory:   {} (auto-save: {})",
5492        effective_backend,
5493        if config.memory.auto_save { "on" } else { "off" }
5494    );
5495    println!(
5496        "  📡 Channels: {}",
5497        channels
5498            .iter()
5499            .map(|c| c.name())
5500            .collect::<Vec<_>>()
5501            .join(", ")
5502    );
5503    println!();
5504    println!("  Listening for messages... (Ctrl+C to stop)");
5505    println!();
5506
5507    crate::health::mark_component_ok("channels");
5508
5509    let initial_backoff_secs = config
5510        .reliability
5511        .channel_initial_backoff_secs
5512        .max(DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS);
5513    let max_backoff_secs = config
5514        .reliability
5515        .channel_max_backoff_secs
5516        .max(DEFAULT_CHANNEL_MAX_BACKOFF_SECS);
5517
5518    // Single message bus — all channels send messages here
5519    let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(100);
5520
5521    // Spawn a listener for each channel
5522    let mut handles = Vec::new();
5523    for ch in &channels {
5524        handles.push(spawn_supervised_listener(
5525            ch.clone(),
5526            tx.clone(),
5527            initial_backoff_secs,
5528            max_backoff_secs,
5529        ));
5530    }
5531    drop(tx); // Drop our copy so rx closes when all channels stop
5532
5533    let channels_by_name = Arc::new(
5534        channels
5535            .iter()
5536            .map(|ch| (ch.name().to_string(), Arc::clone(ch)))
5537            .collect::<HashMap<_, _>>(),
5538    );
5539
5540    // Populate the reaction tool's channel map now that channels are initialized.
5541    if let Some(ref handle) = reaction_handle_ch {
5542        let mut map = handle.write();
5543        for (name, ch) in channels_by_name.as_ref() {
5544            map.insert(name.clone(), Arc::clone(ch));
5545        }
5546    }
5547
5548    // Populate the ask_user tool's channel map now that channels are initialized.
5549    if let Some(ref handle) = ask_user_handle_ch {
5550        let mut map = handle.write();
5551        for (name, ch) in channels_by_name.as_ref() {
5552            map.insert(name.clone(), Arc::clone(ch));
5553        }
5554    }
5555
5556    // Populate the escalate_to_human tool's channel map now that channels are initialized.
5557    if let Some(ref handle) = escalate_handle_ch {
5558        let mut map = handle.write();
5559        for (name, ch) in channels_by_name.as_ref() {
5560            map.insert(name.clone(), Arc::clone(ch));
5561        }
5562    }
5563
5564    let max_in_flight_messages = compute_max_in_flight_messages(channels.len());
5565
5566    println!("  🚦 In-flight message limit: {max_in_flight_messages}");
5567
5568    let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
5569    provider_cache_seed.insert(provider_name.clone(), Arc::clone(&provider));
5570    let message_timeout_secs =
5571        effective_channel_message_timeout_secs(config.channels_config.message_timeout_secs);
5572    let interrupt_on_new_message = config
5573        .channels_config
5574        .telegram
5575        .as_ref()
5576        .is_some_and(|tg| tg.interrupt_on_new_message);
5577    let interrupt_on_new_message_slack = config
5578        .channels_config
5579        .slack
5580        .as_ref()
5581        .is_some_and(|sl| sl.interrupt_on_new_message);
5582    let interrupt_on_new_message_discord = config
5583        .channels_config
5584        .discord
5585        .as_ref()
5586        .is_some_and(|dc| dc.interrupt_on_new_message);
5587    let interrupt_on_new_message_mattermost = config
5588        .channels_config
5589        .mattermost
5590        .as_ref()
5591        .is_some_and(|mm| mm.interrupt_on_new_message);
5592    let interrupt_on_new_message_matrix = config
5593        .channels_config
5594        .matrix
5595        .as_ref()
5596        .is_some_and(|mx| mx.interrupt_on_new_message);
5597
5598    // Create audit logger for channel message tracking (shares log file with gateway).
5599    let audit_logger: Option<Arc<crate::security::audit::AuditLogger>> =
5600        if config.security.audit.enabled {
5601            match crate::security::audit::AuditLogger::new(
5602                config.security.audit.clone(),
5603                std::path::PathBuf::from(&config.workspace_dir),
5604            ) {
5605                Ok(logger) => Some(Arc::new(logger)),
5606                Err(e) => {
5607                    tracing::warn!("Channel audit logger disabled: {e}");
5608                    None
5609                }
5610            }
5611        } else {
5612            None
5613        };
5614
5615    let runtime_ctx = Arc::new(ChannelRuntimeContext {
5616        channels_by_name,
5617        provider: Arc::clone(&provider),
5618        default_provider: Arc::new(provider_name),
5619        prompt_config: Arc::new(config.clone()),
5620        memory: Arc::clone(&mem),
5621        tools_registry: Arc::clone(&tools_registry),
5622        observer,
5623        system_prompt: Arc::new(system_prompt),
5624        model: Arc::new(model.clone()),
5625        temperature,
5626        auto_save_memory: config.memory.auto_save,
5627        max_tool_iterations: crate::agent::loop_::effective_max_tool_iterations(&config),
5628        min_relevance_score: config.memory.min_relevance_score,
5629        conversation_histories: Arc::new(Mutex::new(HashMap::new())),
5630        pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
5631        provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
5632        route_overrides: Arc::new(Mutex::new(HashMap::new())),
5633        api_key: config.api_key.clone(),
5634        api_url: config.api_url.clone(),
5635        reliability: Arc::new(config.reliability.clone()),
5636        provider_runtime_options,
5637        workspace_dir: Arc::new(config.workspace_dir.clone()),
5638        message_timeout_secs,
5639        interrupt_on_new_message: InterruptOnNewMessageConfig {
5640            telegram: interrupt_on_new_message,
5641            slack: interrupt_on_new_message_slack,
5642            discord: interrupt_on_new_message_discord,
5643            mattermost: interrupt_on_new_message_mattermost,
5644            matrix: interrupt_on_new_message_matrix,
5645        },
5646        multimodal: config.multimodal.clone(),
5647        media_pipeline: config.media_pipeline.clone(),
5648        transcription_config: config.transcription.clone(),
5649        hooks: if config.hooks.enabled {
5650            let mut runner = crate::hooks::HookRunner::new();
5651            if config.hooks.builtin.command_logger {
5652                runner.register(Box::new(crate::hooks::builtin::CommandLoggerHook::new()));
5653            }
5654            if config.hooks.builtin.webhook_audit.enabled {
5655                runner.register(Box::new(crate::hooks::builtin::WebhookAuditHook::new(
5656                    config.hooks.builtin.webhook_audit.clone(),
5657                )));
5658            }
5659            Some(Arc::new(runner))
5660        } else {
5661            None
5662        },
5663        non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()),
5664        autonomy_level: config.autonomy.level,
5665        tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
5666        model_routes: Arc::new(config.model_routes.clone()),
5667        query_classification: config.query_classification.clone(),
5668        ack_reactions: config.channels_config.ack_reactions,
5669        show_tool_calls: config.channels_config.show_tool_calls,
5670        session_store: if config.channels_config.session_persistence {
5671            match session_store::SessionStore::new(&config.workspace_dir) {
5672                Ok(store) => {
5673                    tracing::info!("📂 Session persistence enabled");
5674                    Some(Arc::new(store))
5675                }
5676                Err(e) => {
5677                    tracing::warn!("Session persistence disabled: {e}");
5678                    None
5679                }
5680            }
5681        } else {
5682            None
5683        },
5684        approval_manager: Arc::new(ApprovalManager::for_non_interactive(&config.autonomy)),
5685        activated_tools: ch_activated_handle,
5686        mcp_registry: ch_mcp_registry,
5687        cost_tracking: crate::cost::CostTracker::get_or_init_global(
5688            config.cost.clone(),
5689            &config.workspace_dir,
5690        )
5691        .map(|tracker| ChannelCostTrackingState {
5692            tracker,
5693            prices: Arc::new(config.cost.prices.clone()),
5694        }),
5695        pacing: config.pacing.clone(),
5696        max_tool_result_chars: config.agent.max_tool_result_chars,
5697        context_token_budget: config.agent.max_context_tokens,
5698        audit_logger,
5699        debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::from_millis(
5700            config.channels_config.debounce_ms,
5701        ))),
5702    });
5703
5704    // Hydrate in-memory conversation histories from persisted JSONL session files.
5705    // If the last persisted turn is a user message (orphan from a crash mid-query),
5706    // close it with a marker so the LLM doesn't try to continue the old request.
5707    if let Some(ref store) = runtime_ctx.session_store {
5708        let mut hydrated = 0usize;
5709        let mut orphans_closed = 0usize;
5710        let mut histories = runtime_ctx
5711            .conversation_histories
5712            .lock()
5713            .unwrap_or_else(|e| e.into_inner());
5714        for key in store.list_sessions() {
5715            let mut msgs = store.load(&key);
5716            if msgs.is_empty() {
5717                continue;
5718            }
5719            // Close orphaned user turns from crashed sessions.
5720            if msgs.last().is_some_and(|m| m.role == "user") {
5721                let closure =
5722                    ChatMessage::assistant("[Session interrupted — not continuing this request]");
5723                if let Err(e) = store.append(&key, &closure) {
5724                    tracing::debug!("Failed to persist orphan closure for {key}: {e}");
5725                }
5726                msgs.push(closure);
5727                orphans_closed += 1;
5728            }
5729            hydrated += 1;
5730            histories.insert(key, msgs);
5731        }
5732        drop(histories);
5733        if hydrated > 0 {
5734            tracing::info!("📂 Restored {hydrated} session(s) from disk");
5735        }
5736        if orphans_closed > 0 {
5737            tracing::info!(
5738                "🔒 Closed {orphans_closed} orphaned session turn(s) from previous crash"
5739            );
5740        }
5741    }
5742
5743    run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await;
5744
5745    // Wait for all channel tasks
5746    for h in handles {
5747        let _ = h.await;
5748    }
5749
5750    Ok(())
5751}
5752
5753#[cfg(test)]
5754mod tests {
5755    use super::*;
5756    use crate::memory::Memory;
5757    use crate::observability::NoopObserver;
5758    use crate::providers::{ChatMessage, Provider};
5759    use crate::tools::{Tool, ToolResult};
5760    use std::collections::{HashMap, HashSet};
5761    use std::sync::Arc;
5762    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5763    use tempfile::TempDir;
5764
5765    fn make_workspace() -> TempDir {
5766        let tmp = TempDir::new().unwrap();
5767        // Create minimal workspace files
5768        std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap();
5769        std::fs::write(
5770            tmp.path().join("IDENTITY.md"),
5771            "# Identity\nName: Construct",
5772        )
5773        .unwrap();
5774        std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap();
5775        std::fs::write(
5776            tmp.path().join("AGENTS.md"),
5777            "# Agents\nFollow instructions.",
5778        )
5779        .unwrap();
5780        std::fs::write(tmp.path().join("TOOLS.md"), "# Tools\nUse shell carefully.").unwrap();
5781        std::fs::write(
5782            tmp.path().join("HEARTBEAT.md"),
5783            "# Heartbeat\nCheck status.",
5784        )
5785        .unwrap();
5786        std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap();
5787        tmp
5788    }
5789
5790    #[test]
5791    fn effective_channel_message_timeout_secs_clamps_to_minimum() {
5792        assert_eq!(
5793            effective_channel_message_timeout_secs(0),
5794            MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
5795        );
5796        assert_eq!(
5797            effective_channel_message_timeout_secs(15),
5798            MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
5799        );
5800        assert_eq!(effective_channel_message_timeout_secs(300), 300);
5801    }
5802
5803    #[test]
5804    fn channel_message_timeout_budget_scales_with_tool_iterations() {
5805        assert_eq!(channel_message_timeout_budget_secs(300, 1), 300);
5806        assert_eq!(channel_message_timeout_budget_secs(300, 2), 600);
5807        assert_eq!(channel_message_timeout_budget_secs(300, 3), 900);
5808    }
5809
5810    #[test]
5811    fn channel_message_timeout_budget_uses_safe_defaults_and_cap() {
5812        // 0 iterations falls back to 1x timeout budget.
5813        assert_eq!(channel_message_timeout_budget_secs(300, 0), 300);
5814        // Large iteration counts are capped to avoid runaway waits.
5815        assert_eq!(
5816            channel_message_timeout_budget_secs(300, 10),
5817            300 * CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
5818        );
5819    }
5820
5821    #[test]
5822    fn channel_message_timeout_budget_with_custom_scale_cap() {
5823        assert_eq!(
5824            channel_message_timeout_budget_secs_with_cap(300, 8, 8),
5825            300 * 8
5826        );
5827        assert_eq!(
5828            channel_message_timeout_budget_secs_with_cap(300, 20, 8),
5829            300 * 8
5830        );
5831        assert_eq!(
5832            channel_message_timeout_budget_secs_with_cap(300, 10, 1),
5833            300
5834        );
5835    }
5836
5837    #[test]
5838    fn pacing_config_defaults_preserve_existing_behavior() {
5839        let pacing = crate::config::PacingConfig::default();
5840        assert!(pacing.step_timeout_secs.is_none());
5841        assert!(pacing.loop_detection_min_elapsed_secs.is_none());
5842        assert!(pacing.loop_ignore_tools.is_empty());
5843        assert!(pacing.message_timeout_scale_max.is_none());
5844    }
5845
5846    #[test]
5847    fn pacing_message_timeout_scale_max_overrides_default_cap() {
5848        // Custom cap of 8 scales budget proportionally
5849        assert_eq!(
5850            channel_message_timeout_budget_secs_with_cap(300, 10, 8),
5851            300 * 8
5852        );
5853        // Default cap produces the standard behavior
5854        assert_eq!(
5855            channel_message_timeout_budget_secs_with_cap(
5856                300,
5857                10,
5858                CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
5859            ),
5860            300 * CHANNEL_MESSAGE_TIMEOUT_SCALE_CAP
5861        );
5862    }
5863
5864    #[test]
5865    fn context_window_overflow_error_detector_matches_known_messages() {
5866        let overflow_err = anyhow::anyhow!(
5867            "OpenAI Codex stream error: Your input exceeds the context window of this model."
5868        );
5869        assert!(is_context_window_overflow_error(&overflow_err));
5870
5871        let other_err =
5872            anyhow::anyhow!("OpenAI Codex API error (502 Bad Gateway): error code: 502");
5873        assert!(!is_context_window_overflow_error(&other_err));
5874    }
5875
5876    #[test]
5877    fn memory_context_skip_rules_exclude_history_blobs() {
5878        assert!(should_skip_memory_context_entry(
5879            "telegram_123_history",
5880            r#"[{"role":"user"}]"#
5881        ));
5882        assert!(should_skip_memory_context_entry(
5883            "assistant_resp_legacy",
5884            "fabricated memory"
5885        ));
5886        assert!(!should_skip_memory_context_entry("telegram_123_45", "hi"));
5887
5888        // Entries containing image markers must be skipped to prevent
5889        // auto-saved photo messages from duplicating image blocks (#2403).
5890        assert!(should_skip_memory_context_entry(
5891            "telegram_user_msg_99",
5892            "[IMAGE:/tmp/workspace/photo_1_2.jpg]"
5893        ));
5894        assert!(should_skip_memory_context_entry(
5895            "telegram_user_msg_100",
5896            "[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nCheck this screenshot"
5897        ));
5898        // Plain text without image markers should not be skipped.
5899        assert!(!should_skip_memory_context_entry(
5900            "telegram_user_msg_101",
5901            "Please describe the image"
5902        ));
5903
5904        // Entries containing tool_result blocks must be skipped (#3402).
5905        assert!(should_skip_memory_context_entry(
5906            "telegram_user_msg_200",
5907            r#"[Tool results]
5908<tool_result name="shell">Mon Feb 20</tool_result>"#
5909        ));
5910        assert!(!should_skip_memory_context_entry(
5911            "telegram_user_msg_201",
5912            "plain text without tool results"
5913        ));
5914    }
5915
5916    #[test]
5917    fn strip_tool_result_content_removes_blocks_and_header() {
5918        let input = r#"[Tool results]
5919<tool_result name="shell">Mon Feb 20</tool_result>
5920<tool_result name="http_request">{"status":200}</tool_result>"#;
5921        assert_eq!(strip_tool_result_content(input), "");
5922
5923        let mixed = "Some context\n<tool_result name=\"shell\">ok</tool_result>\nMore text";
5924        let cleaned = strip_tool_result_content(mixed);
5925        assert!(cleaned.contains("Some context"));
5926        assert!(cleaned.contains("More text"));
5927        assert!(!cleaned.contains("tool_result"));
5928
5929        assert_eq!(
5930            strip_tool_result_content("no tool results here"),
5931            "no tool results here"
5932        );
5933        assert_eq!(strip_tool_result_content(""), "");
5934    }
5935
5936    #[test]
5937    fn strip_tool_summary_prefix_removes_prefix_and_preserves_content() {
5938        let input = "[Used tools: browser_open, shell]\nI opened the page successfully.";
5939        assert_eq!(
5940            strip_tool_summary_prefix(input),
5941            "I opened the page successfully."
5942        );
5943    }
5944
5945    #[test]
5946    fn strip_tool_summary_prefix_returns_empty_when_only_prefix() {
5947        let input = "[Used tools: browser_open]";
5948        assert_eq!(strip_tool_summary_prefix(input), "");
5949    }
5950
5951    #[test]
5952    fn strip_tool_summary_prefix_preserves_text_without_prefix() {
5953        let input = "Here is the result of the search.";
5954        assert_eq!(strip_tool_summary_prefix(input), input);
5955    }
5956
5957    #[test]
5958    fn strip_tool_summary_prefix_handles_multiple_newlines() {
5959        let input = "[Used tools: shell]\n\nThe command output is 42.";
5960        assert_eq!(
5961            strip_tool_summary_prefix(input),
5962            "The command output is 42."
5963        );
5964    }
5965
5966    #[test]
5967    fn sanitize_channel_response_strips_used_tools_with_leading_whitespace() {
5968        let tools: Vec<Box<dyn Tool>> = Vec::new();
5969        // Issue #4478: response with leading whitespace before [Used tools: ...]
5970        let input = "  [Used tools: web_search_tool]\nHere is the search result.";
5971
5972        let result = sanitize_channel_response(input, &tools);
5973
5974        assert!(!result.contains("[Used tools:"));
5975        assert!(result.contains("Here is the search result."));
5976    }
5977
5978    #[test]
5979    fn normalize_cached_channel_turns_merges_consecutive_user_turns() {
5980        let turns = vec![
5981            ChatMessage::user("forwarded content"),
5982            ChatMessage::user("summarize this"),
5983        ];
5984
5985        let normalized = normalize_cached_channel_turns(turns);
5986        assert_eq!(normalized.len(), 1);
5987        assert_eq!(normalized[0].role, "user");
5988        assert!(normalized[0].content.contains("forwarded content"));
5989        assert!(normalized[0].content.contains("summarize this"));
5990    }
5991
5992    #[test]
5993    fn normalize_cached_channel_turns_merges_consecutive_assistant_turns() {
5994        let turns = vec![
5995            ChatMessage::user("first user"),
5996            ChatMessage::assistant("assistant part 1"),
5997            ChatMessage::assistant("assistant part 2"),
5998            ChatMessage::user("next user"),
5999        ];
6000
6001        let normalized = normalize_cached_channel_turns(turns);
6002        assert_eq!(normalized.len(), 3);
6003        assert_eq!(normalized[0].role, "user");
6004        assert_eq!(normalized[1].role, "assistant");
6005        assert_eq!(normalized[2].role, "user");
6006        assert!(normalized[1].content.contains("assistant part 1"));
6007        assert!(normalized[1].content.contains("assistant part 2"));
6008    }
6009
6010    /// Verify that an orphan user turn followed by a failure-marker assistant
6011    /// turn normalizes correctly, so the LLM sees the failed request as closed
6012    /// and does not re-execute it on the next user message.
6013    #[test]
6014    fn normalize_preserves_failure_marker_after_orphan_user_turn() {
6015        let turns = vec![
6016            ChatMessage::user("download something from GitHub"),
6017            ChatMessage::assistant("[Task failed — not continuing this request]"),
6018            ChatMessage::user("what is WAL?"),
6019        ];
6020
6021        let normalized = normalize_cached_channel_turns(turns);
6022        assert_eq!(normalized.len(), 3);
6023        assert_eq!(normalized[0].role, "user");
6024        assert_eq!(normalized[1].role, "assistant");
6025        assert!(normalized[1].content.contains("Task failed"));
6026        assert_eq!(normalized[2].role, "user");
6027        assert_eq!(normalized[2].content, "what is WAL?");
6028    }
6029
6030    /// Same as above but for the timeout variant.
6031    #[test]
6032    fn normalize_preserves_timeout_marker_after_orphan_user_turn() {
6033        let turns = vec![
6034            ChatMessage::user("run a long task"),
6035            ChatMessage::assistant("[Task timed out — not continuing this request]"),
6036            ChatMessage::user("next question"),
6037        ];
6038
6039        let normalized = normalize_cached_channel_turns(turns);
6040        assert_eq!(normalized.len(), 3);
6041        assert_eq!(normalized[1].role, "assistant");
6042        assert!(normalized[1].content.contains("Task timed out"));
6043        assert_eq!(normalized[2].content, "next question");
6044    }
6045
6046    #[test]
6047    fn compact_sender_history_keeps_recent_truncated_messages() {
6048        let mut histories = HashMap::new();
6049        let sender = "telegram_u1".to_string();
6050        histories.insert(
6051            sender.clone(),
6052            (0..20)
6053                .map(|idx| {
6054                    let content = format!("msg-{idx}-{}", "x".repeat(700));
6055                    if idx % 2 == 0 {
6056                        ChatMessage::user(content)
6057                    } else {
6058                        ChatMessage::assistant(content)
6059                    }
6060                })
6061                .collect::<Vec<_>>(),
6062        );
6063
6064        let ctx = ChannelRuntimeContext {
6065            channels_by_name: Arc::new(HashMap::new()),
6066            provider: Arc::new(DummyProvider),
6067            default_provider: Arc::new("test-provider".to_string()),
6068            memory: Arc::new(NoopMemory),
6069            tools_registry: Arc::new(vec![]),
6070            observer: Arc::new(NoopObserver),
6071            system_prompt: Arc::new("system".to_string()),
6072            model: Arc::new("test-model".to_string()),
6073            temperature: 0.0,
6074            auto_save_memory: false,
6075            max_tool_iterations: 5,
6076            min_relevance_score: 0.0,
6077            conversation_histories: Arc::new(Mutex::new(histories)),
6078            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
6079            provider_cache: Arc::new(Mutex::new(HashMap::new())),
6080            route_overrides: Arc::new(Mutex::new(HashMap::new())),
6081            api_key: None,
6082            api_url: None,
6083            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
6084            interrupt_on_new_message: InterruptOnNewMessageConfig {
6085                telegram: false,
6086                slack: false,
6087                discord: false,
6088                mattermost: false,
6089                matrix: false,
6090            },
6091            multimodal: crate::config::MultimodalConfig::default(),
6092            media_pipeline: crate::config::MediaPipelineConfig::default(),
6093            transcription_config: crate::config::TranscriptionConfig::default(),
6094            hooks: None,
6095            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
6096            workspace_dir: Arc::new(std::env::temp_dir()),
6097            prompt_config: Arc::new(crate::config::Config::default()),
6098            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
6099            non_cli_excluded_tools: Arc::new(Vec::new()),
6100            autonomy_level: AutonomyLevel::default(),
6101            tool_call_dedup_exempt: Arc::new(Vec::new()),
6102            model_routes: Arc::new(Vec::new()),
6103            query_classification: crate::config::QueryClassificationConfig::default(),
6104            ack_reactions: true,
6105            show_tool_calls: true,
6106            session_store: None,
6107            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
6108                &crate::config::AutonomyConfig::default(),
6109            )),
6110            activated_tools: None,
6111            mcp_registry: None,
6112            cost_tracking: None,
6113            pacing: crate::config::PacingConfig::default(),
6114            max_tool_result_chars: 0,
6115            context_token_budget: 0,
6116            audit_logger: None,
6117            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
6118        };
6119
6120        assert!(compact_sender_history(&ctx, &sender));
6121
6122        let locked_histories = ctx
6123            .conversation_histories
6124            .lock()
6125            .unwrap_or_else(|e| e.into_inner());
6126        let kept = locked_histories
6127            .get(&sender)
6128            .expect("sender history should remain");
6129        assert_eq!(kept.len(), CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
6130        assert!(kept.iter().all(|turn| {
6131            let len = turn.content.chars().count();
6132            len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS
6133                || (len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS + 3
6134                    && turn.content.ends_with("..."))
6135        }));
6136    }
6137
6138    #[test]
6139    fn proactive_trim_drops_oldest_turns_when_over_budget() {
6140        // Each message is 100 chars; 10 messages = 1000 chars total.
6141        let mut turns: Vec<ChatMessage> = (0..10)
6142            .map(|i| {
6143                let content = format!("m{i}-{}", "a".repeat(96));
6144                if i % 2 == 0 {
6145                    ChatMessage::user(content)
6146                } else {
6147                    ChatMessage::assistant(content)
6148                }
6149            })
6150            .collect();
6151
6152        // Budget of 500 should drop roughly half (oldest turns).
6153        let dropped = proactive_trim_turns(&mut turns, 500);
6154        assert!(dropped > 0, "should have dropped some turns");
6155        assert!(turns.len() < 10, "should have fewer turns after trimming");
6156        // Last turn should always be preserved.
6157        assert!(
6158            turns.last().unwrap().content.starts_with("m9-"),
6159            "most recent turn must be preserved"
6160        );
6161        // Total chars should now be within budget.
6162        let total: usize = turns.iter().map(|t| t.content.chars().count()).sum();
6163        assert!(total <= 500, "total chars {total} should be within budget");
6164    }
6165
6166    #[test]
6167    fn proactive_trim_noop_when_within_budget() {
6168        let mut turns = vec![
6169            ChatMessage::user("hello".to_string()),
6170            ChatMessage::assistant("hi there".to_string()),
6171        ];
6172        let dropped = proactive_trim_turns(&mut turns, 10_000);
6173        assert_eq!(dropped, 0);
6174        assert_eq!(turns.len(), 2);
6175    }
6176
6177    #[test]
6178    fn proactive_trim_preserves_last_turn_even_when_over_budget() {
6179        let mut turns = vec![ChatMessage::user("x".repeat(2000))];
6180        let dropped = proactive_trim_turns(&mut turns, 100);
6181        assert_eq!(dropped, 0, "single turn must never be dropped");
6182        assert_eq!(turns.len(), 1);
6183    }
6184
6185    #[test]
6186    fn append_sender_turn_stores_single_turn_per_call() {
6187        let sender = "telegram_u2".to_string();
6188        let ctx = ChannelRuntimeContext {
6189            channels_by_name: Arc::new(HashMap::new()),
6190            provider: Arc::new(DummyProvider),
6191            default_provider: Arc::new("test-provider".to_string()),
6192            memory: Arc::new(NoopMemory),
6193            tools_registry: Arc::new(vec![]),
6194            observer: Arc::new(NoopObserver),
6195            system_prompt: Arc::new("system".to_string()),
6196            model: Arc::new("test-model".to_string()),
6197            temperature: 0.0,
6198            auto_save_memory: false,
6199            max_tool_iterations: 5,
6200            min_relevance_score: 0.0,
6201            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
6202            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
6203            provider_cache: Arc::new(Mutex::new(HashMap::new())),
6204            route_overrides: Arc::new(Mutex::new(HashMap::new())),
6205            api_key: None,
6206            api_url: None,
6207            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
6208            interrupt_on_new_message: InterruptOnNewMessageConfig {
6209                telegram: false,
6210                slack: false,
6211                discord: false,
6212                mattermost: false,
6213                matrix: false,
6214            },
6215            multimodal: crate::config::MultimodalConfig::default(),
6216            media_pipeline: crate::config::MediaPipelineConfig::default(),
6217            transcription_config: crate::config::TranscriptionConfig::default(),
6218            hooks: None,
6219            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
6220            workspace_dir: Arc::new(std::env::temp_dir()),
6221            prompt_config: Arc::new(crate::config::Config::default()),
6222            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
6223            non_cli_excluded_tools: Arc::new(Vec::new()),
6224            autonomy_level: AutonomyLevel::default(),
6225            tool_call_dedup_exempt: Arc::new(Vec::new()),
6226            model_routes: Arc::new(Vec::new()),
6227            query_classification: crate::config::QueryClassificationConfig::default(),
6228            ack_reactions: true,
6229            show_tool_calls: true,
6230            session_store: None,
6231            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
6232                &crate::config::AutonomyConfig::default(),
6233            )),
6234            activated_tools: None,
6235            mcp_registry: None,
6236            cost_tracking: None,
6237            pacing: crate::config::PacingConfig::default(),
6238            max_tool_result_chars: 0,
6239            context_token_budget: 0,
6240            audit_logger: None,
6241            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
6242        };
6243
6244        append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));
6245
6246        let histories = ctx
6247            .conversation_histories
6248            .lock()
6249            .unwrap_or_else(|e| e.into_inner());
6250        let turns = histories.get(&sender).expect("sender history should exist");
6251        assert_eq!(turns.len(), 1);
6252        assert_eq!(turns[0].role, "user");
6253        assert_eq!(turns[0].content, "hello");
6254    }
6255
6256    #[test]
6257    fn rollback_orphan_user_turn_removes_only_latest_matching_user_turn() {
6258        let sender = "telegram_u3".to_string();
6259        let mut histories = HashMap::new();
6260        histories.insert(
6261            sender.clone(),
6262            vec![
6263                ChatMessage::user("first"),
6264                ChatMessage::assistant("ok"),
6265                ChatMessage::user("pending"),
6266            ],
6267        );
6268        let ctx = ChannelRuntimeContext {
6269            channels_by_name: Arc::new(HashMap::new()),
6270            provider: Arc::new(DummyProvider),
6271            default_provider: Arc::new("test-provider".to_string()),
6272            memory: Arc::new(NoopMemory),
6273            tools_registry: Arc::new(vec![]),
6274            observer: Arc::new(NoopObserver),
6275            system_prompt: Arc::new("system".to_string()),
6276            model: Arc::new("test-model".to_string()),
6277            temperature: 0.0,
6278            auto_save_memory: false,
6279            max_tool_iterations: 5,
6280            min_relevance_score: 0.0,
6281            conversation_histories: Arc::new(Mutex::new(histories)),
6282            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
6283            provider_cache: Arc::new(Mutex::new(HashMap::new())),
6284            route_overrides: Arc::new(Mutex::new(HashMap::new())),
6285            api_key: None,
6286            api_url: None,
6287            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
6288            interrupt_on_new_message: InterruptOnNewMessageConfig {
6289                telegram: false,
6290                slack: false,
6291                discord: false,
6292                mattermost: false,
6293                matrix: false,
6294            },
6295            multimodal: crate::config::MultimodalConfig::default(),
6296            media_pipeline: crate::config::MediaPipelineConfig::default(),
6297            transcription_config: crate::config::TranscriptionConfig::default(),
6298            hooks: None,
6299            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
6300            workspace_dir: Arc::new(std::env::temp_dir()),
6301            prompt_config: Arc::new(crate::config::Config::default()),
6302            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
6303            non_cli_excluded_tools: Arc::new(Vec::new()),
6304            autonomy_level: AutonomyLevel::default(),
6305            tool_call_dedup_exempt: Arc::new(Vec::new()),
6306            model_routes: Arc::new(Vec::new()),
6307            query_classification: crate::config::QueryClassificationConfig::default(),
6308            ack_reactions: true,
6309            show_tool_calls: true,
6310            session_store: None,
6311            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
6312                &crate::config::AutonomyConfig::default(),
6313            )),
6314            activated_tools: None,
6315            mcp_registry: None,
6316            cost_tracking: None,
6317            pacing: crate::config::PacingConfig::default(),
6318            max_tool_result_chars: 0,
6319            context_token_budget: 0,
6320            audit_logger: None,
6321            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
6322        };
6323
6324        assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
6325
6326        let locked_histories = ctx
6327            .conversation_histories
6328            .lock()
6329            .unwrap_or_else(|e| e.into_inner());
6330        let turns = locked_histories
6331            .get(&sender)
6332            .expect("sender history should remain");
6333        assert_eq!(turns.len(), 2);
6334        assert_eq!(turns[0].content, "first");
6335        assert_eq!(turns[1].content, "ok");
6336    }
6337
6338    #[test]
6339    fn rollback_orphan_user_turn_also_removes_from_session_store() {
6340        let tmp = tempfile::TempDir::new().unwrap();
6341        let store = Arc::new(session_store::SessionStore::new(tmp.path()).unwrap());
6342
6343        let sender = "telegram_u4".to_string();
6344
6345        // Pre-populate the session store with the same turns.
6346        store.append(&sender, &ChatMessage::user("first")).unwrap();
6347        store
6348            .append(&sender, &ChatMessage::assistant("ok"))
6349            .unwrap();
6350        store
6351            .append(
6352                &sender,
6353                &ChatMessage::user("[IMAGE:/tmp/photo.jpg]\n\nDescribe this"),
6354            )
6355            .unwrap();
6356
6357        let mut histories = HashMap::new();
6358        histories.insert(
6359            sender.clone(),
6360            vec![
6361                ChatMessage::user("first"),
6362                ChatMessage::assistant("ok"),
6363                ChatMessage::user("[IMAGE:/tmp/photo.jpg]\n\nDescribe this"),
6364            ],
6365        );
6366
6367        let ctx = ChannelRuntimeContext {
6368            channels_by_name: Arc::new(HashMap::new()),
6369            provider: Arc::new(DummyProvider),
6370            default_provider: Arc::new("test-provider".to_string()),
6371            memory: Arc::new(NoopMemory),
6372            tools_registry: Arc::new(vec![]),
6373            observer: Arc::new(NoopObserver),
6374            system_prompt: Arc::new("system".to_string()),
6375            model: Arc::new("test-model".to_string()),
6376            temperature: 0.0,
6377            auto_save_memory: false,
6378            max_tool_iterations: 5,
6379            min_relevance_score: 0.0,
6380            conversation_histories: Arc::new(Mutex::new(histories)),
6381            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
6382            provider_cache: Arc::new(Mutex::new(HashMap::new())),
6383            route_overrides: Arc::new(Mutex::new(HashMap::new())),
6384            api_key: None,
6385            api_url: None,
6386            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
6387            interrupt_on_new_message: InterruptOnNewMessageConfig {
6388                telegram: false,
6389                slack: false,
6390                discord: false,
6391                mattermost: false,
6392                matrix: false,
6393            },
6394            multimodal: crate::config::MultimodalConfig::default(),
6395            media_pipeline: crate::config::MediaPipelineConfig::default(),
6396            transcription_config: crate::config::TranscriptionConfig::default(),
6397            hooks: None,
6398            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
6399            workspace_dir: Arc::new(std::env::temp_dir()),
6400            prompt_config: Arc::new(crate::config::Config::default()),
6401            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
6402            non_cli_excluded_tools: Arc::new(Vec::new()),
6403            autonomy_level: AutonomyLevel::default(),
6404            tool_call_dedup_exempt: Arc::new(Vec::new()),
6405            model_routes: Arc::new(Vec::new()),
6406            query_classification: crate::config::QueryClassificationConfig::default(),
6407            ack_reactions: true,
6408            show_tool_calls: true,
6409            session_store: Some(Arc::clone(&store)),
6410            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
6411                &crate::config::AutonomyConfig::default(),
6412            )),
6413            activated_tools: None,
6414            mcp_registry: None,
6415            cost_tracking: None,
6416            pacing: crate::config::PacingConfig::default(),
6417            max_tool_result_chars: 0,
6418            context_token_budget: 0,
6419            audit_logger: None,
6420            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
6421        };
6422
6423        assert!(rollback_orphan_user_turn(
6424            &ctx,
6425            &sender,
6426            "[IMAGE:/tmp/photo.jpg]\n\nDescribe this"
6427        ));
6428
6429        // In-memory history should have 2 turns remaining.
6430        let locked = ctx
6431            .conversation_histories
6432            .lock()
6433            .unwrap_or_else(|e| e.into_inner());
6434        let turns = locked.get(&sender).expect("history should remain");
6435        assert_eq!(turns.len(), 2);
6436
6437        // Session store should also have only 2 entries.
6438        let persisted = store.load(&sender);
6439        assert_eq!(
6440            persisted.len(),
6441            2,
6442            "session store should also lose the rolled-back turn"
6443        );
6444        assert_eq!(persisted[0].content, "first");
6445        assert_eq!(persisted[1].content, "ok");
6446    }
6447
6448    struct DummyProvider;
6449
6450    #[async_trait::async_trait]
6451    impl Provider for DummyProvider {
6452        async fn chat_with_system(
6453            &self,
6454            _system_prompt: Option<&str>,
6455            _message: &str,
6456            _model: &str,
6457            _temperature: f64,
6458        ) -> anyhow::Result<String> {
6459            Ok("ok".to_string())
6460        }
6461    }
6462
6463    struct FormatErrorProvider;
6464
6465    #[async_trait::async_trait]
6466    impl Provider for FormatErrorProvider {
6467        async fn chat_with_system(
6468            &self,
6469            _system_prompt: Option<&str>,
6470            _message: &str,
6471            _model: &str,
6472            _temperature: f64,
6473        ) -> anyhow::Result<String> {
6474            Ok("ok".to_string())
6475        }
6476
6477        async fn chat_with_history(
6478            &self,
6479            messages: &[ChatMessage],
6480            _model: &str,
6481            _temperature: f64,
6482        ) -> anyhow::Result<String> {
6483            if messages
6484                .iter()
6485                .any(|msg| msg.content.contains("trigger format error"))
6486            {
6487                anyhow::bail!(
6488                    "All providers/models failed. Attempts:\nprovider=custom:https://example.invalid/v1 model=test-model attempt 1/3: non_retryable; error=Custom API error (400 Bad Request): {{\"error\":{{\"message\":\"Format Error\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"400\"}},\"request_id\":\"test-request-id\"}}"
6489                );
6490            }
6491
6492            Ok("ok".to_string())
6493        }
6494    }
6495
6496    #[derive(Default)]
6497    struct RecordingChannel {
6498        sent_messages: tokio::sync::Mutex<Vec<String>>,
6499        start_typing_calls: AtomicUsize,
6500        stop_typing_calls: AtomicUsize,
6501        reactions_added: tokio::sync::Mutex<Vec<(String, String, String)>>,
6502        reactions_removed: tokio::sync::Mutex<Vec<(String, String, String)>>,
6503    }
6504
6505    #[derive(Default)]
6506    struct TelegramRecordingChannel {
6507        sent_messages: tokio::sync::Mutex<Vec<String>>,
6508    }
6509
6510    #[derive(Default)]
6511    struct SlackRecordingChannel {
6512        sent_messages: tokio::sync::Mutex<Vec<String>>,
6513    }
6514
6515    #[async_trait::async_trait]
6516    impl Channel for TelegramRecordingChannel {
6517        fn name(&self) -> &str {
6518            "telegram"
6519        }
6520
6521        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
6522            self.sent_messages
6523                .lock()
6524                .await
6525                .push(format!("{}:{}", message.recipient, message.content));
6526            Ok(())
6527        }
6528
6529        async fn listen(
6530            &self,
6531            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
6532        ) -> anyhow::Result<()> {
6533            Ok(())
6534        }
6535
6536        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
6537            Ok(())
6538        }
6539
6540        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
6541            Ok(())
6542        }
6543    }
6544
6545    #[async_trait::async_trait]
6546    impl Channel for SlackRecordingChannel {
6547        fn name(&self) -> &str {
6548            "slack"
6549        }
6550
6551        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
6552            self.sent_messages
6553                .lock()
6554                .await
6555                .push(format!("{}:{}", message.recipient, message.content));
6556            Ok(())
6557        }
6558
6559        async fn listen(
6560            &self,
6561            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
6562        ) -> anyhow::Result<()> {
6563            Ok(())
6564        }
6565
6566        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
6567            Ok(())
6568        }
6569
6570        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
6571            Ok(())
6572        }
6573    }
6574
6575    #[async_trait::async_trait]
6576    impl Channel for RecordingChannel {
6577        fn name(&self) -> &str {
6578            "test-channel"
6579        }
6580
6581        async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
6582            self.sent_messages
6583                .lock()
6584                .await
6585                .push(format!("{}:{}", message.recipient, message.content));
6586            Ok(())
6587        }
6588
6589        async fn listen(
6590            &self,
6591            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
6592        ) -> anyhow::Result<()> {
6593            Ok(())
6594        }
6595
6596        async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
6597            self.start_typing_calls.fetch_add(1, Ordering::SeqCst);
6598            Ok(())
6599        }
6600
6601        async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
6602            self.stop_typing_calls.fetch_add(1, Ordering::SeqCst);
6603            Ok(())
6604        }
6605
6606        async fn add_reaction(
6607            &self,
6608            channel_id: &str,
6609            message_id: &str,
6610            emoji: &str,
6611        ) -> anyhow::Result<()> {
6612            self.reactions_added.lock().await.push((
6613                channel_id.to_string(),
6614                message_id.to_string(),
6615                emoji.to_string(),
6616            ));
6617            Ok(())
6618        }
6619
6620        async fn remove_reaction(
6621            &self,
6622            channel_id: &str,
6623            message_id: &str,
6624            emoji: &str,
6625        ) -> anyhow::Result<()> {
6626            self.reactions_removed.lock().await.push((
6627                channel_id.to_string(),
6628                message_id.to_string(),
6629                emoji.to_string(),
6630            ));
6631            Ok(())
6632        }
6633    }
6634
6635    struct SlowProvider {
6636        delay: Duration,
6637    }
6638
6639    #[async_trait::async_trait]
6640    impl Provider for SlowProvider {
6641        async fn chat_with_system(
6642            &self,
6643            _system_prompt: Option<&str>,
6644            message: &str,
6645            _model: &str,
6646            _temperature: f64,
6647        ) -> anyhow::Result<String> {
6648            tokio::time::sleep(self.delay).await;
6649            Ok(format!("echo: {message}"))
6650        }
6651    }
6652
6653    struct ToolCallingProvider;
6654
6655    fn tool_call_payload() -> String {
6656        r#"<tool_call>
6657{"name":"mock_price","arguments":{"symbol":"BTC"}}
6658</tool_call>"#
6659            .to_string()
6660    }
6661
6662    fn tool_call_payload_with_alias_tag() -> String {
6663        r#"<toolcall>
6664{"name":"mock_price","arguments":{"symbol":"BTC"}}
6665</toolcall>"#
6666            .to_string()
6667    }
6668
6669    #[async_trait::async_trait]
6670    impl Provider for ToolCallingProvider {
6671        async fn chat_with_system(
6672            &self,
6673            _system_prompt: Option<&str>,
6674            _message: &str,
6675            _model: &str,
6676            _temperature: f64,
6677        ) -> anyhow::Result<String> {
6678            Ok(tool_call_payload())
6679        }
6680
6681        async fn chat_with_history(
6682            &self,
6683            messages: &[ChatMessage],
6684            _model: &str,
6685            _temperature: f64,
6686        ) -> anyhow::Result<String> {
6687            let has_tool_results = messages
6688                .iter()
6689                .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
6690            if has_tool_results {
6691                Ok("BTC is currently around $65,000 based on latest tool output.".to_string())
6692            } else {
6693                Ok(tool_call_payload())
6694            }
6695        }
6696    }
6697
6698    struct ToolCallingAliasProvider;
6699
6700    #[async_trait::async_trait]
6701    impl Provider for ToolCallingAliasProvider {
6702        async fn chat_with_system(
6703            &self,
6704            _system_prompt: Option<&str>,
6705            _message: &str,
6706            _model: &str,
6707            _temperature: f64,
6708        ) -> anyhow::Result<String> {
6709            Ok(tool_call_payload_with_alias_tag())
6710        }
6711
6712        async fn chat_with_history(
6713            &self,
6714            messages: &[ChatMessage],
6715            _model: &str,
6716            _temperature: f64,
6717        ) -> anyhow::Result<String> {
6718            let has_tool_results = messages
6719                .iter()
6720                .any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
6721            if has_tool_results {
6722                Ok("BTC alias-tag flow resolved to final text output.".to_string())
6723            } else {
6724                Ok(tool_call_payload_with_alias_tag())
6725            }
6726        }
6727    }
6728
6729    struct RawToolArtifactProvider;
6730
6731    #[async_trait::async_trait]
6732    impl Provider for RawToolArtifactProvider {
6733        async fn chat_with_system(
6734            &self,
6735            _system_prompt: Option<&str>,
6736            _message: &str,
6737            _model: &str,
6738            _temperature: f64,
6739        ) -> anyhow::Result<String> {
6740            Ok("fallback".to_string())
6741        }
6742
6743        async fn chat_with_history(
6744            &self,
6745            _messages: &[ChatMessage],
6746            _model: &str,
6747            _temperature: f64,
6748        ) -> anyhow::Result<String> {
6749            Ok(r#"{"name":"mock_price","parameters":{"symbol":"BTC"}}
6750{"result":{"symbol":"BTC","price_usd":65000}}
6751BTC is currently around $65,000 based on latest tool output."#
6752                .to_string())
6753        }
6754    }
6755
6756    struct IterativeToolProvider {
6757        required_tool_iterations: usize,
6758    }
6759
6760    impl IterativeToolProvider {
6761        fn completed_tool_iterations(messages: &[ChatMessage]) -> usize {
6762            messages
6763                .iter()
6764                .filter(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
6765                .count()
6766        }
6767    }
6768
6769    #[async_trait::async_trait]
6770    impl Provider for IterativeToolProvider {
6771        async fn chat_with_system(
6772            &self,
6773            _system_prompt: Option<&str>,
6774            _message: &str,
6775            _model: &str,
6776            _temperature: f64,
6777        ) -> anyhow::Result<String> {
6778            Ok(tool_call_payload())
6779        }
6780
6781        async fn chat_with_history(
6782            &self,
6783            messages: &[ChatMessage],
6784            _model: &str,
6785            _temperature: f64,
6786        ) -> anyhow::Result<String> {
6787            let completed_iterations = Self::completed_tool_iterations(messages);
6788            if completed_iterations >= self.required_tool_iterations {
6789                Ok(format!(
6790                    "Completed after {completed_iterations} tool iterations."
6791                ))
6792            } else {
6793                Ok(tool_call_payload())
6794            }
6795        }
6796    }
6797
6798    #[derive(Default)]
6799    struct HistoryCaptureProvider {
6800        calls: std::sync::Mutex<Vec<Vec<(String, String)>>>,
6801    }
6802
6803    #[async_trait::async_trait]
6804    impl Provider for HistoryCaptureProvider {
6805        async fn chat_with_system(
6806            &self,
6807            _system_prompt: Option<&str>,
6808            _message: &str,
6809            _model: &str,
6810            _temperature: f64,
6811        ) -> anyhow::Result<String> {
6812            Ok("fallback".to_string())
6813        }
6814
6815        async fn chat_with_history(
6816            &self,
6817            messages: &[ChatMessage],
6818            _model: &str,
6819            _temperature: f64,
6820        ) -> anyhow::Result<String> {
6821            let snapshot = messages
6822                .iter()
6823                .map(|m| (m.role.clone(), m.content.clone()))
6824                .collect::<Vec<_>>();
6825            let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
6826            calls.push(snapshot);
6827            Ok(format!("response-{}", calls.len()))
6828        }
6829    }
6830
6831    struct DelayedHistoryCaptureProvider {
6832        delay: Duration,
6833        calls: std::sync::Mutex<Vec<Vec<(String, String)>>>,
6834    }
6835
6836    #[async_trait::async_trait]
6837    impl Provider for DelayedHistoryCaptureProvider {
6838        async fn chat_with_system(
6839            &self,
6840            _system_prompt: Option<&str>,
6841            _message: &str,
6842            _model: &str,
6843            _temperature: f64,
6844        ) -> anyhow::Result<String> {
6845            Ok("fallback".to_string())
6846        }
6847
6848        async fn chat_with_history(
6849            &self,
6850            messages: &[ChatMessage],
6851            _model: &str,
6852            _temperature: f64,
6853        ) -> anyhow::Result<String> {
6854            let snapshot = messages
6855                .iter()
6856                .map(|m| (m.role.clone(), m.content.clone()))
6857                .collect::<Vec<_>>();
6858            let call_index = {
6859                let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
6860                calls.push(snapshot);
6861                calls.len()
6862            };
6863            tokio::time::sleep(self.delay).await;
6864            Ok(format!("response-{call_index}"))
6865        }
6866    }
6867
6868    struct MockPriceTool;
6869
6870    #[derive(Default)]
6871    struct ModelCaptureProvider {
6872        call_count: AtomicUsize,
6873        models: std::sync::Mutex<Vec<String>>,
6874    }
6875
6876    #[async_trait::async_trait]
6877    impl Provider for ModelCaptureProvider {
6878        async fn chat_with_system(
6879            &self,
6880            _system_prompt: Option<&str>,
6881            _message: &str,
6882            _model: &str,
6883            _temperature: f64,
6884        ) -> anyhow::Result<String> {
6885            Ok("fallback".to_string())
6886        }
6887
6888        async fn chat_with_history(
6889            &self,
6890            _messages: &[ChatMessage],
6891            model: &str,
6892            _temperature: f64,
6893        ) -> anyhow::Result<String> {
6894            self.call_count.fetch_add(1, Ordering::SeqCst);
6895            self.models
6896                .lock()
6897                .unwrap_or_else(|e| e.into_inner())
6898                .push(model.to_string());
6899            Ok("ok".to_string())
6900        }
6901    }
6902
6903    #[async_trait::async_trait]
6904    impl Tool for MockPriceTool {
6905        fn name(&self) -> &str {
6906            "mock_price"
6907        }
6908
6909        fn description(&self) -> &str {
6910            "Return a mocked BTC price"
6911        }
6912
6913        fn parameters_schema(&self) -> serde_json::Value {
6914            serde_json::json!({
6915                "type": "object",
6916                "properties": {
6917                    "symbol": { "type": "string" }
6918                },
6919                "required": ["symbol"]
6920            })
6921        }
6922
6923        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
6924            let symbol = args.get("symbol").and_then(serde_json::Value::as_str);
6925            if symbol != Some("BTC") {
6926                return Ok(ToolResult {
6927                    success: false,
6928                    output: String::new(),
6929                    error: Some("unexpected symbol".to_string()),
6930                });
6931            }
6932
6933            Ok(ToolResult {
6934                success: true,
6935                output: r#"{"symbol":"BTC","price_usd":65000}"#.to_string(),
6936                error: None,
6937            })
6938        }
6939    }
6940
6941    #[tokio::test]
6942    async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json() {
6943        let channel_impl = Arc::new(RecordingChannel::default());
6944        let channel: Arc<dyn Channel> = channel_impl.clone();
6945
6946        let mut channels_by_name = HashMap::new();
6947        channels_by_name.insert(channel.name().to_string(), channel);
6948
6949        let runtime_ctx = Arc::new(ChannelRuntimeContext {
6950            channels_by_name: Arc::new(channels_by_name),
6951            provider: Arc::new(ToolCallingProvider),
6952            default_provider: Arc::new("test-provider".to_string()),
6953            memory: Arc::new(NoopMemory),
6954            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
6955            observer: Arc::new(NoopObserver),
6956            system_prompt: Arc::new("test-system-prompt".to_string()),
6957            model: Arc::new("test-model".to_string()),
6958            temperature: 0.0,
6959            auto_save_memory: false,
6960            max_tool_iterations: 10,
6961            min_relevance_score: 0.0,
6962            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
6963            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
6964            provider_cache: Arc::new(Mutex::new(HashMap::new())),
6965            route_overrides: Arc::new(Mutex::new(HashMap::new())),
6966            api_key: None,
6967            api_url: None,
6968            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
6969            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
6970            workspace_dir: Arc::new(std::env::temp_dir()),
6971            prompt_config: Arc::new(crate::config::Config::default()),
6972            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
6973            interrupt_on_new_message: InterruptOnNewMessageConfig {
6974                telegram: false,
6975                slack: false,
6976                discord: false,
6977                mattermost: false,
6978                matrix: false,
6979            },
6980            non_cli_excluded_tools: Arc::new(Vec::new()),
6981            autonomy_level: AutonomyLevel::default(),
6982            tool_call_dedup_exempt: Arc::new(Vec::new()),
6983            multimodal: crate::config::MultimodalConfig::default(),
6984            media_pipeline: crate::config::MediaPipelineConfig::default(),
6985            transcription_config: crate::config::TranscriptionConfig::default(),
6986            hooks: None,
6987            model_routes: Arc::new(Vec::new()),
6988            query_classification: crate::config::QueryClassificationConfig::default(),
6989            ack_reactions: true,
6990            show_tool_calls: true,
6991            session_store: None,
6992            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
6993                &crate::config::AutonomyConfig::default(),
6994            )),
6995            activated_tools: None,
6996            mcp_registry: None,
6997            cost_tracking: None,
6998            pacing: crate::config::PacingConfig::default(),
6999            max_tool_result_chars: 0,
7000            context_token_budget: 0,
7001            audit_logger: None,
7002            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7003        });
7004
7005        process_channel_message(
7006            runtime_ctx,
7007            traits::ChannelMessage {
7008                id: "msg-1".to_string(),
7009                sender: "alice".to_string(),
7010                reply_target: "chat-42".to_string(),
7011                content: "What is the BTC price now?".to_string(),
7012                channel: "test-channel".to_string(),
7013                timestamp: 1,
7014                thread_ts: None,
7015                interruption_scope_id: None,
7016                attachments: vec![],
7017            },
7018            CancellationToken::new(),
7019        )
7020        .await;
7021
7022        let sent_messages = channel_impl.sent_messages.lock().await;
7023        assert!(!sent_messages.is_empty());
7024        let reply = sent_messages.last().unwrap();
7025        assert!(reply.starts_with("chat-42:"));
7026        assert!(reply.contains("BTC is currently around"));
7027        assert!(!reply.contains("\"tool_calls\""));
7028        assert!(!reply.contains("mock_price"));
7029    }
7030
7031    #[tokio::test]
7032    async fn process_channel_message_telegram_does_not_persist_tool_summary_prefix() {
7033        let channel_impl = Arc::new(TelegramRecordingChannel::default());
7034        let channel: Arc<dyn Channel> = channel_impl.clone();
7035
7036        let mut channels_by_name = HashMap::new();
7037        channels_by_name.insert(channel.name().to_string(), channel);
7038
7039        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7040            channels_by_name: Arc::new(channels_by_name),
7041            provider: Arc::new(ToolCallingProvider),
7042            default_provider: Arc::new("test-provider".to_string()),
7043            memory: Arc::new(NoopMemory),
7044            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
7045            observer: Arc::new(NoopObserver),
7046            system_prompt: Arc::new("test-system-prompt".to_string()),
7047            model: Arc::new("test-model".to_string()),
7048            temperature: 0.0,
7049            auto_save_memory: false,
7050            max_tool_iterations: 10,
7051            min_relevance_score: 0.0,
7052            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7053            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7054            provider_cache: Arc::new(Mutex::new(HashMap::new())),
7055            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7056            api_key: None,
7057            api_url: None,
7058            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7059            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7060            workspace_dir: Arc::new(std::env::temp_dir()),
7061            prompt_config: Arc::new(crate::config::Config::default()),
7062            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7063            interrupt_on_new_message: InterruptOnNewMessageConfig {
7064                telegram: false,
7065                slack: false,
7066                discord: false,
7067                mattermost: false,
7068                matrix: false,
7069            },
7070            non_cli_excluded_tools: Arc::new(Vec::new()),
7071            autonomy_level: AutonomyLevel::default(),
7072            tool_call_dedup_exempt: Arc::new(Vec::new()),
7073            multimodal: crate::config::MultimodalConfig::default(),
7074            media_pipeline: crate::config::MediaPipelineConfig::default(),
7075            transcription_config: crate::config::TranscriptionConfig::default(),
7076            hooks: None,
7077            model_routes: Arc::new(Vec::new()),
7078            query_classification: crate::config::QueryClassificationConfig::default(),
7079            ack_reactions: true,
7080            show_tool_calls: true,
7081            session_store: None,
7082            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7083                &crate::config::AutonomyConfig::default(),
7084            )),
7085            activated_tools: None,
7086            mcp_registry: None,
7087            cost_tracking: None,
7088            pacing: crate::config::PacingConfig::default(),
7089            max_tool_result_chars: 0,
7090            context_token_budget: 0,
7091            audit_logger: None,
7092            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7093        });
7094
7095        process_channel_message(
7096            runtime_ctx.clone(),
7097            traits::ChannelMessage {
7098                id: "msg-telegram-tool-1".to_string(),
7099                sender: "alice".to_string(),
7100                reply_target: "chat-telegram".to_string(),
7101                content: "What is the BTC price now?".to_string(),
7102                channel: "telegram".to_string(),
7103                timestamp: 1,
7104                thread_ts: None,
7105                interruption_scope_id: None,
7106                attachments: vec![],
7107            },
7108            CancellationToken::new(),
7109        )
7110        .await;
7111
7112        let sent_messages = channel_impl.sent_messages.lock().await;
7113        assert!(!sent_messages.is_empty());
7114        let reply = sent_messages.last().unwrap();
7115        assert!(reply.contains("BTC is currently around"));
7116
7117        let histories = runtime_ctx
7118            .conversation_histories
7119            .lock()
7120            .unwrap_or_else(|e| e.into_inner());
7121        let turns = histories
7122            .get("telegram_chat-telegram_alice")
7123            .expect("telegram history should be stored");
7124        let assistant_turn = turns
7125            .iter()
7126            .rev()
7127            .find(|turn| turn.role == "assistant")
7128            .expect("assistant turn should be present");
7129        assert!(
7130            !assistant_turn.content.contains("[Used tools:"),
7131            "telegram history should not persist tool-summary prefix"
7132        );
7133    }
7134
7135    #[tokio::test]
7136    async fn process_channel_message_strips_unexecuted_tool_json_artifacts_from_reply() {
7137        let channel_impl = Arc::new(RecordingChannel::default());
7138        let channel: Arc<dyn Channel> = channel_impl.clone();
7139
7140        let mut channels_by_name = HashMap::new();
7141        channels_by_name.insert(channel.name().to_string(), channel);
7142
7143        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7144            channels_by_name: Arc::new(channels_by_name),
7145            provider: Arc::new(RawToolArtifactProvider),
7146            default_provider: Arc::new("test-provider".to_string()),
7147            memory: Arc::new(NoopMemory),
7148            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
7149            observer: Arc::new(NoopObserver),
7150            system_prompt: Arc::new("test-system-prompt".to_string()),
7151            model: Arc::new("test-model".to_string()),
7152            temperature: 0.0,
7153            auto_save_memory: false,
7154            max_tool_iterations: 10,
7155            min_relevance_score: 0.0,
7156            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7157            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7158            provider_cache: Arc::new(Mutex::new(HashMap::new())),
7159            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7160            api_key: None,
7161            api_url: None,
7162            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7163            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7164            workspace_dir: Arc::new(std::env::temp_dir()),
7165            prompt_config: Arc::new(crate::config::Config::default()),
7166            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7167            interrupt_on_new_message: InterruptOnNewMessageConfig {
7168                telegram: false,
7169                slack: false,
7170                discord: false,
7171                mattermost: false,
7172                matrix: false,
7173            },
7174            multimodal: crate::config::MultimodalConfig::default(),
7175            media_pipeline: crate::config::MediaPipelineConfig::default(),
7176            transcription_config: crate::config::TranscriptionConfig::default(),
7177            hooks: None,
7178            non_cli_excluded_tools: Arc::new(Vec::new()),
7179            autonomy_level: AutonomyLevel::default(),
7180            tool_call_dedup_exempt: Arc::new(Vec::new()),
7181            model_routes: Arc::new(Vec::new()),
7182            query_classification: crate::config::QueryClassificationConfig::default(),
7183            ack_reactions: true,
7184            show_tool_calls: true,
7185            session_store: None,
7186            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7187                &crate::config::AutonomyConfig::default(),
7188            )),
7189            activated_tools: None,
7190            mcp_registry: None,
7191            cost_tracking: None,
7192            pacing: crate::config::PacingConfig::default(),
7193            max_tool_result_chars: 0,
7194            context_token_budget: 0,
7195            audit_logger: None,
7196            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7197        });
7198
7199        process_channel_message(
7200            runtime_ctx,
7201            traits::ChannelMessage {
7202                id: "msg-raw-json".to_string(),
7203                sender: "alice".to_string(),
7204                reply_target: "chat-raw".to_string(),
7205                content: "What is the BTC price now?".to_string(),
7206                channel: "test-channel".to_string(),
7207                timestamp: 3,
7208                thread_ts: None,
7209                interruption_scope_id: None,
7210                attachments: vec![],
7211            },
7212            CancellationToken::new(),
7213        )
7214        .await;
7215
7216        let sent_messages = channel_impl.sent_messages.lock().await;
7217        assert_eq!(sent_messages.len(), 1);
7218        assert!(sent_messages[0].starts_with("chat-raw:"));
7219        assert!(sent_messages[0].contains("BTC is currently around"));
7220        assert!(!sent_messages[0].contains("\"name\":\"mock_price\""));
7221        assert!(!sent_messages[0].contains("\"result\""));
7222    }
7223
7224    #[tokio::test]
7225    async fn process_channel_message_executes_tool_calls_with_alias_tags() {
7226        let channel_impl = Arc::new(RecordingChannel::default());
7227        let channel: Arc<dyn Channel> = channel_impl.clone();
7228
7229        let mut channels_by_name = HashMap::new();
7230        channels_by_name.insert(channel.name().to_string(), channel);
7231
7232        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7233            channels_by_name: Arc::new(channels_by_name),
7234            provider: Arc::new(ToolCallingAliasProvider),
7235            default_provider: Arc::new("test-provider".to_string()),
7236            memory: Arc::new(NoopMemory),
7237            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
7238            observer: Arc::new(NoopObserver),
7239            system_prompt: Arc::new("test-system-prompt".to_string()),
7240            model: Arc::new("test-model".to_string()),
7241            temperature: 0.0,
7242            auto_save_memory: false,
7243            max_tool_iterations: 10,
7244            min_relevance_score: 0.0,
7245            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7246            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7247            provider_cache: Arc::new(Mutex::new(HashMap::new())),
7248            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7249            api_key: None,
7250            api_url: None,
7251            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7252            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7253            workspace_dir: Arc::new(std::env::temp_dir()),
7254            prompt_config: Arc::new(crate::config::Config::default()),
7255            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7256            interrupt_on_new_message: InterruptOnNewMessageConfig {
7257                telegram: false,
7258                slack: false,
7259                discord: false,
7260                mattermost: false,
7261                matrix: false,
7262            },
7263            multimodal: crate::config::MultimodalConfig::default(),
7264            media_pipeline: crate::config::MediaPipelineConfig::default(),
7265            transcription_config: crate::config::TranscriptionConfig::default(),
7266            hooks: None,
7267            non_cli_excluded_tools: Arc::new(Vec::new()),
7268            autonomy_level: AutonomyLevel::default(),
7269            tool_call_dedup_exempt: Arc::new(Vec::new()),
7270            model_routes: Arc::new(Vec::new()),
7271            query_classification: crate::config::QueryClassificationConfig::default(),
7272            ack_reactions: true,
7273            show_tool_calls: true,
7274            session_store: None,
7275            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7276                &crate::config::AutonomyConfig::default(),
7277            )),
7278            activated_tools: None,
7279            mcp_registry: None,
7280            cost_tracking: None,
7281            pacing: crate::config::PacingConfig::default(),
7282            max_tool_result_chars: 0,
7283            context_token_budget: 0,
7284            audit_logger: None,
7285            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7286        });
7287
7288        process_channel_message(
7289            runtime_ctx,
7290            traits::ChannelMessage {
7291                id: "msg-2".to_string(),
7292                sender: "bob".to_string(),
7293                reply_target: "chat-84".to_string(),
7294                content: "What is the BTC price now?".to_string(),
7295                channel: "test-channel".to_string(),
7296                timestamp: 2,
7297                thread_ts: None,
7298                interruption_scope_id: None,
7299                attachments: vec![],
7300            },
7301            CancellationToken::new(),
7302        )
7303        .await;
7304
7305        let sent_messages = channel_impl.sent_messages.lock().await;
7306        assert!(!sent_messages.is_empty());
7307        let reply = sent_messages.last().unwrap();
7308        assert!(reply.starts_with("chat-84:"));
7309        assert!(reply.contains("alias-tag flow resolved"));
7310        assert!(!reply.contains("<toolcall>"));
7311        assert!(!reply.contains("mock_price"));
7312    }
7313
7314    #[tokio::test]
7315    async fn process_channel_message_handles_models_command_without_llm_call() {
7316        let channel_impl = Arc::new(TelegramRecordingChannel::default());
7317        let channel: Arc<dyn Channel> = channel_impl.clone();
7318
7319        let mut channels_by_name = HashMap::new();
7320        channels_by_name.insert(channel.name().to_string(), channel);
7321
7322        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
7323        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
7324        let fallback_provider_impl = Arc::new(ModelCaptureProvider::default());
7325        let fallback_provider: Arc<dyn Provider> = fallback_provider_impl.clone();
7326
7327        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
7328        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
7329        provider_cache_seed.insert("openrouter".to_string(), fallback_provider);
7330
7331        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7332            channels_by_name: Arc::new(channels_by_name),
7333            provider: Arc::clone(&default_provider),
7334            default_provider: Arc::new("test-provider".to_string()),
7335            memory: Arc::new(NoopMemory),
7336            tools_registry: Arc::new(vec![]),
7337            observer: Arc::new(NoopObserver),
7338            system_prompt: Arc::new("test-system-prompt".to_string()),
7339            model: Arc::new("default-model".to_string()),
7340            temperature: 0.0,
7341            auto_save_memory: false,
7342            max_tool_iterations: 5,
7343            min_relevance_score: 0.0,
7344            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7345            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7346            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
7347            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7348            api_key: None,
7349            api_url: None,
7350            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7351            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7352            workspace_dir: Arc::new(std::env::temp_dir()),
7353            prompt_config: Arc::new(crate::config::Config::default()),
7354            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7355            interrupt_on_new_message: InterruptOnNewMessageConfig {
7356                telegram: false,
7357                slack: false,
7358                discord: false,
7359                mattermost: false,
7360                matrix: false,
7361            },
7362            multimodal: crate::config::MultimodalConfig::default(),
7363            media_pipeline: crate::config::MediaPipelineConfig::default(),
7364            transcription_config: crate::config::TranscriptionConfig::default(),
7365            hooks: None,
7366            non_cli_excluded_tools: Arc::new(Vec::new()),
7367            autonomy_level: AutonomyLevel::default(),
7368            tool_call_dedup_exempt: Arc::new(Vec::new()),
7369            model_routes: Arc::new(Vec::new()),
7370            query_classification: crate::config::QueryClassificationConfig::default(),
7371            ack_reactions: true,
7372            show_tool_calls: true,
7373            session_store: None,
7374            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7375                &crate::config::AutonomyConfig::default(),
7376            )),
7377            activated_tools: None,
7378            mcp_registry: None,
7379            cost_tracking: None,
7380            pacing: crate::config::PacingConfig::default(),
7381            max_tool_result_chars: 0,
7382            context_token_budget: 0,
7383            audit_logger: None,
7384            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7385        });
7386
7387        process_channel_message(
7388            runtime_ctx.clone(),
7389            traits::ChannelMessage {
7390                id: "msg-cmd-1".to_string(),
7391                sender: "alice".to_string(),
7392                reply_target: "chat-1".to_string(),
7393                content: "/models openrouter".to_string(),
7394                channel: "telegram".to_string(),
7395                timestamp: 1,
7396                thread_ts: None,
7397                interruption_scope_id: None,
7398                attachments: vec![],
7399            },
7400            CancellationToken::new(),
7401        )
7402        .await;
7403
7404        let sent = channel_impl.sent_messages.lock().await;
7405        assert_eq!(sent.len(), 1);
7406        assert!(sent[0].contains("Provider switched to `openrouter`"));
7407
7408        let route_key = "telegram_chat-1_alice";
7409        let route = runtime_ctx
7410            .route_overrides
7411            .lock()
7412            .unwrap_or_else(|e| e.into_inner())
7413            .get(route_key)
7414            .cloned()
7415            .expect("route should be stored for sender");
7416        assert_eq!(route.provider, "openrouter");
7417        assert_eq!(route.model, "default-model");
7418
7419        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
7420        assert_eq!(fallback_provider_impl.call_count.load(Ordering::SeqCst), 0);
7421    }
7422
7423    #[tokio::test]
7424    async fn process_channel_message_uses_route_override_provider_and_model() {
7425        let channel_impl = Arc::new(TelegramRecordingChannel::default());
7426        let channel: Arc<dyn Channel> = channel_impl.clone();
7427
7428        let mut channels_by_name = HashMap::new();
7429        channels_by_name.insert(channel.name().to_string(), channel);
7430
7431        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
7432        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
7433        let routed_provider_impl = Arc::new(ModelCaptureProvider::default());
7434        let routed_provider: Arc<dyn Provider> = routed_provider_impl.clone();
7435
7436        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
7437        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
7438        provider_cache_seed.insert("openrouter".to_string(), routed_provider);
7439
7440        let route_key = "telegram_chat-1_alice".to_string();
7441        let mut route_overrides = HashMap::new();
7442        route_overrides.insert(
7443            route_key,
7444            ChannelRouteSelection {
7445                provider: "openrouter".to_string(),
7446                model: "route-model".to_string(),
7447                api_key: None,
7448            },
7449        );
7450
7451        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7452            channels_by_name: Arc::new(channels_by_name),
7453            provider: Arc::clone(&default_provider),
7454            default_provider: Arc::new("test-provider".to_string()),
7455            memory: Arc::new(NoopMemory),
7456            tools_registry: Arc::new(vec![]),
7457            observer: Arc::new(NoopObserver),
7458            system_prompt: Arc::new("test-system-prompt".to_string()),
7459            model: Arc::new("default-model".to_string()),
7460            temperature: 0.0,
7461            auto_save_memory: false,
7462            max_tool_iterations: 5,
7463            min_relevance_score: 0.0,
7464            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7465            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7466            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
7467            route_overrides: Arc::new(Mutex::new(route_overrides)),
7468            api_key: None,
7469            api_url: None,
7470            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7471            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7472            workspace_dir: Arc::new(std::env::temp_dir()),
7473            prompt_config: Arc::new(crate::config::Config::default()),
7474            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7475            interrupt_on_new_message: InterruptOnNewMessageConfig {
7476                telegram: false,
7477                slack: false,
7478                discord: false,
7479                mattermost: false,
7480                matrix: false,
7481            },
7482            multimodal: crate::config::MultimodalConfig::default(),
7483            media_pipeline: crate::config::MediaPipelineConfig::default(),
7484            transcription_config: crate::config::TranscriptionConfig::default(),
7485            hooks: None,
7486            non_cli_excluded_tools: Arc::new(Vec::new()),
7487            autonomy_level: AutonomyLevel::default(),
7488            tool_call_dedup_exempt: Arc::new(Vec::new()),
7489            model_routes: Arc::new(Vec::new()),
7490            query_classification: crate::config::QueryClassificationConfig::default(),
7491            ack_reactions: true,
7492            show_tool_calls: true,
7493            session_store: None,
7494            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7495                &crate::config::AutonomyConfig::default(),
7496            )),
7497            activated_tools: None,
7498            mcp_registry: None,
7499            cost_tracking: None,
7500            pacing: crate::config::PacingConfig::default(),
7501            max_tool_result_chars: 0,
7502            context_token_budget: 0,
7503            audit_logger: None,
7504            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7505        });
7506
7507        process_channel_message(
7508            runtime_ctx,
7509            traits::ChannelMessage {
7510                id: "msg-routed-1".to_string(),
7511                sender: "alice".to_string(),
7512                reply_target: "chat-1".to_string(),
7513                content: "hello routed provider".to_string(),
7514                channel: "telegram".to_string(),
7515                timestamp: 2,
7516                thread_ts: None,
7517                interruption_scope_id: None,
7518                attachments: vec![],
7519            },
7520            CancellationToken::new(),
7521        )
7522        .await;
7523
7524        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
7525        assert_eq!(routed_provider_impl.call_count.load(Ordering::SeqCst), 1);
7526        assert_eq!(
7527            routed_provider_impl
7528                .models
7529                .lock()
7530                .unwrap_or_else(|e| e.into_inner())
7531                .as_slice(),
7532            &["route-model".to_string()]
7533        );
7534    }
7535
7536    #[tokio::test]
7537    async fn process_channel_message_prefers_cached_default_provider_instance() {
7538        let channel_impl = Arc::new(TelegramRecordingChannel::default());
7539        let channel: Arc<dyn Channel> = channel_impl.clone();
7540
7541        let mut channels_by_name = HashMap::new();
7542        channels_by_name.insert(channel.name().to_string(), channel);
7543
7544        let startup_provider_impl = Arc::new(ModelCaptureProvider::default());
7545        let startup_provider: Arc<dyn Provider> = startup_provider_impl.clone();
7546        let reloaded_provider_impl = Arc::new(ModelCaptureProvider::default());
7547        let reloaded_provider: Arc<dyn Provider> = reloaded_provider_impl.clone();
7548
7549        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
7550        provider_cache_seed.insert("test-provider".to_string(), reloaded_provider);
7551
7552        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7553            channels_by_name: Arc::new(channels_by_name),
7554            provider: Arc::clone(&startup_provider),
7555            default_provider: Arc::new("test-provider".to_string()),
7556            memory: Arc::new(NoopMemory),
7557            tools_registry: Arc::new(vec![]),
7558            observer: Arc::new(NoopObserver),
7559            system_prompt: Arc::new("test-system-prompt".to_string()),
7560            model: Arc::new("default-model".to_string()),
7561            temperature: 0.0,
7562            auto_save_memory: false,
7563            max_tool_iterations: 5,
7564            min_relevance_score: 0.0,
7565            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7566            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7567            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
7568            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7569            api_key: None,
7570            api_url: None,
7571            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7572            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7573            workspace_dir: Arc::new(std::env::temp_dir()),
7574            prompt_config: Arc::new(crate::config::Config::default()),
7575            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7576            interrupt_on_new_message: InterruptOnNewMessageConfig {
7577                telegram: false,
7578                slack: false,
7579                discord: false,
7580                mattermost: false,
7581                matrix: false,
7582            },
7583            multimodal: crate::config::MultimodalConfig::default(),
7584            media_pipeline: crate::config::MediaPipelineConfig::default(),
7585            transcription_config: crate::config::TranscriptionConfig::default(),
7586            hooks: None,
7587            non_cli_excluded_tools: Arc::new(Vec::new()),
7588            autonomy_level: AutonomyLevel::default(),
7589            tool_call_dedup_exempt: Arc::new(Vec::new()),
7590            model_routes: Arc::new(Vec::new()),
7591            query_classification: crate::config::QueryClassificationConfig::default(),
7592            ack_reactions: true,
7593            show_tool_calls: true,
7594            session_store: None,
7595            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7596                &crate::config::AutonomyConfig::default(),
7597            )),
7598            activated_tools: None,
7599            mcp_registry: None,
7600            cost_tracking: None,
7601            pacing: crate::config::PacingConfig::default(),
7602            max_tool_result_chars: 0,
7603            context_token_budget: 0,
7604            audit_logger: None,
7605            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7606        });
7607
7608        process_channel_message(
7609            runtime_ctx,
7610            traits::ChannelMessage {
7611                id: "msg-default-provider-cache".to_string(),
7612                sender: "alice".to_string(),
7613                reply_target: "chat-1".to_string(),
7614                content: "hello cached default provider".to_string(),
7615                channel: "telegram".to_string(),
7616                timestamp: 3,
7617                thread_ts: None,
7618                interruption_scope_id: None,
7619                attachments: vec![],
7620            },
7621            CancellationToken::new(),
7622        )
7623        .await;
7624
7625        assert_eq!(startup_provider_impl.call_count.load(Ordering::SeqCst), 0);
7626        assert_eq!(reloaded_provider_impl.call_count.load(Ordering::SeqCst), 1);
7627    }
7628
7629    #[tokio::test]
7630    async fn process_channel_message_uses_runtime_default_model_from_store() {
7631        let channel_impl = Arc::new(TelegramRecordingChannel::default());
7632        let channel: Arc<dyn Channel> = channel_impl.clone();
7633
7634        let mut channels_by_name = HashMap::new();
7635        channels_by_name.insert(channel.name().to_string(), channel);
7636
7637        let provider_impl = Arc::new(ModelCaptureProvider::default());
7638        let provider: Arc<dyn Provider> = provider_impl.clone();
7639        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
7640        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&provider));
7641
7642        let temp = tempfile::TempDir::new().expect("temp dir");
7643        let config_path = temp.path().join("config.toml");
7644
7645        {
7646            let mut store = runtime_config_store()
7647                .lock()
7648                .unwrap_or_else(|e| e.into_inner());
7649            store.insert(
7650                config_path.clone(),
7651                RuntimeConfigState {
7652                    defaults: ChannelRuntimeDefaults {
7653                        default_provider: "test-provider".to_string(),
7654                        model: "hot-reloaded-model".to_string(),
7655                        temperature: 0.5,
7656                        api_key: None,
7657                        api_url: None,
7658                        reliability: crate::config::ReliabilityConfig::default(),
7659                    },
7660                    last_applied_stamp: None,
7661                },
7662            );
7663        }
7664
7665        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7666            channels_by_name: Arc::new(channels_by_name),
7667            provider: Arc::clone(&provider),
7668            default_provider: Arc::new("test-provider".to_string()),
7669            memory: Arc::new(NoopMemory),
7670            tools_registry: Arc::new(vec![]),
7671            observer: Arc::new(NoopObserver),
7672            system_prompt: Arc::new("test-system-prompt".to_string()),
7673            model: Arc::new("startup-model".to_string()),
7674            temperature: 0.0,
7675            auto_save_memory: false,
7676            max_tool_iterations: 5,
7677            min_relevance_score: 0.0,
7678            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7679            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7680            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
7681            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7682            api_key: None,
7683            api_url: None,
7684            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7685            provider_runtime_options: providers::ProviderRuntimeOptions {
7686                construct_dir: Some(temp.path().to_path_buf()),
7687                ..providers::ProviderRuntimeOptions::default()
7688            },
7689            workspace_dir: Arc::new(std::env::temp_dir()),
7690            prompt_config: Arc::new(crate::config::Config::default()),
7691            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7692            interrupt_on_new_message: InterruptOnNewMessageConfig {
7693                telegram: false,
7694                slack: false,
7695                discord: false,
7696                mattermost: false,
7697                matrix: false,
7698            },
7699            multimodal: crate::config::MultimodalConfig::default(),
7700            media_pipeline: crate::config::MediaPipelineConfig::default(),
7701            transcription_config: crate::config::TranscriptionConfig::default(),
7702            hooks: None,
7703            non_cli_excluded_tools: Arc::new(Vec::new()),
7704            autonomy_level: AutonomyLevel::default(),
7705            tool_call_dedup_exempt: Arc::new(Vec::new()),
7706            model_routes: Arc::new(Vec::new()),
7707            query_classification: crate::config::QueryClassificationConfig::default(),
7708            ack_reactions: true,
7709            show_tool_calls: true,
7710            session_store: None,
7711            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7712                &crate::config::AutonomyConfig::default(),
7713            )),
7714            activated_tools: None,
7715            mcp_registry: None,
7716            cost_tracking: None,
7717            pacing: crate::config::PacingConfig::default(),
7718            max_tool_result_chars: 0,
7719            context_token_budget: 0,
7720            audit_logger: None,
7721            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7722        });
7723
7724        process_channel_message(
7725            runtime_ctx,
7726            traits::ChannelMessage {
7727                id: "msg-runtime-store-model".to_string(),
7728                sender: "alice".to_string(),
7729                reply_target: "chat-1".to_string(),
7730                content: "hello runtime defaults".to_string(),
7731                channel: "telegram".to_string(),
7732                timestamp: 4,
7733                thread_ts: None,
7734                interruption_scope_id: None,
7735                attachments: vec![],
7736            },
7737            CancellationToken::new(),
7738        )
7739        .await;
7740
7741        {
7742            let mut cleanup_store = runtime_config_store()
7743                .lock()
7744                .unwrap_or_else(|e| e.into_inner());
7745            cleanup_store.remove(&config_path);
7746        }
7747
7748        assert_eq!(provider_impl.call_count.load(Ordering::SeqCst), 1);
7749        assert_eq!(
7750            provider_impl
7751                .models
7752                .lock()
7753                .unwrap_or_else(|e| e.into_inner())
7754                .as_slice(),
7755            &["hot-reloaded-model".to_string()]
7756        );
7757    }
7758
7759    #[tokio::test]
7760    async fn process_channel_message_respects_configured_max_tool_iterations_above_default() {
7761        let channel_impl = Arc::new(RecordingChannel::default());
7762        let channel: Arc<dyn Channel> = channel_impl.clone();
7763
7764        let mut channels_by_name = HashMap::new();
7765        channels_by_name.insert(channel.name().to_string(), channel);
7766
7767        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7768            channels_by_name: Arc::new(channels_by_name),
7769            provider: Arc::new(IterativeToolProvider {
7770                required_tool_iterations: 11,
7771            }),
7772            default_provider: Arc::new("test-provider".to_string()),
7773            memory: Arc::new(NoopMemory),
7774            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
7775            observer: Arc::new(NoopObserver),
7776            system_prompt: Arc::new("test-system-prompt".to_string()),
7777            model: Arc::new("test-model".to_string()),
7778            temperature: 0.0,
7779            auto_save_memory: false,
7780            max_tool_iterations: 12,
7781            min_relevance_score: 0.0,
7782            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7783            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7784            provider_cache: Arc::new(Mutex::new(HashMap::new())),
7785            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7786            api_key: None,
7787            api_url: None,
7788            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7789            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7790            workspace_dir: Arc::new(std::env::temp_dir()),
7791            prompt_config: Arc::new(crate::config::Config::default()),
7792            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7793            interrupt_on_new_message: InterruptOnNewMessageConfig {
7794                telegram: false,
7795                slack: false,
7796                discord: false,
7797                mattermost: false,
7798                matrix: false,
7799            },
7800            multimodal: crate::config::MultimodalConfig::default(),
7801            media_pipeline: crate::config::MediaPipelineConfig::default(),
7802            transcription_config: crate::config::TranscriptionConfig::default(),
7803            hooks: None,
7804            non_cli_excluded_tools: Arc::new(Vec::new()),
7805            autonomy_level: AutonomyLevel::default(),
7806            tool_call_dedup_exempt: Arc::new(Vec::new()),
7807            model_routes: Arc::new(Vec::new()),
7808            query_classification: crate::config::QueryClassificationConfig::default(),
7809            ack_reactions: true,
7810            show_tool_calls: true,
7811            session_store: None,
7812            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7813                &crate::config::AutonomyConfig::default(),
7814            )),
7815            activated_tools: None,
7816            mcp_registry: None,
7817            cost_tracking: None,
7818            pacing: crate::config::PacingConfig {
7819                loop_detection_enabled: false,
7820                ..crate::config::PacingConfig::default()
7821            },
7822            max_tool_result_chars: 0,
7823            context_token_budget: 0,
7824            audit_logger: None,
7825            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7826        });
7827
7828        process_channel_message(
7829            runtime_ctx,
7830            traits::ChannelMessage {
7831                id: "msg-iter-success".to_string(),
7832                sender: "alice".to_string(),
7833                reply_target: "chat-iter-success".to_string(),
7834                content: "Loop until done".to_string(),
7835                channel: "test-channel".to_string(),
7836                timestamp: 1,
7837                thread_ts: None,
7838                interruption_scope_id: None,
7839                attachments: vec![],
7840            },
7841            CancellationToken::new(),
7842        )
7843        .await;
7844
7845        let sent_messages = channel_impl.sent_messages.lock().await;
7846        assert!(!sent_messages.is_empty());
7847        let reply = sent_messages.last().unwrap();
7848        assert!(reply.starts_with("chat-iter-success:"));
7849        assert!(reply.contains("Completed after 11 tool iterations."));
7850        assert!(!reply.contains("⚠️ Error:"));
7851    }
7852
7853    #[tokio::test]
7854    async fn process_channel_message_reports_configured_max_tool_iterations_limit() {
7855        let channel_impl = Arc::new(RecordingChannel::default());
7856        let channel: Arc<dyn Channel> = channel_impl.clone();
7857
7858        let mut channels_by_name = HashMap::new();
7859        channels_by_name.insert(channel.name().to_string(), channel);
7860
7861        let runtime_ctx = Arc::new(ChannelRuntimeContext {
7862            channels_by_name: Arc::new(channels_by_name),
7863            provider: Arc::new(IterativeToolProvider {
7864                required_tool_iterations: 20,
7865            }),
7866            default_provider: Arc::new("test-provider".to_string()),
7867            memory: Arc::new(NoopMemory),
7868            tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
7869            observer: Arc::new(NoopObserver),
7870            system_prompt: Arc::new("test-system-prompt".to_string()),
7871            model: Arc::new("test-model".to_string()),
7872            temperature: 0.0,
7873            auto_save_memory: false,
7874            max_tool_iterations: 3,
7875            min_relevance_score: 0.0,
7876            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
7877            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
7878            provider_cache: Arc::new(Mutex::new(HashMap::new())),
7879            route_overrides: Arc::new(Mutex::new(HashMap::new())),
7880            api_key: None,
7881            api_url: None,
7882            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
7883            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
7884            workspace_dir: Arc::new(std::env::temp_dir()),
7885            prompt_config: Arc::new(crate::config::Config::default()),
7886            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
7887            interrupt_on_new_message: InterruptOnNewMessageConfig {
7888                telegram: false,
7889                slack: false,
7890                discord: false,
7891                mattermost: false,
7892                matrix: false,
7893            },
7894            multimodal: crate::config::MultimodalConfig::default(),
7895            media_pipeline: crate::config::MediaPipelineConfig::default(),
7896            transcription_config: crate::config::TranscriptionConfig::default(),
7897            hooks: None,
7898            non_cli_excluded_tools: Arc::new(Vec::new()),
7899            autonomy_level: AutonomyLevel::default(),
7900            tool_call_dedup_exempt: Arc::new(Vec::new()),
7901            model_routes: Arc::new(Vec::new()),
7902            query_classification: crate::config::QueryClassificationConfig::default(),
7903            ack_reactions: true,
7904            show_tool_calls: true,
7905            session_store: None,
7906            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
7907                &crate::config::AutonomyConfig::default(),
7908            )),
7909            activated_tools: None,
7910            mcp_registry: None,
7911            cost_tracking: None,
7912            pacing: crate::config::PacingConfig {
7913                loop_detection_enabled: false,
7914                ..crate::config::PacingConfig::default()
7915            },
7916            max_tool_result_chars: 0,
7917            context_token_budget: 0,
7918            audit_logger: None,
7919            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
7920        });
7921
7922        process_channel_message(
7923            runtime_ctx,
7924            traits::ChannelMessage {
7925                id: "msg-iter-fail".to_string(),
7926                sender: "bob".to_string(),
7927                reply_target: "chat-iter-fail".to_string(),
7928                content: "Loop forever".to_string(),
7929                channel: "test-channel".to_string(),
7930                timestamp: 2,
7931                thread_ts: None,
7932                interruption_scope_id: None,
7933                attachments: vec![],
7934            },
7935            CancellationToken::new(),
7936        )
7937        .await;
7938
7939        let sent_messages = channel_impl.sent_messages.lock().await;
7940        assert!(!sent_messages.is_empty());
7941        let reply = sent_messages.last().unwrap();
7942        assert!(reply.starts_with("chat-iter-fail:"));
7943        // After Phase 9, the agent attempts a graceful summary instead of erroring.
7944        // The mock provider returns a tool call payload as text, which the agent
7945        // returns as its "summary". The key invariant: the loop terminates and
7946        // produces a response (not hanging forever).
7947        assert!(
7948            reply.contains("⚠️ Error: Agent exceeded maximum tool iterations (3)")
7949                || reply.len() > "chat-iter-fail:".len(),
7950            "Expected either an error message or a graceful summary response"
7951        );
7952    }
7953
7954    struct NoopMemory;
7955
7956    #[async_trait::async_trait]
7957    impl Memory for NoopMemory {
7958        fn name(&self) -> &str {
7959            "noop"
7960        }
7961
7962        async fn store(
7963            &self,
7964            _key: &str,
7965            _content: &str,
7966            _category: crate::memory::MemoryCategory,
7967            _session_id: Option<&str>,
7968        ) -> anyhow::Result<()> {
7969            Ok(())
7970        }
7971
7972        async fn recall(
7973            &self,
7974            _query: &str,
7975            _limit: usize,
7976            _session_id: Option<&str>,
7977            _since: Option<&str>,
7978            _until: Option<&str>,
7979        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
7980            Ok(Vec::new())
7981        }
7982
7983        async fn get(&self, _key: &str) -> anyhow::Result<Option<crate::memory::MemoryEntry>> {
7984            Ok(None)
7985        }
7986
7987        async fn list(
7988            &self,
7989            _category: Option<&crate::memory::MemoryCategory>,
7990            _session_id: Option<&str>,
7991        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
7992            Ok(Vec::new())
7993        }
7994
7995        async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
7996            Ok(false)
7997        }
7998
7999        async fn count(&self) -> anyhow::Result<usize> {
8000            Ok(0)
8001        }
8002
8003        async fn health_check(&self) -> bool {
8004            true
8005        }
8006    }
8007
8008    struct RecallMemory;
8009
8010    #[async_trait::async_trait]
8011    impl Memory for RecallMemory {
8012        fn name(&self) -> &str {
8013            "recall-memory"
8014        }
8015
8016        async fn store(
8017            &self,
8018            _key: &str,
8019            _content: &str,
8020            _category: crate::memory::MemoryCategory,
8021            _session_id: Option<&str>,
8022        ) -> anyhow::Result<()> {
8023            Ok(())
8024        }
8025
8026        async fn recall(
8027            &self,
8028            _query: &str,
8029            _limit: usize,
8030            _session_id: Option<&str>,
8031            _since: Option<&str>,
8032            _until: Option<&str>,
8033        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
8034            Ok(vec![crate::memory::MemoryEntry {
8035                id: "entry-1".to_string(),
8036                key: "memory_key_1".to_string(),
8037                content: "Age is 45".to_string(),
8038                category: crate::memory::MemoryCategory::Conversation,
8039                timestamp: "2026-02-20T00:00:00Z".to_string(),
8040                session_id: None,
8041                score: Some(0.9),
8042                namespace: "default".into(),
8043                importance: None,
8044                superseded_by: None,
8045            }])
8046        }
8047
8048        async fn get(&self, _key: &str) -> anyhow::Result<Option<crate::memory::MemoryEntry>> {
8049            Ok(None)
8050        }
8051
8052        async fn list(
8053            &self,
8054            _category: Option<&crate::memory::MemoryCategory>,
8055            _session_id: Option<&str>,
8056        ) -> anyhow::Result<Vec<crate::memory::MemoryEntry>> {
8057            Ok(Vec::new())
8058        }
8059
8060        async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
8061            Ok(false)
8062        }
8063
8064        async fn count(&self) -> anyhow::Result<usize> {
8065            Ok(1)
8066        }
8067
8068        async fn health_check(&self) -> bool {
8069            true
8070        }
8071    }
8072
8073    #[tokio::test]
8074    async fn message_dispatch_processes_messages_in_parallel() {
8075        let channel_impl = Arc::new(RecordingChannel::default());
8076        let channel: Arc<dyn Channel> = channel_impl.clone();
8077
8078        let mut channels_by_name = HashMap::new();
8079        channels_by_name.insert(channel.name().to_string(), channel);
8080
8081        let runtime_ctx = Arc::new(ChannelRuntimeContext {
8082            channels_by_name: Arc::new(channels_by_name),
8083            provider: Arc::new(SlowProvider {
8084                delay: Duration::from_millis(250),
8085            }),
8086            default_provider: Arc::new("test-provider".to_string()),
8087            memory: Arc::new(NoopMemory),
8088            tools_registry: Arc::new(vec![]),
8089            observer: Arc::new(NoopObserver),
8090            system_prompt: Arc::new("test-system-prompt".to_string()),
8091            model: Arc::new("test-model".to_string()),
8092            temperature: 0.0,
8093            auto_save_memory: false,
8094            max_tool_iterations: 10,
8095            min_relevance_score: 0.0,
8096            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
8097            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
8098            provider_cache: Arc::new(Mutex::new(HashMap::new())),
8099            route_overrides: Arc::new(Mutex::new(HashMap::new())),
8100            api_key: None,
8101            api_url: None,
8102            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
8103            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
8104            workspace_dir: Arc::new(std::env::temp_dir()),
8105            prompt_config: Arc::new(crate::config::Config::default()),
8106            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
8107            interrupt_on_new_message: InterruptOnNewMessageConfig {
8108                telegram: false,
8109                slack: false,
8110                discord: false,
8111                mattermost: false,
8112                matrix: false,
8113            },
8114            multimodal: crate::config::MultimodalConfig::default(),
8115            media_pipeline: crate::config::MediaPipelineConfig::default(),
8116            transcription_config: crate::config::TranscriptionConfig::default(),
8117            hooks: None,
8118            non_cli_excluded_tools: Arc::new(Vec::new()),
8119            autonomy_level: AutonomyLevel::default(),
8120            tool_call_dedup_exempt: Arc::new(Vec::new()),
8121            model_routes: Arc::new(Vec::new()),
8122            query_classification: crate::config::QueryClassificationConfig::default(),
8123            ack_reactions: true,
8124            show_tool_calls: true,
8125            session_store: None,
8126            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
8127                &crate::config::AutonomyConfig::default(),
8128            )),
8129            activated_tools: None,
8130            mcp_registry: None,
8131            cost_tracking: None,
8132            pacing: crate::config::PacingConfig::default(),
8133            max_tool_result_chars: 0,
8134            context_token_budget: 0,
8135            audit_logger: None,
8136            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
8137        });
8138
8139        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
8140        tx.send(traits::ChannelMessage {
8141            id: "1".to_string(),
8142            sender: "alice".to_string(),
8143            reply_target: "alice".to_string(),
8144            content: "hello".to_string(),
8145            channel: "test-channel".to_string(),
8146            timestamp: 1,
8147            thread_ts: None,
8148            interruption_scope_id: None,
8149            attachments: vec![],
8150        })
8151        .await
8152        .unwrap();
8153        tx.send(traits::ChannelMessage {
8154            id: "2".to_string(),
8155            sender: "bob".to_string(),
8156            reply_target: "bob".to_string(),
8157            content: "world".to_string(),
8158            channel: "test-channel".to_string(),
8159            timestamp: 2,
8160            thread_ts: None,
8161            interruption_scope_id: None,
8162            attachments: vec![],
8163        })
8164        .await
8165        .unwrap();
8166        drop(tx);
8167
8168        let started = Instant::now();
8169        run_message_dispatch_loop(rx, runtime_ctx, 2).await;
8170        let elapsed = started.elapsed();
8171
8172        assert!(
8173            elapsed < Duration::from_millis(430),
8174            "expected parallel dispatch (<430ms), got {:?}",
8175            elapsed
8176        );
8177
8178        let sent_messages = channel_impl.sent_messages.lock().await;
8179        assert_eq!(sent_messages.len(), 2);
8180    }
8181
8182    #[tokio::test]
8183    async fn message_dispatch_interrupts_in_flight_telegram_request_and_preserves_context() {
8184        let channel_impl = Arc::new(TelegramRecordingChannel::default());
8185        let channel: Arc<dyn Channel> = channel_impl.clone();
8186
8187        let mut channels_by_name = HashMap::new();
8188        channels_by_name.insert(channel.name().to_string(), channel);
8189
8190        let provider_impl = Arc::new(DelayedHistoryCaptureProvider {
8191            delay: Duration::from_millis(250),
8192            calls: std::sync::Mutex::new(Vec::new()),
8193        });
8194
8195        let runtime_ctx = Arc::new(ChannelRuntimeContext {
8196            channels_by_name: Arc::new(channels_by_name),
8197            provider: provider_impl.clone(),
8198            default_provider: Arc::new("test-provider".to_string()),
8199            memory: Arc::new(NoopMemory),
8200            tools_registry: Arc::new(vec![]),
8201            observer: Arc::new(NoopObserver),
8202            system_prompt: Arc::new("test-system-prompt".to_string()),
8203            model: Arc::new("test-model".to_string()),
8204            temperature: 0.0,
8205            auto_save_memory: false,
8206            max_tool_iterations: 10,
8207            min_relevance_score: 0.0,
8208            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
8209            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
8210            provider_cache: Arc::new(Mutex::new(HashMap::new())),
8211            route_overrides: Arc::new(Mutex::new(HashMap::new())),
8212            api_key: None,
8213            api_url: None,
8214            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
8215            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
8216            workspace_dir: Arc::new(std::env::temp_dir()),
8217            prompt_config: Arc::new(crate::config::Config::default()),
8218            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
8219            interrupt_on_new_message: InterruptOnNewMessageConfig {
8220                telegram: true,
8221                slack: false,
8222                discord: false,
8223                mattermost: false,
8224                matrix: false,
8225            },
8226            multimodal: crate::config::MultimodalConfig::default(),
8227            media_pipeline: crate::config::MediaPipelineConfig::default(),
8228            transcription_config: crate::config::TranscriptionConfig::default(),
8229            hooks: None,
8230            non_cli_excluded_tools: Arc::new(Vec::new()),
8231            autonomy_level: AutonomyLevel::default(),
8232            tool_call_dedup_exempt: Arc::new(Vec::new()),
8233            model_routes: Arc::new(Vec::new()),
8234            query_classification: crate::config::QueryClassificationConfig::default(),
8235            ack_reactions: true,
8236            show_tool_calls: true,
8237            session_store: None,
8238            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
8239                &crate::config::AutonomyConfig::default(),
8240            )),
8241            activated_tools: None,
8242            mcp_registry: None,
8243            cost_tracking: None,
8244            pacing: crate::config::PacingConfig::default(),
8245            max_tool_result_chars: 0,
8246            context_token_budget: 0,
8247            audit_logger: None,
8248            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
8249        });
8250
8251        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
8252        let send_task = tokio::spawn(async move {
8253            tx.send(traits::ChannelMessage {
8254                id: "msg-1".to_string(),
8255                sender: "alice".to_string(),
8256                reply_target: "chat-1".to_string(),
8257                content: "forwarded content".to_string(),
8258                channel: "telegram".to_string(),
8259                timestamp: 1,
8260                thread_ts: None,
8261                interruption_scope_id: None,
8262                attachments: vec![],
8263            })
8264            .await
8265            .unwrap();
8266            tokio::time::sleep(Duration::from_millis(40)).await;
8267            tx.send(traits::ChannelMessage {
8268                id: "msg-2".to_string(),
8269                sender: "alice".to_string(),
8270                reply_target: "chat-1".to_string(),
8271                content: "summarize this".to_string(),
8272                channel: "telegram".to_string(),
8273                timestamp: 2,
8274                thread_ts: None,
8275                interruption_scope_id: None,
8276                attachments: vec![],
8277            })
8278            .await
8279            .unwrap();
8280        });
8281
8282        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
8283        send_task.await.unwrap();
8284
8285        let sent_messages = channel_impl.sent_messages.lock().await;
8286        assert_eq!(sent_messages.len(), 1);
8287        assert!(sent_messages[0].starts_with("chat-1:"));
8288        assert!(sent_messages[0].contains("response-2"));
8289        drop(sent_messages);
8290
8291        let calls = provider_impl
8292            .calls
8293            .lock()
8294            .unwrap_or_else(|e| e.into_inner());
8295        assert_eq!(calls.len(), 2);
8296        let second_call = &calls[1];
8297        assert!(
8298            second_call
8299                .iter()
8300                .any(|(role, content)| { role == "user" && content.contains("forwarded content") })
8301        );
8302        assert!(
8303            second_call
8304                .iter()
8305                .any(|(role, content)| { role == "user" && content.contains("summarize this") })
8306        );
8307        assert!(
8308            !second_call.iter().any(|(role, _)| role == "assistant"),
8309            "cancelled turn should not persist an assistant response"
8310        );
8311    }
8312
8313    #[tokio::test]
8314    async fn message_dispatch_interrupts_in_flight_slack_request_and_preserves_context() {
8315        let channel_impl = Arc::new(SlackRecordingChannel::default());
8316        let channel: Arc<dyn Channel> = channel_impl.clone();
8317
8318        let mut channels_by_name = HashMap::new();
8319        channels_by_name.insert(channel.name().to_string(), channel);
8320
8321        let provider_impl = Arc::new(DelayedHistoryCaptureProvider {
8322            delay: Duration::from_millis(250),
8323            calls: std::sync::Mutex::new(Vec::new()),
8324        });
8325
8326        let runtime_ctx = Arc::new(ChannelRuntimeContext {
8327            channels_by_name: Arc::new(channels_by_name),
8328            provider: provider_impl.clone(),
8329            default_provider: Arc::new("test-provider".to_string()),
8330            memory: Arc::new(NoopMemory),
8331            tools_registry: Arc::new(vec![]),
8332            observer: Arc::new(NoopObserver),
8333            system_prompt: Arc::new("test-system-prompt".to_string()),
8334            model: Arc::new("test-model".to_string()),
8335            temperature: 0.0,
8336            auto_save_memory: false,
8337            max_tool_iterations: 10,
8338            min_relevance_score: 0.0,
8339            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
8340            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
8341            provider_cache: Arc::new(Mutex::new(HashMap::new())),
8342            route_overrides: Arc::new(Mutex::new(HashMap::new())),
8343            api_key: None,
8344            api_url: None,
8345            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
8346            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
8347            workspace_dir: Arc::new(std::env::temp_dir()),
8348            prompt_config: Arc::new(crate::config::Config::default()),
8349            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
8350            interrupt_on_new_message: InterruptOnNewMessageConfig {
8351                telegram: false,
8352                slack: true,
8353                discord: false,
8354                mattermost: false,
8355                matrix: false,
8356            },
8357            ack_reactions: true,
8358            show_tool_calls: true,
8359            session_store: None,
8360            multimodal: crate::config::MultimodalConfig::default(),
8361            media_pipeline: crate::config::MediaPipelineConfig::default(),
8362            transcription_config: crate::config::TranscriptionConfig::default(),
8363            hooks: None,
8364            non_cli_excluded_tools: Arc::new(Vec::new()),
8365            autonomy_level: AutonomyLevel::default(),
8366            tool_call_dedup_exempt: Arc::new(Vec::new()),
8367            model_routes: Arc::new(Vec::new()),
8368            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
8369                &crate::config::AutonomyConfig::default(),
8370            )),
8371            activated_tools: None,
8372            mcp_registry: None,
8373            cost_tracking: None,
8374            query_classification: crate::config::QueryClassificationConfig::default(),
8375            pacing: crate::config::PacingConfig::default(),
8376            max_tool_result_chars: 0,
8377            context_token_budget: 0,
8378            audit_logger: None,
8379            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
8380        });
8381
8382        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
8383        let send_task = tokio::spawn(async move {
8384            tx.send(traits::ChannelMessage {
8385                id: "msg-1".to_string(),
8386                sender: "U123".to_string(),
8387                reply_target: "C123".to_string(),
8388                content: "first question".to_string(),
8389                channel: "slack".to_string(),
8390                timestamp: 1,
8391                thread_ts: Some("1741234567.100001".to_string()),
8392                interruption_scope_id: Some("1741234567.100001".to_string()),
8393                attachments: vec![],
8394            })
8395            .await
8396            .unwrap();
8397            tokio::time::sleep(Duration::from_millis(40)).await;
8398            tx.send(traits::ChannelMessage {
8399                id: "msg-2".to_string(),
8400                sender: "U123".to_string(),
8401                reply_target: "C123".to_string(),
8402                content: "second question".to_string(),
8403                channel: "slack".to_string(),
8404                timestamp: 2,
8405                thread_ts: Some("1741234567.100001".to_string()),
8406                interruption_scope_id: Some("1741234567.100001".to_string()),
8407                attachments: vec![],
8408            })
8409            .await
8410            .unwrap();
8411        });
8412
8413        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
8414        send_task.await.unwrap();
8415
8416        let sent_messages = channel_impl.sent_messages.lock().await;
8417        assert_eq!(sent_messages.len(), 1);
8418        assert!(sent_messages[0].starts_with("C123:"));
8419        assert!(sent_messages[0].contains("response-2"));
8420        drop(sent_messages);
8421
8422        let calls = provider_impl
8423            .calls
8424            .lock()
8425            .unwrap_or_else(|e| e.into_inner());
8426        assert_eq!(calls.len(), 2);
8427        let second_call = &calls[1];
8428        assert!(
8429            second_call
8430                .iter()
8431                .any(|(role, content)| { role == "user" && content.contains("first question") })
8432        );
8433        assert!(
8434            second_call
8435                .iter()
8436                .any(|(role, content)| { role == "user" && content.contains("second question") })
8437        );
8438        assert!(
8439            !second_call.iter().any(|(role, _)| role == "assistant"),
8440            "cancelled turn should not persist an assistant response"
8441        );
8442    }
8443
8444    #[tokio::test]
8445    async fn message_dispatch_interrupt_scope_is_same_sender_same_chat() {
8446        let channel_impl = Arc::new(TelegramRecordingChannel::default());
8447        let channel: Arc<dyn Channel> = channel_impl.clone();
8448
8449        let mut channels_by_name = HashMap::new();
8450        channels_by_name.insert(channel.name().to_string(), channel);
8451
8452        let runtime_ctx = Arc::new(ChannelRuntimeContext {
8453            channels_by_name: Arc::new(channels_by_name),
8454            provider: Arc::new(SlowProvider {
8455                delay: Duration::from_millis(180),
8456            }),
8457            default_provider: Arc::new("test-provider".to_string()),
8458            memory: Arc::new(NoopMemory),
8459            tools_registry: Arc::new(vec![]),
8460            observer: Arc::new(NoopObserver),
8461            system_prompt: Arc::new("test-system-prompt".to_string()),
8462            model: Arc::new("test-model".to_string()),
8463            temperature: 0.0,
8464            auto_save_memory: false,
8465            max_tool_iterations: 10,
8466            min_relevance_score: 0.0,
8467            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
8468            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
8469            provider_cache: Arc::new(Mutex::new(HashMap::new())),
8470            route_overrides: Arc::new(Mutex::new(HashMap::new())),
8471            api_key: None,
8472            api_url: None,
8473            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
8474            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
8475            workspace_dir: Arc::new(std::env::temp_dir()),
8476            prompt_config: Arc::new(crate::config::Config::default()),
8477            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
8478            interrupt_on_new_message: InterruptOnNewMessageConfig {
8479                telegram: true,
8480                slack: false,
8481                discord: false,
8482                mattermost: false,
8483                matrix: false,
8484            },
8485            multimodal: crate::config::MultimodalConfig::default(),
8486            media_pipeline: crate::config::MediaPipelineConfig::default(),
8487            transcription_config: crate::config::TranscriptionConfig::default(),
8488            hooks: None,
8489            non_cli_excluded_tools: Arc::new(Vec::new()),
8490            autonomy_level: AutonomyLevel::default(),
8491            tool_call_dedup_exempt: Arc::new(Vec::new()),
8492            model_routes: Arc::new(Vec::new()),
8493            query_classification: crate::config::QueryClassificationConfig::default(),
8494            ack_reactions: true,
8495            show_tool_calls: true,
8496            session_store: None,
8497            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
8498                &crate::config::AutonomyConfig::default(),
8499            )),
8500            activated_tools: None,
8501            mcp_registry: None,
8502            cost_tracking: None,
8503            pacing: crate::config::PacingConfig::default(),
8504            max_tool_result_chars: 0,
8505            context_token_budget: 0,
8506            audit_logger: None,
8507            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
8508        });
8509
8510        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
8511        let send_task = tokio::spawn(async move {
8512            tx.send(traits::ChannelMessage {
8513                id: "msg-a".to_string(),
8514                sender: "alice".to_string(),
8515                reply_target: "chat-1".to_string(),
8516                content: "first chat".to_string(),
8517                channel: "telegram".to_string(),
8518                timestamp: 1,
8519                thread_ts: None,
8520                interruption_scope_id: None,
8521                attachments: vec![],
8522            })
8523            .await
8524            .unwrap();
8525            tokio::time::sleep(Duration::from_millis(30)).await;
8526            tx.send(traits::ChannelMessage {
8527                id: "msg-b".to_string(),
8528                sender: "alice".to_string(),
8529                reply_target: "chat-2".to_string(),
8530                content: "second chat".to_string(),
8531                channel: "telegram".to_string(),
8532                timestamp: 2,
8533                thread_ts: None,
8534                interruption_scope_id: None,
8535                attachments: vec![],
8536            })
8537            .await
8538            .unwrap();
8539        });
8540
8541        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
8542        send_task.await.unwrap();
8543
8544        let sent_messages = channel_impl.sent_messages.lock().await;
8545        assert_eq!(sent_messages.len(), 2);
8546        assert!(sent_messages.iter().any(|msg| msg.starts_with("chat-1:")));
8547        assert!(sent_messages.iter().any(|msg| msg.starts_with("chat-2:")));
8548    }
8549
8550    #[tokio::test]
8551    async fn process_channel_message_cancels_scoped_typing_task() {
8552        let channel_impl = Arc::new(RecordingChannel::default());
8553        let channel: Arc<dyn Channel> = channel_impl.clone();
8554
8555        let mut channels_by_name = HashMap::new();
8556        channels_by_name.insert(channel.name().to_string(), channel);
8557
8558        let runtime_ctx = Arc::new(ChannelRuntimeContext {
8559            channels_by_name: Arc::new(channels_by_name),
8560            provider: Arc::new(SlowProvider {
8561                delay: Duration::from_millis(20),
8562            }),
8563            default_provider: Arc::new("test-provider".to_string()),
8564            memory: Arc::new(NoopMemory),
8565            tools_registry: Arc::new(vec![]),
8566            observer: Arc::new(NoopObserver),
8567            system_prompt: Arc::new("test-system-prompt".to_string()),
8568            model: Arc::new("test-model".to_string()),
8569            temperature: 0.0,
8570            auto_save_memory: false,
8571            max_tool_iterations: 10,
8572            min_relevance_score: 0.0,
8573            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
8574            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
8575            provider_cache: Arc::new(Mutex::new(HashMap::new())),
8576            route_overrides: Arc::new(Mutex::new(HashMap::new())),
8577            api_key: None,
8578            api_url: None,
8579            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
8580            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
8581            workspace_dir: Arc::new(std::env::temp_dir()),
8582            prompt_config: Arc::new(crate::config::Config::default()),
8583            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
8584            interrupt_on_new_message: InterruptOnNewMessageConfig {
8585                telegram: false,
8586                slack: false,
8587                discord: false,
8588                mattermost: false,
8589                matrix: false,
8590            },
8591            multimodal: crate::config::MultimodalConfig::default(),
8592            media_pipeline: crate::config::MediaPipelineConfig::default(),
8593            transcription_config: crate::config::TranscriptionConfig::default(),
8594            hooks: None,
8595            non_cli_excluded_tools: Arc::new(Vec::new()),
8596            autonomy_level: AutonomyLevel::default(),
8597            tool_call_dedup_exempt: Arc::new(Vec::new()),
8598            model_routes: Arc::new(Vec::new()),
8599            query_classification: crate::config::QueryClassificationConfig::default(),
8600            ack_reactions: true,
8601            show_tool_calls: true,
8602            session_store: None,
8603            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
8604                &crate::config::AutonomyConfig::default(),
8605            )),
8606            activated_tools: None,
8607            mcp_registry: None,
8608            cost_tracking: None,
8609            pacing: crate::config::PacingConfig::default(),
8610            max_tool_result_chars: 0,
8611            context_token_budget: 0,
8612            audit_logger: None,
8613            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
8614        });
8615
8616        process_channel_message(
8617            runtime_ctx,
8618            traits::ChannelMessage {
8619                id: "typing-msg".to_string(),
8620                sender: "alice".to_string(),
8621                reply_target: "chat-typing".to_string(),
8622                content: "hello".to_string(),
8623                channel: "test-channel".to_string(),
8624                timestamp: 1,
8625                thread_ts: None,
8626                interruption_scope_id: None,
8627                attachments: vec![],
8628            },
8629            CancellationToken::new(),
8630        )
8631        .await;
8632
8633        let starts = channel_impl.start_typing_calls.load(Ordering::SeqCst);
8634        let stops = channel_impl.stop_typing_calls.load(Ordering::SeqCst);
8635        assert_eq!(starts, 1, "start_typing should be called once");
8636        assert_eq!(stops, 1, "stop_typing should be called once");
8637    }
8638
8639    #[tokio::test]
8640    async fn process_channel_message_adds_and_swaps_reactions() {
8641        let channel_impl = Arc::new(RecordingChannel::default());
8642        let channel: Arc<dyn Channel> = channel_impl.clone();
8643
8644        let mut channels_by_name = HashMap::new();
8645        channels_by_name.insert(channel.name().to_string(), channel);
8646
8647        let runtime_ctx = Arc::new(ChannelRuntimeContext {
8648            channels_by_name: Arc::new(channels_by_name),
8649            provider: Arc::new(SlowProvider {
8650                delay: Duration::from_millis(5),
8651            }),
8652            default_provider: Arc::new("test-provider".to_string()),
8653            memory: Arc::new(NoopMemory),
8654            tools_registry: Arc::new(vec![]),
8655            observer: Arc::new(NoopObserver),
8656            system_prompt: Arc::new("test-system-prompt".to_string()),
8657            model: Arc::new("test-model".to_string()),
8658            temperature: 0.0,
8659            auto_save_memory: false,
8660            max_tool_iterations: 10,
8661            min_relevance_score: 0.0,
8662            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
8663            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
8664            provider_cache: Arc::new(Mutex::new(HashMap::new())),
8665            route_overrides: Arc::new(Mutex::new(HashMap::new())),
8666            api_key: None,
8667            api_url: None,
8668            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
8669            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
8670            workspace_dir: Arc::new(std::env::temp_dir()),
8671            prompt_config: Arc::new(crate::config::Config::default()),
8672            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
8673            interrupt_on_new_message: InterruptOnNewMessageConfig {
8674                telegram: false,
8675                slack: false,
8676                discord: false,
8677                mattermost: false,
8678                matrix: false,
8679            },
8680            multimodal: crate::config::MultimodalConfig::default(),
8681            media_pipeline: crate::config::MediaPipelineConfig::default(),
8682            transcription_config: crate::config::TranscriptionConfig::default(),
8683            hooks: None,
8684            non_cli_excluded_tools: Arc::new(Vec::new()),
8685            autonomy_level: AutonomyLevel::default(),
8686            tool_call_dedup_exempt: Arc::new(Vec::new()),
8687            model_routes: Arc::new(Vec::new()),
8688            query_classification: crate::config::QueryClassificationConfig::default(),
8689            ack_reactions: true,
8690            show_tool_calls: true,
8691            session_store: None,
8692            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
8693                &crate::config::AutonomyConfig::default(),
8694            )),
8695            activated_tools: None,
8696            mcp_registry: None,
8697            cost_tracking: None,
8698            pacing: crate::config::PacingConfig::default(),
8699            max_tool_result_chars: 0,
8700            context_token_budget: 0,
8701            audit_logger: None,
8702            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
8703        });
8704
8705        process_channel_message(
8706            runtime_ctx,
8707            traits::ChannelMessage {
8708                id: "react-msg".to_string(),
8709                sender: "alice".to_string(),
8710                reply_target: "chat-react".to_string(),
8711                content: "hello".to_string(),
8712                channel: "test-channel".to_string(),
8713                timestamp: 1,
8714                thread_ts: None,
8715                interruption_scope_id: None,
8716                attachments: vec![],
8717            },
8718            CancellationToken::new(),
8719        )
8720        .await;
8721
8722        let added = channel_impl.reactions_added.lock().await;
8723        assert!(
8724            added.len() >= 2,
8725            "expected at least 2 reactions added (\u{1F440} then \u{2705}), got {}",
8726            added.len()
8727        );
8728        assert_eq!(added[0].2, "\u{1F440}", "first reaction should be eyes");
8729        assert_eq!(
8730            added.last().unwrap().2,
8731            "\u{2705}",
8732            "last reaction should be checkmark"
8733        );
8734
8735        let removed = channel_impl.reactions_removed.lock().await;
8736        assert_eq!(removed.len(), 1, "eyes reaction should be removed once");
8737        assert_eq!(removed[0].2, "\u{1F440}");
8738    }
8739
8740    #[test]
8741    fn prompt_contains_all_sections() {
8742        let ws = make_workspace();
8743        let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
8744        let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None);
8745
8746        // Section headers
8747        assert!(prompt.contains("## Tools"), "missing Tools section");
8748        assert!(prompt.contains("## Safety"), "missing Safety section");
8749        assert!(prompt.contains("## Workspace"), "missing Workspace section");
8750        assert!(
8751            prompt.contains("## Project Context"),
8752            "missing Project Context"
8753        );
8754        assert!(
8755            prompt.contains("## Current Date & Time"),
8756            "missing Date/Time"
8757        );
8758        assert!(prompt.contains("## Runtime"), "missing Runtime section");
8759    }
8760
8761    #[test]
8762    fn prompt_injects_tools() {
8763        let ws = make_workspace();
8764        let tools = vec![
8765            ("shell", "Run commands"),
8766            ("memory_recall", "Search memory"),
8767        ];
8768        let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);
8769
8770        assert!(prompt.contains("**shell**"));
8771        assert!(prompt.contains("Run commands"));
8772        assert!(prompt.contains("**memory_recall**"));
8773    }
8774
8775    #[test]
8776    fn prompt_includes_single_tool_protocol_block_after_append() {
8777        let ws = make_workspace();
8778        let tools = vec![("shell", "Run commands")];
8779        let mut prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);
8780
8781        assert!(
8782            !prompt.contains("## Tool Use Protocol"),
8783            "build_system_prompt should not emit protocol block directly"
8784        );
8785
8786        prompt.push_str(&build_tool_instructions(&[], None));
8787
8788        assert_eq!(
8789            prompt.matches("## Tool Use Protocol").count(),
8790            1,
8791            "protocol block should appear exactly once in the final prompt"
8792        );
8793    }
8794
8795    #[test]
8796    fn prompt_injects_safety() {
8797        let ws = make_workspace();
8798        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
8799
8800        assert!(prompt.contains("Do not exfiltrate private data"));
8801        assert!(prompt.contains("Respect the runtime autonomy policy"));
8802        assert!(prompt.contains("Prefer `trash` over `rm`"));
8803    }
8804
8805    #[test]
8806    fn prompt_injects_workspace_files() {
8807        let ws = make_workspace();
8808        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
8809
8810        assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
8811        assert!(prompt.contains("Be helpful"), "missing SOUL content");
8812        assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md");
8813        assert!(
8814            prompt.contains("Name: Construct"),
8815            "missing IDENTITY content"
8816        );
8817        assert!(prompt.contains("### USER.md"), "missing USER.md");
8818        assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md");
8819        assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md");
8820        // HEARTBEAT.md is intentionally excluded from channel prompts — it's only
8821        // relevant to the heartbeat worker and causes LLMs to emit spurious
8822        // "HEARTBEAT_OK" acknowledgments in channel conversations.
8823        assert!(
8824            !prompt.contains("### HEARTBEAT.md"),
8825            "HEARTBEAT.md should not be in channel prompt"
8826        );
8827        assert!(prompt.contains("### MEMORY.md"), "missing MEMORY.md");
8828        assert!(prompt.contains("User likes Rust"), "missing MEMORY content");
8829    }
8830
8831    #[test]
8832    fn prompt_missing_file_markers() {
8833        let tmp = TempDir::new().unwrap();
8834        // Empty workspace — no files at all
8835        let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None);
8836
8837        assert!(prompt.contains("[File not found: SOUL.md]"));
8838        assert!(prompt.contains("[File not found: AGENTS.md]"));
8839        assert!(prompt.contains("[File not found: IDENTITY.md]"));
8840    }
8841
8842    #[test]
8843    fn prompt_bootstrap_only_if_exists() {
8844        let ws = make_workspace();
8845        // No BOOTSTRAP.md — should not appear
8846        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
8847        assert!(
8848            !prompt.contains("### BOOTSTRAP.md"),
8849            "BOOTSTRAP.md should not appear when missing"
8850        );
8851
8852        // Create BOOTSTRAP.md — should appear
8853        std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap();
8854        let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None);
8855        assert!(
8856            prompt2.contains("### BOOTSTRAP.md"),
8857            "BOOTSTRAP.md should appear when present"
8858        );
8859        assert!(prompt2.contains("First run"));
8860    }
8861
8862    #[test]
8863    fn prompt_no_daily_memory_injection() {
8864        let ws = make_workspace();
8865        let memory_dir = ws.path().join("memory");
8866        std::fs::create_dir_all(&memory_dir).unwrap();
8867        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
8868        std::fs::write(
8869            memory_dir.join(format!("{today}.md")),
8870            "# Daily\nSome note.",
8871        )
8872        .unwrap();
8873
8874        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
8875
8876        // Daily notes should NOT be in the system prompt (on-demand via tools)
8877        assert!(
8878            !prompt.contains("Daily Notes"),
8879            "daily notes should not be auto-injected"
8880        );
8881        assert!(
8882            !prompt.contains("Some note"),
8883            "daily content should not be in prompt"
8884        );
8885    }
8886
8887    #[test]
8888    fn prompt_runtime_metadata() {
8889        let ws = make_workspace();
8890        let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None);
8891
8892        assert!(prompt.contains("Model: claude-sonnet-4"));
8893        assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS)));
8894        assert!(prompt.contains("Host:"));
8895    }
8896
8897    #[test]
8898    fn prompt_skills_include_instructions_and_tools() {
8899        let ws = make_workspace();
8900        let skills = vec![crate::skills::Skill {
8901            name: "code-review".into(),
8902            description: "Review code for bugs".into(),
8903            version: "1.0.0".into(),
8904            author: None,
8905            tags: vec![],
8906            tools: vec![crate::skills::SkillTool {
8907                name: "lint".into(),
8908                description: "Run static checks".into(),
8909                kind: "shell".into(),
8910                command: "cargo clippy".into(),
8911                args: HashMap::new(),
8912            }],
8913            prompts: vec!["Always run cargo test before final response.".into()],
8914            location: None,
8915        }];
8916
8917        let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
8918
8919        assert!(prompt.contains("<available_skills>"), "missing skills XML");
8920        assert!(prompt.contains("<name>code-review</name>"));
8921        assert!(prompt.contains("<description>Review code for bugs</description>"));
8922        assert!(prompt.contains("SKILL.md</location>"));
8923        assert!(prompt.contains("<instructions>"));
8924        assert!(
8925            prompt.contains(
8926                "<instruction>Always run cargo test before final response.</instruction>"
8927            )
8928        );
8929        // Registered tools (shell kind) appear under <callable_tools> with prefixed names
8930        assert!(prompt.contains("<callable_tools"));
8931        assert!(prompt.contains("<name>code-review.lint</name>"));
8932        assert!(!prompt.contains("loaded on demand"));
8933    }
8934
8935    #[test]
8936    fn prompt_skills_compact_mode_omits_instructions_but_keeps_tools() {
8937        let ws = make_workspace();
8938        let skills = vec![crate::skills::Skill {
8939            name: "code-review".into(),
8940            description: "Review code for bugs".into(),
8941            version: "1.0.0".into(),
8942            author: None,
8943            tags: vec![],
8944            tools: vec![crate::skills::SkillTool {
8945                name: "lint".into(),
8946                description: "Run static checks".into(),
8947                kind: "shell".into(),
8948                command: "cargo clippy".into(),
8949                args: HashMap::new(),
8950            }],
8951            prompts: vec!["Always run cargo test before final response.".into()],
8952            location: None,
8953        }];
8954
8955        let prompt = build_system_prompt_with_mode(
8956            ws.path(),
8957            "model",
8958            &[],
8959            &skills,
8960            None,
8961            None,
8962            false,
8963            crate::config::SkillsPromptInjectionMode::Compact,
8964            AutonomyLevel::default(),
8965        );
8966
8967        assert!(prompt.contains("<available_skills>"), "missing skills XML");
8968        assert!(prompt.contains("<name>code-review</name>"));
8969        assert!(prompt.contains("<location>skills/code-review/SKILL.md</location>"));
8970        assert!(prompt.contains("loaded on demand"));
8971        assert!(!prompt.contains("<instructions>"));
8972        assert!(
8973            !prompt.contains(
8974                "<instruction>Always run cargo test before final response.</instruction>"
8975            )
8976        );
8977        // Compact mode should still include tools so the LLM knows about them.
8978        // Registered tools (shell kind) appear under <callable_tools> with prefixed names.
8979        assert!(prompt.contains("<callable_tools"));
8980        assert!(prompt.contains("<name>code-review.lint</name>"));
8981    }
8982
8983    #[test]
8984    fn prompt_skills_escape_reserved_xml_chars() {
8985        let ws = make_workspace();
8986        let skills = vec![crate::skills::Skill {
8987            name: "code<review>&".into(),
8988            description: "Review \"unsafe\" and 'risky' bits".into(),
8989            version: "1.0.0".into(),
8990            author: None,
8991            tags: vec![],
8992            tools: vec![crate::skills::SkillTool {
8993                name: "run\"linter\"".into(),
8994                description: "Run <lint> & report".into(),
8995                kind: "shell&exec".into(),
8996                command: "cargo clippy".into(),
8997                args: HashMap::new(),
8998            }],
8999            prompts: vec!["Use <tool_call> and & keep output \"safe\"".into()],
9000            location: None,
9001        }];
9002
9003        let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
9004
9005        assert!(prompt.contains("<name>code&lt;review&gt;&amp;</name>"));
9006        assert!(prompt.contains(
9007            "<description>Review &quot;unsafe&quot; and &apos;risky&apos; bits</description>"
9008        ));
9009        assert!(prompt.contains("<name>run&quot;linter&quot;</name>"));
9010        assert!(prompt.contains("<description>Run &lt;lint&gt; &amp; report</description>"));
9011        assert!(prompt.contains("<kind>shell&amp;exec</kind>"));
9012        assert!(prompt.contains(
9013            "<instruction>Use &lt;tool_call&gt; and &amp; keep output &quot;safe&quot;</instruction>"
9014        ));
9015    }
9016
9017    #[test]
9018    fn prompt_truncation() {
9019        let ws = make_workspace();
9020        // Write a file larger than BOOTSTRAP_MAX_CHARS
9021        let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000);
9022        std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap();
9023
9024        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
9025
9026        assert!(
9027            prompt.contains("truncated at"),
9028            "large files should be truncated"
9029        );
9030        assert!(
9031            !prompt.contains(&big_content),
9032            "full content should not appear"
9033        );
9034    }
9035
9036    #[test]
9037    fn prompt_empty_files_skipped() {
9038        let ws = make_workspace();
9039        std::fs::write(ws.path().join("TOOLS.md"), "").unwrap();
9040
9041        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
9042
9043        // Empty file should not produce a header
9044        assert!(
9045            !prompt.contains("### TOOLS.md"),
9046            "empty files should be skipped"
9047        );
9048    }
9049
9050    #[test]
9051    fn channel_log_truncation_is_utf8_safe_for_multibyte_text() {
9052        let msg = "Hello from Construct 🌍. Current status is healthy, and café-style UTF-8 text stays safe in logs.";
9053
9054        // Reproduces the production crash path where channel logs truncate at 80 chars.
9055        let result = std::panic::catch_unwind(|| crate::util::truncate_with_ellipsis(msg, 80));
9056        assert!(
9057            result.is_ok(),
9058            "truncate_with_ellipsis should never panic on UTF-8"
9059        );
9060
9061        let truncated = result.unwrap();
9062        assert!(!truncated.is_empty());
9063        assert!(truncated.is_char_boundary(truncated.len()));
9064    }
9065
9066    #[test]
9067    fn prompt_contains_channel_capabilities() {
9068        let ws = make_workspace();
9069        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
9070
9071        assert!(
9072            prompt.contains("## Channel Capabilities"),
9073            "missing Channel Capabilities section"
9074        );
9075        assert!(
9076            prompt.contains("running as a messaging bot"),
9077            "missing channel context"
9078        );
9079        assert!(
9080            prompt.contains("NEVER repeat, describe, or echo credentials"),
9081            "missing security instruction"
9082        );
9083    }
9084
9085    #[test]
9086    fn full_autonomy_prompt_executes_allowed_tools_without_extra_approval() {
9087        let ws = make_workspace();
9088        let config = crate::config::AutonomyConfig {
9089            level: crate::security::AutonomyLevel::Full,
9090            ..crate::config::AutonomyConfig::default()
9091        };
9092        let prompt = build_system_prompt_with_mode_and_autonomy(
9093            ws.path(),
9094            "model",
9095            &[],
9096            &[],
9097            None,
9098            None,
9099            Some(&config),
9100            false,
9101            crate::config::SkillsPromptInjectionMode::Full,
9102            false,
9103            0,
9104        );
9105
9106        assert!(
9107            prompt.contains("execute it directly instead of asking the user for extra approval"),
9108            "full autonomy should instruct direct execution for allowed tools"
9109        );
9110        assert!(
9111            prompt.contains("Never pretend you are waiting for a human approval"),
9112            "full autonomy should not simulate interactive approval flows"
9113        );
9114    }
9115
9116    #[test]
9117    fn readonly_prompt_explains_policy_blocks_without_fake_approval() {
9118        let ws = make_workspace();
9119        let config = crate::config::AutonomyConfig {
9120            level: crate::security::AutonomyLevel::ReadOnly,
9121            ..crate::config::AutonomyConfig::default()
9122        };
9123        let prompt = build_system_prompt_with_mode_and_autonomy(
9124            ws.path(),
9125            "model",
9126            &[],
9127            &[],
9128            None,
9129            None,
9130            Some(&config),
9131            false,
9132            crate::config::SkillsPromptInjectionMode::Full,
9133            false,
9134            0,
9135        );
9136
9137        assert!(
9138            prompt.contains("this runtime is read-only for side effects"),
9139            "read-only prompt should expose the runtime restriction"
9140        );
9141        assert!(
9142            prompt.contains("instead of simulating an approval flow"),
9143            "read-only prompt should explain restrictions instead of faking approval"
9144        );
9145    }
9146
9147    #[test]
9148    fn prompt_workspace_path() {
9149        let ws = make_workspace();
9150        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
9151
9152        assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
9153    }
9154
9155    #[test]
9156    fn full_autonomy_omits_approval_instructions() {
9157        let ws = make_workspace();
9158        let prompt = build_system_prompt_with_mode(
9159            ws.path(),
9160            "model",
9161            &[],
9162            &[],
9163            None,
9164            None,
9165            false,
9166            crate::config::SkillsPromptInjectionMode::Full,
9167            AutonomyLevel::Full,
9168        );
9169
9170        assert!(
9171            !prompt.contains("without asking"),
9172            "full autonomy prompt must not tell the model to ask before acting"
9173        );
9174        assert!(
9175            !prompt.contains("ask before acting externally"),
9176            "full autonomy prompt must not contain ask-before-acting instruction"
9177        );
9178        // Core safety rules should still be present
9179        assert!(
9180            prompt.contains("Do not exfiltrate private data"),
9181            "data exfiltration guard must remain"
9182        );
9183        assert!(
9184            prompt.contains("Prefer `trash` over `rm`"),
9185            "trash-over-rm hint must remain"
9186        );
9187    }
9188
9189    #[test]
9190    fn supervised_autonomy_includes_approval_instructions() {
9191        let ws = make_workspace();
9192        let prompt = build_system_prompt_with_mode(
9193            ws.path(),
9194            "model",
9195            &[],
9196            &[],
9197            None,
9198            None,
9199            false,
9200            crate::config::SkillsPromptInjectionMode::Full,
9201            AutonomyLevel::Supervised,
9202        );
9203
9204        assert!(
9205            prompt.contains("without asking"),
9206            "supervised prompt must include ask-before-acting instruction"
9207        );
9208        assert!(
9209            prompt.contains("ask before acting externally"),
9210            "supervised prompt must include ask-before-acting instruction"
9211        );
9212    }
9213
9214    #[test]
9215    fn channel_notify_observer_truncates_utf8_arguments_safely() {
9216        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
9217        let observer = ChannelNotifyObserver {
9218            inner: Arc::new(NoopObserver),
9219            tx,
9220            tools_used: AtomicBool::new(false),
9221        };
9222
9223        let payload = (0..300)
9224            .map(|n| serde_json::json!({ "content": format!("{}置tail", "a".repeat(n)) }))
9225            .map(|v| v.to_string())
9226            .find(|raw| raw.len() > 120 && !raw.is_char_boundary(120))
9227            .expect("should produce non-char-boundary data at byte index 120");
9228
9229        observer.record_event(
9230            &crate::observability::traits::ObserverEvent::ToolCallStart {
9231                tool: "file_write".to_string(),
9232                arguments: Some(payload),
9233            },
9234        );
9235
9236        let emitted = rx.try_recv().expect("observer should emit notify message");
9237        assert!(emitted.contains("`file_write`"));
9238        assert!(emitted.is_char_boundary(emitted.len()));
9239    }
9240
9241    #[test]
9242    fn conversation_memory_key_uses_message_id() {
9243        let msg = traits::ChannelMessage {
9244            id: "msg_abc123".into(),
9245            sender: "U123".into(),
9246            reply_target: "C456".into(),
9247            content: "hello".into(),
9248            channel: "slack".into(),
9249            timestamp: 1,
9250            thread_ts: None,
9251            interruption_scope_id: None,
9252            attachments: vec![],
9253        };
9254
9255        assert_eq!(conversation_memory_key(&msg), "slack_U123_msg_abc123");
9256    }
9257
9258    #[test]
9259    fn followup_thread_id_prefers_thread_ts() {
9260        let msg = traits::ChannelMessage {
9261            id: "slack_C123_1741234567.123456".into(),
9262            sender: "U123".into(),
9263            reply_target: "C123".into(),
9264            content: "hello".into(),
9265            channel: "slack".into(),
9266            timestamp: 1,
9267            thread_ts: Some("1741234567.123456".into()),
9268            interruption_scope_id: None,
9269            attachments: vec![],
9270        };
9271
9272        assert_eq!(
9273            followup_thread_id(&msg).as_deref(),
9274            Some("1741234567.123456")
9275        );
9276    }
9277
9278    #[test]
9279    fn followup_thread_id_falls_back_to_message_id() {
9280        let msg = traits::ChannelMessage {
9281            id: "msg_abc123".into(),
9282            sender: "U123".into(),
9283            reply_target: "C456".into(),
9284            content: "hello".into(),
9285            channel: "cli".into(),
9286            timestamp: 1,
9287            thread_ts: None,
9288            interruption_scope_id: None,
9289            attachments: vec![],
9290        };
9291
9292        assert_eq!(followup_thread_id(&msg).as_deref(), Some("msg_abc123"));
9293    }
9294
9295    #[test]
9296    fn conversation_memory_key_is_unique_per_message() {
9297        let msg1 = traits::ChannelMessage {
9298            id: "msg_1".into(),
9299            sender: "U123".into(),
9300            reply_target: "C456".into(),
9301            content: "first".into(),
9302            channel: "slack".into(),
9303            timestamp: 1,
9304            thread_ts: None,
9305            interruption_scope_id: None,
9306            attachments: vec![],
9307        };
9308        let msg2 = traits::ChannelMessage {
9309            id: "msg_2".into(),
9310            sender: "U123".into(),
9311            reply_target: "C456".into(),
9312            content: "second".into(),
9313            channel: "slack".into(),
9314            timestamp: 2,
9315            thread_ts: None,
9316            interruption_scope_id: None,
9317            attachments: vec![],
9318        };
9319
9320        assert_ne!(
9321            conversation_memory_key(&msg1),
9322            conversation_memory_key(&msg2)
9323        );
9324    }
9325
9326    #[tokio::test]
9327    async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
9328        let channel_impl = Arc::new(RecordingChannel::default());
9329        let channel: Arc<dyn Channel> = channel_impl.clone();
9330
9331        let mut channels_by_name = HashMap::new();
9332        channels_by_name.insert(channel.name().to_string(), channel);
9333
9334        let provider_impl = Arc::new(HistoryCaptureProvider::default());
9335
9336        let runtime_ctx = Arc::new(ChannelRuntimeContext {
9337            channels_by_name: Arc::new(channels_by_name),
9338            provider: provider_impl.clone(),
9339            default_provider: Arc::new("test-provider".to_string()),
9340            memory: Arc::new(NoopMemory),
9341            tools_registry: Arc::new(vec![]),
9342            observer: Arc::new(NoopObserver),
9343            system_prompt: Arc::new("test-system-prompt".to_string()),
9344            model: Arc::new("test-model".to_string()),
9345            temperature: 0.0,
9346            auto_save_memory: false,
9347            max_tool_iterations: 5,
9348            min_relevance_score: 0.0,
9349            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
9350            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
9351            provider_cache: Arc::new(Mutex::new(HashMap::new())),
9352            route_overrides: Arc::new(Mutex::new(HashMap::new())),
9353            api_key: None,
9354            api_url: None,
9355            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
9356            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
9357            workspace_dir: Arc::new(std::env::temp_dir()),
9358            prompt_config: Arc::new(crate::config::Config::default()),
9359            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
9360            interrupt_on_new_message: InterruptOnNewMessageConfig {
9361                telegram: false,
9362                slack: false,
9363                discord: false,
9364                mattermost: false,
9365                matrix: false,
9366            },
9367            multimodal: crate::config::MultimodalConfig::default(),
9368            media_pipeline: crate::config::MediaPipelineConfig::default(),
9369            transcription_config: crate::config::TranscriptionConfig::default(),
9370            hooks: None,
9371            non_cli_excluded_tools: Arc::new(Vec::new()),
9372            autonomy_level: AutonomyLevel::default(),
9373            tool_call_dedup_exempt: Arc::new(Vec::new()),
9374            model_routes: Arc::new(Vec::new()),
9375            query_classification: crate::config::QueryClassificationConfig::default(),
9376            ack_reactions: true,
9377            show_tool_calls: true,
9378            session_store: None,
9379            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
9380                &crate::config::AutonomyConfig::default(),
9381            )),
9382            activated_tools: None,
9383            mcp_registry: None,
9384            cost_tracking: None,
9385            pacing: crate::config::PacingConfig::default(),
9386            max_tool_result_chars: 0,
9387            context_token_budget: 0,
9388            audit_logger: None,
9389            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
9390        });
9391
9392        process_channel_message(
9393            runtime_ctx.clone(),
9394            traits::ChannelMessage {
9395                id: "msg-a".to_string(),
9396                sender: "alice".to_string(),
9397                reply_target: "chat-1".to_string(),
9398                content: "hello".to_string(),
9399                channel: "test-channel".to_string(),
9400                timestamp: 1,
9401                thread_ts: None,
9402                interruption_scope_id: None,
9403                attachments: vec![],
9404            },
9405            CancellationToken::new(),
9406        )
9407        .await;
9408
9409        process_channel_message(
9410            runtime_ctx,
9411            traits::ChannelMessage {
9412                id: "msg-b".to_string(),
9413                sender: "alice".to_string(),
9414                reply_target: "chat-1".to_string(),
9415                content: "follow up".to_string(),
9416                channel: "test-channel".to_string(),
9417                timestamp: 2,
9418                thread_ts: None,
9419                interruption_scope_id: None,
9420                attachments: vec![],
9421            },
9422            CancellationToken::new(),
9423        )
9424        .await;
9425
9426        let calls = provider_impl
9427            .calls
9428            .lock()
9429            .unwrap_or_else(|e| e.into_inner());
9430        assert_eq!(calls.len(), 2);
9431        assert_eq!(calls[0].len(), 2);
9432        assert_eq!(calls[0][0].0, "system");
9433        assert_eq!(calls[0][1].0, "user");
9434        assert_eq!(calls[1].len(), 4);
9435        assert_eq!(calls[1][0].0, "system");
9436        assert_eq!(calls[1][1].0, "user");
9437        assert_eq!(calls[1][2].0, "assistant");
9438        assert_eq!(calls[1][3].0, "user");
9439        assert!(calls[1][1].1.contains("hello"));
9440        assert!(calls[1][2].1.contains("response-1"));
9441        assert!(calls[1][3].1.contains("follow up"));
9442    }
9443
9444    #[tokio::test]
9445    async fn process_channel_message_refreshes_available_skills_after_new_session() {
9446        let workspace = make_workspace();
9447        let mut config = Config::default();
9448        config.workspace_dir = workspace.path().to_path_buf();
9449        config.skills.open_skills_enabled = false;
9450
9451        let initial_skills = crate::skills::load_skills_with_config(workspace.path(), &config);
9452        assert!(initial_skills.is_empty());
9453
9454        let initial_system_prompt = build_system_prompt_with_mode(
9455            workspace.path(),
9456            "test-model",
9457            &[],
9458            &initial_skills,
9459            Some(&config.identity),
9460            None,
9461            false,
9462            config.skills.prompt_injection_mode,
9463            AutonomyLevel::default(),
9464        );
9465        assert!(
9466            !initial_system_prompt.contains("refresh-test"),
9467            "initial prompt should not contain the new skill before it exists"
9468        );
9469
9470        let channel_impl = Arc::new(TelegramRecordingChannel::default());
9471        let channel: Arc<dyn Channel> = channel_impl.clone();
9472
9473        let mut channels_by_name = HashMap::new();
9474        channels_by_name.insert(channel.name().to_string(), channel);
9475
9476        let provider_impl = Arc::new(HistoryCaptureProvider::default());
9477        let runtime_ctx = Arc::new(ChannelRuntimeContext {
9478            channels_by_name: Arc::new(channels_by_name),
9479            provider: provider_impl.clone(),
9480            default_provider: Arc::new("test-provider".to_string()),
9481            memory: Arc::new(NoopMemory),
9482            tools_registry: Arc::new(vec![]),
9483            observer: Arc::new(NoopObserver),
9484            system_prompt: Arc::new(initial_system_prompt),
9485            model: Arc::new("test-model".to_string()),
9486            temperature: 0.0,
9487            auto_save_memory: false,
9488            max_tool_iterations: 5,
9489            min_relevance_score: 0.0,
9490            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
9491            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
9492            provider_cache: Arc::new(Mutex::new(HashMap::new())),
9493            route_overrides: Arc::new(Mutex::new(HashMap::new())),
9494            api_key: None,
9495            api_url: None,
9496            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
9497            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
9498            workspace_dir: Arc::new(config.workspace_dir.clone()),
9499            prompt_config: Arc::new(config.clone()),
9500            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
9501            interrupt_on_new_message: InterruptOnNewMessageConfig {
9502                telegram: false,
9503                slack: false,
9504                discord: false,
9505                mattermost: false,
9506                matrix: false,
9507            },
9508            multimodal: crate::config::MultimodalConfig::default(),
9509            media_pipeline: crate::config::MediaPipelineConfig::default(),
9510            transcription_config: crate::config::TranscriptionConfig::default(),
9511            hooks: None,
9512            non_cli_excluded_tools: Arc::new(Vec::new()),
9513            autonomy_level: AutonomyLevel::default(),
9514            tool_call_dedup_exempt: Arc::new(Vec::new()),
9515            model_routes: Arc::new(Vec::new()),
9516            query_classification: crate::config::QueryClassificationConfig::default(),
9517            ack_reactions: true,
9518            show_tool_calls: true,
9519            session_store: None,
9520            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
9521                &crate::config::AutonomyConfig::default(),
9522            )),
9523            activated_tools: None,
9524            mcp_registry: None,
9525            cost_tracking: None,
9526            pacing: crate::config::PacingConfig::default(),
9527            max_tool_result_chars: 0,
9528            context_token_budget: 0,
9529            audit_logger: None,
9530            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
9531        });
9532
9533        process_channel_message(
9534            runtime_ctx.clone(),
9535            traits::ChannelMessage {
9536                id: "msg-before-new".to_string(),
9537                sender: "alice".to_string(),
9538                reply_target: "chat-refresh".to_string(),
9539                content: "hello".to_string(),
9540                channel: "telegram".to_string(),
9541                timestamp: 1,
9542                thread_ts: None,
9543                interruption_scope_id: None,
9544                attachments: vec![],
9545            },
9546            CancellationToken::new(),
9547        )
9548        .await;
9549
9550        let skill_dir = workspace.path().join("skills").join("refresh-test");
9551        std::fs::create_dir_all(&skill_dir).unwrap();
9552        std::fs::write(
9553            skill_dir.join("SKILL.md"),
9554            "---\nname: refresh-test\ndescription: Refresh the available skills section\n---\n# Refresh Test\nExpose this skill after /new.\n",
9555        )
9556        .unwrap();
9557        let refreshed_skills = crate::skills::load_skills_with_config(workspace.path(), &config);
9558        assert_eq!(refreshed_skills.len(), 1);
9559        assert_eq!(refreshed_skills[0].name, "refresh-test");
9560        assert!(
9561            refreshed_new_session_system_prompt(runtime_ctx.as_ref())
9562                .contains("<name>refresh-test</name>"),
9563            "fresh-session prompt should pick up skills added after startup"
9564        );
9565
9566        process_channel_message(
9567            runtime_ctx.clone(),
9568            traits::ChannelMessage {
9569                id: "msg-new-session".to_string(),
9570                sender: "alice".to_string(),
9571                reply_target: "chat-refresh".to_string(),
9572                content: "/new".to_string(),
9573                channel: "telegram".to_string(),
9574                timestamp: 2,
9575                thread_ts: None,
9576                interruption_scope_id: None,
9577                attachments: vec![],
9578            },
9579            CancellationToken::new(),
9580        )
9581        .await;
9582
9583        {
9584            let histories = runtime_ctx
9585                .conversation_histories
9586                .lock()
9587                .unwrap_or_else(|e| e.into_inner());
9588            assert!(
9589                !histories.contains_key("telegram_chat-refresh_alice"),
9590                "/new should clear the cached sender history before the next message"
9591            );
9592        }
9593
9594        {
9595            let pending_new_sessions = runtime_ctx
9596                .pending_new_sessions
9597                .lock()
9598                .unwrap_or_else(|e| e.into_inner());
9599            assert!(
9600                pending_new_sessions.contains("telegram_chat-refresh_alice"),
9601                "/new should mark the sender for a fresh next-message prompt rebuild"
9602            );
9603        }
9604
9605        process_channel_message(
9606            runtime_ctx,
9607            traits::ChannelMessage {
9608                id: "msg-after-new".to_string(),
9609                sender: "alice".to_string(),
9610                reply_target: "chat-refresh".to_string(),
9611                content: "hello again".to_string(),
9612                channel: "telegram".to_string(),
9613                timestamp: 3,
9614                thread_ts: None,
9615                interruption_scope_id: None,
9616                attachments: vec![],
9617            },
9618            CancellationToken::new(),
9619        )
9620        .await;
9621
9622        {
9623            let calls = provider_impl
9624                .calls
9625                .lock()
9626                .unwrap_or_else(|e| e.into_inner());
9627            assert_eq!(calls.len(), 2);
9628            assert_eq!(calls[0][0].0, "system");
9629            assert_eq!(calls[1][0].0, "system");
9630            assert!(
9631                !calls[0][0].1.contains("<name>refresh-test</name>"),
9632                "pre-/new prompt should not advertise a skill that did not exist yet"
9633            );
9634            assert!(
9635                calls[1][0].1.contains("<available_skills>"),
9636                "post-/new prompt should contain the refreshed skills block"
9637            );
9638            assert!(
9639                calls[1][0].1.contains("<name>refresh-test</name>"),
9640                "post-/new prompt should include skills discovered after the reset"
9641            );
9642        }
9643
9644        let sent_messages = channel_impl.sent_messages.lock().await;
9645        assert!(
9646            sent_messages.iter().any(|message| {
9647                message.contains("Conversation history cleared. Starting fresh.")
9648            })
9649        );
9650    }
9651
9652    #[tokio::test]
9653    async fn process_channel_message_enriches_current_turn_without_persisting_context() {
9654        let channel_impl = Arc::new(RecordingChannel::default());
9655        let channel: Arc<dyn Channel> = channel_impl.clone();
9656
9657        let mut channels_by_name = HashMap::new();
9658        channels_by_name.insert(channel.name().to_string(), channel);
9659
9660        let provider_impl = Arc::new(HistoryCaptureProvider::default());
9661        let runtime_ctx = Arc::new(ChannelRuntimeContext {
9662            channels_by_name: Arc::new(channels_by_name),
9663            provider: provider_impl.clone(),
9664            default_provider: Arc::new("test-provider".to_string()),
9665            memory: Arc::new(RecallMemory),
9666            tools_registry: Arc::new(vec![]),
9667            observer: Arc::new(NoopObserver),
9668            system_prompt: Arc::new("test-system-prompt".to_string()),
9669            model: Arc::new("test-model".to_string()),
9670            temperature: 0.0,
9671            auto_save_memory: false,
9672            max_tool_iterations: 5,
9673            min_relevance_score: 0.0,
9674            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
9675            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
9676            provider_cache: Arc::new(Mutex::new(HashMap::new())),
9677            route_overrides: Arc::new(Mutex::new(HashMap::new())),
9678            api_key: None,
9679            api_url: None,
9680            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
9681            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
9682            workspace_dir: Arc::new(std::env::temp_dir()),
9683            prompt_config: Arc::new(crate::config::Config::default()),
9684            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
9685            interrupt_on_new_message: InterruptOnNewMessageConfig {
9686                telegram: false,
9687                slack: false,
9688                discord: false,
9689                mattermost: false,
9690                matrix: false,
9691            },
9692            multimodal: crate::config::MultimodalConfig::default(),
9693            media_pipeline: crate::config::MediaPipelineConfig::default(),
9694            transcription_config: crate::config::TranscriptionConfig::default(),
9695            hooks: None,
9696            non_cli_excluded_tools: Arc::new(Vec::new()),
9697            autonomy_level: AutonomyLevel::default(),
9698            tool_call_dedup_exempt: Arc::new(Vec::new()),
9699            model_routes: Arc::new(Vec::new()),
9700            query_classification: crate::config::QueryClassificationConfig::default(),
9701            ack_reactions: true,
9702            show_tool_calls: true,
9703            session_store: None,
9704            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
9705                &crate::config::AutonomyConfig::default(),
9706            )),
9707            activated_tools: None,
9708            mcp_registry: None,
9709            cost_tracking: None,
9710            pacing: crate::config::PacingConfig::default(),
9711            max_tool_result_chars: 0,
9712            context_token_budget: 0,
9713            audit_logger: None,
9714            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
9715        });
9716
9717        process_channel_message(
9718            runtime_ctx.clone(),
9719            traits::ChannelMessage {
9720                id: "msg-ctx-1".to_string(),
9721                sender: "alice".to_string(),
9722                reply_target: "chat-ctx".to_string(),
9723                content: "hello".to_string(),
9724                channel: "test-channel".to_string(),
9725                timestamp: 1,
9726                thread_ts: None,
9727                interruption_scope_id: None,
9728                attachments: vec![],
9729            },
9730            CancellationToken::new(),
9731        )
9732        .await;
9733
9734        let calls = provider_impl
9735            .calls
9736            .lock()
9737            .unwrap_or_else(|e| e.into_inner());
9738        assert_eq!(calls.len(), 1);
9739        assert_eq!(calls[0].len(), 2);
9740        // Memory context is injected into the system prompt, not the user message.
9741        assert_eq!(calls[0][0].0, "system");
9742        assert!(calls[0][0].1.contains("[Memory context]"));
9743        assert!(calls[0][0].1.contains("Age is 45"));
9744        assert_eq!(calls[0][1].0, "user");
9745        assert_eq!(calls[0][1].1, "hello");
9746
9747        let histories = runtime_ctx
9748            .conversation_histories
9749            .lock()
9750            .unwrap_or_else(|e| e.into_inner());
9751        let turns = histories
9752            .get("test-channel_chat-ctx_alice")
9753            .expect("history should be stored for sender");
9754        assert_eq!(turns[0].role, "user");
9755        assert_eq!(turns[0].content, "hello");
9756        assert!(!turns[0].content.contains("[Memory context]"));
9757    }
9758
9759    #[tokio::test]
9760    async fn process_channel_message_telegram_keeps_system_instruction_at_top_only() {
9761        let channel_impl = Arc::new(TelegramRecordingChannel::default());
9762        let channel: Arc<dyn Channel> = channel_impl.clone();
9763
9764        let mut channels_by_name = HashMap::new();
9765        channels_by_name.insert(channel.name().to_string(), channel);
9766
9767        let provider_impl = Arc::new(HistoryCaptureProvider::default());
9768        let mut histories = HashMap::new();
9769        histories.insert(
9770            "telegram_chat-telegram_alice".to_string(),
9771            vec![
9772                ChatMessage::assistant("stale assistant"),
9773                ChatMessage::user("earlier user question"),
9774                ChatMessage::assistant("earlier assistant reply"),
9775            ],
9776        );
9777
9778        let runtime_ctx = Arc::new(ChannelRuntimeContext {
9779            channels_by_name: Arc::new(channels_by_name),
9780            provider: provider_impl.clone(),
9781            default_provider: Arc::new("test-provider".to_string()),
9782            memory: Arc::new(NoopMemory),
9783            tools_registry: Arc::new(vec![]),
9784            observer: Arc::new(NoopObserver),
9785            system_prompt: Arc::new("test-system-prompt".to_string()),
9786            model: Arc::new("test-model".to_string()),
9787            temperature: 0.0,
9788            auto_save_memory: false,
9789            max_tool_iterations: 5,
9790            min_relevance_score: 0.0,
9791            conversation_histories: Arc::new(Mutex::new(histories)),
9792            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
9793            provider_cache: Arc::new(Mutex::new(HashMap::new())),
9794            route_overrides: Arc::new(Mutex::new(HashMap::new())),
9795            api_key: None,
9796            api_url: None,
9797            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
9798            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
9799            workspace_dir: Arc::new(std::env::temp_dir()),
9800            prompt_config: Arc::new(crate::config::Config::default()),
9801            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
9802            interrupt_on_new_message: InterruptOnNewMessageConfig {
9803                telegram: false,
9804                slack: false,
9805                discord: false,
9806                mattermost: false,
9807                matrix: false,
9808            },
9809            multimodal: crate::config::MultimodalConfig::default(),
9810            media_pipeline: crate::config::MediaPipelineConfig::default(),
9811            transcription_config: crate::config::TranscriptionConfig::default(),
9812            hooks: None,
9813            non_cli_excluded_tools: Arc::new(Vec::new()),
9814            autonomy_level: AutonomyLevel::default(),
9815            tool_call_dedup_exempt: Arc::new(Vec::new()),
9816            model_routes: Arc::new(Vec::new()),
9817            query_classification: crate::config::QueryClassificationConfig::default(),
9818            ack_reactions: true,
9819            show_tool_calls: true,
9820            session_store: None,
9821            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
9822                &crate::config::AutonomyConfig::default(),
9823            )),
9824            activated_tools: None,
9825            mcp_registry: None,
9826            cost_tracking: None,
9827            pacing: crate::config::PacingConfig::default(),
9828            max_tool_result_chars: 0,
9829            context_token_budget: 0,
9830            audit_logger: None,
9831            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
9832        });
9833
9834        process_channel_message(
9835            runtime_ctx.clone(),
9836            traits::ChannelMessage {
9837                id: "tg-msg-1".to_string(),
9838                sender: "alice".to_string(),
9839                reply_target: "chat-telegram".to_string(),
9840                content: "hello".to_string(),
9841                channel: "telegram".to_string(),
9842                timestamp: 1,
9843                thread_ts: None,
9844                interruption_scope_id: None,
9845                attachments: vec![],
9846            },
9847            CancellationToken::new(),
9848        )
9849        .await;
9850
9851        let calls = provider_impl
9852            .calls
9853            .lock()
9854            .unwrap_or_else(|e| e.into_inner());
9855        assert_eq!(calls.len(), 1);
9856        assert_eq!(calls[0].len(), 4);
9857
9858        let roles = calls[0]
9859            .iter()
9860            .map(|(role, _)| role.as_str())
9861            .collect::<Vec<_>>();
9862        assert_eq!(roles, vec!["system", "user", "assistant", "user"]);
9863        assert!(
9864            calls[0][0].1.contains("When responding on Telegram:"),
9865            "telegram channel instructions should be embedded into the system prompt"
9866        );
9867        assert!(
9868            calls[0][0].1.contains("For media attachments use markers:"),
9869            "telegram media marker guidance should live in the system prompt"
9870        );
9871        assert!(!calls[0].iter().skip(1).any(|(role, _)| role == "system"));
9872    }
9873
9874    #[test]
9875    fn extract_tool_context_summary_collects_alias_and_native_tool_calls() {
9876        let history = vec![
9877            ChatMessage::system("sys"),
9878            ChatMessage::assistant(
9879                r#"<toolcall>
9880{"name":"shell","arguments":{"command":"date"}}
9881</toolcall>"#,
9882            ),
9883            ChatMessage::assistant(
9884                r#"{"content":null,"tool_calls":[{"id":"1","name":"web_search","arguments":"{}"}]}"#,
9885            ),
9886        ];
9887
9888        let summary = extract_tool_context_summary(&history, 1);
9889        assert_eq!(summary, "[Used tools: shell, web_search]");
9890    }
9891
9892    #[test]
9893    fn extract_tool_context_summary_collects_prompt_mode_tool_result_names() {
9894        let history = vec![
9895            ChatMessage::system("sys"),
9896            ChatMessage::assistant("Using markdown tool call fence"),
9897            ChatMessage::user(
9898                r#"[Tool results]
9899<tool_result name="http_request">
9900{"status":200}
9901</tool_result>
9902<tool_result name="shell">
9903Mon Feb 20
9904</tool_result>"#,
9905            ),
9906        ];
9907
9908        let summary = extract_tool_context_summary(&history, 1);
9909        assert_eq!(summary, "[Used tools: http_request, shell]");
9910    }
9911
9912    #[test]
9913    fn extract_tool_context_summary_respects_start_index() {
9914        let history = vec![
9915            ChatMessage::assistant(
9916                r#"<tool_call>
9917{"name":"stale_tool","arguments":{}}
9918</tool_call>"#,
9919            ),
9920            ChatMessage::assistant(
9921                r#"<tool_call>
9922{"name":"fresh_tool","arguments":{}}
9923</tool_call>"#,
9924            ),
9925        ];
9926
9927        let summary = extract_tool_context_summary(&history, 1);
9928        assert_eq!(summary, "[Used tools: fresh_tool]");
9929    }
9930
9931    #[test]
9932    fn strip_isolated_tool_json_artifacts_removes_tool_calls_and_results() {
9933        let mut known_tools = HashSet::new();
9934        known_tools.insert("schedule".to_string());
9935
9936        let input = r#"{"name":"schedule","parameters":{"action":"create","message":"test"}}
9937{"name":"schedule","parameters":{"action":"cancel","task_id":"test"}}
9938Let me create the reminder properly:
9939{"name":"schedule","parameters":{"action":"create","message":"Go to sleep"}}
9940{"result":{"task_id":"abc","status":"scheduled"}}
9941Done reminder set for 1:38 AM."#;
9942
9943        let result = strip_isolated_tool_json_artifacts(input, &known_tools);
9944        let normalized = result
9945            .lines()
9946            .filter(|line| !line.trim().is_empty())
9947            .collect::<Vec<_>>()
9948            .join("\n");
9949        assert_eq!(
9950            normalized,
9951            "Let me create the reminder properly:\nDone reminder set for 1:38 AM."
9952        );
9953    }
9954
9955    #[test]
9956    fn strip_isolated_tool_json_artifacts_preserves_non_tool_json() {
9957        let mut known_tools = HashSet::new();
9958        known_tools.insert("shell".to_string());
9959
9960        let input = r#"{"name":"profile","parameters":{"timezone":"UTC"}}
9961This is an example JSON object for profile settings."#;
9962
9963        let result = strip_isolated_tool_json_artifacts(input, &known_tools);
9964        assert_eq!(result, input);
9965    }
9966
9967    // ── AIEOS Identity Tests (Issue #168) ─────────────────────────
9968
9969    #[test]
9970    fn aieos_identity_from_file() {
9971        use crate::config::IdentityConfig;
9972        use tempfile::TempDir;
9973
9974        let tmp = TempDir::new().unwrap();
9975        let identity_path = tmp.path().join("aieos_identity.json");
9976
9977        // Write AIEOS identity file
9978        let aieos_json = r#"{
9979            "identity": {
9980                "names": {"first": "Nova", "nickname": "Nov"},
9981                "bio": "A helpful AI assistant.",
9982                "origin": "Silicon Valley"
9983            },
9984            "psychology": {
9985                "mbti": "INTJ",
9986                "moral_compass": ["Be helpful", "Do no harm"]
9987            },
9988            "linguistics": {
9989                "style": "concise",
9990                "formality": "casual"
9991            }
9992        }"#;
9993        std::fs::write(&identity_path, aieos_json).unwrap();
9994
9995        // Create identity config pointing to the file
9996        let config = IdentityConfig {
9997            format: "aieos".into(),
9998            aieos_path: Some("aieos_identity.json".into()),
9999            aieos_inline: None,
10000        };
10001
10002        let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None);
10003
10004        // Should contain AIEOS sections
10005        assert!(prompt.contains("## Identity"));
10006        assert!(prompt.contains("**Name:** Nova"));
10007        assert!(prompt.contains("**Nickname:** Nov"));
10008        assert!(prompt.contains("**Bio:** A helpful AI assistant."));
10009        assert!(prompt.contains("**Origin:** Silicon Valley"));
10010
10011        assert!(prompt.contains("## Personality"));
10012        assert!(prompt.contains("**MBTI:** INTJ"));
10013        assert!(prompt.contains("**Moral Compass:**"));
10014        assert!(prompt.contains("- Be helpful"));
10015
10016        assert!(prompt.contains("## Communication Style"));
10017        assert!(prompt.contains("**Style:** concise"));
10018        assert!(prompt.contains("**Formality Level:** casual"));
10019
10020        // Should NOT contain OpenClaw bootstrap file headers
10021        assert!(!prompt.contains("### SOUL.md"));
10022        assert!(!prompt.contains("### IDENTITY.md"));
10023        assert!(!prompt.contains("[File not found"));
10024    }
10025
10026    #[test]
10027    fn aieos_identity_from_inline() {
10028        use crate::config::IdentityConfig;
10029
10030        let config = IdentityConfig {
10031            format: "aieos".into(),
10032            aieos_path: None,
10033            aieos_inline: Some(r#"{"identity":{"names":{"first":"Claw"}}}"#.into()),
10034        };
10035
10036        let prompt = build_system_prompt(
10037            std::env::temp_dir().as_path(),
10038            "model",
10039            &[],
10040            &[],
10041            Some(&config),
10042            None,
10043        );
10044
10045        assert!(prompt.contains("**Name:** Claw"));
10046        assert!(prompt.contains("## Identity"));
10047    }
10048
10049    #[test]
10050    fn aieos_fallback_to_openclaw_on_parse_error() {
10051        use crate::config::IdentityConfig;
10052
10053        let config = IdentityConfig {
10054            format: "aieos".into(),
10055            aieos_path: Some("nonexistent.json".into()),
10056            aieos_inline: None,
10057        };
10058
10059        let ws = make_workspace();
10060        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
10061
10062        // Should fall back to OpenClaw format when AIEOS file is not found
10063        // (Error is logged to stderr with filename, not included in prompt)
10064        assert!(prompt.contains("### SOUL.md"));
10065    }
10066
10067    #[test]
10068    fn aieos_empty_uses_openclaw() {
10069        use crate::config::IdentityConfig;
10070
10071        // Format is "aieos" but neither path nor inline is set
10072        let config = IdentityConfig {
10073            format: "aieos".into(),
10074            aieos_path: None,
10075            aieos_inline: None,
10076        };
10077
10078        let ws = make_workspace();
10079        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
10080
10081        // Should use OpenClaw format (not configured for AIEOS)
10082        assert!(prompt.contains("### SOUL.md"));
10083        assert!(prompt.contains("Be helpful"));
10084    }
10085
10086    #[test]
10087    fn openclaw_format_uses_bootstrap_files() {
10088        use crate::config::IdentityConfig;
10089
10090        let config = IdentityConfig {
10091            format: "openclaw".into(),
10092            aieos_path: Some("identity.json".into()),
10093            aieos_inline: None,
10094        };
10095
10096        let ws = make_workspace();
10097        let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
10098
10099        // Should use OpenClaw format even if aieos_path is set
10100        assert!(prompt.contains("### SOUL.md"));
10101        assert!(prompt.contains("Be helpful"));
10102        assert!(!prompt.contains("## Identity"));
10103    }
10104
10105    #[test]
10106    fn none_identity_config_uses_openclaw() {
10107        let ws = make_workspace();
10108        // Pass None for identity config
10109        let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
10110
10111        // Should use OpenClaw format
10112        assert!(prompt.contains("### SOUL.md"));
10113        assert!(prompt.contains("Be helpful"));
10114    }
10115
10116    #[test]
10117    fn classify_health_ok_true() {
10118        let state = classify_health_result(&Ok(true));
10119        assert_eq!(state, ChannelHealthState::Healthy);
10120    }
10121
10122    #[test]
10123    fn classify_health_ok_false() {
10124        let state = classify_health_result(&Ok(false));
10125        assert_eq!(state, ChannelHealthState::Unhealthy);
10126    }
10127
10128    #[tokio::test]
10129    async fn classify_health_timeout() {
10130        let result = tokio::time::timeout(Duration::from_millis(1), async {
10131            tokio::time::sleep(Duration::from_millis(20)).await;
10132            true
10133        })
10134        .await;
10135        let state = classify_health_result(&result);
10136        assert_eq!(state, ChannelHealthState::Timeout);
10137    }
10138
10139    #[test]
10140    fn collect_configured_channels_includes_mattermost_when_configured() {
10141        let mut config = Config::default();
10142        config.channels_config.mattermost = Some(crate::config::schema::MattermostConfig {
10143            url: "https://mattermost.example.com".to_string(),
10144            bot_token: "test-token".to_string(),
10145            channel_id: Some("channel-1".to_string()),
10146            allowed_users: vec![],
10147            thread_replies: Some(true),
10148            mention_only: Some(false),
10149            interrupt_on_new_message: false,
10150            proxy_url: None,
10151        });
10152
10153        let channels = collect_configured_channels(&config, "test");
10154
10155        assert!(
10156            channels
10157                .iter()
10158                .any(|entry| entry.display_name == "Mattermost")
10159        );
10160        assert!(
10161            channels
10162                .iter()
10163                .any(|entry| entry.channel.name() == "mattermost")
10164        );
10165    }
10166
10167    struct AlwaysFailChannel {
10168        name: &'static str,
10169        calls: Arc<AtomicUsize>,
10170    }
10171
10172    struct BlockUntilClosedChannel {
10173        name: String,
10174        calls: Arc<AtomicUsize>,
10175    }
10176
10177    #[async_trait::async_trait]
10178    impl Channel for AlwaysFailChannel {
10179        fn name(&self) -> &str {
10180            self.name
10181        }
10182
10183        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
10184            Ok(())
10185        }
10186
10187        async fn listen(
10188            &self,
10189            _tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
10190        ) -> anyhow::Result<()> {
10191            self.calls.fetch_add(1, Ordering::SeqCst);
10192            anyhow::bail!("listen boom")
10193        }
10194    }
10195
10196    #[async_trait::async_trait]
10197    impl Channel for BlockUntilClosedChannel {
10198        fn name(&self) -> &str {
10199            &self.name
10200        }
10201
10202        async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
10203            Ok(())
10204        }
10205
10206        async fn listen(
10207            &self,
10208            tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
10209        ) -> anyhow::Result<()> {
10210            self.calls.fetch_add(1, Ordering::SeqCst);
10211            tx.closed().await;
10212            Ok(())
10213        }
10214    }
10215
10216    #[tokio::test]
10217    async fn supervised_listener_marks_error_and_restarts_on_failures() {
10218        let calls = Arc::new(AtomicUsize::new(0));
10219        let channel: Arc<dyn Channel> = Arc::new(AlwaysFailChannel {
10220            name: "test-supervised-fail",
10221            calls: Arc::clone(&calls),
10222        });
10223
10224        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1);
10225        let handle = spawn_supervised_listener(channel, tx, 1, 1);
10226
10227        tokio::time::sleep(Duration::from_millis(80)).await;
10228        drop(rx);
10229        handle.abort();
10230        let _ = handle.await;
10231
10232        let snapshot = crate::health::snapshot_json();
10233        let component = &snapshot["components"]["channel:test-supervised-fail"];
10234        assert_eq!(component["status"], "error");
10235        assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1);
10236        assert!(
10237            component["last_error"]
10238                .as_str()
10239                .unwrap_or("")
10240                .contains("listen boom")
10241        );
10242        assert!(calls.load(Ordering::SeqCst) >= 1);
10243    }
10244
10245    #[tokio::test]
10246    async fn supervised_listener_refreshes_health_while_running() {
10247        let calls = Arc::new(AtomicUsize::new(0));
10248        let channel_name = format!("test-supervised-heartbeat-{}", uuid::Uuid::new_v4());
10249        let component_name = format!("channel:{channel_name}");
10250        let channel: Arc<dyn Channel> = Arc::new(BlockUntilClosedChannel {
10251            name: channel_name,
10252            calls: Arc::clone(&calls),
10253        });
10254
10255        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1);
10256        let handle = spawn_supervised_listener_with_health_interval(
10257            channel,
10258            tx,
10259            1,
10260            1,
10261            Duration::from_millis(20),
10262        );
10263
10264        tokio::time::sleep(Duration::from_millis(35)).await;
10265        let first_last_ok =
10266            crate::health::snapshot_json()["components"][&component_name]["last_ok"]
10267                .as_str()
10268                .unwrap_or("")
10269                .to_string();
10270        assert!(!first_last_ok.is_empty());
10271
10272        tokio::time::sleep(Duration::from_millis(70)).await;
10273        let second_last_ok =
10274            crate::health::snapshot_json()["components"][&component_name]["last_ok"]
10275                .as_str()
10276                .unwrap_or("")
10277                .to_string();
10278        let first = chrono::DateTime::parse_from_rfc3339(&first_last_ok)
10279            .expect("last_ok should be valid RFC3339");
10280        let second = chrono::DateTime::parse_from_rfc3339(&second_last_ok)
10281            .expect("last_ok should be valid RFC3339");
10282        assert!(second > first, "expected periodic health heartbeat refresh");
10283
10284        drop(rx);
10285        let join = tokio::time::timeout(Duration::from_secs(1), handle).await;
10286        assert!(join.is_ok(), "listener should stop after channel shutdown");
10287        assert!(calls.load(Ordering::SeqCst) >= 1);
10288    }
10289
10290    #[test]
10291    fn maybe_restart_daemon_systemd_args_regression() {
10292        assert_eq!(
10293            SYSTEMD_STATUS_ARGS,
10294            ["--user", "is-active", "construct.service"]
10295        );
10296        assert_eq!(
10297            SYSTEMD_RESTART_ARGS,
10298            ["--user", "restart", "construct.service"]
10299        );
10300    }
10301
10302    #[test]
10303    fn maybe_restart_daemon_openrc_args_regression() {
10304        assert_eq!(OPENRC_STATUS_ARGS, ["construct", "status"]);
10305        assert_eq!(OPENRC_RESTART_ARGS, ["construct", "restart"]);
10306    }
10307
10308    #[test]
10309    fn normalize_merges_consecutive_user_turns() {
10310        let turns = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
10311        let result = normalize_cached_channel_turns(turns);
10312        assert_eq!(result.len(), 1);
10313        assert_eq!(result[0].role, "user");
10314        assert_eq!(result[0].content, "hello\n\nworld");
10315    }
10316
10317    #[test]
10318    fn normalize_preserves_strict_alternation() {
10319        let turns = vec![
10320            ChatMessage::user("hello"),
10321            ChatMessage::assistant("hi"),
10322            ChatMessage::user("bye"),
10323        ];
10324        let result = normalize_cached_channel_turns(turns);
10325        assert_eq!(result.len(), 3);
10326        assert_eq!(result[0].content, "hello");
10327        assert_eq!(result[1].content, "hi");
10328        assert_eq!(result[2].content, "bye");
10329    }
10330
10331    #[test]
10332    fn normalize_merges_multiple_consecutive_user_turns() {
10333        let turns = vec![
10334            ChatMessage::user("a"),
10335            ChatMessage::user("b"),
10336            ChatMessage::user("c"),
10337        ];
10338        let result = normalize_cached_channel_turns(turns);
10339        assert_eq!(result.len(), 1);
10340        assert_eq!(result[0].role, "user");
10341        assert_eq!(result[0].content, "a\n\nb\n\nc");
10342    }
10343
10344    #[test]
10345    fn normalize_empty_input() {
10346        let result = normalize_cached_channel_turns(vec![]);
10347        assert!(result.is_empty());
10348    }
10349
10350    // ── E2E: photo [IMAGE:] marker rejected by non-vision provider ───
10351
10352    /// End-to-end test: a photo attachment message (containing `[IMAGE:]`
10353    /// marker) sent through `process_channel_message` with a non-vision
10354    /// provider must produce a `"⚠️ Error: …does not support vision"` reply
10355    /// on the recording channel — no real Telegram or LLM API required.
10356    #[tokio::test]
10357    async fn e2e_photo_attachment_rejected_by_non_vision_provider() {
10358        let channel_impl = Arc::new(RecordingChannel::default());
10359        let channel: Arc<dyn Channel> = channel_impl.clone();
10360
10361        let mut channels_by_name = HashMap::new();
10362        channels_by_name.insert(channel.name().to_string(), channel);
10363
10364        // DummyProvider has default capabilities (vision: false).
10365        let runtime_ctx = Arc::new(ChannelRuntimeContext {
10366            channels_by_name: Arc::new(channels_by_name),
10367            provider: Arc::new(DummyProvider),
10368            default_provider: Arc::new("dummy".to_string()),
10369            memory: Arc::new(NoopMemory),
10370            tools_registry: Arc::new(vec![]),
10371            observer: Arc::new(NoopObserver),
10372            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
10373            model: Arc::new("test-model".to_string()),
10374            temperature: 0.0,
10375            auto_save_memory: false,
10376            max_tool_iterations: 5,
10377            min_relevance_score: 0.0,
10378            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
10379            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10380            provider_cache: Arc::new(Mutex::new(HashMap::new())),
10381            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10382            api_key: None,
10383            api_url: None,
10384            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
10385            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
10386            workspace_dir: Arc::new(std::env::temp_dir()),
10387            prompt_config: Arc::new(crate::config::Config::default()),
10388            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10389            interrupt_on_new_message: InterruptOnNewMessageConfig {
10390                telegram: false,
10391                slack: false,
10392                discord: false,
10393                mattermost: false,
10394                matrix: false,
10395            },
10396            multimodal: crate::config::MultimodalConfig::default(),
10397            media_pipeline: crate::config::MediaPipelineConfig::default(),
10398            transcription_config: crate::config::TranscriptionConfig::default(),
10399            hooks: None,
10400            non_cli_excluded_tools: Arc::new(Vec::new()),
10401            autonomy_level: AutonomyLevel::default(),
10402            tool_call_dedup_exempt: Arc::new(Vec::new()),
10403            model_routes: Arc::new(Vec::new()),
10404            query_classification: crate::config::QueryClassificationConfig::default(),
10405            ack_reactions: true,
10406            show_tool_calls: true,
10407            session_store: None,
10408            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10409                &crate::config::AutonomyConfig::default(),
10410            )),
10411            activated_tools: None,
10412            mcp_registry: None,
10413            cost_tracking: None,
10414            pacing: crate::config::PacingConfig::default(),
10415            max_tool_result_chars: 0,
10416            context_token_budget: 0,
10417            audit_logger: None,
10418            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
10419        });
10420
10421        // Simulate a photo attachment message with [IMAGE:] marker.
10422        process_channel_message(
10423            runtime_ctx,
10424            traits::ChannelMessage {
10425                id: "msg-photo-1".to_string(),
10426                sender: "construct_user".to_string(),
10427                reply_target: "chat-photo".to_string(),
10428                content: "[IMAGE:/tmp/workspace/photo_99_1.jpg]\n\nWhat is this?".to_string(),
10429                channel: "test-channel".to_string(),
10430                timestamp: 1,
10431                thread_ts: None,
10432                interruption_scope_id: None,
10433                attachments: vec![],
10434            },
10435            CancellationToken::new(),
10436        )
10437        .await;
10438
10439        let sent = channel_impl.sent_messages.lock().await;
10440        assert_eq!(sent.len(), 1, "expected exactly one reply message");
10441        assert!(
10442            sent[0].contains("does not support vision"),
10443            "reply must mention vision capability error, got: {}",
10444            sent[0]
10445        );
10446        assert!(
10447            sent[0].contains("⚠️ Error"),
10448            "reply must start with error prefix, got: {}",
10449            sent[0]
10450        );
10451    }
10452
10453    #[tokio::test]
10454    async fn e2e_failed_vision_turn_does_not_poison_follow_up_text_turn() {
10455        let channel_impl = Arc::new(RecordingChannel::default());
10456        let channel: Arc<dyn Channel> = channel_impl.clone();
10457
10458        let mut channels_by_name = HashMap::new();
10459        channels_by_name.insert(channel.name().to_string(), channel);
10460
10461        let runtime_ctx = Arc::new(ChannelRuntimeContext {
10462            channels_by_name: Arc::new(channels_by_name),
10463            provider: Arc::new(DummyProvider),
10464            default_provider: Arc::new("dummy".to_string()),
10465            memory: Arc::new(NoopMemory),
10466            tools_registry: Arc::new(vec![]),
10467            observer: Arc::new(NoopObserver),
10468            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
10469            model: Arc::new("test-model".to_string()),
10470            temperature: 0.0,
10471            auto_save_memory: false,
10472            max_tool_iterations: 5,
10473            min_relevance_score: 0.0,
10474            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
10475            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10476            provider_cache: Arc::new(Mutex::new(HashMap::new())),
10477            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10478            api_key: None,
10479            api_url: None,
10480            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
10481            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
10482            workspace_dir: Arc::new(std::env::temp_dir()),
10483            prompt_config: Arc::new(crate::config::Config::default()),
10484            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10485            interrupt_on_new_message: InterruptOnNewMessageConfig {
10486                telegram: false,
10487                slack: false,
10488                discord: false,
10489                mattermost: false,
10490                matrix: false,
10491            },
10492            multimodal: crate::config::MultimodalConfig::default(),
10493            media_pipeline: crate::config::MediaPipelineConfig::default(),
10494            transcription_config: crate::config::TranscriptionConfig::default(),
10495            hooks: None,
10496            non_cli_excluded_tools: Arc::new(Vec::new()),
10497            autonomy_level: AutonomyLevel::default(),
10498            tool_call_dedup_exempt: Arc::new(Vec::new()),
10499            model_routes: Arc::new(Vec::new()),
10500            query_classification: crate::config::QueryClassificationConfig::default(),
10501            ack_reactions: true,
10502            show_tool_calls: true,
10503            session_store: None,
10504            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10505                &crate::config::AutonomyConfig::default(),
10506            )),
10507            activated_tools: None,
10508            mcp_registry: None,
10509            cost_tracking: None,
10510            pacing: crate::config::PacingConfig::default(),
10511            max_tool_result_chars: 0,
10512            context_token_budget: 0,
10513            audit_logger: None,
10514            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
10515        });
10516
10517        process_channel_message(
10518            Arc::clone(&runtime_ctx),
10519            traits::ChannelMessage {
10520                id: "msg-photo-1".to_string(),
10521                sender: "construct_user".to_string(),
10522                reply_target: "chat-photo".to_string(),
10523                content: "[IMAGE:/tmp/workspace/photo_99_1.jpg]\n\nWhat is this?".to_string(),
10524                channel: "test-channel".to_string(),
10525                timestamp: 1,
10526                thread_ts: None,
10527                interruption_scope_id: None,
10528                attachments: vec![],
10529            },
10530            CancellationToken::new(),
10531        )
10532        .await;
10533
10534        process_channel_message(
10535            Arc::clone(&runtime_ctx),
10536            traits::ChannelMessage {
10537                id: "msg-text-2".to_string(),
10538                sender: "construct_user".to_string(),
10539                reply_target: "chat-photo".to_string(),
10540                content: "What is WAL?".to_string(),
10541                channel: "test-channel".to_string(),
10542                timestamp: 2,
10543                thread_ts: None,
10544                interruption_scope_id: None,
10545                attachments: vec![],
10546            },
10547            CancellationToken::new(),
10548        )
10549        .await;
10550
10551        let sent = channel_impl.sent_messages.lock().await;
10552        assert_eq!(sent.len(), 2, "expected one error and one successful reply");
10553        assert!(
10554            sent[0].contains("does not support vision"),
10555            "first reply must mention vision capability error, got: {}",
10556            sent[0]
10557        );
10558        assert!(
10559            sent[1].ends_with(":ok"),
10560            "second reply should succeed for text-only turn, got: {}",
10561            sent[1]
10562        );
10563        drop(sent);
10564
10565        let histories = runtime_ctx
10566            .conversation_histories
10567            .lock()
10568            .unwrap_or_else(|e| e.into_inner());
10569        let turns = histories
10570            .get("test-channel_chat-photo_construct_user")
10571            .expect("history should exist for sender");
10572        assert_eq!(turns.len(), 2);
10573        assert_eq!(turns[0].role, "user");
10574        assert_eq!(turns[0].content, "What is WAL?");
10575        assert_eq!(turns[1].role, "assistant");
10576        assert_eq!(turns[1].content, "ok");
10577        assert!(
10578            turns.iter().all(|turn| !turn.content.contains("[IMAGE:")),
10579            "failed vision turn must not persist image marker content"
10580        );
10581    }
10582
10583    #[tokio::test]
10584    async fn e2e_failed_non_retryable_turn_does_not_poison_follow_up_text_turn() {
10585        let channel_impl = Arc::new(RecordingChannel::default());
10586        let channel: Arc<dyn Channel> = channel_impl.clone();
10587
10588        let mut channels_by_name = HashMap::new();
10589        channels_by_name.insert(channel.name().to_string(), channel);
10590
10591        let runtime_ctx = Arc::new(ChannelRuntimeContext {
10592            channels_by_name: Arc::new(channels_by_name),
10593            provider: Arc::new(FormatErrorProvider),
10594            default_provider: Arc::new("dummy".to_string()),
10595            memory: Arc::new(NoopMemory),
10596            tools_registry: Arc::new(vec![]),
10597            observer: Arc::new(NoopObserver),
10598            system_prompt: Arc::new("You are a helpful assistant.".to_string()),
10599            model: Arc::new("test-model".to_string()),
10600            temperature: 0.0,
10601            auto_save_memory: false,
10602            max_tool_iterations: 5,
10603            min_relevance_score: 0.0,
10604            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
10605            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10606            provider_cache: Arc::new(Mutex::new(HashMap::new())),
10607            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10608            api_key: None,
10609            api_url: None,
10610            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
10611            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
10612            workspace_dir: Arc::new(std::env::temp_dir()),
10613            prompt_config: Arc::new(crate::config::Config::default()),
10614            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10615            interrupt_on_new_message: InterruptOnNewMessageConfig {
10616                telegram: false,
10617                slack: false,
10618                discord: false,
10619                mattermost: false,
10620                matrix: false,
10621            },
10622            multimodal: crate::config::MultimodalConfig::default(),
10623            hooks: None,
10624            non_cli_excluded_tools: Arc::new(Vec::new()),
10625            autonomy_level: AutonomyLevel::default(),
10626            tool_call_dedup_exempt: Arc::new(Vec::new()),
10627            model_routes: Arc::new(Vec::new()),
10628            query_classification: crate::config::QueryClassificationConfig::default(),
10629            ack_reactions: true,
10630            show_tool_calls: true,
10631            session_store: None,
10632            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10633                &crate::config::AutonomyConfig::default(),
10634            )),
10635            activated_tools: None,
10636            mcp_registry: None,
10637            cost_tracking: None,
10638            pacing: crate::config::PacingConfig::default(),
10639            max_tool_result_chars: 50000,
10640            context_token_budget: 128_000,
10641            audit_logger: None,
10642            debouncer: Arc::new(debounce::MessageDebouncer::new(std::time::Duration::ZERO)),
10643            media_pipeline: crate::config::MediaPipelineConfig::default(),
10644            transcription_config: crate::config::TranscriptionConfig::default(),
10645        });
10646
10647        process_channel_message(
10648            Arc::clone(&runtime_ctx),
10649            traits::ChannelMessage {
10650                id: "msg-bad-1".to_string(),
10651                sender: "construct_user".to_string(),
10652                reply_target: "chat-format".to_string(),
10653                content: "trigger format error".to_string(),
10654                channel: "test-channel".to_string(),
10655                timestamp: 1,
10656                thread_ts: None,
10657                interruption_scope_id: None,
10658                attachments: vec![],
10659            },
10660            CancellationToken::new(),
10661        )
10662        .await;
10663
10664        process_channel_message(
10665            Arc::clone(&runtime_ctx),
10666            traits::ChannelMessage {
10667                id: "msg-text-2".to_string(),
10668                sender: "construct_user".to_string(),
10669                reply_target: "chat-format".to_string(),
10670                content: "What is WAL?".to_string(),
10671                channel: "test-channel".to_string(),
10672                timestamp: 2,
10673                thread_ts: None,
10674                interruption_scope_id: None,
10675                attachments: vec![],
10676            },
10677            CancellationToken::new(),
10678        )
10679        .await;
10680
10681        let sent = channel_impl.sent_messages.lock().await;
10682        assert_eq!(sent.len(), 2, "expected one error and one successful reply");
10683        assert!(
10684            sent[0].contains("Format Error"),
10685            "first reply must mention the request format error, got: {}",
10686            sent[0]
10687        );
10688        assert!(
10689            sent[1].ends_with(":ok"),
10690            "second reply should succeed for follow-up text, got: {}",
10691            sent[1]
10692        );
10693        drop(sent);
10694
10695        let histories = runtime_ctx
10696            .conversation_histories
10697            .lock()
10698            .unwrap_or_else(|e| e.into_inner());
10699        let turns = histories
10700            .get("test-channel_chat-format_construct_user")
10701            .expect("history should exist for sender");
10702        assert_eq!(turns.len(), 2);
10703        assert_eq!(turns[0].role, "user");
10704        assert_eq!(turns[0].content, "What is WAL?");
10705        assert_eq!(turns[1].role, "assistant");
10706        assert_eq!(turns[1].content, "ok");
10707        assert!(
10708            turns
10709                .iter()
10710                .all(|turn| turn.content != "trigger format error"),
10711            "failed non-retryable turn must not persist in history"
10712        );
10713    }
10714
10715    #[test]
10716    fn build_channel_by_id_unknown_channel_returns_error() {
10717        let config = Config::default();
10718        match build_channel_by_id(&config, "nonexistent") {
10719            Err(e) => {
10720                let err_msg = e.to_string();
10721                assert!(
10722                    err_msg.contains("Unknown channel"),
10723                    "expected 'Unknown channel' in error, got: {err_msg}"
10724                );
10725            }
10726            Ok(_) => panic!("should fail for unknown channel"),
10727        }
10728    }
10729
10730    // ── Query classification in channel message processing ─────────
10731
10732    #[tokio::test]
10733    async fn process_channel_message_applies_query_classification_route() {
10734        let channel_impl = Arc::new(TelegramRecordingChannel::default());
10735        let channel: Arc<dyn Channel> = channel_impl.clone();
10736
10737        let mut channels_by_name = HashMap::new();
10738        channels_by_name.insert(channel.name().to_string(), channel);
10739
10740        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
10741        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
10742        let vision_provider_impl = Arc::new(ModelCaptureProvider::default());
10743        let vision_provider: Arc<dyn Provider> = vision_provider_impl.clone();
10744
10745        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
10746        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
10747        provider_cache_seed.insert("vision-provider".to_string(), vision_provider);
10748
10749        let classification_config = crate::config::QueryClassificationConfig {
10750            enabled: true,
10751            rules: vec![crate::config::schema::ClassificationRule {
10752                hint: "vision".into(),
10753                keywords: vec!["analyze-image".into()],
10754                ..Default::default()
10755            }],
10756        };
10757
10758        let model_routes = vec![crate::config::ModelRouteConfig {
10759            hint: "vision".into(),
10760            provider: "vision-provider".into(),
10761            model: "gpt-4-vision".into(),
10762            api_key: None,
10763        }];
10764
10765        let runtime_ctx = Arc::new(ChannelRuntimeContext {
10766            channels_by_name: Arc::new(channels_by_name),
10767            provider: Arc::clone(&default_provider),
10768            default_provider: Arc::new("test-provider".to_string()),
10769            memory: Arc::new(NoopMemory),
10770            tools_registry: Arc::new(vec![]),
10771            observer: Arc::new(NoopObserver),
10772            system_prompt: Arc::new("test-system-prompt".to_string()),
10773            model: Arc::new("default-model".to_string()),
10774            temperature: 0.0,
10775            auto_save_memory: false,
10776            max_tool_iterations: 5,
10777            min_relevance_score: 0.0,
10778            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
10779            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10780            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
10781            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10782            api_key: None,
10783            api_url: None,
10784            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
10785            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
10786            workspace_dir: Arc::new(std::env::temp_dir()),
10787            prompt_config: Arc::new(crate::config::Config::default()),
10788            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10789            interrupt_on_new_message: InterruptOnNewMessageConfig {
10790                telegram: false,
10791                slack: false,
10792                discord: false,
10793                mattermost: false,
10794                matrix: false,
10795            },
10796            multimodal: crate::config::MultimodalConfig::default(),
10797            media_pipeline: crate::config::MediaPipelineConfig::default(),
10798            transcription_config: crate::config::TranscriptionConfig::default(),
10799            hooks: None,
10800            non_cli_excluded_tools: Arc::new(Vec::new()),
10801            autonomy_level: AutonomyLevel::default(),
10802            tool_call_dedup_exempt: Arc::new(Vec::new()),
10803            model_routes: Arc::new(model_routes),
10804            query_classification: classification_config,
10805            ack_reactions: true,
10806            show_tool_calls: true,
10807            session_store: None,
10808            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10809                &crate::config::AutonomyConfig::default(),
10810            )),
10811            activated_tools: None,
10812            mcp_registry: None,
10813            cost_tracking: None,
10814            pacing: crate::config::PacingConfig::default(),
10815            max_tool_result_chars: 0,
10816            context_token_budget: 0,
10817            audit_logger: None,
10818            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
10819        });
10820
10821        process_channel_message(
10822            runtime_ctx,
10823            traits::ChannelMessage {
10824                id: "msg-qc-1".to_string(),
10825                sender: "alice".to_string(),
10826                reply_target: "chat-1".to_string(),
10827                content: "please analyze-image from the dataset".to_string(),
10828                channel: "telegram".to_string(),
10829                timestamp: 1,
10830                thread_ts: None,
10831                interruption_scope_id: None,
10832                attachments: vec![],
10833            },
10834            CancellationToken::new(),
10835        )
10836        .await;
10837
10838        // Vision provider should have been called instead of the default.
10839        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
10840        assert_eq!(vision_provider_impl.call_count.load(Ordering::SeqCst), 1);
10841        assert_eq!(
10842            vision_provider_impl
10843                .models
10844                .lock()
10845                .unwrap_or_else(|e| e.into_inner())
10846                .as_slice(),
10847            &["gpt-4-vision".to_string()]
10848        );
10849    }
10850
10851    #[tokio::test]
10852    async fn process_channel_message_classification_disabled_uses_default_route() {
10853        let channel_impl = Arc::new(TelegramRecordingChannel::default());
10854        let channel: Arc<dyn Channel> = channel_impl.clone();
10855
10856        let mut channels_by_name = HashMap::new();
10857        channels_by_name.insert(channel.name().to_string(), channel);
10858
10859        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
10860        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
10861        let vision_provider_impl = Arc::new(ModelCaptureProvider::default());
10862        let vision_provider: Arc<dyn Provider> = vision_provider_impl.clone();
10863
10864        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
10865        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
10866        provider_cache_seed.insert("vision-provider".to_string(), vision_provider);
10867
10868        // Classification is disabled — matching keyword should NOT trigger reroute.
10869        let classification_config = crate::config::QueryClassificationConfig {
10870            enabled: false,
10871            rules: vec![crate::config::schema::ClassificationRule {
10872                hint: "vision".into(),
10873                keywords: vec!["analyze-image".into()],
10874                ..Default::default()
10875            }],
10876        };
10877
10878        let model_routes = vec![crate::config::ModelRouteConfig {
10879            hint: "vision".into(),
10880            provider: "vision-provider".into(),
10881            model: "gpt-4-vision".into(),
10882            api_key: None,
10883        }];
10884
10885        let runtime_ctx = Arc::new(ChannelRuntimeContext {
10886            channels_by_name: Arc::new(channels_by_name),
10887            provider: Arc::clone(&default_provider),
10888            default_provider: Arc::new("test-provider".to_string()),
10889            memory: Arc::new(NoopMemory),
10890            tools_registry: Arc::new(vec![]),
10891            observer: Arc::new(NoopObserver),
10892            system_prompt: Arc::new("test-system-prompt".to_string()),
10893            model: Arc::new("default-model".to_string()),
10894            temperature: 0.0,
10895            auto_save_memory: false,
10896            max_tool_iterations: 5,
10897            min_relevance_score: 0.0,
10898            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
10899            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
10900            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
10901            route_overrides: Arc::new(Mutex::new(HashMap::new())),
10902            api_key: None,
10903            api_url: None,
10904            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
10905            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
10906            workspace_dir: Arc::new(std::env::temp_dir()),
10907            prompt_config: Arc::new(crate::config::Config::default()),
10908            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
10909            interrupt_on_new_message: InterruptOnNewMessageConfig {
10910                telegram: false,
10911                slack: false,
10912                discord: false,
10913                mattermost: false,
10914                matrix: false,
10915            },
10916            multimodal: crate::config::MultimodalConfig::default(),
10917            media_pipeline: crate::config::MediaPipelineConfig::default(),
10918            transcription_config: crate::config::TranscriptionConfig::default(),
10919            hooks: None,
10920            non_cli_excluded_tools: Arc::new(Vec::new()),
10921            autonomy_level: AutonomyLevel::default(),
10922            tool_call_dedup_exempt: Arc::new(Vec::new()),
10923            model_routes: Arc::new(model_routes),
10924            query_classification: classification_config,
10925            ack_reactions: true,
10926            show_tool_calls: true,
10927            session_store: None,
10928            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
10929                &crate::config::AutonomyConfig::default(),
10930            )),
10931            activated_tools: None,
10932            mcp_registry: None,
10933            cost_tracking: None,
10934            pacing: crate::config::PacingConfig::default(),
10935            max_tool_result_chars: 0,
10936            context_token_budget: 0,
10937            audit_logger: None,
10938            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
10939        });
10940
10941        process_channel_message(
10942            runtime_ctx,
10943            traits::ChannelMessage {
10944                id: "msg-qc-disabled".to_string(),
10945                sender: "alice".to_string(),
10946                reply_target: "chat-1".to_string(),
10947                content: "please analyze-image from the dataset".to_string(),
10948                channel: "telegram".to_string(),
10949                timestamp: 1,
10950                thread_ts: None,
10951                interruption_scope_id: None,
10952                attachments: vec![],
10953            },
10954            CancellationToken::new(),
10955        )
10956        .await;
10957
10958        // Default provider should be used since classification is disabled.
10959        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 1);
10960        assert_eq!(vision_provider_impl.call_count.load(Ordering::SeqCst), 0);
10961    }
10962
10963    #[tokio::test]
10964    async fn process_channel_message_classification_no_match_uses_default_route() {
10965        let channel_impl = Arc::new(TelegramRecordingChannel::default());
10966        let channel: Arc<dyn Channel> = channel_impl.clone();
10967
10968        let mut channels_by_name = HashMap::new();
10969        channels_by_name.insert(channel.name().to_string(), channel);
10970
10971        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
10972        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
10973        let vision_provider_impl = Arc::new(ModelCaptureProvider::default());
10974        let vision_provider: Arc<dyn Provider> = vision_provider_impl.clone();
10975
10976        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
10977        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
10978        provider_cache_seed.insert("vision-provider".to_string(), vision_provider);
10979
10980        // Classification enabled with a rule that won't match the message.
10981        let classification_config = crate::config::QueryClassificationConfig {
10982            enabled: true,
10983            rules: vec![crate::config::schema::ClassificationRule {
10984                hint: "vision".into(),
10985                keywords: vec!["analyze-image".into()],
10986                ..Default::default()
10987            }],
10988        };
10989
10990        let model_routes = vec![crate::config::ModelRouteConfig {
10991            hint: "vision".into(),
10992            provider: "vision-provider".into(),
10993            model: "gpt-4-vision".into(),
10994            api_key: None,
10995        }];
10996
10997        let runtime_ctx = Arc::new(ChannelRuntimeContext {
10998            channels_by_name: Arc::new(channels_by_name),
10999            provider: Arc::clone(&default_provider),
11000            default_provider: Arc::new("test-provider".to_string()),
11001            memory: Arc::new(NoopMemory),
11002            tools_registry: Arc::new(vec![]),
11003            observer: Arc::new(NoopObserver),
11004            system_prompt: Arc::new("test-system-prompt".to_string()),
11005            model: Arc::new("default-model".to_string()),
11006            temperature: 0.0,
11007            auto_save_memory: false,
11008            max_tool_iterations: 5,
11009            min_relevance_score: 0.0,
11010            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
11011            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
11012            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
11013            route_overrides: Arc::new(Mutex::new(HashMap::new())),
11014            api_key: None,
11015            api_url: None,
11016            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
11017            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
11018            workspace_dir: Arc::new(std::env::temp_dir()),
11019            prompt_config: Arc::new(crate::config::Config::default()),
11020            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
11021            interrupt_on_new_message: InterruptOnNewMessageConfig {
11022                telegram: false,
11023                slack: false,
11024                discord: false,
11025                mattermost: false,
11026                matrix: false,
11027            },
11028            multimodal: crate::config::MultimodalConfig::default(),
11029            media_pipeline: crate::config::MediaPipelineConfig::default(),
11030            transcription_config: crate::config::TranscriptionConfig::default(),
11031            hooks: None,
11032            non_cli_excluded_tools: Arc::new(Vec::new()),
11033            autonomy_level: AutonomyLevel::default(),
11034            tool_call_dedup_exempt: Arc::new(Vec::new()),
11035            model_routes: Arc::new(model_routes),
11036            query_classification: classification_config,
11037            ack_reactions: true,
11038            show_tool_calls: true,
11039            session_store: None,
11040            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
11041                &crate::config::AutonomyConfig::default(),
11042            )),
11043            activated_tools: None,
11044            mcp_registry: None,
11045            cost_tracking: None,
11046            pacing: crate::config::PacingConfig::default(),
11047            max_tool_result_chars: 0,
11048            context_token_budget: 0,
11049            audit_logger: None,
11050            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
11051        });
11052
11053        process_channel_message(
11054            runtime_ctx,
11055            traits::ChannelMessage {
11056                id: "msg-qc-nomatch".to_string(),
11057                sender: "alice".to_string(),
11058                reply_target: "chat-1".to_string(),
11059                content: "just a regular text message".to_string(),
11060                channel: "telegram".to_string(),
11061                timestamp: 1,
11062                thread_ts: None,
11063                interruption_scope_id: None,
11064                attachments: vec![],
11065            },
11066            CancellationToken::new(),
11067        )
11068        .await;
11069
11070        // Default provider should be used since no classification rule matched.
11071        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 1);
11072        assert_eq!(vision_provider_impl.call_count.load(Ordering::SeqCst), 0);
11073    }
11074
11075    #[tokio::test]
11076    async fn process_channel_message_classification_priority_selects_highest() {
11077        let channel_impl = Arc::new(TelegramRecordingChannel::default());
11078        let channel: Arc<dyn Channel> = channel_impl.clone();
11079
11080        let mut channels_by_name = HashMap::new();
11081        channels_by_name.insert(channel.name().to_string(), channel);
11082
11083        let default_provider_impl = Arc::new(ModelCaptureProvider::default());
11084        let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
11085        let fast_provider_impl = Arc::new(ModelCaptureProvider::default());
11086        let fast_provider: Arc<dyn Provider> = fast_provider_impl.clone();
11087        let code_provider_impl = Arc::new(ModelCaptureProvider::default());
11088        let code_provider: Arc<dyn Provider> = code_provider_impl.clone();
11089
11090        let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
11091        provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
11092        provider_cache_seed.insert("fast-provider".to_string(), fast_provider);
11093        provider_cache_seed.insert("code-provider".to_string(), code_provider);
11094
11095        // Both rules match "code" keyword, but "code" rule has higher priority.
11096        let classification_config = crate::config::QueryClassificationConfig {
11097            enabled: true,
11098            rules: vec![
11099                crate::config::schema::ClassificationRule {
11100                    hint: "fast".into(),
11101                    keywords: vec!["code".into()],
11102                    priority: 1,
11103                    ..Default::default()
11104                },
11105                crate::config::schema::ClassificationRule {
11106                    hint: "code".into(),
11107                    keywords: vec!["code".into()],
11108                    priority: 10,
11109                    ..Default::default()
11110                },
11111            ],
11112        };
11113
11114        let model_routes = vec![
11115            crate::config::ModelRouteConfig {
11116                hint: "fast".into(),
11117                provider: "fast-provider".into(),
11118                model: "fast-model".into(),
11119                api_key: None,
11120            },
11121            crate::config::ModelRouteConfig {
11122                hint: "code".into(),
11123                provider: "code-provider".into(),
11124                model: "code-model".into(),
11125                api_key: None,
11126            },
11127        ];
11128
11129        let runtime_ctx = Arc::new(ChannelRuntimeContext {
11130            channels_by_name: Arc::new(channels_by_name),
11131            provider: Arc::clone(&default_provider),
11132            default_provider: Arc::new("test-provider".to_string()),
11133            memory: Arc::new(NoopMemory),
11134            tools_registry: Arc::new(vec![]),
11135            observer: Arc::new(NoopObserver),
11136            system_prompt: Arc::new("test-system-prompt".to_string()),
11137            model: Arc::new("default-model".to_string()),
11138            temperature: 0.0,
11139            auto_save_memory: false,
11140            max_tool_iterations: 5,
11141            min_relevance_score: 0.0,
11142            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
11143            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
11144            provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
11145            route_overrides: Arc::new(Mutex::new(HashMap::new())),
11146            api_key: None,
11147            api_url: None,
11148            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
11149            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
11150            workspace_dir: Arc::new(std::env::temp_dir()),
11151            prompt_config: Arc::new(crate::config::Config::default()),
11152            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
11153            interrupt_on_new_message: InterruptOnNewMessageConfig {
11154                telegram: false,
11155                slack: false,
11156                discord: false,
11157                mattermost: false,
11158                matrix: false,
11159            },
11160            multimodal: crate::config::MultimodalConfig::default(),
11161            media_pipeline: crate::config::MediaPipelineConfig::default(),
11162            transcription_config: crate::config::TranscriptionConfig::default(),
11163            hooks: None,
11164            non_cli_excluded_tools: Arc::new(Vec::new()),
11165            autonomy_level: AutonomyLevel::default(),
11166            tool_call_dedup_exempt: Arc::new(Vec::new()),
11167            model_routes: Arc::new(model_routes),
11168            query_classification: classification_config,
11169            ack_reactions: true,
11170            show_tool_calls: true,
11171            session_store: None,
11172            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
11173                &crate::config::AutonomyConfig::default(),
11174            )),
11175            activated_tools: None,
11176            mcp_registry: None,
11177            cost_tracking: None,
11178            pacing: crate::config::PacingConfig::default(),
11179            max_tool_result_chars: 0,
11180            context_token_budget: 0,
11181            audit_logger: None,
11182            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
11183        });
11184
11185        process_channel_message(
11186            runtime_ctx,
11187            traits::ChannelMessage {
11188                id: "msg-qc-prio".to_string(),
11189                sender: "alice".to_string(),
11190                reply_target: "chat-1".to_string(),
11191                content: "write some code for me".to_string(),
11192                channel: "telegram".to_string(),
11193                timestamp: 1,
11194                thread_ts: None,
11195                interruption_scope_id: None,
11196                attachments: vec![],
11197            },
11198            CancellationToken::new(),
11199        )
11200        .await;
11201
11202        // Higher-priority "code" rule (priority=10) should win over "fast" (priority=1).
11203        assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
11204        assert_eq!(fast_provider_impl.call_count.load(Ordering::SeqCst), 0);
11205        assert_eq!(code_provider_impl.call_count.load(Ordering::SeqCst), 1);
11206        assert_eq!(
11207            code_provider_impl
11208                .models
11209                .lock()
11210                .unwrap_or_else(|e| e.into_inner())
11211                .as_slice(),
11212            &["code-model".to_string()]
11213        );
11214    }
11215
11216    #[test]
11217    fn build_channel_by_id_unconfigured_telegram_returns_error() {
11218        let config = Config::default();
11219        match build_channel_by_id(&config, "telegram") {
11220            Err(e) => {
11221                let err_msg = e.to_string();
11222                assert!(
11223                    err_msg.contains("not configured"),
11224                    "expected 'not configured' in error, got: {err_msg}"
11225                );
11226            }
11227            Ok(_) => panic!("should fail when telegram is not configured"),
11228        }
11229    }
11230
11231    #[test]
11232    fn build_channel_by_id_configured_telegram_succeeds() {
11233        let mut config = Config::default();
11234        config.channels_config.telegram = Some(crate::config::schema::TelegramConfig {
11235            bot_token: "test-token".to_string(),
11236            allowed_users: vec![],
11237            stream_mode: crate::config::StreamMode::Off,
11238            draft_update_interval_ms: 1000,
11239            interrupt_on_new_message: false,
11240            mention_only: false,
11241            ack_reactions: None,
11242            proxy_url: None,
11243            notification_chat_id: None,
11244        });
11245        match build_channel_by_id(&config, "telegram") {
11246            Ok(channel) => assert_eq!(channel.name(), "telegram"),
11247            Err(e) => panic!("should succeed when telegram is configured: {e}"),
11248        }
11249    }
11250
11251    // ── is_stop_command tests ─────────────────────────────────────────────
11252
11253    #[test]
11254    fn is_stop_command_matches_bare_slash_stop() {
11255        assert!(is_stop_command("/stop"));
11256    }
11257
11258    #[test]
11259    fn is_stop_command_matches_with_leading_trailing_whitespace() {
11260        assert!(is_stop_command("  /stop  "));
11261    }
11262
11263    #[test]
11264    fn is_stop_command_is_case_insensitive() {
11265        assert!(is_stop_command("/STOP"));
11266        assert!(is_stop_command("/Stop"));
11267    }
11268
11269    #[test]
11270    fn is_stop_command_matches_with_bot_suffix() {
11271        assert!(is_stop_command("/stop@construct_bot"));
11272    }
11273
11274    #[test]
11275    fn is_stop_command_rejects_other_slash_commands() {
11276        assert!(!is_stop_command("/new"));
11277        assert!(!is_stop_command("/model gpt-4"));
11278        assert!(!is_stop_command("/models"));
11279    }
11280
11281    #[test]
11282    fn is_stop_command_rejects_plain_text() {
11283        assert!(!is_stop_command("stop"));
11284        assert!(!is_stop_command("please stop"));
11285        assert!(!is_stop_command(""));
11286    }
11287
11288    #[test]
11289    fn is_stop_command_rejects_stop_as_substring() {
11290        assert!(!is_stop_command("/stopwatch"));
11291        assert!(!is_stop_command("/stop-all"));
11292    }
11293
11294    #[test]
11295    fn interrupt_on_new_message_enabled_for_mattermost_when_true() {
11296        let cfg = InterruptOnNewMessageConfig {
11297            telegram: false,
11298            slack: false,
11299            discord: false,
11300            mattermost: true,
11301            matrix: false,
11302        };
11303        assert!(cfg.enabled_for_channel("mattermost"));
11304    }
11305
11306    #[test]
11307    fn interrupt_on_new_message_disabled_for_mattermost_by_default() {
11308        let cfg = InterruptOnNewMessageConfig {
11309            telegram: false,
11310            slack: false,
11311            discord: false,
11312            mattermost: false,
11313            matrix: false,
11314        };
11315        assert!(!cfg.enabled_for_channel("mattermost"));
11316    }
11317
11318    #[test]
11319    fn interrupt_on_new_message_enabled_for_discord() {
11320        let cfg = InterruptOnNewMessageConfig {
11321            telegram: false,
11322            slack: false,
11323            discord: true,
11324            mattermost: false,
11325            matrix: false,
11326        };
11327        assert!(cfg.enabled_for_channel("discord"));
11328    }
11329
11330    #[test]
11331    fn interrupt_on_new_message_disabled_for_discord_by_default() {
11332        let cfg = InterruptOnNewMessageConfig {
11333            telegram: false,
11334            slack: false,
11335            discord: false,
11336            mattermost: false,
11337            matrix: false,
11338        };
11339        assert!(!cfg.enabled_for_channel("discord"));
11340    }
11341
11342    // ── interruption_scope_key tests ──────────────────────────────────────
11343
11344    #[test]
11345    fn interruption_scope_key_without_scope_id_is_three_component() {
11346        let msg = traits::ChannelMessage {
11347            id: "1".into(),
11348            sender: "alice".into(),
11349            reply_target: "room".into(),
11350            content: "hi".into(),
11351            channel: "matrix".into(),
11352            timestamp: 0,
11353            thread_ts: None,
11354            interruption_scope_id: None,
11355            attachments: vec![],
11356        };
11357        assert_eq!(interruption_scope_key(&msg), "matrix_room_alice");
11358    }
11359
11360    #[test]
11361    fn interruption_scope_key_with_scope_id_is_four_component() {
11362        let msg = traits::ChannelMessage {
11363            id: "1".into(),
11364            sender: "alice".into(),
11365            reply_target: "room".into(),
11366            content: "hi".into(),
11367            channel: "matrix".into(),
11368            timestamp: 0,
11369            thread_ts: Some("$thread1".into()),
11370            interruption_scope_id: Some("$thread1".into()),
11371            attachments: vec![],
11372        };
11373        assert_eq!(interruption_scope_key(&msg), "matrix_room_alice_$thread1");
11374    }
11375
11376    #[test]
11377    fn interruption_scope_key_thread_ts_alone_does_not_affect_key() {
11378        // thread_ts used for reply anchoring should not bleed into scope key
11379        let msg = traits::ChannelMessage {
11380            id: "1".into(),
11381            sender: "alice".into(),
11382            reply_target: "C123".into(),
11383            content: "hi".into(),
11384            channel: "slack".into(),
11385            timestamp: 0,
11386            thread_ts: Some("1234567890.000100".into()), // Slack top-level fallback
11387            interruption_scope_id: None,                 // but NOT a thread reply
11388            attachments: vec![],
11389        };
11390        assert_eq!(interruption_scope_key(&msg), "slack_C123_alice");
11391    }
11392
11393    #[tokio::test]
11394    async fn message_dispatch_different_threads_do_not_cancel_each_other() {
11395        let channel_impl = Arc::new(SlackRecordingChannel::default());
11396        let channel: Arc<dyn Channel> = channel_impl.clone();
11397
11398        let mut channels_by_name = HashMap::new();
11399        channels_by_name.insert(channel.name().to_string(), channel);
11400
11401        let runtime_ctx = Arc::new(ChannelRuntimeContext {
11402            channels_by_name: Arc::new(channels_by_name),
11403            provider: Arc::new(SlowProvider {
11404                delay: Duration::from_millis(150),
11405            }),
11406            default_provider: Arc::new("test-provider".to_string()),
11407            memory: Arc::new(NoopMemory),
11408            tools_registry: Arc::new(vec![]),
11409            observer: Arc::new(NoopObserver),
11410            system_prompt: Arc::new("test-system-prompt".to_string()),
11411            model: Arc::new("test-model".to_string()),
11412            temperature: 0.0,
11413            auto_save_memory: false,
11414            max_tool_iterations: 10,
11415            min_relevance_score: 0.0,
11416            conversation_histories: Arc::new(Mutex::new(HashMap::new())),
11417            pending_new_sessions: Arc::new(Mutex::new(HashSet::new())),
11418            provider_cache: Arc::new(Mutex::new(HashMap::new())),
11419            route_overrides: Arc::new(Mutex::new(HashMap::new())),
11420            api_key: None,
11421            api_url: None,
11422            reliability: Arc::new(crate::config::ReliabilityConfig::default()),
11423            provider_runtime_options: providers::ProviderRuntimeOptions::default(),
11424            workspace_dir: Arc::new(std::env::temp_dir()),
11425            prompt_config: Arc::new(crate::config::Config::default()),
11426            message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
11427            interrupt_on_new_message: InterruptOnNewMessageConfig {
11428                telegram: false,
11429                slack: true,
11430                discord: false,
11431                mattermost: false,
11432                matrix: false,
11433            },
11434            multimodal: crate::config::MultimodalConfig::default(),
11435            media_pipeline: crate::config::MediaPipelineConfig::default(),
11436            transcription_config: crate::config::TranscriptionConfig::default(),
11437            hooks: None,
11438            non_cli_excluded_tools: Arc::new(Vec::new()),
11439            autonomy_level: AutonomyLevel::default(),
11440            tool_call_dedup_exempt: Arc::new(Vec::new()),
11441            model_routes: Arc::new(Vec::new()),
11442            query_classification: crate::config::QueryClassificationConfig::default(),
11443            ack_reactions: true,
11444            show_tool_calls: true,
11445            session_store: None,
11446            approval_manager: Arc::new(ApprovalManager::for_non_interactive(
11447                &crate::config::AutonomyConfig::default(),
11448            )),
11449            activated_tools: None,
11450            mcp_registry: None,
11451            cost_tracking: None,
11452            pacing: crate::config::PacingConfig::default(),
11453            max_tool_result_chars: 0,
11454            context_token_budget: 0,
11455            audit_logger: None,
11456            debouncer: Arc::new(debounce::MessageDebouncer::new(Duration::ZERO)),
11457        });
11458
11459        let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
11460        let send_task = tokio::spawn(async move {
11461            // Two messages from same sender but in different Slack threads —
11462            // they must NOT cancel each other.
11463            tx.send(traits::ChannelMessage {
11464                id: "1741234567.100001".to_string(),
11465                sender: "alice".to_string(),
11466                reply_target: "C123".to_string(),
11467                content: "thread-a question".to_string(),
11468                channel: "slack".to_string(),
11469                timestamp: 1,
11470                thread_ts: Some("1741234567.100001".to_string()),
11471                interruption_scope_id: Some("1741234567.100001".to_string()),
11472                attachments: vec![],
11473            })
11474            .await
11475            .unwrap();
11476            tokio::time::sleep(Duration::from_millis(30)).await;
11477            tx.send(traits::ChannelMessage {
11478                id: "1741234567.200002".to_string(),
11479                sender: "alice".to_string(),
11480                reply_target: "C123".to_string(),
11481                content: "thread-b question".to_string(),
11482                channel: "slack".to_string(),
11483                timestamp: 2,
11484                thread_ts: Some("1741234567.200002".to_string()),
11485                interruption_scope_id: Some("1741234567.200002".to_string()),
11486                attachments: vec![],
11487            })
11488            .await
11489            .unwrap();
11490        });
11491
11492        run_message_dispatch_loop(rx, runtime_ctx, 4).await;
11493        send_task.await.unwrap();
11494
11495        // Both tasks should have completed — different threads, no cancellation.
11496        let sent_messages = channel_impl.sent_messages.lock().await;
11497        assert_eq!(
11498            sent_messages.len(),
11499            2,
11500            "both Slack thread messages should complete, got: {sent_messages:?}"
11501        );
11502    }
11503
11504    #[test]
11505    fn sanitize_channel_response_redacts_detected_credentials() {
11506        let tools: Vec<Box<dyn Tool>> = Vec::new();
11507        let leaked = "Temporary key: AKIAABCDEFGHIJKLMNOP"; // gitleaks:allow
11508
11509        let result = sanitize_channel_response(leaked, &tools);
11510
11511        assert!(!result.contains("AKIAABCDEFGHIJKLMNOP")); // gitleaks:allow
11512        assert!(result.contains("[REDACTED"));
11513    }
11514
11515    #[test]
11516    fn sanitize_channel_response_passes_clean_text() {
11517        let tools: Vec<Box<dyn Tool>> = Vec::new();
11518        let clean_text = "This is a normal message with no credentials.";
11519
11520        let result = sanitize_channel_response(clean_text, &tools);
11521
11522        assert_eq!(result, clean_text);
11523    }
11524
11525    // ── Tests for #4827: tool context preservation ──────────────
11526
11527    #[test]
11528    fn extract_current_turn_tool_messages_returns_intermediate_messages() {
11529        let history = vec![
11530            ChatMessage::system("sys"),
11531            ChatMessage::user("older msg"),
11532            ChatMessage::assistant("older reply"),
11533            ChatMessage::user("block the iPad"),
11534            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
11535            ChatMessage::tool("ok"),
11536            ChatMessage::assistant("Done, iPad is blocked."),
11537        ];
11538
11539        let tool_msgs = extract_current_turn_tool_messages(&history);
11540        assert_eq!(tool_msgs.len(), 2);
11541        assert_eq!(tool_msgs[0].role, "assistant");
11542        assert!(tool_msgs[0].content.contains("tool_call"));
11543        assert_eq!(tool_msgs[1].role, "tool");
11544    }
11545
11546    #[test]
11547    fn extract_current_turn_tool_messages_empty_when_no_tools() {
11548        let history = vec![
11549            ChatMessage::user("hello"),
11550            ChatMessage::assistant("Hi there!"),
11551        ];
11552
11553        let tool_msgs = extract_current_turn_tool_messages(&history);
11554        assert!(tool_msgs.is_empty());
11555    }
11556
11557    #[test]
11558    fn extract_current_turn_tool_messages_multiple_tool_rounds() {
11559        let history = vec![
11560            ChatMessage::user("do two things"),
11561            ChatMessage::assistant("{\"tool_call\": \"read_skill\"}"),
11562            ChatMessage::tool("skill content"),
11563            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
11564            ChatMessage::tool("shell output"),
11565            ChatMessage::assistant("All done."),
11566        ];
11567
11568        let tool_msgs = extract_current_turn_tool_messages(&history);
11569        assert_eq!(tool_msgs.len(), 4);
11570    }
11571
11572    #[test]
11573    fn is_tool_call_content_detects_tool_calls() {
11574        assert!(is_tool_call_content("{\"tool_call\": \"shell\"}"));
11575        assert!(is_tool_call_content("<tool_call>shell</tool_call>"));
11576        assert!(is_tool_call_content(
11577            "{\"name\": \"read_file\", \"args\": {}}"
11578        ));
11579        assert!(!is_tool_call_content("The iPad has been blocked."));
11580        assert!(!is_tool_call_content(""));
11581    }
11582
11583    #[test]
11584    fn normalize_cached_channel_turns_passes_through_tool_messages() {
11585        let turns = vec![
11586            ChatMessage::user("block the iPad"),
11587            ChatMessage::assistant("{\"tool_call\": \"shell\"}"),
11588            ChatMessage::tool("ok"),
11589            ChatMessage::assistant("iPad blocked."),
11590            ChatMessage::user("next question"),
11591        ];
11592
11593        let normalized = normalize_cached_channel_turns(turns);
11594        // user, assistant(tool_call), tool, assistant(final), user
11595        assert_eq!(normalized.len(), 5);
11596        assert_eq!(normalized[2].role, "tool");
11597    }
11598
11599    #[test]
11600    fn default_keep_tool_context_turns_is_two() {
11601        let config = crate::config::schema::AgentConfig::default();
11602        assert_eq!(config.keep_tool_context_turns, 2);
11603    }
11604}