diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 41ba633..fa6e146 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -145,6 +145,7 @@ export interface SearchResult { export interface AgentCostEntry { agent_name: string; + model: string | null; input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; diff --git a/frontend/src/components/WorkItemDetailPanel.test.tsx b/frontend/src/components/WorkItemDetailPanel.test.tsx index 353555c..dde835b 100644 --- a/frontend/src/components/WorkItemDetailPanel.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentEvent, AgentInfo } from "../api/agents"; -import type { TestResultsResponse } from "../api/client"; +import type { TestResultsResponse, TokenCostResponse } from "../api/client"; vi.mock("../api/client", async () => { const actual = @@ -12,6 +12,7 @@ vi.mock("../api/client", async () => { ...actual.api, getWorkItemContent: vi.fn(), getTestResults: vi.fn(), + getTokenCost: vi.fn(), }, }; }); @@ -30,6 +31,7 @@ const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel"); const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent); const mockedGetTestResults = vi.mocked(api.getTestResults); +const mockedGetTokenCost = vi.mocked(api.getTokenCost); const mockedListAgents = vi.mocked(agentsApi.listAgents); const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream); @@ -52,6 +54,7 @@ beforeEach(() => { vi.clearAllMocks(); mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT); mockedGetTestResults.mockResolvedValue(null); + mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] }); mockedListAgents.mockResolvedValue([]); mockedSubscribeAgentStream.mockReturnValue(() => {}); }); @@ -608,3 +611,146 @@ describe("WorkItemDetailPanel - Test Results", () => { }); }); }); + +describe("WorkItemDetailPanel - Token Cost", () => { + const sampleTokenCost: TokenCostResponse = { + total_cost_usd: 0.012345, + agents: [ + { + agent_name: "coder-1", + model: "claude-sonnet-4-6", + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 100, + total_cost_usd: 0.009, + }, + { + agent_name: "coder-2", + model: null, + input_tokens: 800, + output_tokens: 300, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + total_cost_usd: 0.003345, + }, + ], + }; + + it("shows empty state when no token data exists", async () => { + mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] }); + + render( + {}} + />, + ); + + await waitFor(() => { + expect(screen.getByTestId("token-cost-empty")).toBeInTheDocument(); + }); + expect(screen.getByText("No token data recorded")).toBeInTheDocument(); + }); + + it("shows per-agent breakdown and total cost when data exists", async () => { + mockedGetTokenCost.mockResolvedValue(sampleTokenCost); + + render( + {}} + />, + ); + + await waitFor(() => { + expect(screen.getByTestId("token-cost-content")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("token-cost-total")).toHaveTextContent( + "$0.012345", + ); + expect(screen.getByTestId("token-cost-agent-coder-1")).toBeInTheDocument(); + expect(screen.getByTestId("token-cost-agent-coder-2")).toBeInTheDocument(); + }); + + it("shows agent name and model when model is present", async () => { + mockedGetTokenCost.mockResolvedValue(sampleTokenCost); + + render( + {}} + />, + ); + + await waitFor(() => { + expect( + screen.getByTestId("token-cost-agent-coder-1"), + ).toBeInTheDocument(); + }); + + const agentRow = screen.getByTestId("token-cost-agent-coder-1"); + expect(agentRow).toHaveTextContent("coder-1"); + expect(agentRow).toHaveTextContent("claude-sonnet-4-6"); + }); + + it("shows agent name without model when model is null", async () => { + mockedGetTokenCost.mockResolvedValue(sampleTokenCost); + + render( + {}} + />, + ); + + await waitFor(() => { + expect( + screen.getByTestId("token-cost-agent-coder-2"), + ).toBeInTheDocument(); + }); + + const agentRow = screen.getByTestId("token-cost-agent-coder-2"); + expect(agentRow).toHaveTextContent("coder-2"); + expect(agentRow).not.toHaveTextContent("null"); + }); + + it("re-fetches token cost when pipelineVersion changes", async () => { + mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] }); + + const { rerender } = render( + {}} + />, + ); + + await waitFor(() => { + expect(mockedGetTokenCost).toHaveBeenCalledTimes(1); + }); + + mockedGetTokenCost.mockResolvedValue(sampleTokenCost); + + rerender( + {}} + />, + ); + + await waitFor(() => { + expect(mockedGetTokenCost).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(screen.getByTestId("token-cost-content")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx index dc2b34c..89cc07b 100644 --- a/frontend/src/components/WorkItemDetailPanel.tsx +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -2,7 +2,12 @@ import * as React from "react"; import Markdown from "react-markdown"; import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents"; -import type { TestCaseResult, TestResultsResponse } from "../api/client"; +import type { + AgentCostEntry, + TestCaseResult, + TestResultsResponse, + TokenCostResponse, +} from "../api/client"; import { api } from "../api/client"; const { useEffect, useRef, useState } = React; @@ -125,6 +130,7 @@ export function WorkItemDetailPanel({ const [testResults, setTestResults] = useState( null, ); + const [tokenCost, setTokenCost] = useState(null); const panelRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); @@ -159,6 +165,18 @@ export function WorkItemDetailPanel({ }); }, [storyId, pipelineVersion]); + // Fetch token cost on mount and when pipeline updates arrive. + useEffect(() => { + api + .getTokenCost(storyId) + .then((data) => { + setTokenCost(data); + }) + .catch(() => { + // Silently ignore — token cost may not exist yet. + }); + }, [storyId, pipelineVersion]); + useEffect(() => { cleanupRef.current?.(); cleanupRef.current = null; @@ -365,6 +383,96 @@ export function WorkItemDetailPanel({ )} + {/* Token Cost section */} +
+
+ Token Cost +
+ {tokenCost && tokenCost.agents.length > 0 ? ( +
+
+ Total:{" "} + + ${tokenCost.total_cost_usd.toFixed(6)} + +
+ {tokenCost.agents.map((agent: AgentCostEntry) => ( +
+
+ + {agent.agent_name} + {agent.model ? ( + {` (${agent.model})`} + ) : null} + + + ${agent.total_cost_usd.toFixed(6)} + +
+
+ in {agent.input_tokens.toLocaleString()} / out{" "} + {agent.output_tokens.toLocaleString()} + {(agent.cache_creation_input_tokens > 0 || + agent.cache_read_input_tokens > 0) && ( + <> + {" "} + / cache + + {agent.cache_creation_input_tokens.toLocaleString()}{" "} + read {agent.cache_read_input_tokens.toLocaleString()} + + )} +
+
+ ))} +
+ ) : ( +
+ No token data recorded +
+ )} +
+ {/* Test Results section */}
, input_tokens: u64, output_tokens: u64, cache_creation_input_tokens: u64, @@ -531,6 +532,7 @@ impl AgentsApi { .entry(record.agent_name.clone()) .or_insert_with(|| AgentCostEntry { agent_name: record.agent_name.clone(), + model: record.model.clone(), input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0,