From 060d5a40a4e2d98078e3a7197adcda0941575977 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 19:57:09 +0000 Subject: [PATCH] story-kit: merge 314_story_show_token_cost_per_story_in_bot_status_command_output --- server/src/matrix/commands.rs | 122 +++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs index 4083879..3af2673 100644 --- a/server/src/matrix/commands.rs +++ b/server/src/matrix/commands.rs @@ -270,6 +270,16 @@ pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) .map(|a| (a.story_id.clone(), a)) .collect(); + // Read token usage once for all stories to avoid repeated file I/O. + let cost_by_story: std::collections::HashMap = + crate::agents::token_usage::read_all(project_root) + .unwrap_or_default() + .into_iter() + .fold(std::collections::HashMap::new(), |mut map, r| { + *map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd; + map + }); + let config = ProjectConfig::load(project_root).ok(); let mut out = String::from("**Pipeline Status**\n\n"); @@ -291,6 +301,11 @@ pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) } else { for (story_id, name) in &items { let display = story_short_label(story_id, name.as_deref()); + let cost_suffix = cost_by_story + .get(story_id) + .filter(|&&c| c > 0.0) + .map(|c| format!(" — ${c:.2}")) + .unwrap_or_default(); if let Some(agent) = active_map.get(story_id) { let model_str = config .as_ref() @@ -298,11 +313,11 @@ pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) .and_then(|ac| ac.model.as_deref()) .unwrap_or("?"); out.push_str(&format!( - " • {display} — {} ({model_str})\n", + " • {display}{cost_suffix} — {} ({model_str})\n", agent.agent_name )); } else { - out.push_str(&format!(" • {display}\n")); + out.push_str(&format!(" • {display}{cost_suffix}\n")); } } } @@ -1037,6 +1052,109 @@ mod tests { ); } + // -- token cost in status output ---------------------------------------- + + #[test] + fn status_shows_cost_when_token_usage_exists() { + use std::io::Write; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let stage_dir = tmp.path().join(".story_kit/work/2_current"); + std::fs::create_dir_all(&stage_dir).unwrap(); + + let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); + let mut f = std::fs::File::create(&story_path).unwrap(); + writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); + + // Write token usage for this story. + let usage = crate::agents::TokenUsage { + input_tokens: 100, + output_tokens: 200, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + total_cost_usd: 0.29, + }; + let record = crate::agents::token_usage::build_record( + "293_story_register_all_bot_commands", + "coder-1", + None, + usage, + ); + crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); + + let agents = AgentPool::new_test(3000); + let output = build_pipeline_status(tmp.path(), &agents); + + assert!( + output.contains("293 — Register all bot commands — $0.29"), + "output must show cost next to story: {output}" + ); + } + + #[test] + fn status_no_cost_when_no_usage() { + use std::io::Write; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let stage_dir = tmp.path().join(".story_kit/work/2_current"); + std::fs::create_dir_all(&stage_dir).unwrap(); + + let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); + let mut f = std::fs::File::create(&story_path).unwrap(); + writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); + + // No token usage written. + let agents = AgentPool::new_test(3000); + let output = build_pipeline_status(tmp.path(), &agents); + + assert!( + !output.contains("$"), + "output must not show cost when no usage exists: {output}" + ); + } + + #[test] + fn status_aggregates_multiple_records_per_story() { + use std::io::Write; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let stage_dir = tmp.path().join(".story_kit/work/2_current"); + std::fs::create_dir_all(&stage_dir).unwrap(); + + let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); + let mut f = std::fs::File::create(&story_path).unwrap(); + writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); + + // Write two records for the same story — costs should be summed. + for cost in [0.10, 0.19] { + let usage = crate::agents::TokenUsage { + input_tokens: 50, + output_tokens: 100, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + total_cost_usd: cost, + }; + let record = crate::agents::token_usage::build_record( + "293_story_register_all_bot_commands", + "coder-1", + None, + usage, + ); + crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); + } + + let agents = AgentPool::new_test(3000); + let output = build_pipeline_status(tmp.path(), &agents); + + assert!( + output.contains("293 — Register all bot commands — $0.29"), + "output must show aggregated cost: {output}" + ); + } + // -- commands registry -------------------------------------------------- #[test]