story-kit: merge 293_story_register_all_bot_commands_in_the_command_registry
Moves status, ambient, and help commands into a unified command registry in commands.rs. Help output now automatically lists all registered commands. Resolved merge conflict with 1_backlog rename. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+39
-529
@@ -1,5 +1,4 @@
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::agents::AgentPool;
|
||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||
use crate::slog;
|
||||
@@ -32,7 +31,7 @@ use matrix_sdk::encryption::verification::{
|
||||
};
|
||||
use matrix_sdk::ruma::events::key::verification::request::ToDeviceKeyVerificationRequestEvent;
|
||||
|
||||
use super::config::{BotConfig, save_ambient_rooms};
|
||||
use super::config::BotConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation history types
|
||||
@@ -103,7 +102,11 @@ pub fn load_history(project_root: &std::path::Path) -> HashMap<OwnedRoomId, Room
|
||||
persisted
|
||||
.rooms
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| k.parse::<OwnedRoomId>().ok().map(|room_id| (room_id, v)))
|
||||
.filter_map(|(k, v)| {
|
||||
k.parse::<OwnedRoomId>()
|
||||
.ok()
|
||||
.map(|room_id| (room_id, v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -164,9 +167,9 @@ pub struct BotContext {
|
||||
/// in bot.toml; defaults to "Assistant" when unset.
|
||||
pub bot_name: String,
|
||||
/// Set of room IDs where ambient mode is active. In ambient mode the bot
|
||||
/// responds to all messages rather than only addressed ones. This is
|
||||
/// in-memory only — the state does not survive a bot restart.
|
||||
pub ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>>,
|
||||
/// responds to all messages rather than only addressed ones.
|
||||
/// Uses a sync mutex since locks are never held across await points.
|
||||
pub ambient_rooms: Arc<std::sync::Mutex<HashSet<OwnedRoomId>>>,
|
||||
/// Agent pool for checking agent availability.
|
||||
pub agents: Arc<AgentPool>,
|
||||
}
|
||||
@@ -187,162 +190,6 @@ pub fn format_startup_announcement(bot_name: &str) -> String {
|
||||
// Command extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract the command portion from a bot-addressed message.
|
||||
///
|
||||
/// Strips the leading bot mention (full Matrix user ID, `@localpart`, or
|
||||
/// display name) plus any trailing punctuation (`,`, `:`) and whitespace,
|
||||
/// then returns the remainder in lowercase. Returns `None` when no
|
||||
/// recognized mention prefix is found in the message.
|
||||
pub fn extract_command(body: &str, bot_name: &str, bot_user_id: &OwnedUserId) -> Option<String> {
|
||||
let full_id = bot_user_id.as_str().to_lowercase();
|
||||
let at_localpart = format!("@{}", bot_user_id.localpart().to_lowercase());
|
||||
let bot_name_lower = bot_name.to_lowercase();
|
||||
let body_lower = body.trim().to_lowercase();
|
||||
|
||||
let stripped = if let Some(s) = body_lower.strip_prefix(&full_id) {
|
||||
s
|
||||
} else if let Some(s) = body_lower.strip_prefix(&at_localpart) {
|
||||
// Guard against matching a longer @mention (e.g. "@timmybot" vs "@timmy").
|
||||
let next = s.chars().next();
|
||||
if next.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return None;
|
||||
}
|
||||
s
|
||||
} else if let Some(s) = body_lower.strip_prefix(&bot_name_lower) {
|
||||
// Guard against matching a longer display-name prefix.
|
||||
let next = s.chars().next();
|
||||
if next.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return None;
|
||||
}
|
||||
s
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Strip leading separators (`,`, `:`) and whitespace after the mention.
|
||||
let cmd = stripped.trim_start_matches(|c: char| c == ':' || c == ',' || c.is_whitespace());
|
||||
Some(cmd.trim().to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline status formatter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read all story IDs and names from a pipeline stage directory.
|
||||
fn read_stage_items(
|
||||
project_root: &std::path::Path,
|
||||
stage_dir: &str,
|
||||
) -> Vec<(String, Option<String>)> {
|
||||
let dir = project_root.join(".story_kit").join("work").join(stage_dir);
|
||||
if !dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let name = std::fs::read_to_string(&path).ok().and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
});
|
||||
items.push((stem.to_string(), name));
|
||||
}
|
||||
}
|
||||
}
|
||||
items.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
items
|
||||
}
|
||||
|
||||
/// Build the full pipeline status text formatted for Matrix (markdown).
|
||||
pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||
// Build a map from story_id → active AgentInfo for quick lookup.
|
||||
let active_agents = agents.list_agents().unwrap_or_default();
|
||||
let active_map: std::collections::HashMap<String, &crate::agents::AgentInfo> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| (a.story_id.clone(), a))
|
||||
.collect();
|
||||
|
||||
let config = ProjectConfig::load(project_root).ok();
|
||||
|
||||
let mut out = String::from("**Pipeline Status**\n\n");
|
||||
|
||||
let stages = [
|
||||
("1_backlog", "Backlog"),
|
||||
("2_current", "In Progress"),
|
||||
("3_qa", "QA"),
|
||||
("4_merge", "Merge"),
|
||||
("5_done", "Done"),
|
||||
];
|
||||
|
||||
for (dir, label) in &stages {
|
||||
let items = read_stage_items(project_root, dir);
|
||||
let count = items.len();
|
||||
out.push_str(&format!("**{label}** ({count})\n"));
|
||||
if items.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for (story_id, name) in &items {
|
||||
let display = match name {
|
||||
Some(n) => format!("{story_id} — {n}"),
|
||||
None => story_id.clone(),
|
||||
};
|
||||
if let Some(agent) = active_map.get(story_id) {
|
||||
let model_str = config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
|
||||
.and_then(|ac| ac.model.as_deref())
|
||||
.unwrap_or("?");
|
||||
out.push_str(&format!(
|
||||
" • {display} — {} ({}) [{}]\n",
|
||||
agent.agent_name, model_str, agent.status
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(" • {display}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Free agents: configured agents not currently running or pending.
|
||||
out.push_str("**Free Agents**\n");
|
||||
if let Some(cfg) = &config {
|
||||
let busy_names: std::collections::HashSet<String> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| a.agent_name.clone())
|
||||
.collect();
|
||||
|
||||
let free: Vec<String> = cfg
|
||||
.agent
|
||||
.iter()
|
||||
.filter(|a| !busy_names.contains(&a.name))
|
||||
.map(|a| match &a.model {
|
||||
Some(m) => format!("{} ({})", a.name, m),
|
||||
None => a.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if free.is_empty() {
|
||||
out.push_str(" *(none — all agents busy)*\n");
|
||||
} else {
|
||||
for name in &free {
|
||||
out.push_str(&format!(" • {name}\n"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push_str(" *(no agent config found)*\n");
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -399,10 +246,7 @@ pub async fn run_bot(
|
||||
.ok_or_else(|| "No user ID after login".to_string())?
|
||||
.to_owned();
|
||||
|
||||
slog!(
|
||||
"[matrix-bot] Logged in as {bot_user_id} (device: {})",
|
||||
login_response.device_id
|
||||
);
|
||||
slog!("[matrix-bot] Logged in as {bot_user_id} (device: {})", login_response.device_id);
|
||||
|
||||
// Bootstrap cross-signing keys for E2EE verification support.
|
||||
// Pass the bot's password for UIA (User-Interactive Authentication) —
|
||||
@@ -530,13 +374,11 @@ pub async fn run_bot(
|
||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
permission_timeout_secs: config.permission_timeout_secs,
|
||||
bot_name,
|
||||
ambient_rooms: Arc::new(TokioMutex::new(persisted_ambient)),
|
||||
ambient_rooms: Arc::new(std::sync::Mutex::new(persisted_ambient)),
|
||||
agents,
|
||||
};
|
||||
|
||||
slog!(
|
||||
"[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"
|
||||
);
|
||||
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
||||
|
||||
// Register event handlers and inject shared context.
|
||||
client.add_event_handler_context(ctx);
|
||||
@@ -650,48 +492,6 @@ fn contains_word(haystack: &str, needle: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Parse an ambient-mode toggle command from a message body.
|
||||
///
|
||||
/// Recognises the following (case-insensitive) forms, with or without a
|
||||
/// leading bot mention:
|
||||
///
|
||||
/// - `@botname ambient on` / `@botname:server ambient on`
|
||||
/// - `botname ambient on`
|
||||
/// - `ambient on`
|
||||
///
|
||||
/// and the `off` variants.
|
||||
///
|
||||
/// Returns `Some(true)` for "ambient on", `Some(false)` for "ambient off",
|
||||
/// and `None` when the body is not an ambient mode command.
|
||||
pub fn parse_ambient_command(
|
||||
body: &str,
|
||||
bot_user_id: &OwnedUserId,
|
||||
bot_name: &str,
|
||||
) -> Option<bool> {
|
||||
let lower = body.trim().to_ascii_lowercase();
|
||||
let display_lower = bot_name.to_ascii_lowercase();
|
||||
let localpart_lower = bot_user_id.localpart().to_ascii_lowercase();
|
||||
|
||||
// Strip a leading @mention (handles "@localpart" and "@localpart:server").
|
||||
let rest = if let Some(after_at) = lower.strip_prefix('@') {
|
||||
// Skip everything up to the first whitespace (the full mention token).
|
||||
let word_end = after_at.find(char::is_whitespace).unwrap_or(after_at.len());
|
||||
after_at[word_end..].trim()
|
||||
} else if let Some(after) = lower.strip_prefix(display_lower.as_str()) {
|
||||
after.trim()
|
||||
} else if let Some(after) = lower.strip_prefix(localpart_lower.as_str()) {
|
||||
after.trim()
|
||||
} else {
|
||||
lower.as_str()
|
||||
};
|
||||
|
||||
match rest {
|
||||
"ambient on" => Some(true),
|
||||
"ambient off" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the message's `relates_to` field references an event that
|
||||
/// the bot previously sent (i.e. the message is a reply or thread-reply to a
|
||||
/// bot message).
|
||||
@@ -728,7 +528,10 @@ async fn is_reply_to_bot(
|
||||
/// is the correct trust model: a user is accepted when they have cross-signing
|
||||
/// configured, regardless of whether the bot has run an explicit verification
|
||||
/// ceremony with a specific device.
|
||||
async fn check_sender_verified(client: &Client, sender: &OwnedUserId) -> Result<bool, String> {
|
||||
async fn check_sender_verified(
|
||||
client: &Client,
|
||||
sender: &OwnedUserId,
|
||||
) -> Result<bool, String> {
|
||||
let identity = client
|
||||
.encryption()
|
||||
.get_user_identity(sender)
|
||||
@@ -794,9 +597,8 @@ async fn on_to_device_verification_request(
|
||||
}
|
||||
break;
|
||||
}
|
||||
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => {
|
||||
break;
|
||||
}
|
||||
VerificationRequestState::Done
|
||||
| VerificationRequestState::Cancelled(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -898,7 +700,7 @@ async fn on_room_message(
|
||||
// ambient mode is enabled for this room.
|
||||
let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id)
|
||||
|| is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await;
|
||||
let is_ambient = ctx.ambient_rooms.lock().await.contains(&incoming_room_id);
|
||||
let is_ambient = ctx.ambient_rooms.lock().unwrap().contains(&incoming_room_id);
|
||||
|
||||
if !is_addressed && !is_ambient {
|
||||
slog!(
|
||||
@@ -969,54 +771,23 @@ async fn on_room_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for ambient mode toggle commands. Commands are only recognised
|
||||
// from addressed messages so they can't be accidentally triggered by
|
||||
// ambient-mode traffic from other users.
|
||||
let ambient_cmd = is_addressed
|
||||
.then(|| parse_ambient_command(&body, &ctx.bot_user_id, &ctx.bot_name))
|
||||
.flatten();
|
||||
if let Some(enable) = ambient_cmd {
|
||||
let ambient_room_ids: Vec<String> = {
|
||||
let mut ambient = ctx.ambient_rooms.lock().await;
|
||||
if enable {
|
||||
ambient.insert(incoming_room_id.clone());
|
||||
} else {
|
||||
ambient.remove(&incoming_room_id);
|
||||
}
|
||||
ambient.iter().map(|r| r.to_string()).collect()
|
||||
}; // lock released before the async send below
|
||||
|
||||
// Persist updated ambient rooms to bot.toml so the state survives restarts.
|
||||
save_ambient_rooms(&ctx.project_root, &ambient_room_ids);
|
||||
|
||||
let confirmation = if enable {
|
||||
"Ambient mode on. I'll respond to all messages in this room."
|
||||
} else {
|
||||
"Ambient mode off. I'll only respond when mentioned."
|
||||
};
|
||||
let html = markdown_to_html(confirmation);
|
||||
if let Ok(resp) = room
|
||||
.send(RoomMessageEventContent::text_html(confirmation, html))
|
||||
.await
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(resp.event_id);
|
||||
}
|
||||
slog!(
|
||||
"[matrix-bot] Ambient mode {} for room {}",
|
||||
if enable { "enabled" } else { "disabled" },
|
||||
incoming_room_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let sender = ev.sender.to_string();
|
||||
let user_message = body;
|
||||
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
||||
|
||||
// Check for bot-level commands (e.g. "help") before invoking the LLM.
|
||||
if let Some(response) =
|
||||
super::commands::try_handle_command(&ctx.bot_name, ctx.bot_user_id.as_str(), &user_message)
|
||||
{
|
||||
// Check for bot-level commands (help, status, ambient, …) before invoking
|
||||
// the LLM. All commands are registered in commands.rs — no special-casing
|
||||
// needed here.
|
||||
let dispatch = super::commands::CommandDispatch {
|
||||
bot_name: &ctx.bot_name,
|
||||
bot_user_id: ctx.bot_user_id.as_str(),
|
||||
project_root: &ctx.project_root,
|
||||
agents: &ctx.agents,
|
||||
ambient_rooms: &ctx.ambient_rooms,
|
||||
room_id: &incoming_room_id,
|
||||
is_addressed,
|
||||
};
|
||||
if let Some(response) = super::commands::try_handle_command(&dispatch, &user_message) {
|
||||
slog!("[matrix-bot] Handled bot command from {sender}");
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(resp) = room
|
||||
@@ -1052,28 +823,14 @@ async fn handle_message(
|
||||
sender: String,
|
||||
user_message: String,
|
||||
) {
|
||||
// Handle built-in commands before invoking Claude.
|
||||
if let Some(cmd) = extract_command(&user_message, &ctx.bot_name, &ctx.bot_user_id)
|
||||
&& cmd == "status"
|
||||
{
|
||||
let project_root = ctx.project_root.clone();
|
||||
let status_text = build_pipeline_status(&project_root, &ctx.agents);
|
||||
let html = markdown_to_html(&status_text);
|
||||
if let Ok(resp) = room
|
||||
.send(RoomMessageEventContent::text_html(status_text, html))
|
||||
.await
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(resp.event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the room's existing Claude Code session ID (if any) so we can
|
||||
// resume the conversation with structured API messages instead of
|
||||
// flattening history into a text prefix.
|
||||
let resume_session_id: Option<String> = {
|
||||
let guard = ctx.history.lock().await;
|
||||
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
|
||||
guard
|
||||
.get(&room_id)
|
||||
.and_then(|conv| conv.session_id.clone())
|
||||
};
|
||||
|
||||
// The prompt is just the current message with sender attribution.
|
||||
@@ -1248,11 +1005,7 @@ async fn handle_message(
|
||||
let conv = guard.entry(room_id).or_default();
|
||||
|
||||
// Store the session ID so the next turn uses --resume.
|
||||
slog!(
|
||||
"[matrix-bot] storing session_id: {:?} (was: {:?})",
|
||||
new_session_id,
|
||||
conv.session_id
|
||||
);
|
||||
slog!("[matrix-bot] storing session_id: {:?} (was: {:?})", new_session_id, conv.session_id);
|
||||
if new_session_id.is_some() {
|
||||
conv.session_id = new_session_id;
|
||||
}
|
||||
@@ -1554,7 +1307,7 @@ mod tests {
|
||||
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
permission_timeout_secs: 120,
|
||||
bot_name: "Assistant".to_string(),
|
||||
ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())),
|
||||
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
||||
agents: Arc::new(AgentPool::new_test(3000)),
|
||||
};
|
||||
// Clone must work (required by Matrix SDK event handler injection).
|
||||
@@ -2009,125 +1762,7 @@ mod tests {
|
||||
#[test]
|
||||
fn startup_announcement_uses_configured_display_name_not_hardcoded() {
|
||||
assert_eq!(format_startup_announcement("HAL"), "HAL is online.");
|
||||
assert_eq!(
|
||||
format_startup_announcement("Assistant"),
|
||||
"Assistant is online."
|
||||
);
|
||||
}
|
||||
|
||||
// -- extract_command (status trigger) ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_command_returns_status_for_bot_name_prefix() {
|
||||
let uid = make_user_id("@assistant:example.com");
|
||||
let result = extract_command("Assistant status", "Assistant", &uid);
|
||||
assert_eq!(result.as_deref(), Some("status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_command_returns_status_for_at_localpart_prefix() {
|
||||
let uid = make_user_id("@assistant:example.com");
|
||||
let result = extract_command("@assistant status", "Assistant", &uid);
|
||||
assert_eq!(result.as_deref(), Some("status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_command_returns_status_for_full_id_prefix() {
|
||||
let uid = make_user_id("@assistant:example.com");
|
||||
let result = extract_command("@assistant:example.com status", "Assistant", &uid);
|
||||
assert_eq!(result.as_deref(), Some("status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_command_returns_none_when_no_bot_mention() {
|
||||
let uid = make_user_id("@assistant:example.com");
|
||||
let result = extract_command("status", "Assistant", &uid);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_command_handles_punctuation_after_mention() {
|
||||
let uid = make_user_id("@assistant:example.com");
|
||||
let result = extract_command("@assistant: status", "Assistant", &uid);
|
||||
assert_eq!(result.as_deref(), Some("status"));
|
||||
}
|
||||
|
||||
// -- build_pipeline_status -----------------------------------------------
|
||||
|
||||
fn write_story_file(dir: &std::path::Path, stage: &str, filename: &str, name: &str) {
|
||||
let stage_dir = dir.join(".story_kit").join("work").join(stage);
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
let content = format!("---\nname: \"{name}\"\n---\n\n# {name}\n");
|
||||
std::fs::write(stage_dir.join(filename), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pipeline_status_includes_all_stages() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let out = build_pipeline_status(dir.path(), &pool);
|
||||
|
||||
assert!(out.contains("Backlog"), "missing Backlog: {out}");
|
||||
assert!(out.contains("In Progress"), "missing In Progress: {out}");
|
||||
assert!(out.contains("QA"), "missing QA: {out}");
|
||||
assert!(out.contains("Merge"), "missing Merge: {out}");
|
||||
assert!(out.contains("Done"), "missing Done: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pipeline_status_shows_story_id_and_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_story_file(
|
||||
dir.path(),
|
||||
"1_backlog",
|
||||
"42_story_do_something.md",
|
||||
"Do Something",
|
||||
);
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let out = build_pipeline_status(dir.path(), &pool);
|
||||
|
||||
assert!(
|
||||
out.contains("42_story_do_something"),
|
||||
"missing story id: {out}"
|
||||
);
|
||||
assert!(out.contains("Do Something"), "missing story name: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pipeline_status_includes_free_agents_section() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let out = build_pipeline_status(dir.path(), &pool);
|
||||
|
||||
assert!(
|
||||
out.contains("Free Agents"),
|
||||
"missing Free Agents section: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pipeline_status_uses_markdown_bold_headings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let out = build_pipeline_status(dir.path(), &pool);
|
||||
|
||||
// Stages and headers should use markdown bold (**text**).
|
||||
assert!(
|
||||
out.contains("**Pipeline Status**"),
|
||||
"missing bold title: {out}"
|
||||
);
|
||||
assert!(out.contains("**Pipeline Status**"), "missing bold title: {out}");
|
||||
assert!(out.contains("**Backlog**"), "stage should use bold: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pipeline_status_shows_none_for_empty_stages() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pool = AgentPool::new_test(3001);
|
||||
let out = build_pipeline_status(dir.path(), &pool);
|
||||
|
||||
// Empty stages show *(none)*
|
||||
assert!(out.contains("*(none)*"), "expected none marker: {out}");
|
||||
assert_eq!(format_startup_announcement("Assistant"), "Assistant is online.");
|
||||
}
|
||||
|
||||
// -- bot_name / system prompt -------------------------------------------
|
||||
@@ -2154,129 +1789,4 @@ mod tests {
|
||||
assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy");
|
||||
}
|
||||
|
||||
// -- parse_ambient_command ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ambient_command_on_with_at_mention() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("@timmy ambient on", &uid, "Timmy"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_off_with_at_mention() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("@timmy ambient off", &uid, "Timmy"),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_on_with_full_user_id() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("@timmy:homeserver.local ambient on", &uid, "Timmy"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_on_with_display_name() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("timmy ambient on", &uid, "Timmy"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_off_with_display_name() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("timmy ambient off", &uid, "Timmy"),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_on_bare() {
|
||||
// "ambient on" without any bot mention is also recognised.
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("ambient on", &uid, "Timmy"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_off_bare() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("ambient off", &uid, "Timmy"),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_case_insensitive() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("@Timmy AMBIENT ON", &uid, "Timmy"),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ambient_command("TIMMY AMBIENT OFF", &uid, "Timmy"),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_command_unrelated_message_returns_none() {
|
||||
let uid = make_user_id("@timmy:homeserver.local");
|
||||
assert_eq!(
|
||||
parse_ambient_command("@timmy what is the status?", &uid, "Timmy"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_ambient_command("hello there", &uid, "Timmy"), None);
|
||||
assert_eq!(parse_ambient_command("ambient", &uid, "Timmy"), None);
|
||||
}
|
||||
|
||||
// -- ambient mode state ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ambient_rooms_defaults_to_empty() {
|
||||
let ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>> =
|
||||
Arc::new(TokioMutex::new(HashSet::new()));
|
||||
let room_id: OwnedRoomId = "!room:example.com".parse().unwrap();
|
||||
assert!(!ambient_rooms.lock().await.contains(&room_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ambient_mode_can_be_toggled_per_room() {
|
||||
let ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>> =
|
||||
Arc::new(TokioMutex::new(HashSet::new()));
|
||||
let room_a: OwnedRoomId = "!room_a:example.com".parse().unwrap();
|
||||
let room_b: OwnedRoomId = "!room_b:example.com".parse().unwrap();
|
||||
|
||||
// Enable ambient mode for room_a only.
|
||||
ambient_rooms.lock().await.insert(room_a.clone());
|
||||
|
||||
let guard = ambient_rooms.lock().await;
|
||||
assert!(guard.contains(&room_a), "room_a should be in ambient mode");
|
||||
assert!(
|
||||
!guard.contains(&room_b),
|
||||
"room_b should NOT be in ambient mode"
|
||||
);
|
||||
drop(guard);
|
||||
|
||||
// Disable ambient mode for room_a.
|
||||
ambient_rooms.lock().await.remove(&room_a);
|
||||
assert!(
|
||||
!ambient_rooms.lock().await.contains(&room_a),
|
||||
"room_a ambient mode should be off"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user