From 2aaf91618542396a9cebfe0b9da1f27bfbe47ce8 Mon Sep 17 00:00:00 2001 From: Nim G Date: Wed, 18 Mar 2026 16:40:25 -0300 Subject: [PATCH] feat(channel): add interrupt_on_new_message support for Mattermost --- src/channels/mod.rs | 131 ++++++++++++++++++++++++++++++++++++++++++ src/config/schema.rs | 19 ++++++ src/daemon/mod.rs | 1 + src/onboard/wizard.rs | 1 + 4 files changed, 152 insertions(+) diff --git a/src/channels/mod.rs b/src/channels/mod.rs index ceb970d1d..d9f93c8c7 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -291,6 +291,7 @@ const OPENRC_RESTART_ARGS: [&str; 2] = ["zeroclaw", "restart"]; struct InterruptOnNewMessageConfig { telegram: bool, slack: bool, + mattermost: bool, } impl InterruptOnNewMessageConfig { @@ -298,6 +299,7 @@ impl InterruptOnNewMessageConfig { match channel { "telegram" => self.telegram, "slack" => self.slack, + "mattermost" => self.mattermost, _ => false, } } @@ -4383,6 +4385,11 @@ pub async fn start_channels(config: Config) -> Result<()> { .slack .as_ref() .is_some_and(|sl| sl.interrupt_on_new_message); + let interrupt_on_new_message_mattermost = config + .channels_config + .mattermost + .as_ref() + .is_some_and(|mm| mm.interrupt_on_new_message); let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name, @@ -4411,6 +4418,7 @@ pub async fn start_channels(config: Config) -> Result<()> { interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: interrupt_on_new_message, slack: interrupt_on_new_message_slack, + mattermost: interrupt_on_new_message_mattermost, }, multimodal: config.multimodal.clone(), hooks: if config.hooks.enabled { @@ -4727,6 +4735,7 @@ mod tests { interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -4839,6 +4848,7 @@ mod tests { interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -4907,6 +4917,7 @@ mod tests { interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -4994,6 +5005,7 @@ mod tests { interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -5502,6 +5514,80 @@ BTC is currently around $65,000 based on latest tool output."# #[tokio::test] async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json() { + let channel_impl = Arc::new(RecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel); + + let runtime_ctx = Arc::new(ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: Arc::new(ToolCallingProvider), + default_provider: Arc::new("test-provider".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![Box::new(MockPriceTool)]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new("test-system-prompt".to_string()), + model: Arc::new("test-model".to_string()), + temperature: 0.0, + auto_save_memory: false, + max_tool_iterations: 10, + min_relevance_score: 0.0, + conversation_histories: Arc::new(Mutex::new(HashMap::new())), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(std::env::temp_dir()), + message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, + interrupt_on_new_message: InterruptOnNewMessageConfig { + telegram: false, + slack: false, + mattermost: false, + }, + non_cli_excluded_tools: Arc::new(Vec::new()), + tool_call_dedup_exempt: Arc::new(Vec::new()), + multimodal: crate::config::MultimodalConfig::default(), + hooks: None, + model_routes: Arc::new(Vec::new()), + query_classification: crate::config::QueryClassificationConfig::default(), + ack_reactions: true, + show_tool_calls: true, + session_store: None, + approval_manager: Arc::new(ApprovalManager::for_non_interactive( + &crate::config::AutonomyConfig::default(), + )), + activated_tools: None, + }); + + process_channel_message( + runtime_ctx, + traits::ChannelMessage { + id: "msg-1".to_string(), + sender: "alice".to_string(), + reply_target: "chat-42".to_string(), + content: "What is the BTC price now?".to_string(), + channel: "test-channel".to_string(), + timestamp: 1, + thread_ts: None, + }, + CancellationToken::new(), + ) + .await; + + let sent_messages = channel_impl.sent_messages.lock().await; + assert!(!sent_messages.is_empty()); + let reply = sent_messages.last().unwrap(); + assert!(reply.starts_with("chat-42:")); + assert!(reply.contains("BTC is currently around")); + assert!(!reply.contains("\"tool_calls\"")); + assert!(!reply.contains("mock_price")); + } + + #[tokio::test] + async fn process_channel_message_telegram_does_not_persist_tool_summary_prefix() { let channel_impl = Arc::new(TelegramRecordingChannel::default()); let channel: Arc = channel_impl.clone(); @@ -5535,6 +5621,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, non_cli_excluded_tools: Arc::new(Vec::new()), autonomy_level: AutonomyLevel::default(), @@ -5625,6 +5712,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -5700,6 +5788,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -5785,6 +5874,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -5891,6 +5981,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -5978,6 +6069,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6080,6 +6172,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6167,6 +6260,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6244,6 +6338,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6432,6 +6527,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6528,6 +6624,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: true, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6639,6 +6736,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: true, + mattermost: false, }, ack_reactions: true, show_tool_calls: true, @@ -6747,6 +6845,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: true, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6837,6 +6936,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -6912,6 +7012,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -7663,6 +7764,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -7952,6 +8054,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -8053,6 +8156,7 @@ BTC is currently around $65,000 based on latest tool output."# interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -8383,6 +8487,7 @@ This is an example JSON object for profile settings."#; allowed_users: vec![], thread_replies: Some(true), mention_only: Some(false), + interrupt_on_new_message: false, }); let channels = collect_configured_channels(&config, "test"); @@ -8618,6 +8723,7 @@ This is an example JSON object for profile settings."#; interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -8700,6 +8806,7 @@ This is an example JSON object for profile settings."#; interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -8856,6 +8963,7 @@ This is an example JSON object for profile settings."#; interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -8962,6 +9070,7 @@ This is an example JSON object for profile settings."#; interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -9060,6 +9169,7 @@ This is an example JSON object for profile settings."#; interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -9178,6 +9288,7 @@ This is an example JSON object for profile settings."#; interrupt_on_new_message: InterruptOnNewMessageConfig { telegram: false, slack: false, + mattermost: false, }, multimodal: crate::config::MultimodalConfig::default(), hooks: None, @@ -9299,4 +9410,24 @@ This is an example JSON object for profile settings."#; assert!(!is_stop_command("/stopwatch")); assert!(!is_stop_command("/stop-all")); } + + #[test] + fn interrupt_on_new_message_enabled_for_mattermost_when_true() { + let cfg = InterruptOnNewMessageConfig { + telegram: false, + slack: false, + mattermost: true, + }; + assert!(cfg.enabled_for_channel("mattermost")); + } + + #[test] + fn interrupt_on_new_message_disabled_for_mattermost_by_default() { + let cfg = InterruptOnNewMessageConfig { + telegram: false, + slack: false, + mattermost: false, + }; + assert!(!cfg.enabled_for_channel("mattermost")); + } } diff --git a/src/config/schema.rs b/src/config/schema.rs index 4b0f5af9e..f9b39014d 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -4731,6 +4731,10 @@ pub struct MattermostConfig { /// Other messages in the channel are silently ignored. #[serde(default)] pub mention_only: Option, + /// When true, a newer Mattermost message from the same sender in the same channel + /// cancels the in-flight request and starts a fresh response with preserved history. + #[serde(default)] + pub interrupt_on_new_message: bool, } impl ChannelConfig for MattermostConfig { @@ -9713,6 +9717,21 @@ channel_id = "C123" assert_eq!(parsed.channel_id.as_deref(), Some("C123")); } + #[test] + async fn mattermost_config_default_interrupt_on_new_message_is_false() { + let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#; + let parsed: MattermostConfig = serde_json::from_str(json).unwrap(); + assert!(!parsed.interrupt_on_new_message); + } + + #[test] + async fn mattermost_config_deserializes_interrupt_on_new_message_true() { + let json = + r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#; + let parsed: MattermostConfig = serde_json::from_str(json).unwrap(); + assert!(parsed.interrupt_on_new_message); + } + #[test] async fn webhook_config_with_secret() { let json = r#"{"port":8080,"secret":"my-secret-key"}"#; diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 179dd7a1d..7ac9b4f3d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -671,6 +671,7 @@ mod tests { allowed_users: vec!["*".into()], thread_replies: Some(true), mention_only: Some(false), + interrupt_on_new_message: false, }); assert!(has_supervised_channels(&config)); } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index c756bf2d2..5a4371e5d 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -7371,6 +7371,7 @@ mod tests { allowed_users: vec!["*".into()], thread_replies: Some(true), mention_only: Some(false), + interrupt_on_new_message: false, }); assert!(has_launchable_channels(&channels));