From 1a9833d820cbf378d67d693a768f6393f24b0755 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 24 Mar 2026 15:03:17 +0000 Subject: [PATCH] storkit: merge 380_story_assign_command_restarts_coder_when_story_is_already_in_progress --- server/src/http/bot_command.rs | 27 +- server/src/matrix/assign.rs | 537 +++++++++++++++++++++++++++ server/src/matrix/bot.rs | 40 ++ server/src/matrix/commands/assign.rs | 360 +----------------- server/src/matrix/mod.rs | 1 + 5 files changed, 617 insertions(+), 348 deletions(-) create mode 100644 server/src/matrix/assign.rs diff --git a/server/src/http/bot_command.rs b/server/src/http/bot_command.rs index 446e841..6f03bcb 100644 --- a/server/src/http/bot_command.rs +++ b/server/src/http/bot_command.rs @@ -3,10 +3,10 @@ //! `POST /api/bot/command` lets the web UI invoke the same deterministic bot //! commands available in Matrix without going through the LLM. //! -//! Synchronous commands (status, assign, git, cost, move, show, overview, -//! help) are dispatched directly through the matrix command registry. -//! Asynchronous commands (start, delete, rebuild) are dispatched to their -//! dedicated async handlers. The `reset` command is handled by the frontend +//! Synchronous commands (status, git, cost, move, show, overview, help) are +//! dispatched directly through the matrix command registry. +//! Asynchronous commands (assign, start, delete, rebuild) are dispatched to +//! their dedicated async handlers. The `reset` command is handled by the frontend //! (it clears local session state and message history) and is not routed here. use crate::http::context::{AppContext, OpenApiResult}; @@ -75,6 +75,7 @@ async fn dispatch_command( agents: &Arc, ) -> String { match cmd { + "assign" => dispatch_assign(args, project_root, agents).await, "start" => dispatch_start(args, project_root, agents).await, "delete" => dispatch_delete(args, project_root, agents).await, "rebuild" => dispatch_rebuild(project_root, agents).await, @@ -123,6 +124,24 @@ fn dispatch_sync( } } +async fn dispatch_assign( + args: &str, + project_root: &std::path::Path, + agents: &Arc, +) -> String { + // args: " " + let mut parts = args.splitn(2, char::is_whitespace); + let number_str = parts.next().unwrap_or("").trim(); + let model_str = parts.next().unwrap_or("").trim(); + + if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) || model_str.is_empty() { + return "Usage: `/assign ` (e.g. `/assign 42 opus`)".to_string(); + } + + crate::matrix::assign::handle_assign("web-ui", number_str, model_str, project_root, agents) + .await +} + async fn dispatch_start( args: &str, project_root: &std::path::Path, diff --git a/server/src/matrix/assign.rs b/server/src/matrix/assign.rs new file mode 100644 index 0000000..2313e79 --- /dev/null +++ b/server/src/matrix/assign.rs @@ -0,0 +1,537 @@ +//! Assign command: pre-assign or re-assign a coder model to a story. +//! +//! `{bot_name} assign {number} {model}` finds the story by number, updates the +//! `agent` field in its front matter, and — when a coder is already running on +//! the story — stops the current coder and starts the newly-assigned one. +//! +//! When no coder is running (the story has not been started yet), the command +//! behaves as before: it simply persists the assignment in the front matter so +//! that the next `start` invocation picks it up automatically. + +use crate::agents::{AgentPool, AgentStatus}; +use crate::io::story_metadata::{parse_front_matter, set_front_matter_field}; +use std::path::Path; + +/// All pipeline stage directories to search when finding a work item by number. +const STAGES: &[&str] = &[ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", +]; + +/// A parsed assign command from a Matrix message body. +#[derive(Debug, PartialEq)] +pub enum AssignCommand { + /// Assign the story with this number to the given model. + Assign { + story_number: String, + model: String, + }, + /// The user typed `assign` but without valid arguments. + BadArgs, +} + +/// Parse an assign command from a raw Matrix message body. +/// +/// Strips the bot mention prefix and checks whether the first word is `assign`. +/// Returns `None` when the message is not an assign command at all. +pub fn extract_assign_command( + message: &str, + bot_name: &str, + bot_user_id: &str, +) -> Option { + let stripped = strip_mention(message, bot_name, bot_user_id); + let trimmed = stripped + .trim() + .trim_start_matches(|c: char| !c.is_alphanumeric()); + + let (cmd, args) = match trimmed.split_once(char::is_whitespace) { + Some((c, a)) => (c, a.trim()), + None => (trimmed, ""), + }; + + if !cmd.eq_ignore_ascii_case("assign") { + return None; + } + + // Split args into story number and model. + let (number_str, model_str) = match args.split_once(char::is_whitespace) { + Some((n, m)) => (n.trim(), m.trim()), + None => (args, ""), + }; + + if number_str.is_empty() + || !number_str.chars().all(|c| c.is_ascii_digit()) + || model_str.is_empty() + { + return Some(AssignCommand::BadArgs); + } + + Some(AssignCommand::Assign { + story_number: number_str.to_string(), + model: model_str.to_string(), + }) +} + +/// Resolve a model name hint (e.g. `"opus"`) to a full agent name +/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`, +/// it is returned unchanged to prevent double-prefixing. +pub fn resolve_agent_name(model: &str) -> String { + if model.starts_with("coder-") { + model.to_string() + } else { + format!("coder-{model}") + } +} + +/// Handle an assign command asynchronously. +/// +/// Finds the work item by `story_number` across all pipeline stages, updates +/// the `agent` field in its front matter, and — if a coder is currently +/// running on the story — stops it and starts the newly-assigned agent. +/// Returns a markdown-formatted response string. +pub async fn handle_assign( + bot_name: &str, + story_number: &str, + model_str: &str, + project_root: &Path, + agents: &AgentPool, +) -> String { + // Find the story file across all pipeline stages. + let mut found: Option<(std::path::PathBuf, String)> = None; + 'outer: for stage in STAGES { + let dir = project_root.join(".storkit").join("work").join(stage); + if !dir.exists() { + continue; + } + 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()) + .map(|s| s.to_string()) + { + let file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or("") + .to_string(); + if file_num == story_number { + found = Some((path, stem)); + break 'outer; + } + } + } + } + } + + let (path, story_id) = match found { + Some(f) => f, + None => { + return format!( + "No story, bug, or spike with number **{story_number}** found." + ); + } + }; + + // Read the human-readable name from front matter for the response. + let story_name = std::fs::read_to_string(&path) + .ok() + .and_then(|contents| { + parse_front_matter(&contents) + .ok() + .and_then(|m| m.name) + }) + .unwrap_or_else(|| story_id.clone()); + + let agent_name = resolve_agent_name(model_str); + + // Write `agent: ` into the story's front matter. + let write_result = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read story file: {e}")) + .and_then(|contents| { + let updated = set_front_matter_field(&contents, "agent", &agent_name); + std::fs::write(&path, &updated) + .map_err(|e| format!("Failed to write story file: {e}")) + }); + + if let Err(e) = write_result { + return format!("Failed to assign model to **{story_name}**: {e}"); + } + + // Check whether a coder is already running on this story. + let running_coders: Vec<_> = agents + .list_agents() + .unwrap_or_default() + .into_iter() + .filter(|a| { + a.story_id == story_id + && a.agent_name.starts_with("coder") + && matches!(a.status, AgentStatus::Running | AgentStatus::Pending) + }) + .collect(); + + if running_coders.is_empty() { + // No coder running — just persist the assignment. + return format!( + "Assigned **{agent_name}** to **{story_name}** (story {story_number}). \ + The model will be used when the story starts." + ); + } + + // Stop each running coder, then start the newly assigned one. + let stopped: Vec = running_coders + .iter() + .map(|a| a.agent_name.clone()) + .collect(); + + for coder in &running_coders { + if let Err(e) = agents + .stop_agent(project_root, &story_id, &coder.agent_name) + .await + { + crate::slog!( + "[matrix-bot] assign: failed to stop agent {} for {}: {e}", + coder.agent_name, + story_id + ); + } + } + + crate::slog!( + "[matrix-bot] assign (bot={bot_name}): stopped {:?} for {}; starting {agent_name}", + stopped, + story_id + ); + + match agents + .start_agent(project_root, &story_id, Some(&agent_name), None) + .await + { + Ok(info) => { + format!( + "Reassigned **{story_name}** (story {story_number}): \ + stopped **{}** and started **{}**.", + stopped.join(", "), + info.agent_name + ) + } + Err(e) => { + format!( + "Assigned **{agent_name}** to **{story_name}** (story {story_number}): \ + stopped **{}** but failed to start the new agent: {e}", + stopped.join(", ") + ) + } + } +} + +/// Strip the bot mention prefix from a raw Matrix message body. +/// +/// Mirrors the logic in `commands::strip_bot_mention` and `start::strip_mention`. +fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { + let trimmed = message.trim(); + if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { + return rest; + } + if let Some(localpart) = bot_user_id.split(':').next() + && let Some(rest) = strip_prefix_ci(trimmed, localpart) + { + return rest; + } + if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { + return rest; + } + trimmed +} + +fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + if text.len() < prefix.len() { + return None; + } + if !text[..prefix.len()].eq_ignore_ascii_case(prefix) { + return None; + } + let rest = &text[prefix.len()..]; + match rest.chars().next() { + None => Some(rest), + Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, + _ => Some(rest), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- extract_assign_command ----------------------------------------------- + + #[test] + fn extract_with_full_user_id() { + let cmd = extract_assign_command( + "@timmy:home.local assign 42 opus", + "Timmy", + "@timmy:home.local", + ); + assert_eq!( + cmd, + Some(AssignCommand::Assign { + story_number: "42".to_string(), + model: "opus".to_string() + }) + ); + } + + #[test] + fn extract_with_display_name() { + let cmd = extract_assign_command("Timmy assign 42 sonnet", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(AssignCommand::Assign { + story_number: "42".to_string(), + model: "sonnet".to_string() + }) + ); + } + + #[test] + fn extract_with_localpart() { + let cmd = extract_assign_command("@timmy assign 7 opus", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(AssignCommand::Assign { + story_number: "7".to_string(), + model: "opus".to_string() + }) + ); + } + + #[test] + fn extract_case_insensitive_command() { + let cmd = extract_assign_command("Timmy ASSIGN 99 opus", "Timmy", "@timmy:home.local"); + assert_eq!( + cmd, + Some(AssignCommand::Assign { + story_number: "99".to_string(), + model: "opus".to_string() + }) + ); + } + + #[test] + fn extract_no_args_is_bad_args() { + let cmd = extract_assign_command("Timmy assign", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(AssignCommand::BadArgs)); + } + + #[test] + fn extract_missing_model_is_bad_args() { + let cmd = extract_assign_command("Timmy assign 42", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(AssignCommand::BadArgs)); + } + + #[test] + fn extract_non_numeric_number_is_bad_args() { + let cmd = extract_assign_command("Timmy assign abc opus", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, Some(AssignCommand::BadArgs)); + } + + #[test] + fn extract_non_assign_command_returns_none() { + let cmd = extract_assign_command("Timmy help", "Timmy", "@timmy:home.local"); + assert_eq!(cmd, None); + } + + // -- resolve_agent_name -------------------------------------------------- + + #[test] + fn resolve_agent_name_prefixes_bare_model() { + assert_eq!(resolve_agent_name("opus"), "coder-opus"); + assert_eq!(resolve_agent_name("sonnet"), "coder-sonnet"); + assert_eq!(resolve_agent_name("haiku"), "coder-haiku"); + } + + #[test] + fn resolve_agent_name_does_not_double_prefix() { + assert_eq!(resolve_agent_name("coder-opus"), "coder-opus"); + assert_eq!(resolve_agent_name("coder-sonnet"), "coder-sonnet"); + } + + // -- handle_assign (no running coder) ------------------------------------ + + fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) { + let dir = root.join(".storkit/work").join(stage); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(filename), content).unwrap(); + } + + #[tokio::test] + async fn handle_assign_returns_not_found_for_unknown_number() { + let tmp = tempfile::tempdir().unwrap(); + for stage in STAGES { + std::fs::create_dir_all(tmp.path().join(".storkit/work").join(stage)).unwrap(); + } + let agents = std::sync::Arc::new(AgentPool::new_test(3000)); + let response = handle_assign("Timmy", "999", "opus", tmp.path(), &agents).await; + assert!( + response.contains("No story") && response.contains("999"), + "unexpected response: {response}" + ); + } + + #[tokio::test] + async fn handle_assign_writes_front_matter_when_no_coder_running() { + let tmp = tempfile::tempdir().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "42_story_test.md", + "---\nname: Test Feature\n---\n\n# Story 42\n", + ); + + let agents = std::sync::Arc::new(AgentPool::new_test(3000)); + let response = handle_assign("Timmy", "42", "opus", tmp.path(), &agents).await; + + assert!( + response.contains("coder-opus"), + "response should mention agent: {response}" + ); + assert!( + response.contains("Test Feature"), + "response should mention story name: {response}" + ); + // Should say "will be used when the story starts" (no restart) + assert!( + response.contains("start"), + "response should indicate assignment for future start: {response}" + ); + + let contents = std::fs::read_to_string( + tmp.path().join(".storkit/work/1_backlog/42_story_test.md"), + ) + .unwrap(); + assert!( + contents.contains("agent: coder-opus"), + "front matter should contain agent field: {contents}" + ); + } + + #[tokio::test] + async fn handle_assign_with_already_prefixed_name_does_not_double_prefix() { + let tmp = tempfile::tempdir().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "7_story_small.md", + "---\nname: Small Story\n---\n", + ); + + let agents = std::sync::Arc::new(AgentPool::new_test(3000)); + let response = handle_assign("Timmy", "7", "coder-opus", tmp.path(), &agents).await; + + assert!( + response.contains("coder-opus"), + "should not double-prefix: {response}" + ); + assert!( + !response.contains("coder-coder-opus"), + "must not double-prefix: {response}" + ); + + let contents = std::fs::read_to_string( + tmp.path().join(".storkit/work/1_backlog/7_story_small.md"), + ) + .unwrap(); + assert!( + contents.contains("agent: coder-opus"), + "must write coder-opus, not coder-coder-opus: {contents}" + ); + } + + #[tokio::test] + async fn handle_assign_overwrites_existing_agent_field() { + let tmp = tempfile::tempdir().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "5_story_existing.md", + "---\nname: Existing\nagent: coder-sonnet\n---\n", + ); + + let agents = std::sync::Arc::new(AgentPool::new_test(3000)); + handle_assign("Timmy", "5", "opus", tmp.path(), &agents).await; + + let contents = std::fs::read_to_string( + tmp.path().join(".storkit/work/1_backlog/5_story_existing.md"), + ) + .unwrap(); + assert!( + contents.contains("agent: coder-opus"), + "should overwrite old agent: {contents}" + ); + assert!( + !contents.contains("coder-sonnet"), + "old agent should no longer appear: {contents}" + ); + } + + #[tokio::test] + async fn handle_assign_finds_story_in_any_stage() { + let tmp = tempfile::tempdir().unwrap(); + write_story_file( + tmp.path(), + "3_qa", + "99_story_in_qa.md", + "---\nname: In QA\n---\n", + ); + + let agents = std::sync::Arc::new(AgentPool::new_test(3000)); + let response = handle_assign("Timmy", "99", "opus", tmp.path(), &agents).await; + assert!( + response.contains("coder-opus"), + "should find story in qa stage: {response}" + ); + } + + // -- handle_assign (with running coder) ---------------------------------- + + #[tokio::test] + async fn handle_assign_stops_running_coder_and_reports_reassignment() { + let tmp = tempfile::tempdir().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "10_story_current.md", + "---\nname: Current Story\nagent: coder-sonnet\n---\n", + ); + + let agents = std::sync::Arc::new(AgentPool::new_test(3000)); + // Inject a running coder for this story. + agents.inject_test_agent("10_story_current", "coder-sonnet", AgentStatus::Running); + + let response = handle_assign("Timmy", "10", "opus", tmp.path(), &agents).await; + + // The response should mention both stopped and started agents. + assert!( + response.contains("coder-sonnet"), + "response should mention the stopped agent: {response}" + ); + // Should indicate a restart occurred (not just "will be used when starts") + assert!( + response.to_lowercase().contains("stop") || response.to_lowercase().contains("reassign"), + "response should indicate stop/reassign: {response}" + ); + } +} diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 7ecd5c8..af02b54 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -861,6 +861,46 @@ async fn on_room_message( return; } + // Check for the assign command, which requires async agent ops (stop + + // start) and cannot be handled by the sync command registry. + if let Some(assign_cmd) = super::assign::extract_assign_command( + &user_message, + &ctx.bot_name, + ctx.bot_user_id.as_str(), + ) { + let response = match assign_cmd { + super::assign::AssignCommand::Assign { + story_number, + model, + } => { + slog!( + "[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}" + ); + super::assign::handle_assign( + &ctx.bot_name, + &story_number, + &model, + &ctx.project_root, + &ctx.agents, + ) + .await + } + super::assign::AssignCommand::BadArgs => { + format!( + "Usage: `{} assign ` (e.g. `assign 42 opus`)", + ctx.bot_name + ) + } + }; + let html = markdown_to_html(&response); + if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await + && let Ok(event_id) = msg_id.parse() + { + ctx.bot_sent_event_ids.lock().await.insert(event_id); + } + return; + } + // Check for the htop command, which requires async Matrix access (Room) // and cannot be handled by the sync command registry. if let Some(htop_cmd) = diff --git a/server/src/matrix/commands/assign.rs b/server/src/matrix/commands/assign.rs index e734733..1954bba 100644 --- a/server/src/matrix/commands/assign.rs +++ b/server/src/matrix/commands/assign.rs @@ -1,135 +1,15 @@ -//! Handler for the `assign` command. +//! Handler stub for the `assign` command. //! -//! `assign ` pre-assigns a coder model (e.g. `opus`, `sonnet`) -//! to a story before it starts. The assignment persists in the story file's -//! front matter as `agent: coder-` so that when the pipeline picks up -//! the story — either via auto-assign or the `start` command — it uses the -//! assigned model instead of the default. +//! The real implementation lives in `crate::matrix::assign` (async). This +//! stub exists only so that `assign` appears in the help registry — the +//! handler always returns `None` so the bot's message loop falls through to +//! the async handler in `bot.rs`. use super::CommandContext; -use crate::io::story_metadata::{parse_front_matter, set_front_matter_field}; -/// All pipeline stage directories to search when finding a work item by number. -const STAGES: &[&str] = &[ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", -]; - -/// Resolve a model name hint (e.g. `"opus"`) to a full agent name -/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`, -/// it is returned unchanged to prevent double-prefixing. -fn resolve_agent_name(model: &str) -> String { - if model.starts_with("coder-") { - model.to_string() - } else { - format!("coder-{model}") - } -} - -pub(super) fn handle_assign(ctx: &CommandContext) -> Option { - let args = ctx.args.trim(); - - // Parse ` ` from args. - let (number_str, model_str) = match args.split_once(char::is_whitespace) { - Some((n, m)) => (n.trim(), m.trim()), - None => { - return Some(format!( - "Usage: `{} assign ` (e.g. `assign 42 opus`)", - ctx.bot_name - )); - } - }; - - if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) { - return Some(format!( - "Invalid story number `{number_str}`. Usage: `{} assign `", - ctx.bot_name - )); - } - - if model_str.is_empty() { - return Some(format!( - "Usage: `{} assign ` (e.g. `assign 42 opus`)", - ctx.bot_name - )); - } - - // Find the story file across all pipeline stages. - let mut found: Option<(std::path::PathBuf, String)> = None; - 'outer: for stage in STAGES { - let dir = ctx.project_root.join(".storkit").join("work").join(stage); - if !dir.exists() { - continue; - } - 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()) - .map(|s| s.to_string()) - { - let file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or("") - .to_string(); - if file_num == number_str { - found = Some((path, stem)); - break 'outer; - } - } - } - } - } - - let (path, story_id) = match found { - Some(f) => f, - None => { - return Some(format!( - "No story, bug, or spike with number **{number_str}** found." - )); - } - }; - - // Read the human-readable name from front matter for the response. - let story_name = std::fs::read_to_string(&path) - .ok() - .and_then(|contents| { - parse_front_matter(&contents) - .ok() - .and_then(|m| m.name) - }) - .unwrap_or_else(|| story_id.clone()); - - let agent_name = resolve_agent_name(model_str); - - // Write `agent: ` into the story's front matter. - let result = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read story file: {e}")) - .and_then(|contents| { - let updated = set_front_matter_field(&contents, "agent", &agent_name); - std::fs::write(&path, &updated) - .map_err(|e| format!("Failed to write story file: {e}")) - }); - - match result { - Ok(()) => Some(format!( - "Assigned **{agent_name}** to **{story_name}** (story {number_str}). \ - The model will be used when the story starts." - )), - Err(e) => Some(format!( - "Failed to assign model to **{story_name}**: {e}" - )), - } +pub(super) fn handle_assign(_ctx: &CommandContext) -> Option { + // Handled asynchronously in bot.rs / crate::matrix::assign. + None } // --------------------------------------------------------------------------- @@ -138,33 +18,6 @@ pub(super) fn handle_assign(ctx: &CommandContext) -> Option { #[cfg(test)] mod tests { - use crate::agents::AgentPool; - use std::collections::HashSet; - use std::sync::{Arc, Mutex}; - - use super::super::{CommandDispatch, try_handle_command}; - - fn assign_cmd_with_root(root: &std::path::Path, args: &str) -> Option { - let agents = Arc::new(AgentPool::new_test(3000)); - let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); - let room_id = "!test:example.com".to_string(); - let dispatch = CommandDispatch { - bot_name: "Timmy", - bot_user_id: "@timmy:homeserver.local", - project_root: root, - agents: &agents, - ambient_rooms: &ambient_rooms, - room_id: &room_id, - }; - try_handle_command(&dispatch, &format!("@timmy assign {args}")) - } - - fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) { - let dir = root.join(".storkit/work").join(stage); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(filename), content).unwrap(); - } - // -- registration / help ------------------------------------------------ #[test] @@ -188,198 +41,17 @@ mod tests { ); } - // -- argument validation ------------------------------------------------ - #[test] - fn assign_no_args_returns_usage() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = assign_cmd_with_root(tmp.path(), "").unwrap(); - assert!( - output.contains("Usage"), - "no args should show usage: {output}" - ); - } - - #[test] - fn assign_missing_model_returns_usage() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = assign_cmd_with_root(tmp.path(), "42").unwrap(); - assert!( - output.contains("Usage"), - "missing model should show usage: {output}" - ); - } - - #[test] - fn assign_non_numeric_number_returns_error() { - let tmp = tempfile::TempDir::new().unwrap(); - let output = assign_cmd_with_root(tmp.path(), "abc opus").unwrap(); - assert!( - output.contains("Invalid story number"), - "non-numeric number should return error: {output}" - ); - } - - // -- story not found ---------------------------------------------------- - - #[test] - fn assign_unknown_story_returns_friendly_message() { - let tmp = tempfile::TempDir::new().unwrap(); - // Create stage dirs but no matching story. - for stage in &["1_backlog", "2_current"] { - std::fs::create_dir_all(tmp.path().join(".storkit/work").join(stage)).unwrap(); - } - let output = assign_cmd_with_root(tmp.path(), "999 opus").unwrap(); - assert!( - output.contains("999") && output.contains("found"), - "not-found message should include number and 'found': {output}" - ); - } - - // -- successful assignment ---------------------------------------------- - - #[test] - fn assign_writes_agent_field_to_front_matter() { - let tmp = tempfile::TempDir::new().unwrap(); - write_story_file( - tmp.path(), - "1_backlog", - "42_story_test_feature.md", - "---\nname: Test Feature\n---\n\n# Story 42\n", - ); - - let output = assign_cmd_with_root(tmp.path(), "42 opus").unwrap(); - assert!( - output.contains("coder-opus"), - "confirmation should include resolved agent name: {output}" + fn assign_command_falls_through_to_none_in_registry() { + // The assign handler in the registry returns None (handled async in bot.rs). + let result = super::super::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy assign 42 opus", ); assert!( - output.contains("Test Feature"), - "confirmation should include story name: {output}" + result.is_none(), + "assign should not produce a sync response (handled async): {result:?}" ); - - // Verify the file was updated. - let contents = std::fs::read_to_string( - tmp.path() - .join(".storkit/work/1_backlog/42_story_test_feature.md"), - ) - .unwrap(); - assert!( - contents.contains("agent: coder-opus"), - "front matter should contain agent field: {contents}" - ); - } - - #[test] - fn assign_with_sonnet_writes_coder_sonnet() { - let tmp = tempfile::TempDir::new().unwrap(); - write_story_file( - tmp.path(), - "2_current", - "10_story_current.md", - "---\nname: Current Story\n---\n", - ); - - assign_cmd_with_root(tmp.path(), "10 sonnet").unwrap(); - - let contents = std::fs::read_to_string( - tmp.path() - .join(".storkit/work/2_current/10_story_current.md"), - ) - .unwrap(); - assert!( - contents.contains("agent: coder-sonnet"), - "front matter should contain agent: coder-sonnet: {contents}" - ); - } - - #[test] - fn assign_with_already_prefixed_name_does_not_double_prefix() { - let tmp = tempfile::TempDir::new().unwrap(); - write_story_file( - tmp.path(), - "1_backlog", - "7_story_small.md", - "---\nname: Small Story\n---\n", - ); - - let output = assign_cmd_with_root(tmp.path(), "7 coder-opus").unwrap(); - assert!( - output.contains("coder-opus"), - "should not double-prefix: {output}" - ); - assert!( - !output.contains("coder-coder-opus"), - "must not double-prefix: {output}" - ); - - let contents = std::fs::read_to_string( - tmp.path().join(".storkit/work/1_backlog/7_story_small.md"), - ) - .unwrap(); - assert!( - contents.contains("agent: coder-opus"), - "must write coder-opus, not coder-coder-opus: {contents}" - ); - } - - #[test] - fn assign_overwrites_existing_agent_field() { - let tmp = tempfile::TempDir::new().unwrap(); - write_story_file( - tmp.path(), - "1_backlog", - "5_story_existing.md", - "---\nname: Existing\nagent: coder-sonnet\n---\n", - ); - - assign_cmd_with_root(tmp.path(), "5 opus").unwrap(); - - let contents = std::fs::read_to_string( - tmp.path() - .join(".storkit/work/1_backlog/5_story_existing.md"), - ) - .unwrap(); - assert!( - contents.contains("agent: coder-opus"), - "should overwrite old agent with new: {contents}" - ); - assert!( - !contents.contains("coder-sonnet"), - "old agent should no longer appear: {contents}" - ); - } - - #[test] - fn assign_finds_story_in_any_stage() { - let tmp = tempfile::TempDir::new().unwrap(); - // Story is in 3_qa/, not backlog. - write_story_file( - tmp.path(), - "3_qa", - "99_story_in_qa.md", - "---\nname: In QA\n---\n", - ); - - let output = assign_cmd_with_root(tmp.path(), "99 opus").unwrap(); - assert!( - output.contains("coder-opus"), - "should find story in qa stage: {output}" - ); - } - - // -- resolve_agent_name unit tests -------------------------------------- - - #[test] - fn resolve_agent_name_prefixes_bare_model() { - assert_eq!(super::resolve_agent_name("opus"), "coder-opus"); - assert_eq!(super::resolve_agent_name("sonnet"), "coder-sonnet"); - assert_eq!(super::resolve_agent_name("haiku"), "coder-haiku"); - } - - #[test] - fn resolve_agent_name_does_not_double_prefix() { - assert_eq!(super::resolve_agent_name("coder-opus"), "coder-opus"); - assert_eq!(super::resolve_agent_name("coder-sonnet"), "coder-sonnet"); } } diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index 11427a8..d0da21e 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -15,6 +15,7 @@ //! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in //! `bot.toml`. Each room maintains its own independent conversation history. +pub mod assign; mod bot; pub mod commands; mod config;