story-kit: merge 314_story_show_token_cost_per_story_in_bot_status_command_output
This commit is contained in:
@@ -270,6 +270,16 @@ pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool)
|
|||||||
.map(|a| (a.story_id.clone(), a))
|
.map(|a| (a.story_id.clone(), a))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Read token usage once for all stories to avoid repeated file I/O.
|
||||||
|
let cost_by_story: std::collections::HashMap<String, f64> =
|
||||||
|
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 config = ProjectConfig::load(project_root).ok();
|
||||||
|
|
||||||
let mut out = String::from("**Pipeline Status**\n\n");
|
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 {
|
} else {
|
||||||
for (story_id, name) in &items {
|
for (story_id, name) in &items {
|
||||||
let display = story_short_label(story_id, name.as_deref());
|
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) {
|
if let Some(agent) = active_map.get(story_id) {
|
||||||
let model_str = config
|
let model_str = config
|
||||||
.as_ref()
|
.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())
|
.and_then(|ac| ac.model.as_deref())
|
||||||
.unwrap_or("?");
|
.unwrap_or("?");
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" • {display} — {} ({model_str})\n",
|
" • {display}{cost_suffix} — {} ({model_str})\n",
|
||||||
agent.agent_name
|
agent.agent_name
|
||||||
));
|
));
|
||||||
} else {
|
} 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 --------------------------------------------------
|
// -- commands registry --------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user