Skip to content
Merged
16 changes: 12 additions & 4 deletions src/bot_turns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ use std::collections::HashMap;
/// between human resets.
pub const HARD_BOT_TURN_LIMIT: u32 = 1000;

/// Stable prefix used in all bot turn limit warning messages.
/// Referenced by the dedup check in the Discord adapter — changing this
/// string requires updating the dedup check too.
pub const BOT_TURN_LIMIT_WARNING_PREFIX: &str = "⚠️ Bot turn limit reached";

#[derive(Debug, PartialEq, Eq)]
pub enum TurnResult {
/// Counter below limits — continue normally.
Expand Down Expand Up @@ -75,8 +80,9 @@ impl BotTurnTracker {
severity: TurnSeverity::Soft,
turns: n,
user_message: format!(
"⚠️ Bot turn limit reached ({n}/{soft}). \
"{} ({n}/{soft}). \
A human must reply in this thread to continue bot-to-bot conversation.",
BOT_TURN_LIMIT_WARNING_PREFIX,
soft = self.soft_limit,
),
},
Expand Down Expand Up @@ -276,9 +282,11 @@ mod tests {
TurnAction::WarnAndStop {
severity: TurnSeverity::Soft,
turns: 3,
user_message: "⚠️ Bot turn limit reached (3/3). \
A human must reply in this thread to continue bot-to-bot conversation."
.to_string(),
user_message: format!(
"{} (3/3). \
A human must reply in this thread to continue bot-to-bot conversation.",
BOT_TURN_LIMIT_WARNING_PREFIX,
),
},
);
}
Expand Down
64 changes: 61 additions & 3 deletions src/discord.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::acp::protocol::ConfigOption;
use crate::acp::ContentBlock;
use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext};
use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity};
use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity, BOT_TURN_LIMIT_WARNING_PREFIX};
use crate::config::{AllowBots, AllowUsers, SttConfig};
use crate::format;
use crate::media;
Expand Down Expand Up @@ -397,7 +397,25 @@ impl EventHandler for Handler {
.bot_participated_in_thread(&ctx.http, msg.channel_id, bot_id)
.await;
if participated {
let _ = msg.channel_id.say(&ctx.http, &user_message).await;
// Dedup: skip if another bot already posted the same
// warning in this thread. Prevents N duplicate warnings
// when N bot processes each hit the soft limit. (#530)
let recent = msg
.channel_id
.messages(
&ctx.http,
serenity::builder::GetMessages::new().limit(10),
)
.await
.unwrap_or_default();
let pairs: Vec<(bool, &str)> = recent
.iter()
.map(|m| (m.author.bot, m.content.as_str()))
.collect();
let already_warned = turn_limit_warning_present(&pairs);
if !already_warned {
let _ = msg.channel_id.say(&ctx.http, &user_message).await;
}
}
}
return;
Expand Down Expand Up @@ -2151,10 +2169,26 @@ fn should_process_user_message(
}
}

/// Returns true if any bot message in `messages` contains a turn limit warning.
/// Used to dedup `WarnAndStop` across multiple bot processes sharing a thread. (#530)
/// Note: this is best-effort — a narrow race window exists where two bots fetch
/// simultaneously and both see no warning, resulting in a duplicate. For most
/// deployments this is acceptable; strict once-only semantics would require
/// shared state (e.g. gateway-owned emission or distributed lock).
///
/// Accepts `(is_bot, content)` pairs so the logic can be unit-tested without
/// constructing `serenity::model::channel::Message` values (see existing test
/// boundary comment at `format_thread_export`).
fn turn_limit_warning_present(messages: &[(bool, &str)]) -> bool {
messages
.iter()
.any(|(is_bot, content)| *is_bot && content.contains(BOT_TURN_LIMIT_WARNING_PREFIX))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT};
use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT, BOT_TURN_LIMIT_WARNING_PREFIX};

// --- resolve_mentions tests ---

Expand Down Expand Up @@ -2939,4 +2973,28 @@ mod tests {
fn normal_channel_creates_thread() {
assert!(!should_skip_thread_creation(false, false));
}

// --- WarnAndStop dedup tests (#530) ---

#[test]
fn dedup_detects_existing_bot_warning() {
let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX);
assert!(turn_limit_warning_present(&[(true, &msg)]));
}

#[test]
fn dedup_ignores_human_warning_text() {
let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX);
assert!(!turn_limit_warning_present(&[(false, &msg)]));
}

#[test]
fn dedup_returns_false_when_no_warning() {
assert!(!turn_limit_warning_present(&[(true, "hello"), (false, "world")]));
}

#[test]
fn dedup_returns_false_for_empty_messages() {
assert!(!turn_limit_warning_present(&[]));
}
}
Loading