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:
parent
08851b0145
commit
2b8297dc31
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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#"
|
||||
|
||||
@ -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
0
web/dist/.gitkeep
vendored
Loading…
Reference in New Issue
Block a user