story-kit: merge 309_story_show_token_cost_breakdown_in_expanded_work_item_detail_panel
This commit is contained in:
@@ -145,6 +145,7 @@ export interface SearchResult {
|
|||||||
|
|
||||||
export interface AgentCostEntry {
|
export interface AgentCostEntry {
|
||||||
agent_name: string;
|
agent_name: string;
|
||||||
|
model: string | null;
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
cache_creation_input_tokens: number;
|
cache_creation_input_tokens: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { AgentEvent, AgentInfo } from "../api/agents";
|
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 () => {
|
vi.mock("../api/client", async () => {
|
||||||
const actual =
|
const actual =
|
||||||
@@ -12,6 +12,7 @@ vi.mock("../api/client", async () => {
|
|||||||
...actual.api,
|
...actual.api,
|
||||||
getWorkItemContent: vi.fn(),
|
getWorkItemContent: vi.fn(),
|
||||||
getTestResults: vi.fn(),
|
getTestResults: vi.fn(),
|
||||||
|
getTokenCost: vi.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -30,6 +31,7 @@ const { WorkItemDetailPanel } = await import("./WorkItemDetailPanel");
|
|||||||
|
|
||||||
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
const mockedGetWorkItemContent = vi.mocked(api.getWorkItemContent);
|
||||||
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
const mockedGetTestResults = vi.mocked(api.getTestResults);
|
||||||
|
const mockedGetTokenCost = vi.mocked(api.getTokenCost);
|
||||||
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
const mockedListAgents = vi.mocked(agentsApi.listAgents);
|
||||||
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
const mockedSubscribeAgentStream = vi.mocked(subscribeAgentStream);
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
mockedGetWorkItemContent.mockResolvedValue(DEFAULT_CONTENT);
|
||||||
mockedGetTestResults.mockResolvedValue(null);
|
mockedGetTestResults.mockResolvedValue(null);
|
||||||
|
mockedGetTokenCost.mockResolvedValue({ total_cost_usd: 0, agents: [] });
|
||||||
mockedListAgents.mockResolvedValue([]);
|
mockedListAgents.mockResolvedValue([]);
|
||||||
mockedSubscribeAgentStream.mockReturnValue(() => {});
|
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(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_foo"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_foo"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_foo"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_foo"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_foo"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedGetTokenCost).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedGetTokenCost.mockResolvedValue(sampleTokenCost);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="42_story_foo"
|
||||||
|
pipelineVersion={1}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedGetTokenCost).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("token-cost-content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import * as React from "react";
|
|||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
|
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
|
||||||
import { agentsApi, subscribeAgentStream } 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";
|
import { api } from "../api/client";
|
||||||
|
|
||||||
const { useEffect, useRef, useState } = React;
|
const { useEffect, useRef, useState } = React;
|
||||||
@@ -125,6 +130,7 @@ export function WorkItemDetailPanel({
|
|||||||
const [testResults, setTestResults] = useState<TestResultsResponse | null>(
|
const [testResults, setTestResults] = useState<TestResultsResponse | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [tokenCost, setTokenCost] = useState<TokenCostResponse | null>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cleanupRef = useRef<(() => void) | null>(null);
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
@@ -159,6 +165,18 @@ export function WorkItemDetailPanel({
|
|||||||
});
|
});
|
||||||
}, [storyId, pipelineVersion]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
cleanupRef.current?.();
|
cleanupRef.current?.();
|
||||||
cleanupRef.current = null;
|
cleanupRef.current = null;
|
||||||
@@ -365,6 +383,96 @@ export function WorkItemDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Token Cost section */}
|
||||||
|
<div
|
||||||
|
data-testid="token-cost-section"
|
||||||
|
style={{
|
||||||
|
border: "1px solid #2a2a2a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "#161616",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.8em",
|
||||||
|
color: "#555",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Token Cost
|
||||||
|
</div>
|
||||||
|
{tokenCost && tokenCost.agents.length > 0 ? (
|
||||||
|
<div data-testid="token-cost-content">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75em",
|
||||||
|
color: "#888",
|
||||||
|
marginBottom: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Total:{" "}
|
||||||
|
<span data-testid="token-cost-total" style={{ color: "#ccc" }}>
|
||||||
|
${tokenCost.total_cost_usd.toFixed(6)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tokenCost.agents.map((agent: AgentCostEntry) => (
|
||||||
|
<div
|
||||||
|
key={agent.agent_name}
|
||||||
|
data-testid={`token-cost-agent-${agent.agent_name}`}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75em",
|
||||||
|
color: "#888",
|
||||||
|
padding: "4px 0",
|
||||||
|
borderTop: "1px solid #222",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#ccc", fontWeight: 600 }}>
|
||||||
|
{agent.agent_name}
|
||||||
|
{agent.model ? (
|
||||||
|
<span
|
||||||
|
style={{ color: "#666", fontWeight: 400 }}
|
||||||
|
>{` (${agent.model})`}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#aaa" }}>
|
||||||
|
${agent.total_cost_usd.toFixed(6)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#555" }}>
|
||||||
|
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()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
data-testid="token-cost-empty"
|
||||||
|
style={{ fontSize: "0.75em", color: "#444" }}
|
||||||
|
>
|
||||||
|
No token data recorded
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test Results section */}
|
{/* Test Results section */}
|
||||||
<div
|
<div
|
||||||
data-testid="test-results-section"
|
data-testid="test-results-section"
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ struct AgentOutputResponse {
|
|||||||
#[derive(Object, Serialize)]
|
#[derive(Object, Serialize)]
|
||||||
struct AgentCostEntry {
|
struct AgentCostEntry {
|
||||||
agent_name: String,
|
agent_name: String,
|
||||||
|
model: Option<String>,
|
||||||
input_tokens: u64,
|
input_tokens: u64,
|
||||||
output_tokens: u64,
|
output_tokens: u64,
|
||||||
cache_creation_input_tokens: u64,
|
cache_creation_input_tokens: u64,
|
||||||
@@ -531,6 +532,7 @@ impl AgentsApi {
|
|||||||
.entry(record.agent_name.clone())
|
.entry(record.agent_name.clone())
|
||||||
.or_insert_with(|| AgentCostEntry {
|
.or_insert_with(|| AgentCostEntry {
|
||||||
agent_name: record.agent_name.clone(),
|
agent_name: record.agent_name.clone(),
|
||||||
|
model: record.model.clone(),
|
||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
cache_creation_input_tokens: 0,
|
cache_creation_input_tokens: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user