//! HTTP agent endpoints — thin adapters over `service::agents`. //! //! Each handler: extracts payload → calls `service::agents::X` → shapes //! response DTO → returns HTTP result. No filesystem access, no inline //! validation, no process invocations. use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; use crate::service::agents::{self as svc, AgentConfigEntry, WorkItemContent}; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; use poem::http::StatusCode; use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use serde::Serialize; 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, stage: Option, model: Option, allowed_tools: Option>, max_turns: Option, max_budget_usd: Option, } impl From for AgentConfigInfoResponse { fn from(e: AgentConfigEntry) -> Self { Self { name: e.name, role: e.role, stage: e.stage, model: e.model, allowed_tools: e.allowed_tools, max_turns: e.max_turns, max_budget_usd: e.max_budget_usd, } } } #[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, } impl From for WorkItemContentResponse { fn from(w: WorkItemContent) -> Self { Self { content: w.content, stage: w.stage, name: w.name, agent: w.agent, } } } /// 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, model: Option, 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, } /// Map a `service::agents::Error` to a Poem HTTP error with the correct status. fn map_svc_error(err: svc::Error) -> poem::Error { match err { svc::Error::AgentNotFound(_) => { poem::Error::from_string(err.to_string(), StatusCode::NOT_FOUND) } svc::Error::WorkItemNotFound(_) => { poem::Error::from_string(err.to_string(), StatusCode::NOT_FOUND) } svc::Error::Worktree(_) => { poem::Error::from_string(err.to_string(), StatusCode::BAD_REQUEST) } svc::Error::Config(_) => poem::Error::from_string(err.to_string(), StatusCode::BAD_REQUEST), svc::Error::Io(_) => { poem::Error::from_string(err.to_string(), StatusCode::INTERNAL_SERVER_ERROR) } } } 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 = svc::start_agent( &self.ctx.agents, &project_root, &payload.0.story_id, payload.0.agent_name.as_deref(), None, None, ) .await .map_err(map_svc_error)?; 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)?; svc::stop_agent( &self.ctx.agents, &project_root, &payload.0.story_id, &payload.0.agent_name, ) .await .map_err(map_svc_error)?; 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 = svc::list_agents(&self.ctx.agents, project_root.as_deref()).map_err(map_svc_error)?; Ok(Json( agents .into_iter() .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 entries = svc::get_agent_config(&project_root).map_err(map_svc_error)?; Ok(Json( entries .into_iter() .map(AgentConfigInfoResponse::from) .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 entries = svc::reload_config(&project_root).map_err(map_svc_error)?; Ok(Json( entries .into_iter() .map(AgentConfigInfoResponse::from) .collect(), )) } /// Create a git worktree for a story under .huskies/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 = svc::create_worktree(&self.ctx.agents, &project_root, &payload.0.story_id) .await .map_err(map_svc_error)?; 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 .huskies/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 = svc::list_worktrees(&project_root).map_err(map_svc_error)?; 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 item = svc::get_work_item_content(&project_root, &story_id.0).map_err(|e| match e { svc::Error::WorkItemNotFound(_) => not_found(e.to_string()), other => map_svc_error(other), })?; Ok(Json(WorkItemContentResponse::from(item))) } /// 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>> { // Fast path: return from in-memory state without requiring project_root. let in_memory = { let workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(format!("Lock error: {e}")))?; workflow.results.get(&story_id.0).cloned() }; if let Some(results) = in_memory { return Ok(Json(Some(TestResultsResponse::from_story_results( &results, )))); } // Slow path: fall back to results persisted in the story file. let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(format!("Lock error: {e}")))?; let results = svc::get_test_results(&project_root, &story_id.0, &workflow); Ok(Json( 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 output = svc::get_agent_output(&project_root, &story_id.0, &agent_name.0) .map_err(map_svc_error)?; 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)?; svc::remove_worktree(&project_root, &story_id.0) .await .map_err(map_svc_error)?; 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 summary = svc::get_work_item_token_cost(&project_root, &story_id.0).map_err(map_svc_error)?; let agents = summary .agents .into_iter() .map(|a| AgentCostEntry { agent_name: a.agent_name, model: a.model, input_tokens: a.input_tokens, output_tokens: a.output_tokens, cache_creation_input_tokens: a.cache_creation_input_tokens, cache_read_input_tokens: a.cache_read_input_tokens, total_cost_usd: a.total_cost_usd, }) .collect(); Ok(Json(TokenCostResponse { total_cost_usd: summary.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 = svc::get_all_token_usage(&project_root).map_err(map_svc_error)?; 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 std::path; 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(".huskies").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!(!svc::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(".huskies/work/5_done/79_story_foo.md"), "---\nname: test\n---\n", ) .unwrap(); assert!(svc::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(".huskies/work/6_archived/79_story_foo.md"), "---\nname: test\n---\n", ) .unwrap(); assert!(svc::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(".huskies/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(".huskies"); 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(".huskies").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(".huskies").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(".huskies/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(".huskies/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_falls_back_to_crdt_when_no_file() { let tmp = TempDir::new().unwrap(); let root = tmp.path().to_path_buf(); // Seed content + CRDT with no .md file on disk. crate::db::write_item_with_content( "44_story_crdt_only", "1_backlog", "---\nname: \"CRDT Only\"\n---\n\nCRDT content.", ); let ctx = AppContext::new_test(root); let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_work_item_content(Path("44_story_crdt_only".to_string())) .await .unwrap() .0; assert!(result.content.contains("CRDT content.")); assert_eq!(result.stage, "backlog"); assert_eq!(result.name, Some("CRDT Only".to_string())); } #[tokio::test] async fn get_work_item_content_crdt_fallback_with_current_stage() { let tmp = TempDir::new().unwrap(); let root = tmp.path().to_path_buf(); // Seed a CRDT-only story in the coding/current stage. crate::db::write_item_with_content( "45_story_crdt_current", "2_current", "---\nname: \"Current CRDT\"\n---\n\nIn progress.", ); let ctx = AppContext::new_test(root); let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_work_item_content(Path("45_story_crdt_current".to_string())) .await .unwrap() .0; assert!(result.content.contains("In progress.")); assert_eq!(result.stage, "current"); assert_eq!(result.name, Some("Current CRDT".to_string())); } #[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(".huskies").join("work").join(stage)).unwrap(); } // Use a unique high-numbered story ID to avoid collisions with the // "42_story_foo" entry used by get_test_results_returns_none_when_no_results. let story_content = r#"--- name: "Test story" --- # Test story ## Test Results "#; std::fs::write( root.join(".huskies/work/2_current/9906_story_persisted_results.md"), story_content, ) .unwrap(); // Also write to the content store so read_story_content returns this // test's content even when another test left a stale entry in the // global content store. crate::db::ensure_content_store(); crate::db::write_content("9906_story_persisted_results", story_content); let ctx = AppContext::new_test(root); let api = AgentsApi { ctx: Arc::new(ctx) }; let result = api .get_test_results(Path("9906_story_persisted_results".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"); } }