feat(channel): add interrupt_on_new_message support for Discord (#3918)

* feat(channel): add /stop command to cancel in-flight tasks

Adds an explicit /stop slash command that allows users on any non-CLI
channel (Matrix, Telegram, Discord, Slack, etc.) to cancel an agent
task that is currently running.

Changes:
- is_stop_command(): new helper that detects /stop (case-insensitive,
  optional @botname suffix), not gated on channel type
- /stop fast path in run_message_dispatch_loop: intercepts /stop before
  semaphore acquisition so the target task is never replaced in the store;
  fires CancellationToken on the running task; sends reply via tokio::spawn
  using the established two-step channel lookup pattern
- register_in_flight separated from interrupt_enabled: all non-CLI tasks
  now enter the in_flight_by_sender store, enabling /stop to reach them;
  auto-cancel-on-new-message remains gated on interrupt_enabled (Telegram/
  Slack only) — this is a deliberate broadening, not a side effect

Deferred to follow-up (feat/matrix-interrupt-on-new-message):
- interrupt_on_new_message config field for Matrix
- thread-aware interruption_scope_key (requires per-channel thread_ts
  semantics analysis; Slack always sets thread_ts, Matrix only for replies)

Supersedes #2855

Tests: 7 new unit tests for is_stop_command; all 4075 tests pass.

* feat(channel): add interrupt_on_new_message support for Discord

---------

Co-authored-by: argenis de la rosa <theonlyhennygod@gmail.com>
This commit is contained in:
Nim G 2026-03-19 19:33:37 -04:00 committed by Roman Tataurov
parent 08851b0145
commit 2b8297dc31
No known key found for this signature in database
GPG Key ID: 70A51EF3185C334B
5 changed files with 85 additions and 0 deletions

View File

@ -288,9 +288,11 @@ const OPENRC_STATUS_ARGS: [&str; 2] = ["zeroclaw", "status"];
const OPENRC_RESTART_ARGS: [&str; 2] = ["zeroclaw", "restart"];
#[derive(Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
struct InterruptOnNewMessageConfig {
telegram: bool,
slack: bool,
discord: bool,
mattermost: bool,
}
@ -299,6 +301,7 @@ impl InterruptOnNewMessageConfig {
match channel {
"telegram" => self.telegram,
"slack" => self.slack,
"discord" => self.discord,
"mattermost" => self.mattermost,
_ => false,
}
@ -4385,6 +4388,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_discord = config
.channels_config
.discord
.as_ref()
.is_some_and(|dc| dc.interrupt_on_new_message);
let interrupt_on_new_message_mattermost = config
.channels_config
.mattermost
@ -4418,6 +4426,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,
discord: interrupt_on_new_message_discord,
mattermost: interrupt_on_new_message_mattermost,
},
multimodal: config.multimodal.clone(),
@ -4735,6 +4744,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -4848,6 +4858,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -4917,6 +4928,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -5005,6 +5017,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -5545,6 +5558,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
non_cli_excluded_tools: Arc::new(Vec::new()),
@ -5621,6 +5635,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
non_cli_excluded_tools: Arc::new(Vec::new()),
@ -5712,6 +5727,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -5788,6 +5804,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -5874,6 +5891,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -5981,6 +5999,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6069,6 +6088,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6172,6 +6192,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6260,6 +6281,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6338,6 +6360,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6527,6 +6550,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6624,6 +6648,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: true,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6736,6 +6761,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: true,
discord: false,
mattermost: false,
},
ack_reactions: true,
@ -6845,6 +6871,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: true,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -6936,6 +6963,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -7012,6 +7040,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -7764,6 +7793,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -7890,6 +7920,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -8055,6 +8086,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -8157,6 +8189,7 @@ BTC is currently around $65,000 based on latest tool output."#
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -8724,6 +8757,7 @@ This is an example JSON object for profile settings."#;
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -8807,6 +8841,7 @@ This is an example JSON object for profile settings."#;
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -8964,6 +8999,7 @@ This is an example JSON object for profile settings."#;
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -9071,6 +9107,7 @@ This is an example JSON object for profile settings."#;
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -9170,6 +9207,7 @@ This is an example JSON object for profile settings."#;
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -9289,6 +9327,7 @@ This is an example JSON object for profile settings."#;
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -9417,6 +9456,7 @@ This is an example JSON object for profile settings."#;
let cfg = InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: true,
};
assert!(cfg.enabled_for_channel("mattermost"));
@ -9427,8 +9467,31 @@ This is an example JSON object for profile settings."#;
let cfg = InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
};
assert!(!cfg.enabled_for_channel("mattermost"));
}
#[test]
fn interrupt_on_new_message_enabled_for_discord() {
let cfg = InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: true,
mattermost: false,
};
assert!(cfg.enabled_for_channel("discord"));
}
#[test]
fn interrupt_on_new_message_disabled_for_discord_by_default() {
let cfg = InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
};
assert!(!cfg.enabled_for_channel("discord"));
}
}

View File

@ -63,6 +63,7 @@ mod tests {
guild_id: Some("123".into()),
allowed_users: vec![],
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
};

View File

@ -4660,6 +4660,10 @@ pub struct DiscordConfig {
/// The bot still ignores its own messages to prevent feedback loops.
#[serde(default)]
pub listen_to_bots: bool,
/// When true, a newer Discord 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,
/// When true, only respond to messages that @-mention the bot.
/// Other messages in the guild are silently ignored.
#[serde(default)]
@ -9414,6 +9418,7 @@ tool_dispatcher = "xml"
guild_id: Some("12345".into()),
allowed_users: vec![],
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
};
let json = serde_json::to_string(&dc).unwrap();
@ -9429,6 +9434,7 @@ tool_dispatcher = "xml"
guild_id: None,
allowed_users: vec![],
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
};
let json = serde_json::to_string(&dc).unwrap();
@ -9692,6 +9698,20 @@ allowed_users = ["@ops:matrix.org"]
assert!(!parsed.mention_only);
}
#[test]
async fn discord_config_default_interrupt_on_new_message_is_false() {
let json = r#"{"bot_token":"tok"}"#;
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
assert!(!parsed.interrupt_on_new_message);
}
#[test]
async fn discord_config_deserializes_interrupt_on_new_message_true() {
let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
assert!(parsed.interrupt_on_new_message);
}
#[test]
async fn discord_config_toml_backward_compat() {
let toml_str = r#"

View File

@ -3850,6 +3850,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
guild_id: if guild.is_empty() { None } else { Some(guild) },
allowed_users,
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
});
}

0
web/dist/.gitkeep vendored
View File