use crate::config::ProjectConfig; use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::worktree; use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use serde::Serialize; use std::path; use std::sync::Arc; #[derive(Tags)] enum AgentsTags { Agents, } #[derive(Object)] struct StartAgentPayload { story_id: String, agent_name: Option, } #[derive(Object)] struct StopAgentPayload { story_id: String, agent_name: String, } #[derive(Object, Serialize)] struct AgentInfoResponse { story_id: String, agent_name: String, status: String, session_id: Option, worktree_path: Option, } #[derive(Object, Serialize)] struct AgentConfigInfoResponse { name: String, role: String, model: Option, allowed_tools: Option>, max_turns: Option, max_budget_usd: Option, } #[derive(Object)] struct CreateWorktreePayload { story_id: String, } #[derive(Object, Serialize)] struct WorktreeInfoResponse { story_id: String, worktree_path: String, branch: String, base_branch: String, } #[derive(Object, Serialize)] struct WorktreeListEntry { story_id: String, path: String, } /// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`. /// /// Used to exclude agents for already-archived stories from the `list_agents` /// response so the agents panel is not cluttered with old completed items on /// frontend startup. pub fn story_is_archived(project_root: &path::Path, story_id: &str) -> bool { project_root .join(".story_kit") .join("work") .join("5_archived") .join(format!("{story_id}.md")) .exists() } pub struct AgentsApi { pub ctx: Arc, } #[OpenApi(tag = "AgentsTags::Agents")] impl AgentsApi { /// Start an agent for a given story (creates worktree, runs setup, spawns agent). /// If agent_name is omitted, the first configured agent is used. #[oai(path = "/agents/start", method = "post")] async fn start_agent( &self, payload: Json, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let info = self .ctx .agents .start_agent( &project_root, &payload.0.story_id, payload.0.agent_name.as_deref(), None, ) .await .map_err(bad_request)?; Ok(Json(AgentInfoResponse { story_id: info.story_id, agent_name: info.agent_name, status: info.status.to_string(), session_id: info.session_id, worktree_path: info.worktree_path, })) } /// Stop a running agent and clean up its worktree. #[oai(path = "/agents/stop", method = "post")] async fn stop_agent(&self, payload: Json) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; self.ctx .agents .stop_agent( &project_root, &payload.0.story_id, &payload.0.agent_name, ) .await .map_err(bad_request)?; Ok(Json(true)) } /// List all agents with their status. /// /// Agents for stories that have been completed (`work/5_done/` or `work/6_archived/`) are /// excluded so the agents panel is not cluttered with old completed items /// on frontend startup. #[oai(path = "/agents", method = "get")] async fn list_agents(&self) -> OpenApiResult>> { let project_root = self.ctx.agents.get_project_root(&self.ctx.state).ok(); let agents = self.ctx.agents.list_agents().map_err(bad_request)?; Ok(Json( agents .into_iter() .filter(|info| { project_root .as_deref() .map(|root| !story_is_archived(root, &info.story_id)) .unwrap_or(true) }) .map(|info| AgentInfoResponse { story_id: info.story_id, agent_name: info.agent_name, status: info.status.to_string(), session_id: info.session_id, worktree_path: info.worktree_path, }) .collect(), )) } /// Get the configured agent roster from project.toml. #[oai(path = "/agents/config", method = "get")] async fn get_agent_config( &self, ) -> OpenApiResult>> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let config = ProjectConfig::load(&project_root).map_err(bad_request)?; Ok(Json( config .agent .iter() .map(|a| AgentConfigInfoResponse { name: a.name.clone(), role: a.role.clone(), model: a.model.clone(), allowed_tools: a.allowed_tools.clone(), max_turns: a.max_turns, max_budget_usd: a.max_budget_usd, }) .collect(), )) } /// Reload project config and return the updated agent roster. #[oai(path = "/agents/config/reload", method = "post")] async fn reload_config( &self, ) -> OpenApiResult>> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let config = ProjectConfig::load(&project_root).map_err(bad_request)?; Ok(Json( config .agent .iter() .map(|a| AgentConfigInfoResponse { name: a.name.clone(), role: a.role.clone(), model: a.model.clone(), allowed_tools: a.allowed_tools.clone(), max_turns: a.max_turns, max_budget_usd: a.max_budget_usd, }) .collect(), )) } /// Create a git worktree for a story under .story_kit/worktrees/{story_id}. #[oai(path = "/agents/worktrees", method = "post")] async fn create_worktree( &self, payload: Json, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let info = self .ctx .agents .create_worktree(&project_root, &payload.0.story_id) .await .map_err(bad_request)?; Ok(Json(WorktreeInfoResponse { story_id: payload.0.story_id, worktree_path: info.path.to_string_lossy().to_string(), branch: info.branch, base_branch: info.base_branch, })) } /// List all worktrees under .story_kit/worktrees/. #[oai(path = "/agents/worktrees", method = "get")] async fn list_worktrees(&self) -> OpenApiResult>> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let entries = worktree::list_worktrees(&project_root).map_err(bad_request)?; Ok(Json( entries .into_iter() .map(|e| WorktreeListEntry { story_id: e.story_id, path: e.path.to_string_lossy().to_string(), }) .collect(), )) } /// Remove a git worktree and its feature branch for a story. #[oai(path = "/agents/worktrees/:story_id", method = "delete")] async fn remove_worktree(&self, story_id: Path) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let config = ProjectConfig::load(&project_root).map_err(bad_request)?; worktree::remove_worktree_by_story_id(&project_root, &story_id.0, &config) .await .map_err(bad_request)?; Ok(Json(true)) } } #[cfg(test)] mod tests { use super::*; use crate::agents::AgentStatus; use tempfile::TempDir; fn make_archived_dir(tmp: &TempDir) -> path::PathBuf { let root = tmp.path().to_path_buf(); let archived = root .join(".story_kit") .join("work") .join("5_archived"); std::fs::create_dir_all(&archived).unwrap(); root } #[test] fn story_is_archived_false_when_file_absent() { let tmp = TempDir::new().unwrap(); let root = make_archived_dir(&tmp); assert!(!story_is_archived(&root, "79_story_foo")); } #[test] fn story_is_archived_true_when_file_present() { let tmp = TempDir::new().unwrap(); let root = make_archived_dir(&tmp); std::fs::write( root.join(".story_kit/work/5_archived/79_story_foo.md"), "---\nname: test\n---\n", ) .unwrap(); assert!(story_is_archived(&root, "79_story_foo")); } #[tokio::test] async fn list_agents_excludes_archived_stories() { let tmp = TempDir::new().unwrap(); let root = make_archived_dir(&tmp); // Place an archived story file std::fs::write( root.join(".story_kit/work/5_archived/79_story_archived.md"), "---\nname: archived story\n---\n", ) .unwrap(); let ctx = AppContext::new_test(root); // Inject an agent for the archived story (completed) and one for an active story ctx.agents .inject_test_agent("79_story_archived", "coder-1", AgentStatus::Completed); ctx.agents .inject_test_agent("80_story_active", "coder-1", AgentStatus::Running); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.list_agents().await.unwrap().0; // Archived story's agent should not appear assert!( !result.iter().any(|a| a.story_id == "79_story_archived"), "archived story agent should be excluded from list_agents" ); // Active story's agent should still appear assert!( result.iter().any(|a| a.story_id == "80_story_active"), "active story agent should be included in list_agents" ); } #[tokio::test] async fn list_agents_includes_all_when_no_project_root() { // When no project root is configured, all agents are returned (safe default). let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); // Clear the project_root so get_project_root returns Err *ctx.state.project_root.lock().unwrap() = None; ctx.agents .inject_test_agent("42_story_whatever", "coder-1", AgentStatus::Completed); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.list_agents().await.unwrap().0; assert!(result.iter().any(|a| a.story_id == "42_story_whatever")); } fn make_project_toml(root: &path::Path, content: &str) { let sk_dir = root.join(".story_kit"); std::fs::create_dir_all(&sk_dir).unwrap(); std::fs::write(sk_dir.join("project.toml"), content).unwrap(); } // --- get_agent_config tests --- #[tokio::test] async fn get_agent_config_returns_default_when_no_toml() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.get_agent_config().await.unwrap().0; // Default config has one agent named "default" assert_eq!(result.len(), 1); assert_eq!(result[0].name, "default"); } #[tokio::test] async fn get_agent_config_returns_configured_agents() { let tmp = TempDir::new().unwrap(); make_project_toml( tmp.path(), r#" [[agent]] name = "coder-1" role = "Full-stack engineer" model = "sonnet" max_turns = 30 max_budget_usd = 5.0 [[agent]] name = "qa" role = "QA reviewer" model = "haiku" "#, ); let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.get_agent_config().await.unwrap().0; assert_eq!(result.len(), 2); assert_eq!(result[0].name, "coder-1"); assert_eq!(result[0].role, "Full-stack engineer"); assert_eq!(result[0].model, Some("sonnet".to_string())); assert_eq!(result[0].max_turns, Some(30)); assert_eq!(result[0].max_budget_usd, Some(5.0)); assert_eq!(result[1].name, "qa"); assert_eq!(result[1].model, Some("haiku".to_string())); } #[tokio::test] async fn get_agent_config_returns_error_when_no_project_root() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.get_agent_config().await; assert!(result.is_err()); } // --- reload_config tests --- #[tokio::test] async fn reload_config_returns_default_when_no_toml() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.reload_config().await.unwrap().0; assert_eq!(result.len(), 1); assert_eq!(result[0].name, "default"); } #[tokio::test] async fn reload_config_returns_configured_agents() { let tmp = TempDir::new().unwrap(); make_project_toml( tmp.path(), r#" [[agent]] name = "supervisor" role = "Coordinator" model = "opus" allowed_tools = ["Read", "Bash"] "#, ); let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.reload_config().await.unwrap().0; assert_eq!(result.len(), 1); assert_eq!(result[0].name, "supervisor"); assert_eq!(result[0].role, "Coordinator"); assert_eq!(result[0].model, Some("opus".to_string())); assert_eq!( result[0].allowed_tools, Some(vec!["Read".to_string(), "Bash".to_string()]) ); } #[tokio::test] async fn reload_config_returns_error_when_no_project_root() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.reload_config().await; assert!(result.is_err()); } // --- list_worktrees tests --- #[tokio::test] async fn list_worktrees_returns_empty_when_no_worktree_dir() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.list_worktrees().await.unwrap().0; assert!(result.is_empty()); } #[tokio::test] async fn list_worktrees_returns_entries_from_dir() { let tmp = TempDir::new().unwrap(); let worktrees_dir = tmp.path().join(".story_kit").join("worktrees"); std::fs::create_dir_all(worktrees_dir.join("42_story_foo")).unwrap(); std::fs::create_dir_all(worktrees_dir.join("43_story_bar")).unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let mut result = api.list_worktrees().await.unwrap().0; result.sort_by(|a, b| a.story_id.cmp(&b.story_id)); assert_eq!(result.len(), 2); assert_eq!(result[0].story_id, "42_story_foo"); assert_eq!(result[1].story_id, "43_story_bar"); } #[tokio::test] async fn list_worktrees_returns_error_when_no_project_root() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.list_worktrees().await; assert!(result.is_err()); } // --- stop_agent tests --- #[tokio::test] async fn stop_agent_returns_error_when_no_project_root() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .stop_agent(Json(StopAgentPayload { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), })) .await; assert!(result.is_err()); } #[tokio::test] async fn stop_agent_returns_error_when_agent_not_found() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .stop_agent(Json(StopAgentPayload { story_id: "nonexistent_story".to_string(), agent_name: "coder-1".to_string(), })) .await; assert!(result.is_err()); } #[tokio::test] async fn stop_agent_succeeds_with_running_agent() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); ctx.agents .inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .stop_agent(Json(StopAgentPayload { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), })) .await .unwrap() .0; assert!(result); } // --- start_agent error path --- #[tokio::test] async fn start_agent_returns_error_when_no_project_root() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .start_agent(Json(StartAgentPayload { story_id: "42_story_foo".to_string(), agent_name: None, })) .await; assert!(result.is_err()); } // --- create_worktree error path --- #[tokio::test] async fn create_worktree_returns_error_when_no_project_root() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .create_worktree(Json(CreateWorktreePayload { story_id: "42_story_foo".to_string(), })) .await; assert!(result.is_err()); } #[tokio::test] async fn create_worktree_returns_error_when_not_a_git_repo() { let tmp = TempDir::new().unwrap(); // project_root is set but has no git repo — git worktree add will fail let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .create_worktree(Json(CreateWorktreePayload { story_id: "42_story_foo".to_string(), })) .await; assert!(result.is_err()); } // --- remove_worktree error paths --- #[tokio::test] async fn remove_worktree_returns_error_when_no_project_root() { let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); *ctx.state.project_root.lock().unwrap() = None; let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .remove_worktree(Path("42_story_foo".to_string())) .await; assert!(result.is_err()); } #[tokio::test] async fn remove_worktree_returns_error_when_worktree_not_found() { let tmp = TempDir::new().unwrap(); // project_root is set but no worktree exists for this story_id let ctx = AppContext::new_test(tmp.path().to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .remove_worktree(Path("nonexistent_story".to_string())) .await; assert!(result.is_err()); } }