From be61803af0b64072c1bde89ed9193eb1d7be6edb Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 00:46:52 +0000 Subject: [PATCH] story-kit: merge 255_story_show_agent_logs_in_expanded_story_popup --- frontend/src/api/agents.ts | 8 +++ server/src/http/agents.rs | 139 +++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index cf2b79c..e39640f 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -108,6 +108,14 @@ export const agentsApi = { baseUrl, ); }, + + getAgentOutput(storyId: string, agentName: string, baseUrl?: string) { + return requestJson<{ output: string }>( + `/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/output`, + {}, + baseUrl, + ); + }, }; /** diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index 1954e3e..5c5f68b 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -105,6 +105,12 @@ impl TestResultsResponse { } } +/// Response for the agent output endpoint. +#[derive(Object, Serialize)] +struct AgentOutputResponse { + output: String, +} + /// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`. /// /// Used to exclude agents for already-archived stories from the `list_agents` @@ -400,6 +406,45 @@ impl AgentsApi { )) } + /// Get the historical output text for an agent session. + /// + /// Reads the most recent persistent log file for the given story+agent and + /// returns all `output` events concatenated as a single string. Returns an + /// empty string if no log file exists yet. + #[oai(path = "/agents/:story_id/:agent_name/output", method = "get")] + async fn get_agent_output( + &self, + story_id: Path, + agent_name: Path, + ) -> OpenApiResult> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let log_path = + crate::agent_log::find_latest_log(&project_root, &story_id.0, &agent_name.0); + + let Some(path) = log_path else { + return Ok(Json(AgentOutputResponse { + output: String::new(), + })); + }; + + let entries = crate::agent_log::read_log(&path).map_err(bad_request)?; + + let output: String = entries + .iter() + .filter(|e| { + e.event.get("type").and_then(|t| t.as_str()) == Some("output") + }) + .filter_map(|e| e.event.get("text").and_then(|t| t.as_str()).map(str::to_owned)) + .collect(); + + Ok(Json(AgentOutputResponse { output })) + } + /// Remove a git worktree and its feature branch for a story. #[oai(path = "/agents/worktrees/:story_id", method = "delete")] async fn remove_worktree(&self, story_id: Path) -> OpenApiResult> { @@ -835,6 +880,100 @@ allowed_tools = ["Read", "Bash"] 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]