storkit: merge 380_story_assign_command_restarts_coder_when_story_is_already_in_progress
This commit is contained in:
@@ -3,10 +3,10 @@
|
|||||||
//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
|
//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
|
||||||
//! commands available in Matrix without going through the LLM.
|
//! commands available in Matrix without going through the LLM.
|
||||||
//!
|
//!
|
||||||
//! Synchronous commands (status, assign, git, cost, move, show, overview,
|
//! Synchronous commands (status, git, cost, move, show, overview, help) are
|
||||||
//! help) are dispatched directly through the matrix command registry.
|
//! dispatched directly through the matrix command registry.
|
||||||
//! Asynchronous commands (start, delete, rebuild) are dispatched to their
|
//! Asynchronous commands (assign, start, delete, rebuild) are dispatched to
|
||||||
//! dedicated async handlers. The `reset` command is handled by the frontend
|
//! 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.
|
//! (it clears local session state and message history) and is not routed here.
|
||||||
|
|
||||||
use crate::http::context::{AppContext, OpenApiResult};
|
use crate::http::context::{AppContext, OpenApiResult};
|
||||||
@@ -75,6 +75,7 @@ async fn dispatch_command(
|
|||||||
agents: &Arc<crate::agents::AgentPool>,
|
agents: &Arc<crate::agents::AgentPool>,
|
||||||
) -> String {
|
) -> String {
|
||||||
match cmd {
|
match cmd {
|
||||||
|
"assign" => dispatch_assign(args, project_root, agents).await,
|
||||||
"start" => dispatch_start(args, project_root, agents).await,
|
"start" => dispatch_start(args, project_root, agents).await,
|
||||||
"delete" => dispatch_delete(args, project_root, agents).await,
|
"delete" => dispatch_delete(args, project_root, agents).await,
|
||||||
"rebuild" => dispatch_rebuild(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<crate::agents::AgentPool>,
|
||||||
|
) -> String {
|
||||||
|
// args: "<number> <model>"
|
||||||
|
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 <number> <model>` (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(
|
async fn dispatch_start(
|
||||||
args: &str,
|
args: &str,
|
||||||
project_root: &std::path::Path,
|
project_root: &std::path::Path,
|
||||||
|
|||||||
537
server/src/matrix/assign.rs
Normal file
537
server/src/matrix/assign.rs
Normal file
@@ -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<AssignCommand> {
|
||||||
|
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: <agent_name>` 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<String> = 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -861,6 +861,46 @@ async fn on_room_message(
|
|||||||
return;
|
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 <number> <model>` (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)
|
// Check for the htop command, which requires async Matrix access (Room)
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if let Some(htop_cmd) =
|
if let Some(htop_cmd) =
|
||||||
|
|||||||
@@ -1,135 +1,15 @@
|
|||||||
//! Handler for the `assign` command.
|
//! Handler stub for the `assign` command.
|
||||||
//!
|
//!
|
||||||
//! `assign <number> <model>` pre-assigns a coder model (e.g. `opus`, `sonnet`)
|
//! The real implementation lives in `crate::matrix::assign` (async). This
|
||||||
//! to a story before it starts. The assignment persists in the story file's
|
//! stub exists only so that `assign` appears in the help registry — the
|
||||||
//! front matter as `agent: coder-<model>` so that when the pipeline picks up
|
//! handler always returns `None` so the bot's message loop falls through to
|
||||||
//! the story — either via auto-assign or the `start` command — it uses the
|
//! the async handler in `bot.rs`.
|
||||||
//! assigned model instead of the default.
|
|
||||||
|
|
||||||
use super::CommandContext;
|
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.
|
pub(super) fn handle_assign(_ctx: &CommandContext) -> Option<String> {
|
||||||
const STAGES: &[&str] = &[
|
// Handled asynchronously in bot.rs / crate::matrix::assign.
|
||||||
"1_backlog",
|
None
|
||||||
"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<String> {
|
|
||||||
let args = ctx.args.trim();
|
|
||||||
|
|
||||||
// Parse `<number> <model>` 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 <number> <model>` (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 <number> <model>`",
|
|
||||||
ctx.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if model_str.is_empty() {
|
|
||||||
return Some(format!(
|
|
||||||
"Usage: `{} assign <number> <model>` (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: <agent_name>` 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}"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -138,33 +18,6 @@ pub(super) fn handle_assign(ctx: &CommandContext) -> Option<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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<String> {
|
|
||||||
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 ------------------------------------------------
|
// -- registration / help ------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -188,198 +41,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- argument validation ------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assign_no_args_returns_usage() {
|
fn assign_command_falls_through_to_none_in_registry() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
// The assign handler in the registry returns None (handled async in bot.rs).
|
||||||
let output = assign_cmd_with_root(tmp.path(), "").unwrap();
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
assert!(
|
"Timmy",
|
||||||
output.contains("Usage"),
|
"@timmy:homeserver.local",
|
||||||
"no args should show usage: {output}"
|
"@timmy assign 42 opus",
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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}"
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Test Feature"),
|
result.is_none(),
|
||||||
"confirmation should include story name: {output}"
|
"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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in
|
//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in
|
||||||
//! `bot.toml`. Each room maintains its own independent conversation history.
|
//! `bot.toml`. Each room maintains its own independent conversation history.
|
||||||
|
|
||||||
|
pub mod assign;
|
||||||
mod bot;
|
mod bot;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
|||||||
Reference in New Issue
Block a user