1pub 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
136struct 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
188type ConversationHistoryMap = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
190type PendingNewSessionSet = Arc<Mutex<HashSet<String>>>;
192const MAX_CHANNEL_HISTORY: usize = 50;
194const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
198
199const 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;
205const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
208const 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;
222const PROACTIVE_CONTEXT_BUDGET_CHARS: usize = 400_000;
229const 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 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 approval_manager: Arc<ApprovalManager>,
396 activated_tools: Option<std::sync::Arc<std::sync::Mutex<crate::tools::ActivatedToolSet>>>,
397 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 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 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
473fn 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
485fn 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 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 {
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 let rest = &prompt[start + 24..]; 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 (_, "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 (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
722fn 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 cleaned == "[Tool results]" || cleaned.is_empty() {
735 return String::new();
736 }
737
738 cleaned.to_string()
739}
740
741fn strip_tool_summary_prefix(text: &str) -> String {
749 if let Some(rest) = text.strip_prefix("[Used tools:") {
750 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" => Some(ChannelRuntimeCommand::NewSession),
784 "/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 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
1167fn 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 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 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 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
1222fn extract_current_turn_tool_messages(history: &[ChatMessage]) -> Vec<ChatMessage> {
1227 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 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
1251fn 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 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 protect_from = i + 1; break;
1276 }
1277 }
1278 }
1279
1280 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 protect_from = protect_from.saturating_sub(1);
1291 } else {
1292 i += 1;
1293 }
1294 }
1295}
1296
1297fn 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 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 if content.contains("[IMAGE:") {
1371 return true;
1372 }
1373
1374 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
1424fn 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 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 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, ¤t.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
1588fn 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
1620const BLOCK_KIT_PREFIX: &str = "__CONSTRUCT_BLOCK_KIT__";
1624
1625fn 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 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, ¤t.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 !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": ¤t.model },
1681 "value": ¤t.model
1682 }),
1683 );
1684 }
1685
1686 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(¤t),
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(¤t, 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 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 ¤t,
1829 ctx.workspace_dir.as_path(),
1830 &ctx.model_routes,
1831 );
1832 format!("__CONSTRUCT_BLOCK_KIT__{blocks_json}")
1834 } else {
1835 build_config_text_response(¤t, 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
1863async 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 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 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&inner_text) {
1905 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 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 !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
1955fn extract_mcp_text(raw: &str) -> String {
1964 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(raw) {
1965 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 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 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, }) {
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#[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 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 let trimmed_response = response.trim();
2145 let stripped_summary = strip_tool_summary_prefix(trimmed_response);
2146 let stripped_xml = strip_tool_call_tags(&stripped_summary);
2148 let stripped_json = strip_isolated_tool_json_artifacts(&stripped_xml, &known_tool_names);
2150 let sanitized = strip_tool_narration(&stripped_json);
2152
2153 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
2166fn 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 continue;
2206 }
2207 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 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 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 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 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 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 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 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 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 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 append_sender_turn(ctx.as_ref(), &history_key, ChatMessage::user(&msg.content));
2678
2679 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 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 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 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 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 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 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 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 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 {
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 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 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 let draft_updater = if use_draft_streaming {
2887 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 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 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 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(¬ify_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, ¬ify_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, ),
3055 ),
3056 ) => LlmExecutionResult::Completed(result),
3057 };
3058
3059 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 }
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 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 if notify_observer_flag.tools_used.load(Ordering::Relaxed) && msg.channel != "cli" {
3118 msg.thread_ts = followup_thread_id(&msg);
3119 }
3120 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}" } else {
3144 "\u{26A0}\u{FE0F}" };
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 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 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 let keep_tool_turns = ctx.prompt_config.agent.keep_tool_context_turns;
3287 if keep_tool_turns > 0 {
3288 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 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 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 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 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 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
3532async 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 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 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 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 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 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
3727fn 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 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 inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file);
3751}
3752
3753pub 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 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 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 if !tools.is_empty() {
3858 prompt.push_str("## Tools\n\n");
3859 if compact_context {
3860 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 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 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 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 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 let _ = writeln!(
3952 prompt,
3953 "## Workspace\n\nWorking directory: `{}`\n",
3954 workspace_dir.display()
3955 );
3956
3957 prompt.push_str("## Project Context\n\n");
3959
3960 if let Some(config) = identity_config {
3962 if identity::is_aieos_configured(config) {
3963 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 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 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 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 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 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 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 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 } if max_system_prompt_chars > 0 && prompt.len() > max_system_prompt_chars {
4044 let mut end = max_system_prompt_chars;
4046 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
4062fn 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 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 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 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 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 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 {
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
4319fn 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
4484async 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 match wa.backend_type() {
4701 "cloud" => {
4702 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 #[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 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
5038pub 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#[allow(clippy::too_many_lines)]
5100pub async fn start_channels(config: Config) -> Result<()> {
5101 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 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 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 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(®istry));
5218 if config.mcp.deferred_loading {
5219 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(®istry),
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(®istry),
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(®istry),
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 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 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 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 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 if !deferred_section.is_empty() {
5439 system_prompt.push('\n');
5440 system_prompt.push_str(&deferred_section);
5441 }
5442
5443 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 #[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 let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(100);
5520
5521 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); 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 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 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 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 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 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 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 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 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 assert_eq!(channel_message_timeout_budget_secs(300, 0), 300);
5814 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 assert_eq!(
5850 channel_message_timeout_budget_secs_with_cap(300, 10, 8),
5851 300 * 8
5852 );
5853 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 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 assert!(!should_skip_memory_context_entry(
5900 "telegram_user_msg_101",
5901 "Please describe the image"
5902 ));
5903
5904 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 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 #[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 #[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 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 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 assert!(
6158 turns.last().unwrap().content.starts_with("m9-"),
6159 "most recent turn must be preserved"
6160 );
6161 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 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 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 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 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 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 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 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 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 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 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 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 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<review>&</name>"));
9006 assert!(prompt.contains(
9007 "<description>Review "unsafe" and 'risky' bits</description>"
9008 ));
9009 assert!(prompt.contains("<name>run"linter"</name>"));
9010 assert!(prompt.contains("<description>Run <lint> & report</description>"));
9011 assert!(prompt.contains("<kind>shell&exec</kind>"));
9012 assert!(prompt.contains(
9013 "<instruction>Use <tool_call> and & keep output "safe"</instruction>"
9014 ));
9015 }
9016
9017 #[test]
9018 fn prompt_truncation() {
9019 let ws = make_workspace();
9020 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 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 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 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 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 #[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 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 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 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 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 assert!(prompt.contains("### SOUL.md"));
10065 }
10066
10067 #[test]
10068 fn aieos_empty_uses_openclaw() {
10069 use crate::config::IdentityConfig;
10070
10071 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 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 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 let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
10110
10111 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 #[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 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 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 #[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 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 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 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 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 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 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 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 #[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 #[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 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()), interruption_scope_id: None, 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 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 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"; let result = sanitize_channel_response(leaked, &tools);
11510
11511 assert!(!result.contains("AKIAABCDEFGHIJKLMNOP")); 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 #[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 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}