story-kit: merge 300_story_show_token_cost_badge_on_pipeline_board_work_items

This commit is contained in:
Dave
2026-03-19 11:00:10 +00:00
parent b6f99ce7a2
commit 36535b639f
4 changed files with 137 additions and 0 deletions

View File

@@ -143,6 +143,20 @@ export interface SearchResult {
matches: number; matches: number;
} }
export interface AgentCostEntry {
agent_name: string;
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
total_cost_usd: number;
}
export interface TokenCostResponse {
total_cost_usd: number;
agents: AgentCostEntry[];
}
export interface CommandOutput { export interface CommandOutput {
stdout: string; stdout: string;
stderr: string; stderr: string;
@@ -316,6 +330,13 @@ export const api = {
baseUrl, baseUrl,
); );
}, },
getTokenCost(storyId: string, baseUrl?: string) {
return requestJson<TokenCostResponse>(
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
{},
baseUrl,
);
},
/** Approve a story in QA, moving it to merge. */ /** Approve a story in QA, moving it to merge. */
approveQa(storyId: string) { approveQa(storyId: string) {
return callMcpTool("approve_qa", { story_id: storyId }); return callMcpTool("approve_qa", { story_id: storyId });

View File

@@ -202,6 +202,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
const [agentConfigVersion, setAgentConfigVersion] = useState(0); const [agentConfigVersion, setAgentConfigVersion] = useState(0);
const [agentStateVersion, setAgentStateVersion] = useState(0); const [agentStateVersion, setAgentStateVersion] = useState(0);
const [pipelineVersion, setPipelineVersion] = useState(0); const [pipelineVersion, setPipelineVersion] = useState(0);
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
new Map(),
);
const [needsOnboarding, setNeedsOnboarding] = useState(false); const [needsOnboarding, setNeedsOnboarding] = useState(false);
const onboardingTriggeredRef = useRef(false); const onboardingTriggeredRef = useRef(false);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>( const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
@@ -363,6 +366,29 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
onPipelineState: (state) => { onPipelineState: (state) => {
setPipeline(state); setPipeline(state);
setPipelineVersion((v) => v + 1); setPipelineVersion((v) => v + 1);
const allItems = [
...state.backlog,
...state.current,
...state.qa,
...state.merge,
...state.done,
];
for (const item of allItems) {
api
.getTokenCost(item.story_id)
.then((cost) => {
if (cost.total_cost_usd > 0) {
setStoryTokenCosts((prev) => {
const next = new Map(prev);
next.set(item.story_id, cost.total_cost_usd);
return next;
});
}
})
.catch(() => {
// Silently ignore — cost data may not exist yet.
});
}
}, },
onPermissionRequest: (requestId, toolName, toolInput) => { onPermissionRequest: (requestId, toolName, toolInput) => {
setPermissionQueue((prev) => [ setPermissionQueue((prev) => [
@@ -1005,26 +1031,31 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
<StagePanel <StagePanel
title="Done" title="Done"
items={pipeline.done ?? []} items={pipeline.done ?? []}
costs={storyTokenCosts}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)} onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/> />
<StagePanel <StagePanel
title="To Merge" title="To Merge"
items={pipeline.merge} items={pipeline.merge}
costs={storyTokenCosts}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)} onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/> />
<StagePanel <StagePanel
title="QA" title="QA"
items={pipeline.qa} items={pipeline.qa}
costs={storyTokenCosts}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)} onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/> />
<StagePanel <StagePanel
title="Current" title="Current"
items={pipeline.current} items={pipeline.current}
costs={storyTokenCosts}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)} onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/> />
<StagePanel <StagePanel
title="Backlog" title="Backlog"
items={pipeline.backlog} items={pipeline.backlog}
costs={storyTokenCosts}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)} onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/> />
<ServerLogsPanel logs={serverLogs} /> <ServerLogsPanel logs={serverLogs} />

View File

@@ -42,6 +42,8 @@ interface StagePanelProps {
items: PipelineStageItem[]; items: PipelineStageItem[];
emptyMessage?: string; emptyMessage?: string;
onItemClick?: (item: PipelineStageItem) => void; onItemClick?: (item: PipelineStageItem) => void;
/** Map of story_id → total_cost_usd for displaying cost badges. */
costs?: Map<string, number>;
} }
function AgentLozenge({ function AgentLozenge({
@@ -128,6 +130,7 @@ export function StagePanel({
items, items,
emptyMessage = "Empty.", emptyMessage = "Empty.",
onItemClick, onItemClick,
costs,
}: StagePanelProps) { }: StagePanelProps) {
return ( return (
<div <div
@@ -240,6 +243,19 @@ export function StagePanel({
{typeLabel} {typeLabel}
</span> </span>
)} )}
{costs?.has(item.story_id) && (
<span
data-testid={`cost-badge-${item.story_id}`}
style={{
fontSize: "0.65em",
fontWeight: 600,
color: "#e3b341",
marginRight: "8px",
}}
>
${costs.get(item.story_id)?.toFixed(2)}
</span>
)}
{item.name ?? item.story_id} {item.name ?? item.story_id}
</div> </div>
{item.error && ( {item.error && (

View File

@@ -112,6 +112,24 @@ struct AgentOutputResponse {
output: String, output: String,
} }
/// Per-agent cost breakdown entry for the token cost endpoint.
#[derive(Object, Serialize)]
struct AgentCostEntry {
agent_name: String,
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<AgentCostEntry>,
}
/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`. /// 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` /// Used to exclude agents for already-archived stories from the `list_agents`
@@ -463,6 +481,57 @@ impl AgentsApi {
Ok(Json(true)) 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<String>,
) -> OpenApiResult<Json<TokenCostResponse>> {
let project_root = self
.ctx
.agents
.get_project_root(&self.ctx.state)
.map_err(bad_request)?;
let all_records = crate::agents::token_usage::read_all(&project_root)
.map_err(|e| bad_request(format!("Failed to read token usage: {e}")))?;
let mut agent_map: std::collections::HashMap<String, AgentCostEntry> =
std::collections::HashMap::new();
let mut total_cost_usd = 0.0_f64;
for record in all_records.into_iter().filter(|r| r.story_id == story_id.0) {
total_cost_usd += record.usage.total_cost_usd;
let entry = agent_map
.entry(record.agent_name.clone())
.or_insert_with(|| AgentCostEntry {
agent_name: record.agent_name.clone(),
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
total_cost_usd: 0.0,
});
entry.input_tokens += record.usage.input_tokens;
entry.output_tokens += record.usage.output_tokens;
entry.cache_creation_input_tokens += record.usage.cache_creation_input_tokens;
entry.cache_read_input_tokens += record.usage.cache_read_input_tokens;
entry.total_cost_usd += record.usage.total_cost_usd;
}
let mut agents: Vec<AgentCostEntry> = agent_map.into_values().collect();
agents.sort_by(|a, b| a.agent_name.cmp(&b.agent_name));
Ok(Json(TokenCostResponse {
total_cost_usd,
agents,
}))
}
} }
#[cfg(test)] #[cfg(test)]