use crate::config::ProjectConfig; use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; 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, } /// Response for the work item content endpoint. #[derive(Object, Serialize)] struct WorkItemContentResponse { content: String, stage: String, name: Option, agent: Option, } /// A single test case result for the OpenAPI response. #[derive(Object, Serialize)] struct TestCaseResultResponse { name: String, status: String, details: Option, } /// Response for the work item test results endpoint. #[derive(Object, Serialize)] struct TestResultsResponse { unit: Vec, integration: Vec, } impl TestResultsResponse { fn from_story_results(results: &StoryTestResults) -> Self { Self { unit: results.unit.iter().map(Self::map_case).collect(), integration: results.integration.iter().map(Self::map_case).collect(), } } fn map_case(tc: &TestCaseResult) -> TestCaseResultResponse { TestCaseResultResponse { name: tc.name.clone(), status: match tc.status { TestStatus::Pass => "pass".to_string(), TestStatus::Fail => "fail".to_string(), }, details: tc.details.clone(), } } } /// Response for the agent output endpoint. #[derive(Object, Serialize)] struct AgentOutputResponse { output: String, } /// Per-agent cost breakdown entry for the token cost endpoint. #[derive(Object, Serialize)] struct AgentCostEntry { agent_name: String, input_tokens: u64, output_tokens: u64, cache_creation_input_tokens: u64, cache_read_input_tokens: u64, total_cost_usd: f64, } /// Response for the work item token cost endpoint. #[derive(Object, Serialize)] struct TokenCostResponse { total_cost_usd: f64, agents: Vec, } /// A single token usage record in the all-usage response. #[derive(Object, Serialize)] struct TokenUsageRecordResponse { story_id: String, agent_name: String, model: Option, timestamp: String, input_tokens: u64, output_tokens: u64, cache_creation_input_tokens: u64, cache_read_input_tokens: u64, total_cost_usd: f64, } /// Response for the all token usage endpoint. #[derive(Object, Serialize)] struct AllTokenUsageResponse { records: Vec, } /// 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 { let work = project_root.join(".story_kit").join("work"); let filename = format!("{story_id}.md"); work.join("5_done").join(&filename).exists() || work.join("6_archived").join(&filename).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(), )) } /// Get the markdown content of a work item by its story_id. /// /// Searches all active pipeline stages for the file and returns its content /// along with the stage it was found in. #[oai(path = "/work-items/:story_id", method = "get")] async fn get_work_item_content( &self, story_id: Path, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let stages = [ ("1_backlog", "backlog"), ("2_current", "current"), ("3_qa", "qa"), ("4_merge", "merge"), ("5_done", "done"), ("6_archived", "archived"), ]; let work_dir = project_root.join(".story_kit").join("work"); let filename = format!("{}.md", story_id.0); for (stage_dir, stage_name) in &stages { let file_path = work_dir.join(stage_dir).join(&filename); if file_path.exists() { let content = std::fs::read_to_string(&file_path) .map_err(|e| bad_request(format!("Failed to read work item: {e}")))?; let metadata = crate::io::story_metadata::parse_front_matter(&content).ok(); let name = metadata.as_ref().and_then(|m| m.name.clone()); let agent = metadata.and_then(|m| m.agent); return Ok(Json(WorkItemContentResponse { content, stage: stage_name.to_string(), name, agent, })); } } Err(not_found(format!("Work item not found: {}", story_id.0))) } /// Get test results for a work item by its story_id. /// /// Returns unit and integration test results. Checks in-memory workflow /// state first, then falls back to results persisted in the story file. #[oai(path = "/work-items/:story_id/test-results", method = "get")] async fn get_test_results( &self, story_id: Path, ) -> OpenApiResult>> { // Try in-memory workflow state first. let workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(format!("Lock error: {e}")))?; if let Some(results) = workflow.results.get(&story_id.0) { return Ok(Json(Some(TestResultsResponse::from_story_results(results)))); } drop(workflow); // Fall back to file-persisted results. let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let file_results = crate::http::workflow::read_test_results_from_story_file( &project_root, &story_id.0, ); Ok(Json( file_results.map(|r| TestResultsResponse::from_story_results(&r)), )) } /// Get the historical output text for an agent session. /// /// Reads the most recent persistent log file for the given story+agent and /// returns all `output` events concatenated as a single string. Returns an /// empty string if no log file exists yet. #[oai(path = "/agents/:story_id/:agent_name/output", method = "get")] async fn get_agent_output( &self, story_id: Path, agent_name: Path, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let log_path = crate::agent_log::find_latest_log(&project_root, &story_id.0, &agent_name.0); let Some(path) = log_path else { return Ok(Json(AgentOutputResponse { output: String::new(), })); }; let entries = crate::agent_log::read_log(&path).map_err(bad_request)?; let output: String = entries .iter() .filter(|e| { e.event.get("type").and_then(|t| t.as_str()) == Some("output") }) .filter_map(|e| e.event.get("text").and_then(|t| t.as_str()).map(str::to_owned)) .collect(); Ok(Json(AgentOutputResponse { output })) } /// 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)) } /// Get the total token cost and per-agent breakdown for a work item. /// /// Returns the sum of all recorded token usage for the given story_id. /// If no usage has been recorded, returns zero cost with an empty agents list. #[oai(path = "/work-items/:story_id/token-cost", method = "get")] async fn get_work_item_token_cost( &self, story_id: Path, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let all_records = crate::agents::token_usage::read_all(&project_root) .map_err(|e| bad_request(format!("Failed to read token usage: {e}")))?; let mut agent_map: std::collections::HashMap = std::collections::HashMap::new(); let mut total_cost_usd = 0.0_f64; for record in all_records.into_iter().filter(|r| r.story_id == story_id.0) { total_cost_usd += record.usage.total_cost_usd; let entry = agent_map .entry(record.agent_name.clone()) .or_insert_with(|| AgentCostEntry { agent_name: record.agent_name.clone(), input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: 0.0, }); entry.input_tokens += record.usage.input_tokens; entry.output_tokens += record.usage.output_tokens; entry.cache_creation_input_tokens += record.usage.cache_creation_input_tokens; entry.cache_read_input_tokens += record.usage.cache_read_input_tokens; entry.total_cost_usd += record.usage.total_cost_usd; } let mut agents: Vec = agent_map.into_values().collect(); agents.sort_by(|a, b| a.agent_name.cmp(&b.agent_name)); Ok(Json(TokenCostResponse { total_cost_usd, agents, })) } /// Get all token usage records across all stories. /// /// Returns the full history from the persistent token_usage.jsonl log. #[oai(path = "/token-usage", method = "get")] async fn get_all_token_usage( &self, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let records = crate::agents::token_usage::read_all(&project_root) .map_err(|e| bad_request(format!("Failed to read token usage: {e}")))?; let response_records: Vec = records .into_iter() .map(|r| TokenUsageRecordResponse { story_id: r.story_id, agent_name: r.agent_name, model: r.model, timestamp: r.timestamp, input_tokens: r.usage.input_tokens, output_tokens: r.usage.output_tokens, cache_creation_input_tokens: r.usage.cache_creation_input_tokens, cache_read_input_tokens: r.usage.cache_read_input_tokens, total_cost_usd: r.usage.total_cost_usd, }) .collect(); Ok(Json(AllTokenUsageResponse { records: response_records, })) } } #[cfg(test)] mod tests { use super::*; use crate::agents::AgentStatus; use tempfile::TempDir; fn make_work_dirs(tmp: &TempDir) -> path::PathBuf { let root = tmp.path().to_path_buf(); for stage in &["5_done", "6_archived"] { std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap(); } root } #[test] fn story_is_archived_false_when_file_absent() { let tmp = TempDir::new().unwrap(); let root = make_work_dirs(&tmp); assert!(!story_is_archived(&root, "79_story_foo")); } #[test] fn story_is_archived_true_when_file_in_5_done() { let tmp = TempDir::new().unwrap(); let root = make_work_dirs(&tmp); std::fs::write( root.join(".story_kit/work/5_done/79_story_foo.md"), "---\nname: test\n---\n", ) .unwrap(); assert!(story_is_archived(&root, "79_story_foo")); } #[test] fn story_is_archived_true_when_file_in_6_archived() { let tmp = TempDir::new().unwrap(); let root = make_work_dirs(&tmp); std::fs::write( root.join(".story_kit/work/6_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_work_dirs(&tmp); // Place an archived story file in 6_archived std::fs::write( root.join(".story_kit/work/6_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()); } // --- get_work_item_content tests --- fn make_stage_dir(root: &path::Path, stage: &str) { std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap(); } #[tokio::test] async fn get_work_item_content_returns_content_from_backlog() { let tmp = TempDir::new().unwrap(); let root = tmp.path(); make_stage_dir(root, "1_backlog"); std::fs::write( root.join(".story_kit/work/1_backlog/42_story_foo.md"), "---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.", ) .unwrap(); let ctx = AppContext::new_test(root.to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .get_work_item_content(Path("42_story_foo".to_string())) .await .unwrap() .0; assert!(result.content.contains("Some content.")); assert_eq!(result.stage, "backlog"); assert_eq!(result.name, Some("Foo Story".to_string())); } #[tokio::test] async fn get_work_item_content_returns_content_from_current() { let tmp = TempDir::new().unwrap(); let root = tmp.path(); make_stage_dir(root, "2_current"); std::fs::write( root.join(".story_kit/work/2_current/43_story_bar.md"), "---\nname: \"Bar Story\"\n---\n\nBar content.", ) .unwrap(); let ctx = AppContext::new_test(root.to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .get_work_item_content(Path("43_story_bar".to_string())) .await .unwrap() .0; assert_eq!(result.stage, "current"); assert_eq!(result.name, Some("Bar Story".to_string())); } #[tokio::test] async fn get_work_item_content_returns_not_found_when_absent() { 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_work_item_content(Path("99_story_nonexistent".to_string())) .await; assert!(result.is_err()); } #[tokio::test] async fn get_work_item_content_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_work_item_content(Path("42_story_foo".to_string())) .await; assert!(result.is_err()); } // --- get_agent_output tests --- #[tokio::test] async fn get_agent_output_returns_empty_when_no_log_exists() { 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_output( Path("42_story_foo".to_string()), Path("coder-1".to_string()), ) .await .unwrap() .0; assert_eq!(result.output, ""); } #[tokio::test] async fn get_agent_output_returns_concatenated_output_events() { use crate::agent_log::AgentLogWriter; use crate::agents::AgentEvent; let tmp = TempDir::new().unwrap(); let root = tmp.path(); let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-test").unwrap(); writer .write_event(&AgentEvent::Status { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), status: "running".to_string(), }) .unwrap(); writer .write_event(&AgentEvent::Output { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), text: "Hello ".to_string(), }) .unwrap(); writer .write_event(&AgentEvent::Output { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), text: "world\n".to_string(), }) .unwrap(); writer .write_event(&AgentEvent::Done { story_id: "42_story_foo".to_string(), agent_name: "coder-1".to_string(), session_id: None, }) .unwrap(); let ctx = AppContext::new_test(root.to_path_buf()); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .get_agent_output( Path("42_story_foo".to_string()), Path("coder-1".to_string()), ) .await .unwrap() .0; // Only output event texts should be concatenated; status and done are excluded. assert_eq!(result.output, "Hello world\n"); } #[tokio::test] async fn get_agent_output_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_output( Path("42_story_foo".to_string()), Path("coder-1".to_string()), ) .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()); } // --- get_test_results tests --- #[tokio::test] async fn get_test_results_returns_none_when_no_results() { let tmp = TempDir::new().unwrap(); let root = make_work_dirs(&tmp); let ctx = AppContext::new_test(root); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .get_test_results(Path("42_story_foo".to_string())) .await .unwrap() .0; assert!(result.is_none()); } #[tokio::test] async fn get_test_results_returns_in_memory_results() { let tmp = TempDir::new().unwrap(); let root = make_work_dirs(&tmp); let ctx = AppContext::new_test(root); // Record test results in-memory. { let mut workflow = ctx.workflow.lock().unwrap(); workflow .record_test_results_validated( "42_story_foo".to_string(), vec![crate::workflow::TestCaseResult { name: "unit_test_1".to_string(), status: crate::workflow::TestStatus::Pass, details: None, }], vec![crate::workflow::TestCaseResult { name: "int_test_1".to_string(), status: crate::workflow::TestStatus::Fail, details: Some("assertion failed".to_string()), }], ) .unwrap(); } let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .get_test_results(Path("42_story_foo".to_string())) .await .unwrap() .0 .expect("should have test results"); assert_eq!(result.unit.len(), 1); assert_eq!(result.unit[0].name, "unit_test_1"); assert_eq!(result.unit[0].status, "pass"); assert!(result.unit[0].details.is_none()); assert_eq!(result.integration.len(), 1); assert_eq!(result.integration[0].name, "int_test_1"); assert_eq!(result.integration[0].status, "fail"); assert_eq!( result.integration[0].details.as_deref(), Some("assertion failed") ); } #[tokio::test] async fn get_test_results_falls_back_to_file_persisted_results() { let tmp = TempDir::new().unwrap(); let root = tmp.path().to_path_buf(); // Create work dirs including 2_current for the story file. for stage in &["1_backlog", "2_current", "5_done", "6_archived"] { std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap(); } // Write a story file with persisted test results. let story_content = r#"--- name: "Test story" --- # Test story ## Test Results "#; std::fs::write( root.join(".story_kit/work/2_current/42_story_foo.md"), story_content, ) .unwrap(); let ctx = AppContext::new_test(root); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api .get_test_results(Path("42_story_foo".to_string())) .await .unwrap() .0 .expect("should fall back to file results"); assert_eq!(result.unit.len(), 1); assert_eq!(result.unit[0].name, "from_file"); assert_eq!(result.unit[0].status, "pass"); } }