story-kit: merge 331_story_bot_start_command_to_start_a_coder_on_a_story
This commit is contained in:
@@ -888,6 +888,46 @@ async fn on_room_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for the start command, which requires async agent ops and cannot
|
||||||
|
// be handled by the sync command registry.
|
||||||
|
if let Some(start_cmd) = super::start::extract_start_command(
|
||||||
|
&user_message,
|
||||||
|
&ctx.bot_name,
|
||||||
|
ctx.bot_user_id.as_str(),
|
||||||
|
) {
|
||||||
|
let response = match start_cmd {
|
||||||
|
super::start::StartCommand::Start {
|
||||||
|
story_number,
|
||||||
|
agent_hint,
|
||||||
|
} => {
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}"
|
||||||
|
);
|
||||||
|
super::start::handle_start(
|
||||||
|
&ctx.bot_name,
|
||||||
|
&story_number,
|
||||||
|
agent_hint.as_deref(),
|
||||||
|
&ctx.project_root,
|
||||||
|
&ctx.agents,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
super::start::StartCommand::BadArgs => {
|
||||||
|
format!(
|
||||||
|
"Usage: `{} start <number>` or `{} start <number> opus`",
|
||||||
|
ctx.bot_name, 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;
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
||||||
// wait for the LLM response (which can take several seconds).
|
// wait for the LLM response (which can take several seconds).
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "Show implementation summary for a merged story: `overview <number>`",
|
description: "Show implementation summary for a merged story: `overview <number>`",
|
||||||
handler: overview::handle_overview,
|
handler: overview::handle_overview,
|
||||||
},
|
},
|
||||||
|
BotCommand {
|
||||||
|
name: "start",
|
||||||
|
description: "Start a coder on a story: `start <number>` or `start <number> opus`",
|
||||||
|
handler: handle_start_fallback,
|
||||||
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
name: "delete",
|
name: "delete",
|
||||||
description: "Remove a work item from the pipeline: `delete <number>`",
|
description: "Remove a work item from the pipeline: `delete <number>`",
|
||||||
@@ -221,6 +226,16 @@ fn handle_htop_fallback(_ctx: &CommandContext) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback handler for the `start` command when it is not intercepted by
|
||||||
|
/// the async handler in `on_room_message`. In practice this is never called —
|
||||||
|
/// start is detected and handled before `try_handle_command` is invoked.
|
||||||
|
/// The entry exists in the registry only so `help` lists it.
|
||||||
|
///
|
||||||
|
/// Returns `None` to prevent the LLM from receiving "start" as a prompt.
|
||||||
|
fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Fallback handler for the `delete` command when it is not intercepted by
|
/// Fallback handler for the `delete` command when it is not intercepted by
|
||||||
/// the async handler in `on_room_message`. In practice this is never called —
|
/// the async handler in `on_room_message`. In practice this is never called —
|
||||||
/// delete is detected and handled before `try_handle_command` is invoked.
|
/// delete is detected and handled before `try_handle_command` is invoked.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod commands;
|
|||||||
mod config;
|
mod config;
|
||||||
pub mod delete;
|
pub mod delete;
|
||||||
pub mod htop;
|
pub mod htop;
|
||||||
|
pub mod start;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod transport_impl;
|
pub mod transport_impl;
|
||||||
|
|
||||||
|
|||||||
349
server/src/matrix/start.rs
Normal file
349
server/src/matrix/start.rs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
//! Start command: start a coder agent on a story.
|
||||||
|
//!
|
||||||
|
//! `{bot_name} start {number}` finds the story by number, selects the default
|
||||||
|
//! coder agent, and starts it.
|
||||||
|
//!
|
||||||
|
//! `{bot_name} start {number} opus` starts `coder-opus` (or any agent whose
|
||||||
|
//! name ends with the supplied hint, e.g. `coder-{hint}`).
|
||||||
|
|
||||||
|
use crate::agents::AgentPool;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// A parsed start command from a Matrix message body.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum StartCommand {
|
||||||
|
/// Start the story with this number using the (optional) agent hint.
|
||||||
|
Start {
|
||||||
|
story_number: String,
|
||||||
|
/// Optional agent name hint (e.g. `"opus"` → resolved to `"coder-opus"`).
|
||||||
|
agent_hint: Option<String>,
|
||||||
|
},
|
||||||
|
/// The user typed `start` but without a valid numeric argument.
|
||||||
|
BadArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a start command from a raw Matrix message body.
|
||||||
|
///
|
||||||
|
/// Strips the bot mention prefix and checks whether the first word is `start`.
|
||||||
|
/// Returns `None` when the message is not a start command at all.
|
||||||
|
pub fn extract_start_command(
|
||||||
|
message: &str,
|
||||||
|
bot_name: &str,
|
||||||
|
bot_user_id: &str,
|
||||||
|
) -> Option<StartCommand> {
|
||||||
|
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("start") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split args into story number and optional agent hint.
|
||||||
|
let (number_str, hint_str) = match args.split_once(char::is_whitespace) {
|
||||||
|
Some((n, h)) => (n.trim(), h.trim()),
|
||||||
|
None => (args, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !number_str.is_empty() && number_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
let agent_hint = if hint_str.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(hint_str.to_string())
|
||||||
|
};
|
||||||
|
Some(StartCommand::Start {
|
||||||
|
story_number: number_str.to_string(),
|
||||||
|
agent_hint,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Some(StartCommand::BadArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a start command asynchronously.
|
||||||
|
///
|
||||||
|
/// Finds the work item by `story_number` across all pipeline stages, resolves
|
||||||
|
/// the agent name from `agent_hint`, and calls `agents.start_agent`.
|
||||||
|
/// Returns a markdown-formatted response string.
|
||||||
|
pub async fn handle_start(
|
||||||
|
bot_name: &str,
|
||||||
|
story_number: &str,
|
||||||
|
agent_hint: Option<&str>,
|
||||||
|
project_root: &Path,
|
||||||
|
agents: &AgentPool,
|
||||||
|
) -> String {
|
||||||
|
const STAGES: &[&str] = &[
|
||||||
|
"1_backlog",
|
||||||
|
"2_current",
|
||||||
|
"3_qa",
|
||||||
|
"4_merge",
|
||||||
|
"5_done",
|
||||||
|
"6_archived",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Find the story file across all pipeline stages.
|
||||||
|
let mut found: Option<(std::path::PathBuf, String)> = None; // (path, story_id)
|
||||||
|
'outer: for stage in STAGES {
|
||||||
|
let dir = project_root.join(".story_kit").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| {
|
||||||
|
crate::io::story_metadata::parse_front_matter(&contents)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.name)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| story_id.clone());
|
||||||
|
|
||||||
|
// Resolve agent name: try "coder-{hint}" first, then the hint as-is.
|
||||||
|
let resolved_agent: Option<String> = agent_hint.map(|hint| {
|
||||||
|
let with_prefix = format!("coder-{hint}");
|
||||||
|
// We'll pass the prefixed form; start_agent validates against config.
|
||||||
|
// If coder- prefix is already there, don't double-prefix.
|
||||||
|
if hint.starts_with("coder-") {
|
||||||
|
hint.to_string()
|
||||||
|
} else {
|
||||||
|
with_prefix
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
crate::slog!(
|
||||||
|
"[matrix-bot] start command: starting story {story_id} with agent={resolved_agent:?} (bot={bot_name})"
|
||||||
|
);
|
||||||
|
|
||||||
|
match agents
|
||||||
|
.start_agent(project_root, &story_id, resolved_agent.as_deref(), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => {
|
||||||
|
format!(
|
||||||
|
"Started **{story_name}** with agent **{}**.",
|
||||||
|
info.agent_name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
format!("Failed to start **{story_name}**: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||||
|
///
|
||||||
|
/// Mirrors the logic in `commands::strip_bot_mention` and `delete::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_start_command -----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_full_user_id() {
|
||||||
|
let cmd =
|
||||||
|
extract_start_command("@timmy:home.local start 331", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(StartCommand::Start {
|
||||||
|
story_number: "331".to_string(),
|
||||||
|
agent_hint: None
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_display_name() {
|
||||||
|
let cmd = extract_start_command("Timmy start 42", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(StartCommand::Start {
|
||||||
|
story_number: "42".to_string(),
|
||||||
|
agent_hint: None
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_localpart() {
|
||||||
|
let cmd = extract_start_command("@timmy start 7", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(StartCommand::Start {
|
||||||
|
story_number: "7".to_string(),
|
||||||
|
agent_hint: None
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_agent_hint() {
|
||||||
|
let cmd = extract_start_command("Timmy start 331 opus", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(StartCommand::Start {
|
||||||
|
story_number: "331".to_string(),
|
||||||
|
agent_hint: Some("opus".to_string())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_case_insensitive_command() {
|
||||||
|
let cmd = extract_start_command("Timmy START 99", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(StartCommand::Start {
|
||||||
|
story_number: "99".to_string(),
|
||||||
|
agent_hint: None
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_no_args_is_bad_args() {
|
||||||
|
let cmd = extract_start_command("Timmy start", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, Some(StartCommand::BadArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_non_numeric_arg_is_bad_args() {
|
||||||
|
let cmd = extract_start_command("Timmy start foo", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, Some(StartCommand::BadArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_non_start_command_returns_none() {
|
||||||
|
let cmd = extract_start_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- handle_start (integration-style, uses temp filesystem) --------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_start_returns_not_found_for_unknown_number() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_root = tmp.path();
|
||||||
|
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||||
|
std::fs::create_dir_all(project_root.join(".story_kit").join("work").join(stage))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||||
|
let response = handle_start("Timmy", "999", None, project_root, &agents).await;
|
||||||
|
assert!(
|
||||||
|
response.contains("No story") && response.contains("999"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_command_is_registered() {
|
||||||
|
use crate::matrix::commands::commands;
|
||||||
|
let found = commands().iter().any(|c| c.name == "start");
|
||||||
|
assert!(found, "start command must be in the registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_command_appears_in_help() {
|
||||||
|
let result = crate::matrix::commands::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy help",
|
||||||
|
);
|
||||||
|
let output = result.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.contains("start"),
|
||||||
|
"help should list start command: {output}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_command_falls_through_to_none_in_registry() {
|
||||||
|
// The start handler in the registry returns None (handled async in bot.rs).
|
||||||
|
let result = crate::matrix::commands::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy start 42",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"start should not produce a sync response (handled async): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user