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 GitHub
parent 83587eea4a
commit 6a30e24e7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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,
}
@ -4388,6 +4391,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
@ -4421,6 +4429,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(),
@ -4738,6 +4747,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -4851,6 +4861,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -4920,6 +4931,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -5008,6 +5020,7 @@ mod tests {
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
discord: false,
mattermost: false,
},
multimodal: crate::config::MultimodalConfig::default(),
@ -5550,6 +5563,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()),
@ -5627,6 +5641,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()),
@ -5718,6 +5733,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(),
@ -5794,6 +5810,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(),
@ -5880,6 +5897,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(),
@ -5987,6 +6005,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(),
@ -6075,6 +6094,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(),
@ -6178,6 +6198,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(),
@ -6266,6 +6287,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(),
@ -6344,6 +6366,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(),
@ -6533,6 +6556,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(),
@ -6630,6 +6654,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(),
@ -6742,6 +6767,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,
@ -6851,6 +6877,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(),
@ -6942,6 +6969,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(),
@ -7018,6 +7046,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(),
@ -7770,6 +7799,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(),
@ -7896,6 +7926,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(),
@ -8061,6 +8092,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(),
@ -8163,6 +8195,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(),
@ -8730,6 +8763,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(),
@ -8813,6 +8847,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(),
@ -8970,6 +9005,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(),
@ -9077,6 +9113,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(),
@ -9176,6 +9213,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(),
@ -9295,6 +9333,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(),
@ -9423,6 +9462,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"));
@ -9433,8 +9473,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)]
@ -9415,6 +9419,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();
@ -9430,6 +9435,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();
@ -9693,6 +9699,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

@ -3839,6 +3839,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