diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs deleted file mode 100644 index ad75a7f8..00000000 --- a/server/src/http/agents.rs +++ /dev/null @@ -1,1249 +0,0 @@ -//! 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 - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let info = svc::start_agent( - &self.ctx.services.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 - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - svc::stop_agent( - &self.ctx.services.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 - .services - .agents - .get_project_root(&self.ctx.state) - .ok(); - let agents = svc::list_agents(&self.ctx.services.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 - .services - .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 - .services - .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 - .services - .agents - .get_project_root(&self.ctx.state) - .map_err(bad_request)?; - - let info = svc::create_worktree( - &self.ctx.services.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 - .services - .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 - .services - .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 - .services - .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 - .services - .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 - .services - .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 - .services - .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 - .services - .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.services.agents.inject_test_agent( - "79_story_archived", - "coder-1", - AgentStatus::Completed, - ); - ctx.services - .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.services.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.services - .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"); - } -} diff --git a/server/src/http/agents/mod.rs b/server/src/http/agents/mod.rs new file mode 100644 index 00000000..9d9fd9b0 --- /dev/null +++ b/server/src/http/agents/mod.rs @@ -0,0 +1,569 @@ +//! 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 + .services + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let info = svc::start_agent( + &self.ctx.services.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 + .services + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + svc::stop_agent( + &self.ctx.services.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 + .services + .agents + .get_project_root(&self.ctx.state) + .ok(); + let agents = svc::list_agents(&self.ctx.services.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 + .services + .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 + .services + .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 + .services + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let info = svc::create_worktree( + &self.ctx.services.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 + .services + .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 + .services + .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 + .services + .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 + .services + .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 + .services + .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 + .services + .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 + .services + .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; diff --git a/server/src/http/agents/tests.rs b/server/src/http/agents/tests.rs new file mode 100644 index 00000000..7994bae4 --- /dev/null +++ b/server/src/http/agents/tests.rs @@ -0,0 +1,675 @@ +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.services + .agents + .inject_test_agent("79_story_archived", "coder-1", AgentStatus::Completed); + ctx.services + .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.services + .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.services + .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"); +}